@solidrt/3d 0.0.55 → 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 +1083 -183
  2. package/README.md +15 -7
  3. package/examples/README.md +79 -5
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  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 +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 +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 +763 -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 +1343 -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 +667 -232
  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 +204 -35
  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 +1242 -1557
  53. package/tools/environment.ts +69 -0
  54. package/tools/model.ts +4 -3
package/src/glsl.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  // The exported lighting GLSL: string constants an app composes into its
2
2
  // own shaderMaterial sources with plain template literals - the same
3
- // pieces the package's future lit materials will be built from, so a
4
- // custom material never becomes second-class (no preprocessor, no include
5
- // resolver; the policy is argued in okf/research/3d-differentiators.md).
3
+ // pieces the package's lit materials are built from (sceneSource is the
4
+ // whole scene in one set; the lit, standard and unlit programs are
5
+ // assembled from it), so a custom material never becomes second-class
6
+ // (no preprocessor, no include resolver; the policy is argued in
7
+ // okf/notes/3d-differentiators.md).
6
8
  //
7
9
  // The light-model functions are PURE: normals, view vectors, light
8
10
  // directions, colors and exponents all arrive as arguments, so these
@@ -14,23 +16,84 @@
14
16
  // every function returns its raw term, weighting and color belong to the
15
17
  // caller.
16
18
 
17
- import { glsl } from "@solidrt/core/gpu"
19
+ import type { CullMode, InstanceAttribute } from "@solidrt/core/gpu"
18
20
 
19
- /** The directional-light cap of the scene's light list (DirectionalLight nodes)
20
- * and of the `lit` fragment; a custom fragment declares
21
- * `uniform vec3 uLightDir[MAX_LIGHTS]` / `uLightColor[MAX_LIGHTS]` and
22
- * loops to `uLightCount`. A shader-source constant, so it is fixed for
23
- * the app (see okf/backlog/app-runtime-config.md). */
24
- export const MAX_LIGHTS = 4
21
+ // core/gpu's glsl tag, aliased locally (it is String.raw) so this module
22
+ // stays runtime-pure: no flux:gpu import, so the checks/ rigs and any
23
+ // bake-time tool can run it headless on bare flux.
24
+ let glsl = String.raw
25
+
26
+ /** The cap of the scene's light list (directional, spot and point nodes
27
+ * alike; the hemisphere ambient is not in it) and of the `lit` fragment;
28
+ * a custom fragment composes LIGHT_SLOTS and LIGHT_LOOKUP and loops to
29
+ * `uLightCount`. A shader-source constant, so it is fixed for the app
30
+ * (see okf/backlog/app-runtime-config.md). */
31
+ export const MAX_LIGHTS = 8
25
32
 
26
33
  /** The most cascades one casting light splits its shadow into
27
34
  * (`shadow.cascades`, 1..MAX_CASCADES). */
28
35
  export const MAX_CASCADES = 4
29
36
 
30
- /** The shadow-map slot count of the scene's shadow set: every casting
31
- * light owns `shadow.cascades` consecutive slots (one per map), so this
32
- * bounds `uShadowRect`/`uShadowMatrix`. */
33
- export const MAX_SHADOW_MAPS = MAX_LIGHTS * MAX_CASCADES
37
+ /** The shadow-map slot budget of the scene's shadow set: every casting
38
+ * light claims consecutive slots, one per map (a directional light
39
+ * `shadow.cascades`, a point light its six faces, a spot one), dealt in
40
+ * light order, and a caster past the
41
+ * budget throws at attach. Bounds `uShadowRect`/`uShadowMatrix` - its
42
+ * own constant, NOT MAX_LIGHTS * MAX_CASCADES, so raising the light cap
43
+ * does not size the fragment uniform budget for the worst case where
44
+ * every light is a fully cascaded sun. */
45
+ export const MAX_SHADOW_MAPS = 8
46
+
47
+ /** The instance attributes an instanced mesh's material declares
48
+ * (`shaderMaterialClass({ instanceAttributes: INSTANCE_MATRIX_ATTRIBUTES
49
+ * })`): the four vec4 columns of the per-instance matrix the spatial core
50
+ * writes - the instance's placement inside the mesh (createInstancedMesh,
51
+ * addInstance). Read them through INSTANCE_MATRIX. Slot 0, the core's
52
+ * record; an app's own per-instance floats go in slot 1 (the style
53
+ * record, see INSTANCE_COLOR_ATTRIBUTES). */
54
+ export const INSTANCE_MATRIX_ATTRIBUTES: InstanceAttribute[] = [
55
+ { name: "iModel0", format: "vec4" },
56
+ { name: "iModel1", format: "vec4" },
57
+ { name: "iModel2", format: "vec4" },
58
+ { name: "iModel3", format: "vec4" },
59
+ ]
60
+
61
+ /** The per-instance color the stock materials read with `instanceColors`
62
+ * (`in vec4 iColor`, premultiplied linear like aColor, forwarded as
63
+ * vColor): a slot-1 attribute, so it is the instance's STYLE record -
64
+ * app-owned, written with setInstanceStyle(instance, [r, g, b, a]) - beside
65
+ * the core's matrix in slot 0. A custom class appends it (or any slot-1
66
+ * layout of its own) to INSTANCE_MATRIX_ATTRIBUTES; the stock materials
67
+ * start every instance at white. Three's instanceColor, Godot's
68
+ * MultiMesh instance color. */
69
+ export const INSTANCE_COLOR_ATTRIBUTES: InstanceAttribute[] = [{ name: "iColor", format: "vec4", slot: 1 }]
70
+
71
+ /**
72
+ * The vertex-stage declarations over INSTANCE_MATRIX_ATTRIBUTES:
73
+ * `instanceMatrix()` is the instance's placement inside the mesh, applied
74
+ * under the mesh's own uModel (`uModel * instanceMatrix() * vec4(aPos,
75
+ * 1.0)`, and `mat3(uNormal) * instanceNormalMatrix() * aNormal` for the
76
+ * normal). `instanceNormalMatrix()` is Three's derivation: each column
77
+ * divided by its squared length, which is the inverse-transpose of any
78
+ * rotation-times-scale matrix, so it is exact for every instance and for
79
+ * uniformly scaled groups between mesh and instance at no per-instance
80
+ * data. The one case it misses is a sheared hierarchy - a non-uniformly
81
+ * scaled group above a rotated instance; a stage that builds one writes
82
+ * `transpose(inverse(mat3(instanceMatrix())))` instead (Godot's form).
83
+ */
84
+ export const INSTANCE_MATRIX = glsl`
85
+ in vec4 iModel0;
86
+ in vec4 iModel1;
87
+ in vec4 iModel2;
88
+ in vec4 iModel3;
89
+ mat4 instanceMatrix() {
90
+ return mat4(iModel0, iModel1, iModel2, iModel3);
91
+ }
92
+ mat3 instanceNormalMatrix() {
93
+ mat3 m = mat3(iModel0.xyz, iModel1.xyz, iModel2.xyz);
94
+ return mat3(m[0] / dot(m[0], m[0]), m[1] / dot(m[1], m[1]), m[2] / dot(m[2], m[2]));
95
+ }
96
+ `
34
97
 
35
98
  /**
36
99
  * The standard lit vertex stage: clip position via uViewProj * uModel,
@@ -42,25 +105,89 @@ export const MAX_SHADOW_MAPS = MAX_LIGHTS * MAX_CASCADES
42
105
  * Pair it with your own fragment; the view vector there is
43
106
  * `normalize(uCamPos - vWorldPos)`.
44
107
  */
45
- export const LIT_VERTEX = glsl`
108
+ /** The skinned-layout declarations a vertex stage splices in: the
109
+ * aJoints/aWeights channels, the `uBones` palette and `mat4 boneAt(int)`.
110
+ * The palette is a float texture, not a uniform array, so rig size is
111
+ * bounded only by texture height (>= 2048 everywhere), never by the
112
+ * vertex uniform budget: an rgba32f texture 4 texels wide, one row per
113
+ * joint, each row the four columns of that joint's mat4 (the spatial
114
+ * flush writes it from the bound joint nodes; createModel binds it per
115
+ * skinned mesh). Pair with
116
+ * SKIN_MATRIX in main(). */
117
+ export const SKIN_DECLS = glsl`
118
+ in vec4 aJoints;
119
+ in vec4 aWeights;
120
+ uniform sampler2D uBones;
121
+ mat4 boneAt(int j) {
122
+ return mat4(texelFetch(uBones, ivec2(0, j), 0), texelFetch(uBones, ivec2(1, j), 0),
123
+ texelFetch(uBones, ivec2(2, j), 0), texelFetch(uBones, ivec2(3, j), 0));
124
+ }
125
+ `
126
+ /** The linear-blend skin matrix, spliced into main() after SKIN_DECLS:
127
+ * apply as `skin * vec4(aPos, 1.0)` before uModel. Bone-space normals go
128
+ * through `mat3(skin)` - exact for rigid bones, the standard
129
+ * approximation under bone scale. */
130
+ export const SKIN_MATRIX = glsl`
131
+ mat4 skin = aWeights.x * boneAt(int(aJoints.x)) + aWeights.y * boneAt(int(aJoints.y)) +
132
+ aWeights.z * boneAt(int(aJoints.z)) + aWeights.w * boneAt(int(aJoints.w));
133
+ `
134
+
135
+ // The per-instance placement, spliced into a vertex stage's position and
136
+ // normal math for an instanced material: the skin (when any) first, then
137
+ // the instance's matrix inside the mesh, then uModel - so a skinned
138
+ // instanced fleet poses every copy by the one shared palette and places
139
+ // each by its own record.
140
+ function instancedPosition(skinned: boolean, instanced: boolean): string {
141
+ let local = skinned ? "(skin * vec4(aPos, 1.0))" : "vec4(aPos, 1.0)"
142
+ return instanced ? `uModel * (instanceMatrix() * ${local})` : `uModel * ${local}`
143
+ }
144
+ function instancedNormal(skinned: boolean, instanced: boolean): string {
145
+ let local = skinned ? "(mat3(skin) * aNormal)" : "aNormal"
146
+ return instanced ? `mat3(uNormal) * (instanceNormalMatrix() * ${local})` : `mat3(uNormal) * ${local}`
147
+ }
148
+
149
+ // The one lit vertex template: the standard prefix always, aColor and
150
+ // aUV2 only when the fragment reads them (an `in` the source mentions
151
+ // makes the material require that channel, so the blocks must be absent,
152
+ // not inactive), the skin blocks only for a skinned material (they make
153
+ // it require the "skinned" layout the same way), the instance matrix
154
+ // and color only for an instanced one. vColor carries the vertex color,
155
+ // the instance color, or their product. LIT_VERTEX / LIT_VERTEX_COLORED
156
+ // are its two named forms; litVertex(o) picks per option set.
157
+ function litVertexSource(colored: boolean, uv2: boolean, skinned: boolean, instanced: boolean, instanceColors: boolean): string {
158
+ let color = colored && instanceColors ? "aColor * iColor" : colored ? "aColor" : instanceColors ? "iColor" : ""
159
+ return glsl`
46
160
  in vec3 aPos;
47
161
  in vec3 aNormal;
48
162
  in vec2 aUV;
163
+ ${colored ? "in vec4 aColor;" : ""}
164
+ ${uv2 ? "in vec2 aUV2;" : ""}
165
+ ${skinned ? SKIN_DECLS : ""}
166
+ ${instanced ? INSTANCE_MATRIX : ""}
167
+ ${instanceColors ? "in vec4 iColor;" : ""}
49
168
  uniform mat4 uModel;
50
169
  uniform mat4 uViewProj;
51
170
  uniform mat4 uNormal;
52
171
  out vec3 vWorldPos;
53
172
  out vec3 vNormal;
54
173
  out vec2 vUv;
174
+ ${color ? "out vec4 vColor;" : ""}
175
+ ${uv2 ? "out vec2 vUv2;" : ""}
55
176
 
56
177
  void main() {
57
- vec4 world = uModel * vec4(aPos, 1.0);
178
+ ${skinned ? SKIN_MATRIX : ""}
179
+ vec4 world = ${instancedPosition(skinned, instanced)};
58
180
  gl_Position = uViewProj * world;
59
181
  vWorldPos = world.xyz;
60
- vNormal = mat3(uNormal) * aNormal;
182
+ vNormal = ${instancedNormal(skinned, instanced)};
61
183
  vUv = aUV;
184
+ ${color ? `vColor = ${color};` : ""}
185
+ ${uv2 ? "vUv2 = aUV2;" : ""}
62
186
  }
63
187
  `
188
+ }
189
+
190
+ export const LIT_VERTEX = litVertexSource(false, false, false, false, false)
64
191
 
65
192
  /**
66
193
  * LIT_VERTEX for "colored"-layout geometry: the same interface plus the
@@ -70,28 +197,7 @@ export const LIT_VERTEX = glsl`
70
197
  * collects the vertex stage's `in` declarations), so its meshes need
71
198
  * geometry carrying that channel - withColors() - or add() throws.
72
199
  */
73
- export const LIT_VERTEX_COLORED = glsl`
74
- in vec3 aPos;
75
- in vec3 aNormal;
76
- in vec2 aUV;
77
- in vec4 aColor;
78
- uniform mat4 uModel;
79
- uniform mat4 uViewProj;
80
- uniform mat4 uNormal;
81
- out vec3 vWorldPos;
82
- out vec3 vNormal;
83
- out vec2 vUv;
84
- out vec4 vColor;
85
-
86
- void main() {
87
- vec4 world = uModel * vec4(aPos, 1.0);
88
- gl_Position = uViewProj * world;
89
- vWorldPos = world.xyz;
90
- vNormal = mat3(uNormal) * aNormal;
91
- vUv = aUV;
92
- vColor = aColor;
93
- }
94
- `
200
+ export const LIT_VERTEX_COLORED = litVertexSource(true, false, false, false, false)
95
201
 
96
202
  /** `vec3 hemisphere(vec3 n, vec3 sky, vec3 ground)` - ambient from a
97
203
  * sky/ground gradient by the normal's vertical tilt: sky straight up,
@@ -127,6 +233,168 @@ export const FRESNEL = glsl`
127
233
  }
128
234
  `
129
235
 
236
+ /**
237
+ * The metalness/roughness light model `standard` shades with (Three's
238
+ * MeshStandardMaterial, Godot's StandardMaterial3D, Unity's Standard).
239
+ * `vec3 ggxSpecular(n, v, l, f0, roughness)` is one light's specular
240
+ * lobe - GGX distribution x height-correlated Smith visibility x Schlick
241
+ * fresnel, times pi - for the caller to weight by `lambert(n, l)` and
242
+ * the light color, the same convention the diffuse uses (a light of
243
+ * intensity 1 lights a white diffuse surface to 1 face-on; Three's
244
+ * lights are the same lobe with intensities a factor pi larger).
245
+ * `roughness` is perceptual: alpha is its square, floored at
246
+ * MIN_ROUGHNESS so a mirror keeps a lobe of finite width. `vec2
247
+ * envBrdf(nv, roughness)` is the split-sum environment BRDF - the scale
248
+ * and bias on f0 (`f0 * ab.x + ab.y`) that turns prefiltered radiance
249
+ * into the specular image term - as Lazarov's analytic fit (Three's
250
+ * DFGApprox, Godot's brdf_approx; no lookup texture). `DIELECTRIC_F0` is
251
+ * a non-metal's face-on reflectance (0.04 in all three engines); a
252
+ * metal's f0 is its base color. The pieces (`ggxDistribution(nh, a2)`,
253
+ * `smithVisibility(nl, nv, a2)`, `schlickFresnel(f0, vh)`) are there for
254
+ * a custom lobe. Defines PBR_PI, not PI, so a prelude's own PI stands.
255
+ */
256
+ export const PBR = glsl`
257
+ const float PBR_PI = 3.14159265;
258
+ // Face-on reflectance of a dielectric (index of refraction about 1.5).
259
+ const float DIELECTRIC_F0 = 0.04;
260
+ // Floor on the perceptual roughness: alpha never reaches zero, so a
261
+ // perfect mirror keeps a highlight of finite width (Three's clamp).
262
+ const float MIN_ROUGHNESS = 0.0525;
263
+ // Floor on the visibility denominator at grazing angles.
264
+ const float VISIBILITY_MIN = 1e-5;
265
+ // Schlick's approximation exponent.
266
+ const float SCHLICK_POWER = 5.0;
267
+ float ggxDistribution(float nh, float a2) {
268
+ float d = nh * nh * (a2 - 1.0) + 1.0;
269
+ return a2 / (PBR_PI * d * d);
270
+ }
271
+ float smithVisibility(float nl, float nv, float a2) {
272
+ float gv = nl * sqrt(nv * nv * (1.0 - a2) + a2);
273
+ float gl = nv * sqrt(nl * nl * (1.0 - a2) + a2);
274
+ return 0.5 / max(gv + gl, VISIBILITY_MIN);
275
+ }
276
+ vec3 schlickFresnel(vec3 f0, float vh) {
277
+ return f0 + (1.0 - f0) * pow(1.0 - vh, SCHLICK_POWER);
278
+ }
279
+ vec3 ggxSpecular(vec3 n, vec3 v, vec3 l, vec3 f0, float roughness) {
280
+ vec3 h = normalize(l + v);
281
+ float nl = max(dot(n, l), 0.0);
282
+ float nv = max(dot(n, v), 0.0);
283
+ float nh = max(dot(n, h), 0.0);
284
+ float vh = max(dot(v, h), 0.0);
285
+ float r = max(roughness, MIN_ROUGHNESS);
286
+ float a2 = r * r * r * r;
287
+ return schlickFresnel(f0, vh) * (ggxDistribution(nh, a2) * smithVisibility(nl, nv, a2) * PBR_PI);
288
+ }
289
+ // Lazarov's fit of the environment BRDF integral: the coefficients of
290
+ // the two polynomials in roughness, and the grazing-angle exponent.
291
+ vec2 envBrdf(float nv, float roughness) {
292
+ const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022);
293
+ const vec4 c1 = vec4(1.0, 0.0425, 1.04, -0.04);
294
+ vec4 r = roughness * c0 + c1;
295
+ float a004 = min(r.x * r.x, exp2(-9.28 * nv)) * r.x + r.y;
296
+ return vec2(-1.04, 1.04) * a004 + r.zw;
297
+ }
298
+ `
299
+
300
+ /**
301
+ * The scene's light list as a receiving program declares it, written by
302
+ * the scene (`uLightDir` and `uLightPos` core-driven, so a moving light
303
+ * costs no JS): per light i to `uLightCount`, its `uLightType[i]`
304
+ * (LIGHT_DIRECTIONAL | LIGHT_SPOT | LIGHT_POINT), `uLightDir[i]` (world
305
+ * unit vector TOWARD a directional light; a spot's axis toward the
306
+ * light; unused for a point), `uLightPos[i]` (a spot's or point's world
307
+ * position; unused for a directional), `uLightColor[i]` (rgb, intensity
308
+ * folded in) and `uLightParams[i]` (cosInner, cosOuter, distance, decay
309
+ * - the cone cosines a spot fades between, the falloff cutoff and
310
+ * exponent a spot or point attenuates by; unused for a directional).
311
+ * Compose it before LIGHT_LOOKUP; `lit` is the shape.
312
+ */
313
+ export const LIGHT_SLOTS = glsl`
314
+ uniform int uLightCount;
315
+ uniform int uLightType[${MAX_LIGHTS}];
316
+ uniform vec3 uLightDir[${MAX_LIGHTS}];
317
+ uniform vec3 uLightPos[${MAX_LIGHTS}];
318
+ uniform vec3 uLightColor[${MAX_LIGHTS}];
319
+ uniform vec4 uLightParams[${MAX_LIGHTS}];
320
+ const int LIGHT_DIRECTIONAL = 0;
321
+ const int LIGHT_SPOT = 1;
322
+ const int LIGHT_POINT = 2;
323
+ `
324
+
325
+ /**
326
+ * The step from a light index to its incoming vector and strength, over
327
+ * LIGHT_SLOTS (compose it first). `float lightVector(int i, vec3
328
+ * worldPos, out vec3 l)` writes the unit vector from `worldPos` TOWARD
329
+ * light i and returns its attenuation: 1 for a directional light; for a
330
+ * spot or point, the windowed inverse falloff `1 / d^decay` faded to
331
+ * zero at `distance` (0 = no cutoff; Three's punctual-light falloff), a
332
+ * spot's additionally faded across its cone from cosInner to cosOuter.
333
+ * Zero means the light cannot reach the fragment - skip its shadow
334
+ * lookup and its terms (`lit` does exactly that).
335
+ */
336
+ export const LIGHT_LOOKUP = glsl`
337
+ // Floors the falloff divisors so a fragment at the light's own
338
+ // position stays finite (Three's punctual-light rule).
339
+ const float FALLOFF_MIN = 0.01;
340
+
341
+ float lightVector(int i, vec3 worldPos, out vec3 l) {
342
+ if (uLightType[i] == LIGHT_DIRECTIONAL) {
343
+ l = uLightDir[i];
344
+ return 1.0;
345
+ }
346
+ vec3 dv = uLightPos[i] - worldPos;
347
+ float d = length(dv);
348
+ l = dv / max(d, FALLOFF_MIN);
349
+ vec4 p = uLightParams[i];
350
+ float atten = 1.0 / max(pow(d, p.w), FALLOFF_MIN);
351
+ if (p.z > 0.0) {
352
+ float win = clamp(1.0 - pow(d / p.z, 4.0), 0.0, 1.0);
353
+ atten *= win * win;
354
+ }
355
+ if (uLightType[i] == LIGHT_SPOT) {
356
+ atten *= smoothstep(p.y, p.x, dot(l, uLightDir[i]));
357
+ }
358
+ return atten;
359
+ }
360
+ `
361
+
362
+ /**
363
+ * `vec3 perturbNormal(vec3 n, vec3 worldPos, vec2 uv)` - the surface
364
+ * normal bent by a tangent-space normal map (`uniform sampler2D
365
+ * uNormalMap`, OpenGL-style +Y as glTF mandates, `uniform float
366
+ * uNormalScale` weighting the bend). The tangent frame is built per
367
+ * fragment from screen-space derivatives of worldPos and uv (Three's
368
+ * untangented path, Schuler's cotangent frame), so it works on ANY
369
+ * UV-mapped geometry with no tangent attribute; the trade is mild seams
370
+ * on mirrored UVs, the case a real aTangent layout would fix. Needs no
371
+ * varying of its own - pass the same worldPos and uv the caller lights
372
+ * and samples with. `n` must be normalized and already facing the viewer
373
+ * (flip back faces before calling).
374
+ */
375
+ export const NORMAL_MAP = glsl`
376
+ uniform sampler2D uNormalMap;
377
+ uniform float uNormalScale;
378
+ // NORMAL_MAP_EPS floors the tangent frame's magnitude so a face with
379
+ // no UV variation (degenerate derivatives) yields the geometric normal
380
+ // instead of a NaN.
381
+ const float NORMAL_MAP_EPS = 1e-20;
382
+ vec3 perturbNormal(vec3 n, vec3 worldPos, vec2 uv) {
383
+ vec3 mapN = texture(uNormalMap, uv).xyz * 2.0 - 1.0;
384
+ mapN.xy *= uNormalScale;
385
+ vec3 dp1 = dFdx(worldPos);
386
+ vec3 dp2 = dFdy(worldPos);
387
+ vec2 duv1 = dFdx(uv);
388
+ vec2 duv2 = dFdy(uv);
389
+ vec3 dp2perp = cross(dp2, n);
390
+ vec3 dp1perp = cross(n, dp1);
391
+ vec3 t = dp2perp * duv1.x + dp1perp * duv2.x;
392
+ vec3 b = dp2perp * duv1.y + dp1perp * duv2.y;
393
+ float invMax = inversesqrt(max(max(dot(t, t), dot(b, b)), NORMAL_MAP_EPS));
394
+ return normalize(mat3(t * invMax, b * invMax, n) * mapN);
395
+ }
396
+ `
397
+
130
398
  /**
131
399
  * The scene's fog (`scene.setFog`): the uniform set it writes - `uFogColor`,
132
400
  * the linear band `uFogNear` / `uFogInv` (1 / (far - near)), the exp2
@@ -174,31 +442,170 @@ export const FOG = glsl`
174
442
  }
175
443
  `
176
444
 
445
+ /**
446
+ * The sRGB transfer functions (IEC 61966-2-1): `vec3 srgbToLinear(vec3)`
447
+ * and `vec3 linearToSrgb(vec3)`, clamped at zero. An "rgba8-srgb" texture
448
+ * decodes in hardware and needs neither; these serve pixels a format
449
+ * cannot tag (a render target sampled as a map) and the output stage.
450
+ * OUTPUT includes this set - never compose both.
451
+ */
452
+ export const SRGB = glsl`
453
+ vec3 srgbToLinear(vec3 c) {
454
+ c = max(c, vec3(0.0));
455
+ return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));
456
+ }
457
+ vec3 linearToSrgb(vec3 c) {
458
+ c = max(c, vec3(0.0));
459
+ return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));
460
+ }
461
+ `
462
+
463
+ /**
464
+ * The output stage every library fragment ends with: `vec4
465
+ * outputColor(vec3 rgb, float alpha)` takes the PREMULTIPLIED linear
466
+ * result and its alpha, applies the scene's exposure and tone mapping
467
+ * (`uniform float uExposure`; `uniform float uToneMapping`, 0 none, 1
468
+ * ACES - the shared params scene.setExposure / setToneMapping write),
469
+ * encodes to sRGB and returns the premultiplied pixel the target holds.
470
+ * It un-premultiplies inside so the encode is exact at partial alpha.
471
+ * Compose it last in a custom fragment: `fragColor = outputColor(rgb,
472
+ * alpha);` - a fragment that writes fragColor directly writes final
473
+ * encoded pixels and skips exposure and tone mapping. Includes SRGB.
474
+ */
475
+ export const OUTPUT = glsl`
476
+ uniform float uExposure;
477
+ uniform float uToneMapping;
478
+ // 1 (the scene's default) clamps and encodes to sRGB for display; 0
479
+ // writes linear light unclamped, what a reflection probe's faces hold
480
+ // so the environment lookups read radiance (the scene sets it per
481
+ // target; an 8-bit target clamps it on write regardless).
482
+ uniform float uOutputEncode;
483
+ ${SRGB}
484
+ // Stephen Hill's fit of the ACES RRT and ODT (Three's ACESFilmic, Godot's
485
+ // ACES): the matrices between sRGB and the fit's working space, and the
486
+ // exposure scale that puts middle gray where the reference does.
487
+ const float ACES_EXPOSURE_SCALE = 1.0 / 0.6;
488
+ const mat3 ACES_INPUT = mat3(
489
+ 0.59719, 0.07600, 0.02840,
490
+ 0.35458, 0.90834, 0.13383,
491
+ 0.04823, 0.01566, 0.83777);
492
+ const mat3 ACES_OUTPUT = mat3(
493
+ 1.60475, -0.10208, -0.00327,
494
+ -0.53108, 1.10813, -0.07276,
495
+ -0.07367, -0.00605, 1.07602);
496
+ vec3 acesFit(vec3 v) {
497
+ vec3 a = v * (v + 0.0245786) - 0.000090537;
498
+ vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;
499
+ return a / b;
500
+ }
501
+ vec3 acesToneMap(vec3 c) {
502
+ c = ACES_INPUT * (c * ACES_EXPOSURE_SCALE);
503
+ c = acesFit(c);
504
+ return clamp(ACES_OUTPUT * c, 0.0, 1.0);
505
+ }
506
+ vec4 outputColor(vec3 rgb, float alpha) {
507
+ vec3 c = alpha > 0.0 ? rgb / alpha : rgb;
508
+ c *= uExposure;
509
+ if (uToneMapping > 0.5) c = acesToneMap(c);
510
+ // Linear output keeps its range (a half-float probe holds a sun's
511
+ // radiance); the display encode is 8-bit and clamps first.
512
+ if (uOutputEncode > 0.5) c = linearToSrgb(clamp(c, 0.0, 1.0));
513
+ else c = max(c, vec3(0.0));
514
+ return vec4(c * alpha, alpha);
515
+ }
516
+ `
517
+
518
+ /**
519
+ * The scene's environment as a reflecting program declares it: `uEnv`
520
+ * (a samplerCube the scene binds on every receiving target - a 1x1 black
521
+ * cube while no environment is set), `uEnvIntensity`, `uEnvRotation`
522
+ * (the inverse of the environment's y turn, the skybox's convention) and
523
+ * `uEnvOn` (0 with no environment, so the term contributes nothing
524
+ * instead of reflecting black). `envRadiance(r, roughness)` samples the
525
+ * environment along world direction `r` at a mip level from a perceptual
526
+ * roughness - 0 the sharp base level, 1 the level whose faces are
527
+ * ENV_ROUGH_FACE (4) texels wide, linear between (Godot's
528
+ * `roughness * MAX_ROUGHNESS_LOD`; Unity's six LOD steps; Three maps
529
+ * onto a 16-texel floor): the level a baked chain (`srt tool
530
+ * 3d/environment`, loadEnvironment) convolves at that roughness, and a
531
+ * generated chain merely box-filters - scaled by the intensity: the
532
+ * prefiltered radiance of the split sum (`standard` multiplies it by PBR's
533
+ * envBrdf); a cube uploaded without mipmaps stays sharp.
534
+ * `envIrradiance(n)` is the diffuse face of it - the fully rough sample
535
+ * along the normal, what Three (`getIBLIrradiance`) and Godot read for
536
+ * the image-lit diffuse term; `standard` adds it to the hemisphere
537
+ * ambient. `envReflection(n, v, shininess)`
538
+ * is the Blinn-Phong face of it: the mirror direction (v toward the
539
+ * camera) at roughness `sqrt(2 / (shininess + 2))`, so a wide sheen
540
+ * reflects a blurred environment and a mirror dot a sharp one. `envWeight(n, v,
541
+ * reflectivity)` is the Schlick fresnel weight: `reflectivity` face-on,
542
+ * 1 at grazing, 0 with no environment. `lit` applies them as
543
+ * `rgb = mix(rgb, envReflection(n, v, uShininess), envWeight(n, v,
544
+ * uReflectivity))` - Three's MixOperation with a fresnel weight. Every
545
+ * lookup is a plain `texture(uEnv, dir)` in world space: a cube map here
546
+ * holds what a lookup returns (GL's own convention, as Godot and Unity
547
+ * sample theirs), so there is no handedness flip and a Three shader
548
+ * ported with `flipEnvMap` drops it.
549
+ */
550
+ export const ENVIRONMENT = glsl`
551
+ uniform samplerCube uEnv;
552
+ uniform float uEnvIntensity;
553
+ uniform mat4 uEnvRotation;
554
+ uniform float uEnvOn;
555
+ // Schlick's approximation exponent.
556
+ const float ENV_SCHLICK_POWER = 5.0;
557
+ // log2 of the face edge (4 texels) a roughness of 1 samples: the chain
558
+ // is convolved down to it (environment-bake.ts ENV_ROUGH_FACE), and the
559
+ // levels below keep the sampler complete.
560
+ const float ENV_ROUGH_FACE_LOG2 = 2.0;
561
+ vec3 envRadiance(vec3 r, float roughness) {
562
+ float roughLevel = max(log2(float(textureSize(uEnv, 0).x)) - ENV_ROUGH_FACE_LOG2, 0.0);
563
+ return textureLod(uEnv, mat3(uEnvRotation) * r, roughness * roughLevel).rgb * uEnvIntensity;
564
+ }
565
+ vec3 envIrradiance(vec3 n) {
566
+ return envRadiance(n, 1.0);
567
+ }
568
+ vec3 envReflection(vec3 n, vec3 v, float shininess) {
569
+ return envRadiance(reflect(-v, n), sqrt(2.0 / (shininess + 2.0)));
570
+ }
571
+ float envWeight(vec3 n, vec3 v, float reflectivity) {
572
+ float f = pow(1.0 - max(dot(n, v), 0.0), ENV_SCHLICK_POWER);
573
+ return (reflectivity + (1.0 - reflectivity) * f) * uEnvOn;
574
+ }
575
+ `
576
+
177
577
  /**
178
578
  * The scene's shadow set as a receiving program declares it: ONE
179
- * `uShadowAtlas` (every casting light's depth map is a tile of it, so N
180
- * maps render as one pass; a white texel when nothing casts), a MAP slot
579
+ * `uShadowAtlas` (a `sampler2DShadow` - every casting light's depth map is
580
+ * a tile of it, so N maps render as one pass; a cleared one-texel depth
581
+ * map when nothing casts), a MAP slot
181
582
  * set of `MAX_SHADOW_MAPS` - `uShadowRect[M]` (map slot j's tile as x, y,
182
583
  * width, height in atlas 0..1 UV) and `uShadowMatrix[M]` (its light-space
183
- * viewProj) - and, per directional light index, `uShadowFirst[N]` /
584
+ * viewProj) - and, per light index (the light list's, hemisphere
585
+ * excluded), `uShadowFirst[N]` /
184
586
  * `uShadowCount[N]` (light i's maps are slots `first .. first + count - 1`;
185
587
  * count 0 = it does not cast; a box light has one map, a cascaded light
186
- * `shadow.cascades` of them, tightest first), `uShadowBias[N]` and
187
- * `uShadowNormalBias[N]`. The scene binds and writes all of it on every
588
+ * `shadow.cascades` of them tightest first, a point light six face maps
589
+ * in +X, -X, +Y, -Y, +Z, -Z order), `uShadowBias[N]`,
590
+ * `uShadowNormalBias[N]` and `uShadowRadius[N]` (the filter radius in
591
+ * map texels, 1 = one tap). The scene binds and writes all of it on every
188
592
  * target a receiving material can draw into; a custom material composes
189
- * this, then SHADOW, then SHADOW_LOOKUP (in that order) and multiplies
593
+ * LIGHT_SLOTS, this, SHADOW, then SHADOW_LOOKUP (in that order - the
594
+ * lookup reads the light type and position for a point light's face
595
+ * select) and multiplies
190
596
  * light i's term by `lightShadow(i, worldPos, n)` - `lit` is the shape. A
191
597
  * material that does not receive composes none of it, so it declares no
192
598
  * sampler for nothing.
193
599
  */
194
600
  export const SHADOW_SLOTS = glsl`
195
- uniform sampler2D uShadowAtlas;
601
+ uniform sampler2DShadow uShadowAtlas;
196
602
  uniform vec4 uShadowRect[${MAX_SHADOW_MAPS}];
197
603
  uniform mat4 uShadowMatrix[${MAX_SHADOW_MAPS}];
198
604
  uniform int uShadowFirst[${MAX_LIGHTS}];
199
605
  uniform int uShadowCount[${MAX_LIGHTS}];
200
606
  uniform float uShadowBias[${MAX_LIGHTS}];
201
607
  uniform float uShadowNormalBias[${MAX_LIGHTS}];
608
+ uniform float uShadowRadius[${MAX_LIGHTS}];
202
609
  `
203
610
 
204
611
  /**
@@ -208,18 +615,25 @@ export const SHADOW_SLOTS = glsl`
208
615
  * point: xy in 0..1 across the map, z the depth to compare. `bool
209
616
  * shadowInside(vec3 p)` is whether the map has it at all (xy in 0..1, z
210
617
  * not past the far plane) - the cascade select. `float shadowSample(
211
- * sampler2D map, vec4 rect, vec3 p, float bias)` is the factor (1 lit, 0
212
- * shadowed) of a point the map has: a 3x3 PCF over texel neighbours in
213
- * the map's tile `rect` (x, y, width, height in `map`'s 0..1 UV;
214
- * `vec4(0, 0, 1, 1)` is a whole map) comparing the map's `.r` (a stage-1
215
- * depth texture samples nearest, so the softness is this loop, not the
216
- * sampler); every tap is clamped to the tile inset by half a texel, so
217
- * no tap reads a neighbouring map's tile; `bias` is subtracted from the
218
- * point's depth against acne. `float shadow(sampler2D map, vec4 rect,
219
- * vec4 coord, float bias)` composes the three: 1 (lit) outside the map,
220
- * else the sample -
221
- * `shadow(uShadowAtlas, uShadowRect[0], uShadowMatrix[0] * vec4(vWorldPos, 1.0), uShadowBias[0])`.
222
- * SHADOW_LOOKUP uses the steps, so it projects each map once.
618
+ * sampler2DShadow map, vec4 rect, vec3 p, float bias, float radius)` is
619
+ * the factor (1 lit, 0 shadowed) of a point the map has. At `radius` 1
620
+ * it is ONE comparison tap - the sampler compares `p.z - bias` against
621
+ * the map in hardware (LEQUAL) and LINEAR-weights the four neighbours'
622
+ * results, the 2x2 PCF a shader loop cannot match (it weights the
623
+ * compare, not the depth). Above 1 it averages a 3x3 grid of those taps
624
+ * `radius` texels apart (Three's PCF kernel: the edge softens over about
625
+ * 2 * radius texels; past ~3 the taps separate and the edge bands). A tap
626
+ * lands in the map's tile `rect` (x, y, width, height in the atlas's 0..1
627
+ * UV; `vec4(0, 0, 1, 1)` is a whole map), clamped to the tile inset by
628
+ * half a texel so the footprint never reads a neighbouring map's tile.
629
+ * `float shadow(sampler2DShadow map, vec4 rect, vec4 coord, float bias,
630
+ * float radius)` composes the three: 1 (lit) outside the map, else the
631
+ * sample - `shadow(uShadowAtlas, uShadowRect[0], uShadowMatrix[0] *
632
+ * vec4(vWorldPos, 1.0), uShadowBias[0], uShadowRadius[0])`.
633
+ * SHADOW_LOOKUP uses the steps, so it projects each map once. The engine
634
+ * binds the comparison sampler wherever a program declares the uniform
635
+ * as sampler2DShadow; a program declaring plain `sampler2D uShadowAtlas`
636
+ * (hand-rolled old GLSL) still reads raw depth values at nearest.
223
637
  */
224
638
  export const SHADOW = glsl`
225
639
  vec3 shadowPoint(vec4 coord) {
@@ -230,43 +644,50 @@ export const SHADOW = glsl`
230
644
  return all(greaterThanEqual(p.xy, vec2(0.0))) && all(lessThanEqual(p, vec3(1.0)));
231
645
  }
232
646
 
233
- float shadowSample(sampler2D map, vec4 rect, vec3 p, float bias) {
647
+ float shadowSample(sampler2DShadow map, vec4 rect, vec3 p, float bias, float radius) {
234
648
  vec2 texel = 1.0 / vec2(textureSize(map, 0));
235
649
  vec2 lo = rect.xy + 0.5 * texel;
236
650
  vec2 hi = rect.xy + rect.zw - 0.5 * texel;
237
651
  vec2 base = rect.xy + p.xy * rect.zw;
238
- float lit = 0.0;
652
+ float z = p.z - bias;
653
+ if (radius <= 1.0) return texture(map, vec3(clamp(base, lo, hi), z));
654
+ vec2 step = texel * radius;
655
+ float sum = 0.0;
239
656
  for (int y = -1; y <= 1; y++) {
240
657
  for (int x = -1; x <= 1; x++) {
241
- float d = texture(map, clamp(base + vec2(float(x), float(y)) * texel, lo, hi)).r;
242
- lit += p.z - bias <= d ? 1.0 : 0.0;
658
+ sum += texture(map, vec3(clamp(base + vec2(float(x), float(y)) * step, lo, hi), z));
243
659
  }
244
660
  }
245
- return lit / 9.0;
661
+ return sum / 9.0;
246
662
  }
247
663
 
248
- float shadow(sampler2D map, vec4 rect, vec4 coord, float bias) {
664
+ float shadow(sampler2DShadow map, vec4 rect, vec4 coord, float bias, float radius) {
249
665
  vec3 p = shadowPoint(coord);
250
- return shadowInside(p) ? shadowSample(map, rect, p, bias) : 1.0;
666
+ return shadowInside(p) ? shadowSample(map, rect, p, bias, radius) : 1.0;
251
667
  }
252
668
  `
253
669
 
254
670
  /**
255
- * The step from a light index to its shadow factor, over SHADOW_SLOTS and
256
- * SHADOW (compose both first). `float lightShadow(int i, vec3 worldPos,
257
- * vec3 n)` is the one to call per light: 1 for a light that does not
258
- * cast, else the factor for `worldPos` pushed along its normal `n` by
259
- * `uShadowNormalBias[i]` (the acne knob to reach for first), looked up in
260
- * the FIRST of light i's maps that has the point (a box light has one; a
261
- * cascaded light's maps come tightest first, so the sharpest cascade
262
- * that has the point wins and a point past the last is lit) with that
263
- * map's `uShadowMatrix[j]`, its tile and `uShadowBias[i]`. Inside the
264
- * outer SHADOW_BLEND of a map (in map 0..1 units, so 0.1 is its outer
265
- * 10% on each side) the factor fades into the next cascade's, so the
266
- * hand-over is a band and not a seam; the last map, a box light's only
267
- * one, and any rim the next cascade does not reach (the near side, at
268
- * the camera's feet) have no band. Position and normal are arguments, so
269
- * no varying name is pinned and a custom vertex stage composes freely.
671
+ * The step from a light index to its shadow factor, over LIGHT_SLOTS,
672
+ * SHADOW_SLOTS and SHADOW (compose all three first - the light type and
673
+ * position pick a point light's face map). `float lightShadow(int i,
674
+ * vec3 worldPos, vec3 n)` is the one to call per light: 1 for a light
675
+ * that does not cast, else the factor for `worldPos` pushed along its
676
+ * normal `n` by `uShadowNormalBias[i]` (the acne knob to reach for
677
+ * first). A point light's six face maps are selected directly: the
678
+ * dominant axis of the light-to-point vector names the face (the slot
679
+ * order +X, -X, +Y, -Y, +Z, -Z), one projection, one tap. Every other
680
+ * light is looked up in the FIRST of its maps that has the point (a box
681
+ * light has one; a cascaded light's maps come tightest first, so the
682
+ * sharpest cascade that has the point wins and a point past the last is
683
+ * lit) with that map's `uShadowMatrix[j]`, its tile, `uShadowBias[i]`
684
+ * and `uShadowRadius[i]`. Inside the outer SHADOW_BLEND of a map (in map 0..1
685
+ * units, so 0.1 is its outer 10% on each side) the factor fades into
686
+ * the next cascade's, so the hand-over is a band and not a seam; the
687
+ * last map, a box light's only one, and any rim the next cascade does
688
+ * not reach (the near side, at the camera's feet) have no band.
689
+ * Position and normal are arguments, so no varying name is pinned and a
690
+ * custom vertex stage composes freely.
270
691
  */
271
692
  export const SHADOW_LOOKUP = glsl`
272
693
  const float SHADOW_BLEND = 0.1;
@@ -276,19 +697,859 @@ export const SHADOW_LOOKUP = glsl`
276
697
  if (count == 0) return 1.0;
277
698
  vec4 w = vec4(worldPos + n * uShadowNormalBias[i], 1.0);
278
699
  float bias = uShadowBias[i];
700
+ float radius = uShadowRadius[i];
279
701
  int first = uShadowFirst[i];
702
+ if (uLightType[i] == LIGHT_POINT) {
703
+ // Six 90-degree face frusta partition every direction: the
704
+ // dominant axis IS the face, so no scan and no behind-the-camera
705
+ // projection (a perspective map's w flips sign there and xy can
706
+ // land inside 0..1 spuriously).
707
+ vec3 dv = worldPos - uLightPos[i];
708
+ vec3 ad = abs(dv);
709
+ int face;
710
+ if (ad.x >= ad.y && ad.x >= ad.z) face = dv.x > 0.0 ? 0 : 1;
711
+ else if (ad.y >= ad.z) face = dv.y > 0.0 ? 2 : 3;
712
+ else face = dv.z > 0.0 ? 4 : 5;
713
+ int j = first + face;
714
+ return shadow(uShadowAtlas, uShadowRect[j], uShadowMatrix[j] * w, bias, radius);
715
+ }
280
716
  int last = first + count - 1;
281
717
  for (int j = first; j <= last; j++) {
282
718
  vec3 p = shadowPoint(uShadowMatrix[j] * w);
283
719
  if (!shadowInside(p)) continue;
284
- float s = shadowSample(uShadowAtlas, uShadowRect[j], p, bias);
720
+ float s = shadowSample(uShadowAtlas, uShadowRect[j], p, bias, radius);
285
721
  if (j == last) return s;
286
722
  float edge = min(min(p.x, 1.0 - p.x), min(p.y, 1.0 - p.y));
287
723
  if (edge >= SHADOW_BLEND) return s;
288
724
  vec3 q = shadowPoint(uShadowMatrix[j + 1] * w);
289
725
  if (!shadowInside(q)) return s;
290
- return mix(shadowSample(uShadowAtlas, uShadowRect[j + 1], q, bias), s, edge / SHADOW_BLEND);
726
+ return mix(shadowSample(uShadowAtlas, uShadowRect[j + 1], q, bias, radius), s, edge / SHADOW_BLEND);
291
727
  }
292
728
  return 1.0;
293
729
  }
294
730
  `
731
+
732
+ /**
733
+ * The material defaults every Surface starts from (`surfaceOf`) and the
734
+ * `lit`/`standard` options fall back to: one table, so a custom fragment
735
+ * that builds its own Surface and a stock material agree on what
736
+ * "unspecified" means.
737
+ */
738
+ export const SURFACE_DEFAULTS = {
739
+ /** Blinn-Phong highlight strength: none, pure diffuse. */
740
+ specular: 0,
741
+ /** Blinn-Phong exponent: a medium sheen. */
742
+ shininess: 30,
743
+ /** Environment mix weight face-on: none. */
744
+ reflectivity: 0,
745
+ /** A dielectric. */
746
+ metalness: 0,
747
+ /** Fully matte. */
748
+ roughness: 1,
749
+ }
750
+
751
+ /**
752
+ * What a material knows about a fragment, as one struct - the contract
753
+ * between a surface description and the scene's shading (Godot's
754
+ * fragment() outputs, URP's SurfaceData, Filament's MaterialInputs):
755
+ *
756
+ * struct Surface {
757
+ * vec4 base; // linear light, PREMULTIPLIED; alpha = coverage
758
+ * vec3 normal; // unit, facing the viewer
759
+ * vec3 emissive; // added after lighting, unlit by design, fogged
760
+ * vec3 ambient; // extra irradiance (a light map), added to the diffuse sum
761
+ * float specular, shininess, reflectivity; // the Blinn-Phong model (shadeBlinn)
762
+ * float metalness, roughness; // the metalness/roughness model (shadePbr)
763
+ * };
764
+ *
765
+ * `Surface surfaceOf(vec4 base, vec3 normal)` fills the rest with
766
+ * SURFACE_DEFAULTS (no emissive, no ambient), so a fragment sets only
767
+ * what it means. One struct for both light models, as Godot and Unity
768
+ * have it, so a `surface` function (litFragment's slot) has one
769
+ * signature whatever material it sits in; the model that shades reads
770
+ * its own fields and ignores the other's. Included by sceneSource - never
771
+ * compose both.
772
+ */
773
+ export const SURFACE = glsl`
774
+ struct Surface {
775
+ vec4 base;
776
+ vec3 normal;
777
+ vec3 emissive;
778
+ vec3 ambient;
779
+ float specular;
780
+ float shininess;
781
+ float reflectivity;
782
+ float metalness;
783
+ float roughness;
784
+ };
785
+ const float SURFACE_SPECULAR = ${SURFACE_DEFAULTS.specular.toFixed(1)};
786
+ const float SURFACE_SHININESS = ${SURFACE_DEFAULTS.shininess.toFixed(1)};
787
+ const float SURFACE_REFLECTIVITY = ${SURFACE_DEFAULTS.reflectivity.toFixed(1)};
788
+ const float SURFACE_METALNESS = ${SURFACE_DEFAULTS.metalness.toFixed(1)};
789
+ const float SURFACE_ROUGHNESS = ${SURFACE_DEFAULTS.roughness.toFixed(1)};
790
+ Surface surfaceOf(vec4 base, vec3 normal) {
791
+ return Surface(base, normal, vec3(0.0), vec3(0.0), SURFACE_SPECULAR, SURFACE_SHININESS, SURFACE_REFLECTIVITY, SURFACE_METALNESS, SURFACE_ROUGHNESS);
792
+ }
793
+ `
794
+
795
+ /**
796
+ * What the scene set composes: every flag defaults to true, and each one
797
+ * exists only because it changes a DECLARATION (a sampler the program
798
+ * would otherwise bind for nothing, a uniform block it would otherwise
799
+ * carry) - the light list itself is dynamic (uLightCount, uShadowCount),
800
+ * so there is no per-light or per-effect variant to pick.
801
+ */
802
+ export type SceneSourceOptions = {
803
+ /** The light list, the hemisphere, the shade functions and sceneLight.
804
+ * False is an unlit program: only sceneOutput remains (and Surface). */
805
+ lights?: boolean
806
+ /** Compose the shadow set and factor each light by it. False declares
807
+ * no shadow sampler; every light is unshadowed. Needs `lights`. */
808
+ receiveShadow?: boolean
809
+ /** Compose ENVIRONMENT: shadeBlinn mixes the fresnel-weighted mirror
810
+ * reflection in by `Surface.reflectivity`, shadePbr adds the image-lit
811
+ * diffuse and the split-sum specular. False declares no environment
812
+ * cube and shades with the lights alone. Needs `lights`. */
813
+ env?: boolean
814
+ /** Fog inside sceneOutput. False leaves the fog set out. */
815
+ fog?: boolean
816
+ }
817
+
818
+ /**
819
+ * The scene as a program declares it, in one set: everything a fragment
820
+ * needs to be a citizen of the scene - lit by its lights, shadowed by its
821
+ * casters, reflecting its environment, fogged, exposed, tone mapped and
822
+ * encoded like the stock materials, because the stock materials are
823
+ * built from this very source. It declares `uCamPos`, `uHemiSky` /
824
+ * `uHemiGround`, LIGHT_SLOTS + LIGHT_LOOKUP, the SHADOW trio, the pure
825
+ * light functions (HEMISPHERE, LAMBERT, BLINN_SPECULAR, PBR),
826
+ * ENVIRONMENT, FOG and OUTPUT (per the flags), SURFACE, and exports:
827
+ *
828
+ * struct SceneLight { vec3 dir; vec3 color; };
829
+ * SceneLight sceneLight(int i, vec3 position, vec3 normal);
830
+ * vec3 shadeBlinn(Surface s, vec3 position);
831
+ * vec3 shadePbr(Surface s, vec3 position);
832
+ * vec4 sceneOutput(vec3 rgb, float alpha, vec3 position);
833
+ *
834
+ * `sceneLight` is light i seen from a world point: `dir` the unit vector
835
+ * TOWARD it, `color` its rgb already attenuated (falloff, cone) and
836
+ * shadowed - zero when it cannot reach the fragment, and then no shadow
837
+ * tap was spent. It is the accessor a custom light model loops over to
838
+ * `uLightCount` (URP's GetAdditionalLight, Godot's light() inputs), and
839
+ * the one that makes a spot light impossible to render as a directional.
840
+ *
841
+ * The shade functions are the whole light loop of `lit` (Blinn-Phong)
842
+ * and `standard` (GGX): hemisphere plus `Surface.ambient` plus every
843
+ * light's diffuse and specular, the environment term, then the emissive,
844
+ * returning linear PREMULTIPLIED rgb. A term added by hand after them is
845
+ * multiplied by the surface's alpha, the premultiplied rule the functions
846
+ * follow inside. The view vector is derived from `uCamPos` and
847
+ * `position`, so no varying name is pinned: pass whatever your vertex
848
+ * stage carries the world position in.
849
+ *
850
+ * `sceneOutput` is the tail every library fragment ends with: fog over
851
+ * the premultiplied color, then OUTPUT's exposure, tone mapping and
852
+ * encode - the one place a post effect or a scene buffer change lands.
853
+ *
854
+ * ${SCENE}
855
+ * void main() {
856
+ * Surface s = surfaceOf(vec4(albedo, 1.0), normalize(vNormal));
857
+ * s.specular = 0.4;
858
+ * fragColor = sceneOutput(shadeBlinn(s, vWorldPos), 1.0, vWorldPos);
859
+ * }
860
+ *
861
+ * `SCENE` is `sceneSource()` with every flag on; `sceneSource(flags)`
862
+ * leaves the samplers a program does not need undeclared. A fragment that
863
+ * composes this declares none of the sets it includes by itself.
864
+ */
865
+ export function sceneSource(o: SceneSourceOptions = {}): string {
866
+ let lights = o.lights !== false
867
+ let receiveShadow = lights && o.receiveShadow !== false
868
+ let env = lights && o.env !== false
869
+ let fog = o.fog !== false
870
+ return glsl`
871
+ ${SURFACE}
872
+ ${lights || fog ? "uniform vec3 uCamPos;" : ""}
873
+ ${
874
+ lights
875
+ ? `uniform vec3 uHemiSky;
876
+ uniform vec3 uHemiGround;
877
+ ${LIGHT_SLOTS}
878
+ ${LIGHT_LOOKUP}`
879
+ : ""
880
+ }
881
+ ${
882
+ receiveShadow
883
+ ? `${SHADOW_SLOTS}
884
+ ${SHADOW}
885
+ ${SHADOW_LOOKUP}`
886
+ : ""
887
+ }
888
+ ${
889
+ lights
890
+ ? `${HEMISPHERE}
891
+ ${LAMBERT}
892
+ ${BLINN_SPECULAR}
893
+ ${PBR}`
894
+ : ""
895
+ }
896
+ ${env ? ENVIRONMENT : ""}
897
+ ${fog ? FOG : ""}
898
+ ${OUTPUT}
899
+ ${lights ? sceneShadeSource(receiveShadow, env) : ""}
900
+
901
+ vec4 sceneOutput(vec3 rgb, float alpha, vec3 position) {
902
+ ${fog ? "rgb = fog(rgb, alpha, position, uCamPos);" : ""}
903
+ return outputColor(rgb, alpha);
904
+ }
905
+ `
906
+ }
907
+
908
+ /** `sceneSource()` with every flag on. */
909
+ export const SCENE = sceneSource()
910
+
911
+ // The light accessor and the two shade functions, over the sets
912
+ // sceneSource composed before them. A light that cannot reach the point
913
+ // is skipped whole (no shadow tap, no terms), the gate `lit` always had.
914
+ function sceneShadeSource(receiveShadow: boolean, env: boolean): string {
915
+ return glsl`
916
+ struct SceneLight {
917
+ vec3 dir;
918
+ vec3 color;
919
+ };
920
+
921
+ SceneLight sceneLight(int i, vec3 position, vec3 normal) {
922
+ SceneLight light;
923
+ float a = lightVector(i, position, light.dir);
924
+ ${receiveShadow ? "if (a > 0.0) a *= lightShadow(i, position, normal);" : ""}
925
+ light.color = uLightColor[i] * a;
926
+ return light;
927
+ }
928
+
929
+ vec3 shadeBlinn(Surface s, vec3 position) {
930
+ vec3 n = s.normal;
931
+ vec3 v = normalize(uCamPos - position);
932
+ vec3 light = hemisphere(n, uHemiSky, uHemiGround);
933
+ light += s.ambient;
934
+ vec3 spec = vec3(0.0);
935
+ for (int i = 0; i < ${MAX_LIGHTS}; i++) {
936
+ if (i >= uLightCount) break;
937
+ SceneLight l = sceneLight(i, position, n);
938
+ if (all(equal(l.color, vec3(0.0)))) continue;
939
+ light += l.color * lambert(n, l.dir);
940
+ spec += l.color * blinnSpecular(n, v, l.dir, s.shininess);
941
+ }
942
+ vec3 rgb = s.base.rgb * light + spec * s.specular * s.base.a;
943
+ ${env ? "rgb = mix(rgb, envReflection(n, v, s.shininess) * s.base.a, envWeight(n, v, s.reflectivity));" : ""}
944
+ rgb += s.emissive * s.base.a;
945
+ return rgb;
946
+ }
947
+
948
+ vec3 shadePbr(Surface s, vec3 position) {
949
+ vec3 n = s.normal;
950
+ vec3 v = normalize(uCamPos - position);
951
+ vec3 diffuseColor = s.base.rgb * (1.0 - s.metalness);
952
+ vec3 f0 = mix(vec3(DIELECTRIC_F0) * s.base.a, s.base.rgb, s.metalness);
953
+ vec3 light = hemisphere(n, uHemiSky, uHemiGround);
954
+ ${env ? "light += envIrradiance(n);" : ""}
955
+ light += s.ambient;
956
+ vec3 spec = vec3(0.0);
957
+ for (int i = 0; i < ${MAX_LIGHTS}; i++) {
958
+ if (i >= uLightCount) break;
959
+ SceneLight l = sceneLight(i, position, n);
960
+ if (all(equal(l.color, vec3(0.0)))) continue;
961
+ float nl = lambert(n, l.dir);
962
+ light += l.color * nl;
963
+ spec += l.color * (nl * ggxSpecular(n, v, l.dir, f0, s.roughness));
964
+ }
965
+ vec3 rgb = diffuseColor * light + spec;
966
+ ${
967
+ env
968
+ ? `vec2 ab = envBrdf(max(dot(n, v), 0.0), s.roughness);
969
+ rgb += envRadiance(reflect(-v, n), s.roughness) * (f0 * ab.x + ab.y * s.base.a);`
970
+ : ""
971
+ }
972
+ rgb += s.emissive * s.base.a;
973
+ return rgb;
974
+ }
975
+ `
976
+ }
977
+
978
+ /**
979
+ * The option set the lit program builders take: the flags that pick the
980
+ * program - the same names and defaults as LitOptions, minus the values
981
+ * (`map: true` says the fragment samples uMap, not which texture) - plus
982
+ * two slots an app splices its own GLSL into.
983
+ *
984
+ * The slots are a typed contract, not named locals: `prelude` adds
985
+ * declarations, `surface` is a function over the Surface struct (SURFACE)
986
+ * the program fills before shading, so no local of the generated program
987
+ * is part of the contract and restructuring it stays an internal change.
988
+ * A slot can read the varyings (vWorldPos, vNormal, vUv, and vColor with
989
+ * vertexColors), the uniforms the flags declare, and whatever `prelude`
990
+ * declares; a uniform `prelude` declares is an ordinary per-entry param,
991
+ * passed to instance() like uColor. Colors are linear light,
992
+ * PREMULTIPLIED (the engine's pixel contract), `Surface.base` included.
993
+ *
994
+ * Reach past the slots - a different light model, a term added after the
995
+ * shade - by composing a fragment from litVertex and the scene set
996
+ * (sceneSource) the program itself is built from. Same import list, not a
997
+ * lower tier.
998
+ */
999
+ export type LitSourceOptions = {
1000
+ /** The fragment samples a `uniform sampler2D uMap`, tinted by uColor. */
1001
+ map?: boolean
1002
+ /** Multiply the base by the colored layout's per-vertex vColor; pairs
1003
+ * with LIT_VERTEX_COLORED (litVertex picks it). */
1004
+ vertexColors?: boolean
1005
+ /** Sample uMap by world position blended across the three axis planes,
1006
+ * at `uniform float uTriplanar` repeats per world unit, instead of by
1007
+ * UV. Needs `map`. */
1008
+ triplanar?: boolean
1009
+ /** Write the base alpha through and blend; opaque (the default) writes
1010
+ * alpha 1, so a leaked texel alpha cannot punch through it. */
1011
+ transparent?: boolean
1012
+ /** Compose the scene's shadow set and multiply each light's term by its
1013
+ * factor (default true). False declares no shadow sampler at all. */
1014
+ receiveShadow?: boolean
1015
+ /** Which faces the material's own pipeline drops (default "back"). The
1016
+ * fragment's business because a program that shows back faces lights
1017
+ * them with the normal flipped, else a double-sided leaf's back is
1018
+ * black. */
1019
+ cull?: CullMode
1020
+ /** Declare `uniform float uAlphaTest` and discard a fragment whose base
1021
+ * alpha falls below it (the cutoff is a per-entry uniform, so one
1022
+ * program serves every value). */
1023
+ alphaTest?: boolean
1024
+ /** Compose the scene's fog over the result (default true). */
1025
+ fog?: boolean
1026
+ /** Bend the lit normal by a tangent-space normal map (NORMAL_MAP:
1027
+ * `uniform sampler2D uNormalMap` scaled by `uniform float
1028
+ * uNormalScale`), sampled at the same uv as uMap. The frame comes from
1029
+ * screen-space derivatives - no tangent attribute. The cutout still
1030
+ * sees the geometric normal; the surface slot and the lighting see the
1031
+ * bent one. Not with `triplanar` (which samples by world position, not
1032
+ * uv). */
1033
+ normalMap?: boolean
1034
+ /** Add `uniform vec3 uEmissive` after the lighting terms - unlit by
1035
+ * design, shadow-proof, fogged like everything else. */
1036
+ emissive?: boolean
1037
+ /** `uniform sampler2D uEmissiveMap` multiplying uEmissive; implies
1038
+ * `emissive`. */
1039
+ emissiveMap?: boolean
1040
+ /** `uniform sampler2D uSpecularMap`: its RED channel scales uSpecular
1041
+ * per fragment (Three's specularMap - chrome and rubber on one mesh). */
1042
+ specularMap?: boolean
1043
+ /** Compose the scene's ENVIRONMENT and mix its mirror reflection in by
1044
+ * `uniform float uReflectivity` (the Schlick face-on weight), scaled
1045
+ * per fragment by uSpecularMap's red when `specularMap`. */
1046
+ env?: boolean
1047
+ /** Add a baked-light term: `uniform sampler2D uLightMap` times
1048
+ * `uniform float uLightMapIntensity`, sampled by the aUV2 channel and
1049
+ * ADDED to the light sum like the hemisphere term (a fully baked scene
1050
+ * runs with no lights at all). litVertex(o) then reads aUV2, so the
1051
+ * geometry must carry that channel. */
1052
+ lightMap?: boolean
1053
+ /** Declare `uniform vec4 uMapTransform` ([repeatU, repeatV, offsetU,
1054
+ * offsetV]) and sample every uv-driven map at `vUv * repeat + offset` -
1055
+ * ONE transform for the material's uv maps (Godot's uv1_offset/scale,
1056
+ * Unity's Tiling/Offset), deliberately not Three's per-texture
1057
+ * transform: a TextureId is a shared value whose sampling is
1058
+ * creation-time state. The shadow source transforms its cutout the
1059
+ * same way; lightMap's aUV2 is not transformed. Not with `triplanar`. */
1060
+ mapTransform?: boolean
1061
+ /** Skin positions and normals by the "skinned" layout's aJoints/
1062
+ * aWeights against the `uBones` palette texture (rgba32f, 4 texels
1063
+ * wide, one row per joint: model-local jointWorld x inverseBind -
1064
+ * the spatial flush writes it). The vertex stage then requires that layout
1065
+ * and something must bind `uBones`; the fragment is unchanged. */
1066
+ skinned?: boolean
1067
+ /** Place each copy by its instance matrix (INSTANCE_MATRIX under
1068
+ * uModel, normals through instanceNormalMatrix): the vertex stage of a
1069
+ * material for createInstancedMesh. The class then declares
1070
+ * INSTANCE_MATRIX_ATTRIBUTES; the fragment is unchanged. */
1071
+ instanced?: boolean
1072
+ /** Multiply the base by the per-instance `iColor` (implies `instanced`;
1073
+ * the class declares INSTANCE_COLOR_ATTRIBUTES too): vColor carries it,
1074
+ * times aColor under `vertexColors`. */
1075
+ instanceColors?: boolean
1076
+ /** GLSL spliced at file scope, before main: the uniforms, constants and
1077
+ * helper functions `surface` calls. */
1078
+ prelude?: string
1079
+ /** GLSL declaring `void surface(inout Surface s)`: the material's own
1080
+ * say over the fragment, called once the program has filled `s` from
1081
+ * its options (base from uColor, the map and the vertex color; the
1082
+ * normal, bent by the normal map; emissive, ambient, and the light
1083
+ * model's fields from the per-entry uniforms) and before it shades.
1084
+ * Rewrite any field, or `discard`. Runs in the SHADOW source too with
1085
+ * the same base and the geometric normal, so what it discards casts no
1086
+ * shadow. Reads the varyings and prelude's names. */
1087
+ surface?: string
1088
+ }
1089
+
1090
+ /**
1091
+ * The option set `standardFragment` builds from: every lit option but
1092
+ * the Blinn-Phong ones (`specularMap`; `env`, since the environment is
1093
+ * always composed), plus the two packed data maps.
1094
+ */
1095
+ export type StandardSourceOptions = Omit<LitSourceOptions, "specularMap" | "env"> & {
1096
+ /** `uniform sampler2D uMetalnessMap`: its BLUE channel scales
1097
+ * uMetalness per fragment (Three's metalnessMap, glTF's packed
1098
+ * channel). */
1099
+ metalnessMap?: boolean
1100
+ /** `uniform sampler2D uRoughnessMap`: its GREEN channel scales
1101
+ * uRoughness (Three's roughnessMap, glTF's packed channel - bind the
1102
+ * same texture to both). */
1103
+ roughnessMap?: boolean
1104
+ }
1105
+
1106
+ // The resolved option set: every flag concrete, every slot a string, so
1107
+ // the builders below never re-apply a default and `lit`'s class key and
1108
+ // its program cannot drift apart.
1109
+ type LitSource = {
1110
+ map: boolean
1111
+ vertexColors: boolean
1112
+ triplanar: boolean
1113
+ transparent: boolean
1114
+ receiveShadow: boolean
1115
+ cull: CullMode
1116
+ alphaTest: boolean
1117
+ fog: boolean
1118
+ normalMap: boolean
1119
+ emissive: boolean
1120
+ emissiveMap: boolean
1121
+ specularMap: boolean
1122
+ env: boolean
1123
+ lightMap: boolean
1124
+ mapTransform: boolean
1125
+ skinned: boolean
1126
+ instanced: boolean
1127
+ instanceColors: boolean
1128
+ prelude: string
1129
+ surface: string
1130
+ // The light model: Blinn-Phong (`lit`) or GGX metalness/roughness
1131
+ // (`standard`, which also samples the environment unconditionally).
1132
+ brdf: "blinn" | "ggx"
1133
+ metalnessMap: boolean
1134
+ roughnessMap: boolean
1135
+ }
1136
+
1137
+ function resolveLit(o: LitSourceOptions): LitSource {
1138
+ return {
1139
+ map: o.map === true,
1140
+ vertexColors: o.vertexColors === true,
1141
+ triplanar: o.triplanar === true && o.map === true,
1142
+ transparent: o.transparent === true,
1143
+ receiveShadow: o.receiveShadow !== false,
1144
+ cull: o.cull ?? "back",
1145
+ alphaTest: o.alphaTest === true,
1146
+ fog: o.fog !== false,
1147
+ normalMap: o.normalMap === true,
1148
+ emissive: o.emissive === true || o.emissiveMap === true,
1149
+ emissiveMap: o.emissiveMap === true,
1150
+ specularMap: o.specularMap === true,
1151
+ env: o.env === true,
1152
+ lightMap: o.lightMap === true,
1153
+ mapTransform: o.mapTransform === true,
1154
+ skinned: o.skinned === true,
1155
+ instanced: o.instanced === true || o.instanceColors === true,
1156
+ instanceColors: o.instanceColors === true,
1157
+ prelude: o.prelude ?? "",
1158
+ surface: o.surface ?? "",
1159
+ brdf: "blinn",
1160
+ metalnessMap: false,
1161
+ roughnessMap: false,
1162
+ }
1163
+ }
1164
+
1165
+ function resolveStandard(o: StandardSourceOptions): LitSource {
1166
+ return {
1167
+ ...resolveLit(o),
1168
+ specularMap: false,
1169
+ env: true,
1170
+ brdf: "ggx",
1171
+ metalnessMap: o.metalnessMap === true,
1172
+ roughnessMap: o.roughnessMap === true,
1173
+ }
1174
+ }
1175
+
1176
+ /** Whether the source samples anything by uv - then litBase resolves the
1177
+ * `uv` local (transformed when mapTransform) both passes sample by. */
1178
+ function litUv(c: LitSource): boolean {
1179
+ return (c.map && !c.triplanar) || c.normalMap || c.emissiveMap || c.specularMap || c.metalnessMap || c.roughnessMap
1180
+ }
1181
+
1182
+ // The varyings both lit sources read. The shadow source declares the same
1183
+ // set even where it uses none of them, so a surface function written
1184
+ // against the main pass compiles unchanged there.
1185
+ function litVaryings(c: LitSource): string {
1186
+ return glsl`
1187
+ in vec3 vWorldPos;
1188
+ in vec3 vNormal;
1189
+ in vec2 vUv;
1190
+ ${c.vertexColors || c.instanceColors ? "in vec4 vColor;" : ""}
1191
+ ${c.lightMap ? "in vec2 vUv2;" : ""}
1192
+ `
1193
+ }
1194
+
1195
+ // Base color to cutout, shared by the main and shadow sources: uColor,
1196
+ // the map (by UV or triplanar), the vertex color, then the alphaTest
1197
+ // discard. `n` is the surface normal the sampling and the surface slot
1198
+ // see: flipped toward the viewer in the main pass of a program that shows
1199
+ // back faces, the plain geometric normal in the shadow pass (which draws
1200
+ // the other side of the surface; triplanar weights are abs(n), so the
1201
+ // sample is the same either way).
1202
+ function litBase(c: LitSource, flip: boolean): string {
1203
+ return glsl`
1204
+ vec3 n = normalize(vNormal);
1205
+ ${flip ? "if (!gl_FrontFacing) n = -n;" : ""}
1206
+ ${litUv(c) ? `vec2 uv = ${c.mapTransform ? "vUv * uMapTransform.xy + uMapTransform.zw" : "vUv"};` : ""}
1207
+ vec4 base = uColor;
1208
+ ${
1209
+ c.map
1210
+ ? c.triplanar
1211
+ ? `vec3 w = pow(abs(n), vec3(4.0));
1212
+ w /= w.x + w.y + w.z;
1213
+ vec3 p = vWorldPos * uTriplanar;
1214
+ base *= texture(uMap, p.yz) * w.x + texture(uMap, p.xz) * w.y + texture(uMap, p.xy) * w.z;`
1215
+ : "base *= texture(uMap, uv);"
1216
+ : ""
1217
+ }
1218
+ ${c.vertexColors || c.instanceColors ? "base *= vColor;" : ""}
1219
+ ${c.alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
1220
+ `
1221
+ }
1222
+
1223
+ /**
1224
+ * The vertex stage `lit` pairs with a given option set: LIT_VERTEX, or
1225
+ * LIT_VERTEX_COLORED when the fragment reads vColor. The shadow source
1226
+ * takes this same stage, which is what lets a surface function read the
1227
+ * same varyings in both passes.
1228
+ */
1229
+ export function litVertex(o: LitSourceOptions = {}): string {
1230
+ let c = resolveLit(o)
1231
+ return litVertexSource(c.vertexColors, c.lightMap, c.skinned, c.instanced, c.instanceColors)
1232
+ }
1233
+
1234
+ /**
1235
+ * The `lit` fragment source for an option set - the exact program `lit`
1236
+ * itself builds, composed from the constants above. Pair it with
1237
+ * litVertex(o) in a shaderMaterialClass to get a lit material with your
1238
+ * own GLSL in it, and pass litShadowFragment(o) alongside when it can
1239
+ * discard.
1240
+ *
1241
+ * Per-entry uniforms to supply on instance(): `uColor` (premultiplied
1242
+ * vec4), `uSpecular`, `uShininess`, plus `uMap`/`uTriplanar`/`uAlphaTest`/
1243
+ * `uNormalMap`+`uNormalScale`/`uEmissive`/`uEmissiveMap`/`uSpecularMap`/
1244
+ * `uLightMap`+`uLightMapIntensity`/`uMapTransform`
1245
+ * for the options that declare them, plus whatever `prelude` declares.
1246
+ * Everything else - the lights, the hemisphere, the camera position, the
1247
+ * shadow set, the fog - is written by the scene.
1248
+ */
1249
+ export function litFragment(o: LitSourceOptions = {}): string {
1250
+ return lightingFragment(resolveLit(o))
1251
+ }
1252
+
1253
+ /**
1254
+ * The `standard` fragment source for an option set - the exact program
1255
+ * `standard` builds: the lit program (base, cutout, normal map, the
1256
+ * light and shadow loop, emissive, fog, output) shaded with the GGX
1257
+ * metalness/roughness model (PBR) instead of Blinn-Phong, and the
1258
+ * scene's ENVIRONMENT sampled unconditionally as the split sum
1259
+ * (envRadiance times envBrdf). Pair it with litVertex(o), and with
1260
+ * litShadowFragment(o) when it can discard, exactly as litFragment.
1261
+ *
1262
+ * Per-entry uniforms: `uColor` (premultiplied vec4), `uMetalness`,
1263
+ * `uRoughness`, `uMetalnessMap` (blue channel) and `uRoughnessMap`
1264
+ * (green) when declared, plus the lit slots the options declare
1265
+ * (`uMap`/`uTriplanar`/`uAlphaTest`/`uNormalMap`+`uNormalScale`/
1266
+ * `uEmissive`/`uEmissiveMap`/`uLightMap`+`uLightMapIntensity`/
1267
+ * `uMapTransform`) and whatever `prelude` declares.
1268
+ */
1269
+ export function standardFragment(o: StandardSourceOptions = {}): string {
1270
+ return lightingFragment(resolveStandard(o))
1271
+ }
1272
+
1273
+ // The one lit program, both light models: the options fill a Surface,
1274
+ // the scene set shades it. What the brdf changes: the material uniforms
1275
+ // and the shade function (shadeBlinn mixes a fresnel-weighted mirror in
1276
+ // when asked; shadePbr adds the split-sum term always). The light
1277
+ // convention is Godot's and Unity's for both: a light of intensity 1
1278
+ // lights a white diffuse surface to 1 face-on, and the GGX lobe carries
1279
+ // the pi its normalisation assumes (PBR's doc).
1280
+ function lightingFragment(c: LitSource): string {
1281
+ let alpha = c.transparent ? "s.base.a" : "1.0"
1282
+ let ggx = c.brdf === "ggx"
1283
+ let specularMap = c.specularMap ? " * texture(uSpecularMap, uv).r" : ""
1284
+ return glsl`
1285
+ ${litVaryings(c)}
1286
+ uniform vec4 uColor;
1287
+ ${c.map ? "uniform sampler2D uMap;" : ""}
1288
+ ${ggx ? "uniform float uMetalness;\n uniform float uRoughness;" : "uniform float uSpecular;\n uniform float uShininess;"}
1289
+ ${c.metalnessMap ? "uniform sampler2D uMetalnessMap;" : ""}
1290
+ ${c.roughnessMap ? "uniform sampler2D uRoughnessMap;" : ""}
1291
+ ${c.triplanar ? "uniform float uTriplanar;" : ""}
1292
+ ${c.alphaTest ? "uniform float uAlphaTest;" : ""}
1293
+ ${c.emissive ? "uniform vec3 uEmissive;" : ""}
1294
+ ${c.emissiveMap ? "uniform sampler2D uEmissiveMap;" : ""}
1295
+ ${c.specularMap ? "uniform sampler2D uSpecularMap;" : ""}
1296
+ ${c.env && !ggx ? "uniform float uReflectivity;" : ""}
1297
+ ${c.lightMap ? "uniform sampler2D uLightMap;\n uniform float uLightMapIntensity;" : ""}
1298
+ ${c.mapTransform ? "uniform vec4 uMapTransform;" : ""}
1299
+ ${c.normalMap ? NORMAL_MAP : ""}
1300
+ ${sceneSource({ receiveShadow: c.receiveShadow, env: c.env, fog: c.fog })}
1301
+ ${c.prelude}
1302
+ ${c.surface}
1303
+
1304
+ void main() {
1305
+ ${litBase(c, c.cull !== "back")}
1306
+ ${c.normalMap ? "n = perturbNormal(n, vWorldPos, uv);" : ""}
1307
+ Surface s = surfaceOf(base, n);
1308
+ ${
1309
+ ggx
1310
+ ? `s.metalness = uMetalness${c.metalnessMap ? " * texture(uMetalnessMap, uv).b" : ""};
1311
+ s.roughness = uRoughness${c.roughnessMap ? " * texture(uRoughnessMap, uv).g" : ""};`
1312
+ : `s.specular = uSpecular${specularMap};
1313
+ s.shininess = uShininess;
1314
+ ${c.env ? `s.reflectivity = uReflectivity${specularMap};` : ""}`
1315
+ }
1316
+ ${c.emissive ? `s.emissive = uEmissive${c.emissiveMap ? " * texture(uEmissiveMap, uv).rgb" : ""};` : ""}
1317
+ ${c.lightMap ? "s.ambient = texture(uLightMap, vUv2).rgb * uLightMapIntensity;" : ""}
1318
+ ${c.surface ? "surface(s);" : ""}
1319
+ vec3 rgb = ${ggx ? "shadePbr" : "shadeBlinn"}(s, vWorldPos);
1320
+ fragColor = sceneOutput(rgb, ${alpha}, vWorldPos);
1321
+ }
1322
+ `
1323
+ }
1324
+
1325
+ /**
1326
+ * The depth-pass source that makes a discarding lit material cast what it
1327
+ * actually draws: the same base, cutout and surface function as
1328
+ * litFragment(o), and nothing after them (no lighting, no fog - a shadow
1329
+ * has neither). Undefined when the option set cannot discard (no
1330
+ * alphaTest, no surface), which means the scene's default depth material
1331
+ * is already right and the material should carry no `shadow` of its own.
1332
+ *
1333
+ * Use it as a second shaderMaterialClass on litVertex(o) with the
1334
+ * OPPOSITE cull (Three's shadowSide rule: `cull: "back"` casts from
1335
+ * "front"), instanced with the uniforms this source declares - uColor,
1336
+ * plus uMap/uTriplanar/uAlphaTest/prelude's as opted into - and passed as
1337
+ * the main instance's `shadow`.
1338
+ */
1339
+ export function litShadowFragment(o: LitSourceOptions = {}): string | undefined {
1340
+ let c = resolveLit(o)
1341
+ if (!c.alphaTest && !c.surface) return undefined
1342
+ return glsl`
1343
+ ${litVaryings(c)}
1344
+ uniform vec4 uColor;
1345
+ ${c.map ? "uniform sampler2D uMap;" : ""}
1346
+ ${c.triplanar ? "uniform float uTriplanar;" : ""}
1347
+ ${c.alphaTest ? "uniform float uAlphaTest;" : ""}
1348
+ ${c.mapTransform ? "uniform vec4 uMapTransform;" : ""}
1349
+ ${SURFACE}
1350
+ ${c.prelude}
1351
+ ${c.surface}
1352
+
1353
+ void main() {
1354
+ ${litBase(c, false)}
1355
+ ${c.surface ? "Surface s = surfaceOf(base, n);\n surface(s);" : ""}
1356
+ fragColor = vec4(1.0);
1357
+ }
1358
+ `
1359
+ }
1360
+
1361
+ /**
1362
+ * The unlit vertex stage: model then view-projection, with UV and world
1363
+ * position as varyings (vWorldPos is the fog distance input; a fragment
1364
+ * that reads neither leaves the outs unmatched, which links fine).
1365
+ * aNormal from the shared layout is deliberately not declared - inactive
1366
+ * attributes are skipped and only the stride accounts for them.
1367
+ */
1368
+ export const UNLIT_VERTEX = glsl`
1369
+ in vec3 aPos;
1370
+ in vec2 aUV;
1371
+ out vec2 vUv;
1372
+ out vec3 vWorldPos;
1373
+ uniform mat4 uModel;
1374
+ uniform mat4 uViewProj;
1375
+
1376
+ void main() {
1377
+ vec4 world = uModel * vec4(aPos, 1.0);
1378
+ vWorldPos = world.xyz;
1379
+ gl_Position = uViewProj * world;
1380
+ vUv = aUV;
1381
+ }
1382
+ `
1383
+
1384
+ /**
1385
+ * The option set the unlit builders take: LitSourceOptions minus
1386
+ * everything lighting decides (no vertexColors, triplanar, receiveShadow)
1387
+ * and minus cull (the unlit fragment has no facing-dependent code - cull
1388
+ * is pure pipeline state, passed to shaderMaterialClass directly). The
1389
+ * slots follow the lit contract; the varyings here are vUv and vWorldPos
1390
+ * only.
1391
+ */
1392
+ export type UnlitSourceOptions = {
1393
+ /** The fragment samples a `uniform sampler2D uMap`, tinted by uColor. */
1394
+ map?: boolean
1395
+ /** Write the base alpha through and blend; opaque (the default) writes
1396
+ * alpha 1. */
1397
+ transparent?: boolean
1398
+ /** Declare `uniform float uAlphaTest` and discard below it. */
1399
+ alphaTest?: boolean
1400
+ /** Compose the scene's fog over the result (default true). */
1401
+ fog?: boolean
1402
+ /** Declare `uniform vec4 uMapTransform` ([repeatU, repeatV, offsetU,
1403
+ * offsetV]) and sample the map at `vUv * repeat + offset` (see the lit
1404
+ * option of the same name). Needs `map`. */
1405
+ mapTransform?: boolean
1406
+ /** Skin positions by the "skinned" layout against uBones (see the lit
1407
+ * option); unlitVertex(o) reads it, the fragment is unchanged. */
1408
+ skinned?: boolean
1409
+ /** Place each copy by its instance matrix (see the lit option);
1410
+ * unlitVertex(o) reads it, the fragment is unchanged. */
1411
+ instanced?: boolean
1412
+ /** Multiply the base by the per-instance `iColor` (implies `instanced`;
1413
+ * see the lit option): the one per-copy value an unlit fleet wants. */
1414
+ instanceColors?: boolean
1415
+ /** GLSL spliced at file scope, before main. */
1416
+ prelude?: string
1417
+ /** GLSL declaring `void surface(inout Surface s)`, called with the base
1418
+ * resolved (see the lit slot): the unlit program shades nothing, so only
1419
+ * `s.base` is read back and `s.normal` is zero (no normal varying). A
1420
+ * `discard` inside it runs in the shadow source too. */
1421
+ surface?: string
1422
+ }
1423
+
1424
+ type UnlitSource = {
1425
+ map: boolean
1426
+ transparent: boolean
1427
+ alphaTest: boolean
1428
+ fog: boolean
1429
+ mapTransform: boolean
1430
+ skinned: boolean
1431
+ instanced: boolean
1432
+ instanceColors: boolean
1433
+ prelude: string
1434
+ surface: string
1435
+ }
1436
+
1437
+ function resolveUnlit(o: UnlitSourceOptions): UnlitSource {
1438
+ return {
1439
+ map: o.map === true,
1440
+ transparent: o.transparent === true,
1441
+ alphaTest: o.alphaTest === true,
1442
+ fog: o.fog !== false,
1443
+ mapTransform: o.mapTransform === true && o.map === true,
1444
+ skinned: o.skinned === true,
1445
+ instanced: o.instanced === true || o.instanceColors === true,
1446
+ instanceColors: o.instanceColors === true,
1447
+ prelude: o.prelude ?? "",
1448
+ surface: o.surface ?? "",
1449
+ }
1450
+ }
1451
+
1452
+ /**
1453
+ * The vertex stage `unlit` pairs with an option set: UNLIT_VERTEX, or its
1454
+ * skinned form (the skin matrix applied before uModel) when `skinned`,
1455
+ * its instanced form (the instance matrix under uModel, iColor forwarded
1456
+ * as vColor under `instanceColors`) when `instanced`.
1457
+ */
1458
+ export function unlitVertex(o: UnlitSourceOptions = {}): string {
1459
+ let c = resolveUnlit(o)
1460
+ if (!c.skinned && !c.instanced) return UNLIT_VERTEX
1461
+ return glsl`
1462
+ in vec3 aPos;
1463
+ in vec2 aUV;
1464
+ ${c.skinned ? SKIN_DECLS : ""}
1465
+ ${c.instanced ? INSTANCE_MATRIX : ""}
1466
+ ${c.instanceColors ? "in vec4 iColor;" : ""}
1467
+ out vec2 vUv;
1468
+ out vec3 vWorldPos;
1469
+ ${c.instanceColors ? "out vec4 vColor;" : ""}
1470
+ uniform mat4 uModel;
1471
+ uniform mat4 uViewProj;
1472
+
1473
+ void main() {
1474
+ ${c.skinned ? SKIN_MATRIX : ""}
1475
+ vec4 world = ${instancedPosition(c.skinned, c.instanced)};
1476
+ vWorldPos = world.xyz;
1477
+ gl_Position = uViewProj * world;
1478
+ vUv = aUV;
1479
+ ${c.instanceColors ? "vColor = iColor;" : ""}
1480
+ }
1481
+ `
1482
+ }
1483
+
1484
+ /** The unlit base sample: uColor times the (possibly transformed) map,
1485
+ * times the instance color when the material carries one. */
1486
+ function unlitBase(c: UnlitSource): string {
1487
+ let uv = c.mapTransform ? "vUv * uMapTransform.xy + uMapTransform.zw" : "vUv"
1488
+ let base = c.map ? `vec4 base = texture(uMap, ${uv}) * uColor;` : "vec4 base = uColor;"
1489
+ return c.instanceColors ? base + "\n base *= vColor;" : base
1490
+ }
1491
+
1492
+ /**
1493
+ * The `unlit` fragment source for an option set (sprite shares it): the
1494
+ * color, times the map when there is one, the cutout, the surface slot,
1495
+ * then the scene tail (sceneOutput: fog unless opted out, exposure, tone
1496
+ * mapping, encode) like every library fragment. An opaque program writes
1497
+ * alpha 1: the scene target is composited premultiplied, so a leaked
1498
+ * texel alpha would punch a hole through an opaque draw. Pair with
1499
+ * UNLIT_VERTEX (or a custom vertex stage writing vUv/vWorldPos, as sprite
1500
+ * does - vWorldPos is read whether or not the program fogs).
1501
+ *
1502
+ * Per-entry uniforms on instance(): `uColor` (linear light, premultiplied
1503
+ * vec4), plus `uMap`/`uAlphaTest` for the options that declare them, plus
1504
+ * whatever `prelude` declares.
1505
+ */
1506
+ export function unlitFragment(o: UnlitSourceOptions = {}): string {
1507
+ let c = resolveUnlit(o)
1508
+ let alpha = c.transparent ? "base.a" : "1.0"
1509
+ return glsl`
1510
+ ${c.map ? "in vec2 vUv;" : ""}
1511
+ in vec3 vWorldPos;
1512
+ ${c.instanceColors ? "in vec4 vColor;" : ""}
1513
+ ${c.map ? "uniform sampler2D uMap;" : ""}
1514
+ uniform vec4 uColor;
1515
+ ${c.alphaTest ? "uniform float uAlphaTest;" : ""}
1516
+ ${c.mapTransform ? "uniform vec4 uMapTransform;" : ""}
1517
+ ${sceneSource({ lights: false, fog: c.fog })}
1518
+ ${c.prelude}
1519
+ ${c.surface}
1520
+ void main() {
1521
+ ${unlitBase(c)}
1522
+ ${c.alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
1523
+ ${c.surface ? "Surface s = surfaceOf(base, vec3(0.0));\n surface(s);\n base = s.base;" : ""}
1524
+ fragColor = sceneOutput(base.rgb, ${alpha}, vWorldPos);
1525
+ }
1526
+ `
1527
+ }
1528
+
1529
+ /**
1530
+ * The depth-pass source for a discarding unlit material, litShadowFragment's
1531
+ * unlit twin: the same base and discards, nothing else. Undefined when
1532
+ * the option set cannot discard. Pair with UNLIT_VERTEX and the opposite
1533
+ * cull, instanced with the uniforms it declares.
1534
+ */
1535
+ export function unlitShadowFragment(o: UnlitSourceOptions = {}): string | undefined {
1536
+ let c = resolveUnlit(o)
1537
+ if (!c.alphaTest && !c.surface) return undefined
1538
+ return glsl`
1539
+ ${c.map ? "in vec2 vUv;" : ""}
1540
+ ${c.instanceColors ? "in vec4 vColor;" : ""}
1541
+ uniform vec4 uColor;
1542
+ ${c.map ? "uniform sampler2D uMap;" : ""}
1543
+ ${c.alphaTest ? "uniform float uAlphaTest;" : ""}
1544
+ ${c.mapTransform ? "uniform vec4 uMapTransform;" : ""}
1545
+ ${SURFACE}
1546
+ ${c.prelude}
1547
+ ${c.surface}
1548
+ void main() {
1549
+ ${unlitBase(c)}
1550
+ ${c.alphaTest ? "if (base.a < uAlphaTest) discard;" : ""}
1551
+ ${c.surface ? "Surface s = surfaceOf(base, vec3(0.0));\n surface(s);" : ""}
1552
+ fragColor = vec4(1.0);
1553
+ }
1554
+ `
1555
+ }