reze-engine 0.41.0 → 0.41.2

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 (48) hide show
  1. package/README.md +4 -2
  2. package/dist/engine.d.ts +31 -3
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +190 -18
  5. package/dist/graph/presets/body.js +2 -2
  6. package/dist/graph/presets/cloth_rough.js +2 -2
  7. package/dist/graph/presets/cloth_smooth.js +2 -2
  8. package/dist/graph/presets/default.js +2 -2
  9. package/dist/graph/presets/eye.js +2 -2
  10. package/dist/graph/presets/face.js +2 -2
  11. package/dist/graph/presets/hair.js +2 -2
  12. package/dist/graph/presets/metal.js +2 -2
  13. package/dist/graph/presets/stockings.js +3 -3
  14. package/dist/graph/registry.d.ts.map +1 -1
  15. package/dist/graph/registry.js +426 -10
  16. package/dist/graph/style-group.d.ts +46 -0
  17. package/dist/graph/style-group.d.ts.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/shaders/agx-lut.d.ts +11 -0
  21. package/dist/shaders/agx-lut.d.ts.map +1 -0
  22. package/dist/shaders/agx-lut.js +29 -0
  23. package/dist/shaders/materials/common.d.ts +1 -1
  24. package/dist/shaders/materials/common.d.ts.map +1 -1
  25. package/dist/shaders/materials/common.js +10 -0
  26. package/dist/shaders/materials/nodes.d.ts +1 -1
  27. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  28. package/dist/shaders/materials/nodes.js +957 -576
  29. package/dist/shaders/passes/composite.d.ts.map +1 -1
  30. package/dist/shaders/passes/composite.js +76 -4
  31. package/package.json +1 -1
  32. package/src/engine.ts +211 -17
  33. package/src/graph/presets/body.ts +2 -2
  34. package/src/graph/presets/cloth_rough.ts +2 -2
  35. package/src/graph/presets/cloth_smooth.ts +2 -2
  36. package/src/graph/presets/default.ts +2 -2
  37. package/src/graph/presets/eye.ts +2 -2
  38. package/src/graph/presets/face.ts +2 -2
  39. package/src/graph/presets/hair.ts +2 -2
  40. package/src/graph/presets/metal.ts +2 -2
  41. package/src/graph/presets/stockings.ts +3 -3
  42. package/src/graph/registry.ts +439 -10
  43. package/src/graph/style-group.ts +44 -0
  44. package/src/index.ts +2 -0
  45. package/src/shaders/agx-lut.ts +30 -0
  46. package/src/shaders/materials/common.ts +10 -0
  47. package/src/shaders/materials/nodes.ts +962 -581
  48. package/src/shaders/passes/composite.ts +76 -4
@@ -1,580 +1,961 @@
1
1
  // Shared WGSL primitives for Blender-style NPR material nodes.
2
2
  // Every function here maps 1:1 to a Blender shader node type used in the preset JSONs.
3
3
  // Hand-ported material shaders concatenate this block before their own code.
4
- export const NODES_WGSL = /* wgsl */ `
5
-
6
- // Baked 64×64 rgba8unorm combined BRDF LUT — created once at engine init by dfg_lut.ts.
7
- // .rg = split-sum DFG (Karis: tint = f0·x + f90·y) → F_brdf_*_scatter
8
- // .ba = Heitz 2016 LTC magnitude (ltc_mag_ggx) → ltc_brdf_scale_from_lut
9
- // Paired with group(0) binding(2) diffuseSampler (linear filter). Sample once per
10
- // fragment via brdf_lut_sample() — callers feed .rg and the whole vec4 into the
11
- // helpers below, halving LUT taps on the default Principled path.
12
- @group(0) @binding(9) var brdfLut: texture_2d<f32>;
13
-
14
- // ─── RGB ↔ HSV ──────────────────────────────────────────────────────
15
-
16
- fn rgb_to_hsv(rgb: vec3f) -> vec3f {
17
- let c_max = max(rgb.r, max(rgb.g, rgb.b));
18
- let c_min = min(rgb.r, min(rgb.g, rgb.b));
19
- let delta = c_max - c_min;
20
-
21
- var h = 0.0;
22
- if (delta > 1e-6) {
23
- if (c_max == rgb.r) {
24
- h = (rgb.g - rgb.b) / delta;
25
- if (h < 0.0) { h += 6.0; }
26
- } else if (c_max == rgb.g) {
27
- h = 2.0 + (rgb.b - rgb.r) / delta;
28
- } else {
29
- h = 4.0 + (rgb.r - rgb.g) / delta;
30
- }
31
- h /= 6.0;
32
- }
33
- let s = select(0.0, delta / c_max, c_max > 1e-6);
34
- return vec3f(h, s, c_max);
35
- }
36
-
37
- fn hsv_to_rgb(hsv: vec3f) -> vec3f {
38
- let h = hsv.x;
39
- let s = hsv.y;
40
- let v = hsv.z;
41
- if (s < 1e-6) { return vec3f(v); }
42
-
43
- let hh = fract(h) * 6.0;
44
- let sector = u32(hh);
45
- let f = hh - f32(sector);
46
- let p = v * (1.0 - s);
47
- let q = v * (1.0 - s * f);
48
- let t = v * (1.0 - s * (1.0 - f));
49
-
50
- switch (sector) {
51
- case 0u: { return vec3f(v, t, p); }
52
- case 1u: { return vec3f(q, v, p); }
53
- case 2u: { return vec3f(p, v, t); }
54
- case 3u: { return vec3f(p, q, v); }
55
- case 4u: { return vec3f(t, p, v); }
56
- default: { return vec3f(v, p, q); }
57
- }
58
- }
59
-
60
- // ─── HUE_SAT node ───────────────────────────────────────────────────
61
-
62
- fn hue_sat(hue: f32, saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {
63
- var hsv = rgb_to_hsv(color);
64
- hsv.x = fract(hsv.x + hue - 0.5);
65
- hsv.y = clamp(hsv.y * saturation, 0.0, 1.0);
66
- hsv.z *= value;
67
- return mix(color, hsv_to_rgb(hsv), fac);
68
- }
69
-
70
- // hue_sat specialization for hue=0.5 (identity hue shift — fract(h + 0.5 - 0.5) = h).
71
- // Branchless equivalent that skips the rgb_to_hsv → hsv_to_rgb roundtrip: WebKit's
72
- // Metal backend serializes the 3-way if chain in rgb_to_hsv and the 6-way switch in
73
- // hsv_to_rgb, where this form compiles to linear SIMD ops + a single select.
74
- fn hue_sat_id(saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {
75
- let m = max(max(color.r, color.g), color.b);
76
- let n = min(min(color.r, color.g), color.b);
77
- // Unclamped (sat*old_s ≤ 1): reproj = mix(vec3f(m), color, saturation).
78
- // Clamped (saturated to 1): reproj = (color - n) * m / (m - n).
79
- let range = max(m - n, 1e-6);
80
- let unclamped = mix(vec3f(m), color, saturation);
81
- let clamped = (color - vec3f(n)) * m / range;
82
- let needs_clamp = (m - n) * saturation >= m;
83
- let reproj = select(unclamped, clamped, needs_clamp);
84
- return mix(color, reproj * value, fac);
85
- }
86
-
87
- // ─── BRIGHTCONTRAST node ────────────────────────────────────────────
88
-
89
- fn bright_contrast(color: vec3f, bright: f32, contrast: f32) -> vec3f {
90
- let a = 1.0 + contrast;
91
- let b = bright - contrast * 0.5;
92
- return max(vec3f(0.0), color * a + vec3f(b));
93
- }
94
-
95
- // ─── INVERT node ────────────────────────────────────────────────────
96
-
97
- fn invert(fac: f32, color: vec3f) -> vec3f {
98
- return mix(color, vec3f(1.0) - color, fac);
99
- }
100
-
101
- fn invert_f(fac: f32, val: f32) -> f32 {
102
- return mix(val, 1.0 - val, fac);
103
- }
104
-
105
- // ─── Color ramp (VALTORGB) — 2-stop variants ───────────────────────
106
- // All 7 presets use exclusively 2-stop ramps.
107
-
108
- fn ramp_constant(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {
109
- return select(c0, c1, f >= p1);
110
- }
111
-
112
- // CONSTANT ramp with screen-space edge AA — kills sparkle where fwidth(f) straddles a hard step (NPR terminator)
113
- fn ramp_constant_edge_aa(f: f32, edge: f32, c0: vec4f, c1: vec4f) -> vec4f {
114
- let w = max(fwidth(f) * 1.75, 6e-6);
115
- let t = smoothstep(edge - w, edge + w, f);
116
- return mix(c0, c1, t);
117
- }
118
-
119
- fn ramp_linear(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {
120
- let t = saturate((f - p0) / max(p1 - p0, 1e-6));
121
- return mix(c0, c1, t);
122
- }
123
-
124
- fn ramp_cardinal(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {
125
- // cardinal spline with 2 stops degrades to smoothstep
126
- let t = saturate((f - p0) / max(p1 - p0, 1e-6));
127
- let ss = t * t * (3.0 - 2.0 * t);
128
- return mix(c0, c1, ss);
129
- }
130
-
131
- // ─── MATH node operations ───────────────────────────────────────────
132
-
133
- fn math_add(a: f32, b: f32) -> f32 { return a + b; }
134
- fn math_multiply(a: f32, b: f32) -> f32 { return a * b; }
135
- fn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }
136
- fn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }
137
-
138
- // Blender's implicit Color → Float socket conversion uses BT.601 grayscale
139
- // (rgb_to_grayscale in blenkernel/intern/node.cc). When a material graph plugs a
140
- // Color output into a Math node's Value input, this is the scalar it actually sees.
141
- fn color_to_value(c: vec3f) -> f32 {
142
- return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;
143
- }
144
-
145
- // ─── MIX node (blend_type variants) ────────────────────────────────
146
-
147
- fn mix_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {
148
- return mix(a, b, fac);
149
- }
150
-
151
- fn mix_overlay(fac: f32, a: vec3f, b: vec3f) -> vec3f {
152
- let lo = 2.0 * a * b;
153
- let hi = vec3f(1.0) - 2.0 * (vec3f(1.0) - a) * (vec3f(1.0) - b);
154
- let overlay = select(hi, lo, a < vec3f(0.5));
155
- return mix(a, overlay, fac);
156
- }
157
-
158
- fn mix_multiply(fac: f32, a: vec3f, b: vec3f) -> vec3f {
159
- return mix(a, a * b, fac);
160
- }
161
-
162
- fn mix_lighten(fac: f32, a: vec3f, b: vec3f) -> vec3f {
163
- return mix(a, max(a, b), fac);
164
- }
165
-
166
- // Blender Mix (Color) blend LINEAR_LIGHT: result = mix(A, A + 2*B - 1, Fac)
167
- fn mix_linear_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {
168
- return mix(a, a + 2.0 * b - vec3f(1.0), fac);
169
- }
170
-
171
- // Luminance for Shader→RGB scalar gates (linear RGB, Rec.709 weights)
172
- fn luminance_rec709_linear(c: vec3f) -> f32 {
173
- return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
174
- }
175
-
176
- // ─── FRESNEL node ───────────────────────────────────────────────────
177
- // Schlick approximation matching Blender's Fresnel node
178
-
179
- fn fresnel(ior: f32, n: vec3f, v: vec3f) -> f32 {
180
- let r = (ior - 1.0) / (ior + 1.0);
181
- let f0 = r * r;
182
- let cos_theta = clamp(dot(n, v), 0.0, 1.0);
183
- let m = 1.0 - cos_theta;
184
- let m2 = m * m;
185
- let m5 = m2 * m2 * m;
186
- return f0 + (1.0 - f0) * m5;
187
- }
188
-
189
- // ─── LAYER_WEIGHT node ──────────────────────────────────────────────
190
-
191
- fn layer_weight_fresnel(blend: f32, n: vec3f, v: vec3f) -> f32 {
192
- let eta = max(1.0 - blend, 1e-4);
193
- let r = (1.0 - eta) / (1.0 + eta);
194
- let f0 = r * r;
195
- let cos_theta = clamp(abs(dot(n, v)), 0.0, 1.0);
196
- let m = 1.0 - cos_theta;
197
- let m2 = m * m;
198
- let m5 = m2 * m2 * m;
199
- return f0 + (1.0 - f0) * m5;
200
- }
201
-
202
- fn layer_weight_facing(blend: f32, n: vec3f, v: vec3f) -> f32 {
203
- var facing = abs(dot(n, v));
204
- let b = clamp(blend, 0.0, 0.99999);
205
- if (b != 0.5) {
206
- let exponent = select(2.0 * b, 0.5 / (1.0 - b), b >= 0.5);
207
- facing = pow(facing, exponent);
208
- }
209
- return 1.0 - facing;
210
- }
211
-
212
- // ─── SHADER_TO_RGB (white DiffuseBSDF) ──────────────────────────────
213
- // Eevee captures lit diffuse: (albedo/π)*sun*N·L*shadow + ambient (linear). Albedo=1.
214
- // Matches default.ts direct term scale so VALTORGB thresholds from Blender JSON stay valid.
215
-
216
- fn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> f32 {
217
- const PI_S: f32 = 3.141592653589793;
218
- let ndotl = max(dot(n, l), 0.0);
219
- let rgb = sun_rgb * (ndotl * shadow / PI_S) + ambient_rgb;
220
- return luminance_rec709_linear(rgb);
221
- }
222
-
223
- // ─── BUMP node ──────────────────────────────────────────────────────
224
- // Screen-space bump from a scalar height field. Needs dFdx/dFdy which
225
- // WGSL provides as dpdx/dpdy.
226
-
227
- fn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {
228
- let dhdx = dpdx(height);
229
- let dhdy = dpdy(height);
230
- let dpdx_pos = dpdx(world_pos);
231
- let dpdy_pos = dpdy(world_pos);
232
- let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) + dhdy * normalize(cross(normal, dpdx_pos)));
233
- return normalize(perturbed);
234
- }
235
-
236
- // LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference
237
- fn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {
238
- let dhdx = dpdx(height);
239
- let dhdy = dpdy(height);
240
- let dpdx_pos = dpdx(world_pos);
241
- let dpdy_pos = dpdy(world_pos);
242
- let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));
243
- return normalize(perturbed);
244
- }
245
-
246
- // ─── NOISE texture (Perlin-style) ───────────────────────────────────
247
- // Simplified gradient noise matching Blender's default noise output.
248
-
249
- // PCG-style integer hash. Replaces the classic 'fract(sin(q) * LARGE)' trick because
250
- // WebKit's Metal backend compiles 'sin' to a full transcendental op (slow), while
251
- // Safari's Apple-GPU scalar ALU handles int muls/xors near free. Inputs arrive as
252
- // integer-valued floats (floor(p) + unit offsets) from _noise3, so vec3i cast is exact.
253
- fn _hash33(p: vec3f) -> vec3f {
254
- var h = vec3u(vec3i(p) + vec3i(32768));
255
- h = h * vec3u(1664525u, 1013904223u, 2654435761u);
256
- h = (h.yzx ^ h) * vec3u(2246822519u, 3266489917u, 668265263u);
257
- h = h ^ (h >> vec3u(16u));
258
- // Mask to 24 bits — above that f32 loses precision on the u32→f32 convert.
259
- let hm = h & vec3u(16777215u);
260
- return vec3f(hm) * (2.0 / 16777216.0) - 1.0;
261
- }
262
-
263
- fn _noise3(p: vec3f) -> f32 {
264
- let i = floor(p);
265
- let f = fract(p);
266
- let u = f * f * (3.0 - 2.0 * f);
267
-
268
- return mix(
269
- mix(
270
- mix(dot(_hash33(i + vec3f(0,0,0)), f - vec3f(0,0,0)),
271
- dot(_hash33(i + vec3f(1,0,0)), f - vec3f(1,0,0)), u.x),
272
- mix(dot(_hash33(i + vec3f(0,1,0)), f - vec3f(0,1,0)),
273
- dot(_hash33(i + vec3f(1,1,0)), f - vec3f(1,1,0)), u.x), u.y),
274
- mix(
275
- mix(dot(_hash33(i + vec3f(0,0,1)), f - vec3f(0,0,1)),
276
- dot(_hash33(i + vec3f(1,0,1)), f - vec3f(1,0,1)), u.x),
277
- mix(dot(_hash33(i + vec3f(0,1,1)), f - vec3f(0,1,1)),
278
- dot(_hash33(i + vec3f(1,1,1)), f - vec3f(1,1,1)), u.x), u.y),
279
- u.z);
280
- }
281
-
282
- fn tex_noise(p: vec3f, scale: f32, detail: f32, roughness: f32, distortion: f32) -> f32 {
283
- var q = p;
284
- if (abs(distortion) > 1e-6) {
285
- let w = _noise3(p * scale * 1.37 + vec3f(2.31, 5.17, 8.09));
286
- q = p + (w * 2.0 - 1.0) * distortion;
287
- }
288
- let coords = q * scale;
289
- var value = 0.0;
290
- var amplitude = 1.0;
291
- var frequency = 1.0;
292
- var total_amp = 0.0;
293
- let octaves = i32(clamp(detail, 0.0, 15.0)) + 1;
294
- for (var i = 0; i < octaves; i++) {
295
- value += amplitude * _noise3(coords * frequency);
296
- total_amp += amplitude;
297
- amplitude *= roughness;
298
- frequency *= 2.0;
299
- }
300
- return value / max(total_amp, 1e-6) * 0.5 + 0.5;
301
- }
302
-
303
- // tex_noise specialization: detail=2.0 (3 octaves), roughness=0.5, distortion=0.
304
- // WebKit can't unroll tex_noise's for-loop because 'octaves' is a runtime value;
305
- // this variant is fully unrolled with constants folded (total_amp = 1.75).
306
- fn tex_noise_d2(p: vec3f, scale: f32) -> f32 {
307
- let c = p * scale;
308
- let v = _noise3(c) + 0.5 * _noise3(c * 2.0) + 0.25 * _noise3(c * 4.0);
309
- return v * (1.0 / 1.75) * 0.5 + 0.5;
310
- }
311
-
312
- // ─── TEX_GRADIENT (linear) ──────────────────────────────────────────
313
- // Used by Stockings preset. Maps the input vector's X to a 0–1 gradient.
314
-
315
- fn tex_gradient_linear(uv: vec3f) -> f32 {
316
- return clamp(uv.x, 0.0, 1.0);
317
- }
318
-
319
- // ─── TEX_VORONOI ────────────────────────────────────────────────────
320
- // 3D F1 voronoi. _f1 returns Distance; _color returns per-cell hash color
321
- // (matches Blender voronoi.cc: outColor = hash_int3_to_float3(cell + targetOffset)).
322
-
323
- fn tex_voronoi_f1(p: vec3f, scale: f32) -> f32 {
324
- let coords = p * scale;
325
- let i = floor(coords);
326
- let f = fract(coords);
327
- var min_dist = 1e10;
328
- for (var z = -1; z <= 1; z++) {
329
- for (var y = -1; y <= 1; y++) {
330
- for (var x = -1; x <= 1; x++) {
331
- let neighbor = vec3f(f32(x), f32(y), f32(z));
332
- let point = _hash33(i + neighbor) * 0.5 + 0.5;
333
- let diff = neighbor + point - f;
334
- min_dist = min(min_dist, dot(diff, diff));
335
- }
336
- }
337
- }
338
- return sqrt(min_dist);
339
- }
340
-
341
- // The per-cell jitter hash IS the Color output in Blender — reuse the same hash
342
- // tap for jitter + color instead of computing two.
343
- fn tex_voronoi_color(p: vec3f, scale: f32) -> vec3f {
344
- let coords = p * scale;
345
- let i = floor(coords);
346
- let f = fract(coords);
347
- var min_dist = 1e10;
348
- var min_hash = vec3f(0.5);
349
- for (var z = -1; z <= 1; z++) {
350
- for (var y = -1; y <= 1; y++) {
351
- for (var x = -1; x <= 1; x++) {
352
- let neighbor = vec3f(f32(x), f32(y), f32(z));
353
- let h = _hash33(i + neighbor) * 0.5 + 0.5;
354
- let diff = neighbor + h - f;
355
- let d = dot(diff, diff);
356
- if (d < min_dist) {
357
- min_dist = d;
358
- min_hash = h;
359
- }
360
- }
361
- }
362
- }
363
- return min_hash;
364
- }
365
-
366
- // ─── SEPXYZ node ────────────────────────────────────────────────────
367
-
368
- fn separate_xyz(v: vec3f) -> vec3f { return v; }
369
-
370
- // ─── VECT_MATH (cross product) ──────────────────────────────────────
371
-
372
- fn vect_math_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }
373
-
374
- // ─── MAPPING node ───────────────────────────────────────────────────
375
- // Point-type mapping: scale, rotate (euler XYZ), translate.
376
-
377
- fn mapping_point(v: vec3f, loc: vec3f, rot: vec3f, scl: vec3f) -> vec3f {
378
- var p = v * scl;
379
- // simplified: skip rotation when all angles are zero (common case)
380
- if (abs(rot.x) + abs(rot.y) + abs(rot.z) > 1e-6) {
381
- let cx = cos(rot.x); let sx = sin(rot.x);
382
- let cy = cos(rot.y); let sy = sin(rot.y);
383
- let cz = cos(rot.z); let sz = sin(rot.z);
384
- let rx = vec3f(p.x, cx*p.y - sx*p.z, sx*p.y + cx*p.z);
385
- let ry = vec3f(cy*rx.x + sy*rx.z, rx.y, -sy*rx.x + cy*rx.z);
386
- p = vec3f(cz*ry.x - sz*ry.y, sz*ry.x + cz*ry.y, ry.z);
387
- }
388
- return p + loc;
389
- }
390
-
391
- // ─── NORMAL_MAP node (tangent-space) ────────────────────────────────
392
- // Applies a tangent-space normal map. Requires TBN from vertex stage.
393
-
394
- fn normal_map(strength: f32, map_color: vec3f, normal: vec3f, tangent: vec3f, bitangent: vec3f) -> vec3f {
395
- let ts = map_color * 2.0 - 1.0;
396
- let perturbed = normalize(tangent * ts.x + bitangent * ts.y + normal * ts.z);
397
- return normalize(mix(normal, perturbed, strength));
398
- }
399
-
400
- // ─── EEVEE Principled BSDF primitives ───────────────────────────────
401
- // Ports from Blender 3.6 source/blender/draw/engines/eevee/shaders/
402
- // bsdf_common_lib.glsl + gpu_shader_material_principled.glsl.
403
- // Usage pattern (see material shaders): direct spec = bsdf_ggx × sun × shadow
404
- // (NL baked in, no F yet); ambient spec = probe_radiance; tint both with
405
- // reflection_color = F_brdf_multi_scatter(f0, f90, split_sum) AFTER summing.
406
-
407
- const EEVEE_PI: f32 = 3.141592653589793;
408
-
409
- // Fused analytic GGX specular (direct lights). Returns BRDF × NL.
410
- // 4·NL·NV is cancelled via G1_Smith reciprocal form see bsdf_common_lib.glsl:115.
411
- // Caller passes NL, NV (already computed for diffuse + brdf_lut_sample) so WebKit
412
- // can reuse them instead of recomputing dot products across the function boundary.
413
- fn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32) -> f32 {
414
- let a = max(roughness, 1e-4);
415
- let a2 = a * a;
416
- let H = normalize(L + V);
417
- let NH = max(dot(N, H), 1e-8);
418
- let NL = max(NL_in, 1e-8);
419
- let NV = max(NV_in, 1e-8);
420
- // G1_Smith_GGX_opti reciprocal form — denominator piece only.
421
- let G1L = NL + sqrt(NL * (NL - NL * a2) + a2);
422
- let G1V = NV + sqrt(NV * (NV - NV * a2) + a2);
423
- let G = G1L * G1V;
424
- // D_ggx_opti = pi * denom² reciprocal D × a².
425
- let tmp = (NH * a2 - NH) * NH + 1.0;
426
- let D_opti = EEVEE_PI * tmp * tmp;
427
- return NL * a2 / (D_opti * G);
428
- }
429
-
430
- // Split-sum DFG LUT Karis 2013 curve fit stand-in for the 64×64 baked LUT.
431
- // Returns (lut.x, lut.y) in Blender convention: tint = f0·lut.x + f90·lut.y.
432
- fn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {
433
- let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);
434
- let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);
435
- let r = roughness * c0 + c1;
436
- let a004 = min(r.x * r.x, exp2(-9.28 * NV)) * r.x + r.y;
437
- return vec2f(-1.04, 1.04) * a004 + r.zw;
438
- }
439
-
440
- // Baked combined BRDF LUT — exact port of Blender bsdf_lut_frag.glsl packed with
441
- // ltc_mag_ggx from eevee_lut.c. Single sample returns DFG (.rg) and LTC mag (.ba).
442
- // Addressed as Blender's common_utiltex_lib.glsl:lut_coords:
443
- // coords = (roughness, sqrt(1 - NV)), then half-texel bias for filtering.
444
- // Requires group(0) binding(9) brdfLut + binding(2) diffuseSampler in the host shader.
445
- fn brdf_lut_sample(NV: f32, roughness: f32) -> vec4f {
446
- let LUT_SIZE: f32 = 64.0;
447
- var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));
448
- uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;
449
- return textureSampleLevel(brdfLut, diffuseSampler, uv, 0.0);
450
- }
451
-
452
- fn F_brdf_single_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {
453
- return lut.y * f90 + lut.x * f0;
454
- }
455
-
456
- // Fdez-Agüera 2019 multi-scatter compensation (EEVEE do_multiscatter=1).
457
- fn F_brdf_multi_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {
458
- let FssEss = lut.y * f90 + lut.x * f0;
459
- let Ess = lut.x + lut.y;
460
- let Ems = 1.0 - Ess;
461
- let Favg = f0 + (1.0 - f0) / 21.0;
462
- let Fms = FssEss * Favg / (1.0 - (1.0 - Ess) * Favg);
463
- return FssEss + Fms * Ems;
464
- }
465
-
466
- // EEVEE direct-specular energy compensation factor closure_eval_glossy_lib.glsl:79-81:
467
- // ltc_brdf_scale = (ltc.x + ltc.y) / (split_sum.x + split_sum.y)
468
- // Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum;
469
- // direct radiance is rescaled so total-energy matches the split-sum LUT.
470
- // Takes a pre-sampled vec4f from brdf_lut_sample() to share the fetch with
471
- // F_brdf_multi_scatter on the same fragment.
472
- fn ltc_brdf_scale_from_lut(lut: vec4f) -> f32 {
473
- return (lut.z + lut.w) / max(lut.x + lut.y, 1e-6);
474
- }
475
-
476
- // Luminance-normalized hue extraction Blender tint_from_color (isolates hue+sat).
477
- fn tint_from_color(color: vec3f) -> vec3f {
478
- let lum = dot(color, vec3f(0.3, 0.6, 0.1));
479
- return select(vec3f(1.0), color / lum, lum > 0.0);
480
- }
481
-
482
- // ─── Principled sheen (gpu_shader_material_principled.glsl:8-14) ────
483
- // Empirical NV-only curve that approximates grazing retroreflection on cloth/velvet.
484
- // Scales the sheen layer's diffuse contribution; no sheen call site has sheen=0
485
- // shortcut because the multiplier is tiny at normal view angles anyway.
486
- fn principled_sheen(NV: f32) -> f32 {
487
- let f = 1.0 - NV;
488
- return f * f * f * 0.077 + f * 0.01 + 0.00026;
489
- }
490
-
491
- // ─── Principled BSDF eval ───────────────────────────────────────────
492
- // Shared EEVEE Principled path used by every material in the engine metallic,
493
- // dielectric, and sheen variants all fold into these ~15 lines via the struct
494
- // fields. NPR materials still compute a separate toon/rim/warm stack on top and
495
- // mix(npr_stack, eval_principled(...), fac); see body.ts / face.ts / etc.
496
- //
497
- // Field conventions:
498
- // base — diffuse albedo. Mixed into f0 only when metallic > 0.
499
- // metallic — 0 = dielectric (f0 from specular), 1 = pure metal (f0 = base).
500
- // specular — Principled Specular input (0.5 default f0 = 0.04). sqrt for f90.
501
- // roughness — GGX roughness; drives BRDF LUT coord + bsdf_ggx.
502
- // spec_clamp — EEVEE Light Clamp equivalent. Caps firefly spec from noise-bumped
503
- // NDF aliasing (Blender hides this via TAA which we don't have).
504
- // Pass 1e30 (effectively disabled) for materials that don't bump.
505
- // sheen — 0 disables. Scales the sheen diffuse add; cloth/stockings use ~0.7.
506
- // sheen_tint — 0 = white sheen, 1 = fully tinted by base. Multiplied by sheen,
507
- // so value is don't-care when sheen=0.
508
- // The PMX sphere map, applied the way MMD applies it: the texture is a
509
- // VIEW-SPACE lighting mask, sampled by the camera-space normal rather than by
510
- // any UV the mesh carries, which is why it tracks the viewer and reads as a
511
- // highlight. The material's own mode picks the operator — .sph (1) multiplies
512
- // the shaded base, .spa (2) adds — so a graph asks for the effect and the model
513
- // decides which it meant. Mode 0, or a material with no sphere texture (the 1×1
514
- // white fallback is bound then), is an exact no-op.
515
- fn pmx_sphere_map(base: vec3f, strength: f32, N: vec3f) -> vec3f {
516
- if (material.sphereMode < 0.5) {
517
- return base;
518
- }
519
- let camera_n = safe_normal((camera.view * vec4f(N, 0.0)).xyz);
520
- let sphere_uv = clamp(camera_n.xy * 0.5 + vec2f(0.5), vec2f(0.0), vec2f(1.0));
521
- let sphere_rgb = textureSample(sphereTexture, diffuseSampler, sphere_uv).rgb;
522
- let amount = max(strength, 0.0);
523
- if (material.sphereMode < 1.5) {
524
- return mix(base, base * sphere_rgb, min(amount, 1.0));
525
- }
526
- return base + sphere_rgb * amount;
527
- }
528
-
529
- struct PrincipledIn {
530
- base: vec3f,
531
- metallic: f32,
532
- specular: f32,
533
- roughness: f32,
534
- spec_clamp: f32,
535
- sheen: f32,
536
- sheen_tint: f32,
537
- };
538
-
539
- fn eval_principled(
540
- p: PrincipledIn,
541
- N: vec3f, L: vec3f, V: vec3f,
542
- sun_rgb: vec3f, amb_rgb: vec3f, shadow: f32
543
- ) -> vec3f {
544
- let NL = max(dot(N, L), 0.0);
545
- let NV = max(dot(N, V), 1e-4);
546
-
547
- // f0/f90 per gpu_shader_material_principled.glsl. specular_tint=0 is assumed
548
- // (all presets in this engine use the default white dielectric tint).
549
- let dielectric_f0 = vec3f(0.08 * p.specular);
550
- let f0 = mix(dielectric_f0, p.base, p.metallic);
551
- let f90 = mix(f0, vec3f(1.0), sqrt(p.specular));
552
-
553
- // Single LUT tap feeds both F_brdf_multi_scatter (split-sum DFG) and
554
- // ltc_brdf_scale_from_lut (LTC mag in .ba). See nodes.ts brdf_lut_sample.
555
- let lut = brdf_lut_sample(NV, p.roughness);
556
- let reflection_color = F_brdf_multi_scatter(f0, f90, lut.xy);
557
-
558
- // Direct glossy bsdf_ggx already includes NL; no F applied here (tinted after
559
- // accum with reflection_color). ltc_brdf_scale rescales direct to match the
560
- // split-sum indirect path, matching EEVEE closure_eval_glossy_lib behavior.
561
- let spec_direct_raw = bsdf_ggx(N, L, V, NL, NV, p.roughness)
562
- * sun_rgb * shadow * ltc_brdf_scale_from_lut(lut);
563
- let spec_direct = min(spec_direct_raw, vec3f(p.spec_clamp));
564
- let spec_indirect = amb_rgb;
565
- let spec_radiance = (spec_direct + spec_indirect) * reflection_color;
566
-
567
- // Sheen add when p.sheen=0 the whole term collapses, leaving diffuse_color=base.
568
- let base_tint = tint_from_color(p.base);
569
- let sheen_color = mix(vec3f(1.0), base_tint, p.sheen_tint);
570
- let diffuse_color = p.base + p.sheen * sheen_color * principled_sheen(NV);
571
-
572
- // diffuse_weight = (1-metallic). Indirect diffuse uses amb (L_w) with no π factor
573
- // (probe_evaluate_world_diff returns SH-projected radiance, not cosine-convolved).
574
- let diffuse_weight = 1.0 - p.metallic;
575
- let diffuse_radiance = diffuse_color * (sun_rgb * NL * shadow / EEVEE_PI + amb_rgb) * diffuse_weight;
576
-
577
- return diffuse_radiance + spec_radiance;
578
- }
579
-
4
+ export const NODES_WGSL = /* wgsl */ `
5
+
6
+ // Baked 64×64 rgba8unorm combined BRDF LUT — created once at engine init by dfg_lut.ts.
7
+ // .rg = split-sum DFG (Karis: tint = f0·x + f90·y) → F_brdf_*_scatter
8
+ // .ba = Heitz 2016 LTC magnitude (ltc_mag_ggx) → ltc_brdf_scale_from_lut
9
+ // Paired with group(0) binding(2) diffuseSampler (linear filter). Sample once per
10
+ // fragment via brdf_lut_sample() — callers feed .rg and the whole vec4 into the
11
+ // helpers below, halving LUT taps on the default Principled path.
12
+ @group(0) @binding(9) var brdfLut: texture_2d<f32>;
13
+
14
+ // ─── RGB ↔ HSV ──────────────────────────────────────────────────────
15
+
16
+ fn rgb_to_hsv(rgb: vec3f) -> vec3f {
17
+ let c_max = max(rgb.r, max(rgb.g, rgb.b));
18
+ let c_min = min(rgb.r, min(rgb.g, rgb.b));
19
+ let delta = c_max - c_min;
20
+
21
+ var h = 0.0;
22
+ if (delta > 1e-6) {
23
+ if (c_max == rgb.r) {
24
+ h = (rgb.g - rgb.b) / delta;
25
+ if (h < 0.0) { h += 6.0; }
26
+ } else if (c_max == rgb.g) {
27
+ h = 2.0 + (rgb.b - rgb.r) / delta;
28
+ } else {
29
+ h = 4.0 + (rgb.r - rgb.g) / delta;
30
+ }
31
+ h /= 6.0;
32
+ }
33
+ let s = select(0.0, delta / c_max, c_max > 1e-6);
34
+ return vec3f(h, s, c_max);
35
+ }
36
+
37
+ fn hsv_to_rgb(hsv: vec3f) -> vec3f {
38
+ let h = hsv.x;
39
+ let s = hsv.y;
40
+ let v = hsv.z;
41
+ if (s < 1e-6) { return vec3f(v); }
42
+
43
+ let hh = fract(h) * 6.0;
44
+ let sector = u32(hh);
45
+ let f = hh - f32(sector);
46
+ let p = v * (1.0 - s);
47
+ let q = v * (1.0 - s * f);
48
+ let t = v * (1.0 - s * (1.0 - f));
49
+
50
+ switch (sector) {
51
+ case 0u: { return vec3f(v, t, p); }
52
+ case 1u: { return vec3f(q, v, p); }
53
+ case 2u: { return vec3f(p, v, t); }
54
+ case 3u: { return vec3f(p, q, v); }
55
+ case 4u: { return vec3f(t, p, v); }
56
+ default: { return vec3f(v, p, q); }
57
+ }
58
+ }
59
+
60
+ // ─── HUE_SAT node ───────────────────────────────────────────────────
61
+
62
+ fn hue_sat(hue: f32, saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {
63
+ var hsv = rgb_to_hsv(color);
64
+ hsv.x = fract(hsv.x + hue - 0.5);
65
+ hsv.y = clamp(hsv.y * saturation, 0.0, 1.0);
66
+ hsv.z *= value;
67
+ return mix(color, hsv_to_rgb(hsv), fac);
68
+ }
69
+
70
+ // hue_sat specialization for hue=0.5 (identity hue shift — fract(h + 0.5 - 0.5) = h).
71
+ // Branchless equivalent that skips the rgb_to_hsv → hsv_to_rgb roundtrip: WebKit's
72
+ // Metal backend serializes the 3-way if chain in rgb_to_hsv and the 6-way switch in
73
+ // hsv_to_rgb, where this form compiles to linear SIMD ops + a single select.
74
+ fn hue_sat_id(saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {
75
+ let m = max(max(color.r, color.g), color.b);
76
+ let n = min(min(color.r, color.g), color.b);
77
+ // Unclamped (sat*old_s ≤ 1): reproj = mix(vec3f(m), color, saturation).
78
+ // Clamped (saturated to 1): reproj = (color - n) * m / (m - n).
79
+ let range = max(m - n, 1e-6);
80
+ let unclamped = mix(vec3f(m), color, saturation);
81
+ let clamped = (color - vec3f(n)) * m / range;
82
+ let needs_clamp = (m - n) * saturation >= m;
83
+ let reproj = select(unclamped, clamped, needs_clamp);
84
+ return mix(color, reproj * value, fac);
85
+ }
86
+
87
+ // ─── BRIGHTCONTRAST node ────────────────────────────────────────────
88
+
89
+ fn bright_contrast(color: vec3f, bright: f32, contrast: f32) -> vec3f {
90
+ let a = 1.0 + contrast;
91
+ let b = bright - contrast * 0.5;
92
+ return max(vec3f(0.0), color * a + vec3f(b));
93
+ }
94
+
95
+ // ─── INVERT node ────────────────────────────────────────────────────
96
+
97
+ fn invert(fac: f32, color: vec3f) -> vec3f {
98
+ return mix(color, vec3f(1.0) - color, fac);
99
+ }
100
+
101
+ fn invert_f(fac: f32, val: f32) -> f32 {
102
+ return mix(val, 1.0 - val, fac);
103
+ }
104
+
105
+ // ─── Color ramp (VALTORGB) — 2-stop variants ───────────────────────
106
+ // All 7 presets use exclusively 2-stop ramps.
107
+
108
+ fn ramp_constant(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {
109
+ return select(c0, c1, f >= p1);
110
+ }
111
+
112
+ // CONSTANT ramp with screen-space edge AA — kills sparkle where fwidth(f) straddles a hard step (NPR terminator)
113
+ fn ramp_constant_edge_aa(f: f32, edge: f32, c0: vec4f, c1: vec4f) -> vec4f {
114
+ let w = max(fwidth(f) * 1.75, 6e-6);
115
+ let t = smoothstep(edge - w, edge + w, f);
116
+ return mix(c0, c1, t);
117
+ }
118
+
119
+ fn ramp_linear(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {
120
+ let t = saturate((f - p0) / max(p1 - p0, 1e-6));
121
+ return mix(c0, c1, t);
122
+ }
123
+
124
+ // LINEAR ramp, three stops. Both halves are evaluated and selected between rather
125
+ // than branched on, so there is no divergence; saturate outside each span already
126
+ // gives Blender's clamp-to-end-stop behaviour.
127
+ fn ramp_linear3(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f, p2: f32, c2: vec4f) -> vec4f {
128
+ let lo = mix(c0, c1, saturate((f - p0) / max(p1 - p0, 1e-6)));
129
+ let hi = mix(c1, c2, saturate((f - p1) / max(p2 - p1, 1e-6)));
130
+ return select(lo, hi, f >= p1);
131
+ }
132
+
133
+ fn ramp_cardinal(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {
134
+ // cardinal spline with 2 stops degrades to smoothstep
135
+ let t = saturate((f - p0) / max(p1 - p0, 1e-6));
136
+ let ss = t * t * (3.0 - 2.0 * t);
137
+ return mix(c0, c1, ss);
138
+ }
139
+
140
+ // ─── MATH node operations ───────────────────────────────────────────
141
+
142
+ // Transcribed from Blender 5.2's gpu_shader_common_math.glsl, including the
143
+ // safe-guards — a Math node that divides by zero yields 0 there, not inf, and a
144
+ // graph written against that behaviour relies on it.
145
+ fn math_add(a: f32, b: f32) -> f32 { return a + b; }
146
+ fn math_subtract(a: f32, b: f32) -> f32 { return a - b; }
147
+ fn math_multiply(a: f32, b: f32) -> f32 { return a * b; }
148
+ fn math_divide(a: f32, b: f32) -> f32 { return select(0.0, a / b, b != 0.0); }
149
+ fn math_multiply_add(a: f32, b: f32, c: f32) -> f32 { return a * b + c; }
150
+ fn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }
151
+ fn math_logarithm(a: f32, b: f32) -> f32 { return select(0.0, log(a) / log(b), a > 0.0 && b > 0.0 && b != 1.0); }
152
+ fn math_sqrt(a: f32) -> f32 { return sqrt(max(a, 0.0)); }
153
+ fn math_inversesqrt(a: f32) -> f32 { return inverseSqrt(max(a, 1e-8)); }
154
+ fn math_absolute(a: f32) -> f32 { return abs(a); }
155
+ fn math_exponent(a: f32) -> f32 { return exp(a); }
156
+ fn math_minimum(a: f32, b: f32) -> f32 { return min(a, b); }
157
+ fn math_maximum(a: f32, b: f32) -> f32 { return max(a, b); }
158
+ fn math_less_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a < b); }
159
+ fn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }
160
+ fn math_sign(a: f32) -> f32 { return sign(a); }
161
+ // The tolerance floors at 1e-5 in Blender, so a zero third input still compares.
162
+ fn math_compare(a: f32, b: f32, c: f32) -> f32 { return select(0.0, 1.0, abs(a - b) <= max(c, 1e-5)); }
163
+ fn math_smooth_min(a: f32, b: f32, c: f32) -> f32 {
164
+ if (c == 0.0) { return min(a, b); }
165
+ let h = max(c - abs(a - b), 0.0) / c;
166
+ return min(a, b) - h * h * h * c * (1.0 / 6.0);
167
+ }
168
+ fn math_smooth_max(a: f32, b: f32, c: f32) -> f32 { return -math_smooth_min(-a, -b, c); }
169
+ fn math_round(a: f32) -> f32 { return floor(a + 0.5); }
170
+ fn math_floor(a: f32) -> f32 { return floor(a); }
171
+ fn math_ceil(a: f32) -> f32 { return ceil(a); }
172
+ fn math_truncate(a: f32) -> f32 { return trunc(a); }
173
+ fn math_fraction(a: f32) -> f32 { return a - floor(a); }
174
+ // Blender's MODULO is fmod (truncated), not floorMod — the sign follows a.
175
+ fn math_modulo(a: f32, b: f32) -> f32 { return select(0.0, a - b * trunc(a / b), b != 0.0); }
176
+ fn math_floored_modulo(a: f32, b: f32) -> f32 { return select(0.0, a - floor(a / b) * b, b != 0.0); }
177
+ fn math_snap(a: f32, b: f32) -> f32 { return floor(select(0.0, a / b, b != 0.0)) * b; }
178
+ fn math_wrap(a: f32, b: f32, c: f32) -> f32 {
179
+ let range = b - c;
180
+ return select(c, a - range * floor((a - c) / range), range != 0.0);
181
+ }
182
+ fn math_pingpong(a: f32, b: f32) -> f32 {
183
+ return select(0.0, abs(fract((a - b) / (b * 2.0)) * b * 2.0 - b), b != 0.0);
184
+ }
185
+ fn math_sine(a: f32) -> f32 { return sin(a); }
186
+ fn math_cosine(a: f32) -> f32 { return cos(a); }
187
+ fn math_tangent(a: f32) -> f32 { return tan(a); }
188
+ fn math_arcsine(a: f32) -> f32 { return asin(clamp(a, -1.0, 1.0)); }
189
+ fn math_arccosine(a: f32) -> f32 { return acos(clamp(a, -1.0, 1.0)); }
190
+ fn math_arctangent(a: f32) -> f32 { return atan(a); }
191
+ fn math_arctan2(a: f32, b: f32) -> f32 { return atan2(a, b); }
192
+ fn math_radians(a: f32) -> f32 { return radians(a); }
193
+ fn math_degrees(a: f32) -> f32 { return degrees(a); }
194
+
195
+ // ─── RGB Curves, as five samples ──────────────────────────────────
196
+
197
+ /**
198
+ * Blender's RGB Curves holds arbitrary control points, which cannot cross into a
199
+ * shader as sockets. So the curve arrives SAMPLED: five values at t = 0, .25,
200
+ * .5, .75, 1, and this rebuilds it with the same monotone (Fritsch-Carlson)
201
+ * interpolation the Filmic LUT uses — C1 continuous, and it cannot overshoot,
202
+ * which matters because a curve that overshoots turns a highlight roll-off into
203
+ * a bright ring.
204
+ *
205
+ * Five points captures the levels-and-contrast adjustments these curves are
206
+ * actually used for. A curve with more structure than that will not survive, and
207
+ * should be a ramp instead.
208
+ */
209
+ fn curve_slope(d0: f32, d1: f32) -> f32 {
210
+ if (d0 * d1 <= 0.0) { return 0.0; }
211
+ let m = (d0 + d1) * 0.5;
212
+ return sign(d0) * min(abs(m), 2.0 * min(abs(d0), abs(d1)));
213
+ }
214
+ fn curve5(t0: f32, y0: f32, y1: f32, y2: f32, y3: f32, y4: f32) -> f32 {
215
+ let t = clamp(t0, 0.0, 1.0) * 4.0;
216
+ let i = min(floor(t), 3.0);
217
+ let f = t - i;
218
+ // A var, not a let: WGSL only allows a dynamic index on a REFERENCE, and a
219
+ // let-bound array is a value. Indexing it with a runtime k is a compile error —
220
+ // and because this file is concatenated into every material shader, that error
221
+ // took every graph in the library down with it, not just curves.
222
+ var ys = array<f32, 5>(y0, y1, y2, y3, y4);
223
+ let k = i32(i);
224
+ let pa = ys[k];
225
+ let pb = ys[k + 1];
226
+ // Secants either side of each knot, clamped at the ends.
227
+ let dPrev = select(ys[max(k, 1)] - ys[max(k, 1) - 1], pb - pa, k == 0);
228
+ let dHere = pb - pa;
229
+ let dNext = select(ys[min(k + 2, 4)] - ys[min(k + 1, 3)], pb - pa, k == 3);
230
+ let m0 = curve_slope(dPrev, dHere);
231
+ let m1 = curve_slope(dHere, dNext);
232
+ let f2 = f * f;
233
+ let f3 = f2 * f;
234
+ return (2.0 * f3 - 3.0 * f2 + 1.0) * pa + (f3 - 2.0 * f2 + f) * m0 +
235
+ (-2.0 * f3 + 3.0 * f2) * pb + (f3 - f2) * m1;
236
+ }
237
+ fn rgb_curve(c: vec3f, y0: f32, y1: f32, y2: f32, y3: f32, y4: f32) -> vec3f {
238
+ return vec3f(
239
+ curve5(c.r, y0, y1, y2, y3, y4),
240
+ curve5(c.g, y0, y1, y2, y3, y4),
241
+ curve5(c.b, y0, y1, y2, y3, y4),
242
+ );
243
+ }
244
+
245
+ // ─── Normal Map / Vector Transform ────────────────────────────────
246
+
247
+ /**
248
+ * Normal Map, with the tangent frame derived rather than read.
249
+ *
250
+ * Blender reads a per-vertex tangent; PMX files do not carry one, so there is
251
+ * nothing to read. The frame is rebuilt per-pixel from screen-space derivatives
252
+ * of position and UV the standard substitute, exact on flat-ish shading and
253
+ * off only where UVs are severely sheared. Cheaper than the alternative (running
254
+ * a tangent-generation pass over every imported mesh) and invisible on the
255
+ * character work this is for.
256
+ */
257
+ fn node_normal_map(color: vec3f, strength: f32, N: vec3f, worldPos: vec3f, uv: vec2f) -> vec3f {
258
+ let dp1 = dpdx(worldPos);
259
+ let dp2 = dpdy(worldPos);
260
+ let duv1 = dpdx(uv);
261
+ let duv2 = dpdy(uv);
262
+ let det = duv1.x * duv2.y - duv2.x * duv1.y;
263
+ if (abs(det) < 1e-12) { return N; }
264
+ let inv = 1.0 / det;
265
+ let T = safe_normal((dp1 * duv2.y - dp2 * duv1.y) * inv);
266
+ let B = safe_normal(cross(N, T));
267
+ // Tangent-space [0,1] → [-1,1], then into world.
268
+ let t = color * 2.0 - vec3f(1.0);
269
+ let mapped = safe_normal(T * t.x + B * t.y + N * t.z);
270
+ return safe_normal(mix(N, mapped, clamp(strength, 0.0, 1.0)));
271
+ }
272
+
273
+ /** Vector Transform, world camera. Object space IS world here: a PMX model is
274
+ * placed by its own transform and shaded in world space, so there is no
275
+ * separate object frame to convert into. */
276
+ fn vector_world_to_camera(v: vec3f) -> vec3f { return (camera.view * vec4f(v, 0.0)).xyz; }
277
+ fn vector_camera_to_world(v: vec3f) -> vec3f { return (transpose(camera.view) * vec4f(v, 0.0)).xyz; }
278
+ fn point_world_to_camera(p: vec3f) -> vec3f { return (camera.view * vec4f(p, 1.0)).xyz; }
279
+
280
+ // ─── Style-group image maps (Image Texture on a non-PMX slot) ──────
281
+ // One function per slot because WGSL has no texture arrays-of-bindings here; the
282
+ // slot is topology, exactly like a Math node's operation.
283
+
284
+ fn group_tex0(uv: vec2f) -> vec4f { return textureSample(groupTexture0, diffuseSampler, uv); }
285
+ fn group_tex1(uv: vec2f) -> vec4f { return textureSample(groupTexture1, diffuseSampler, uv); }
286
+ fn group_tex2(uv: vec2f) -> vec4f { return textureSample(groupTexture2, diffuseSampler, uv); }
287
+ fn group_tex3(uv: vec2f) -> vec4f { return textureSample(groupTexture3, diffuseSampler, uv); }
288
+
289
+ // ─── VECTOR MATH node operations ───────────────────────────────────
290
+
291
+ fn vector_add(a: vec3f, b: vec3f) -> vec3f { return a + b; }
292
+ fn vector_subtract(a: vec3f, b: vec3f) -> vec3f { return a - b; }
293
+ fn vector_multiply(a: vec3f, b: vec3f) -> vec3f { return a * b; }
294
+ fn vector_divide(a: vec3f, b: vec3f) -> vec3f {
295
+ return vec3f(math_divide(a.x, b.x), math_divide(a.y, b.y), math_divide(a.z, b.z));
296
+ }
297
+ fn vector_multiply_add(a: vec3f, b: vec3f, c: vec3f) -> vec3f { return a * b + c; }
298
+ fn vector_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }
299
+ fn vector_project(a: vec3f, b: vec3f) -> vec3f {
300
+ let l = dot(b, b);
301
+ return select(vec3f(0.0), b * (dot(a, b) / l), l != 0.0);
302
+ }
303
+ fn vector_reflect(a: vec3f, b: vec3f) -> vec3f { return reflect(a, safe_normal(b)); }
304
+ fn vector_refract(a: vec3f, b: vec3f, ior: f32) -> vec3f { return refract(a, safe_normal(b), ior); }
305
+ fn vector_faceforward(a: vec3f, b: vec3f, c: vec3f) -> vec3f {
306
+ return select(a, -a, dot(b, c) < 0.0);
307
+ }
308
+ fn vector_dot(a: vec3f, b: vec3f) -> f32 { return dot(a, b); }
309
+ fn vector_distance(a: vec3f, b: vec3f) -> f32 { return length(a - b); }
310
+ fn vector_length(a: vec3f) -> f32 { return length(a); }
311
+ fn vector_scale(a: vec3f, s: f32) -> vec3f { return a * s; }
312
+ // Blender returns the zero vector rather than NaN for a degenerate input.
313
+ fn vector_normalize(a: vec3f) -> vec3f {
314
+ let l = length(a);
315
+ return select(vec3f(0.0), a / l, l > 0.0);
316
+ }
317
+ fn vector_absolute(a: vec3f) -> vec3f { return abs(a); }
318
+ fn vector_minimum(a: vec3f, b: vec3f) -> vec3f { return min(a, b); }
319
+ fn vector_maximum(a: vec3f, b: vec3f) -> vec3f { return max(a, b); }
320
+ fn vector_floor(a: vec3f) -> vec3f { return floor(a); }
321
+ fn vector_ceil(a: vec3f) -> vec3f { return ceil(a); }
322
+ fn vector_fraction(a: vec3f) -> vec3f { return a - floor(a); }
323
+ fn vector_modulo(a: vec3f, b: vec3f) -> vec3f {
324
+ return vec3f(math_modulo(a.x, b.x), math_modulo(a.y, b.y), math_modulo(a.z, b.z));
325
+ }
326
+ fn vector_snap(a: vec3f, b: vec3f) -> vec3f {
327
+ return vec3f(math_snap(a.x, b.x), math_snap(a.y, b.y), math_snap(a.z, b.z));
328
+ }
329
+ fn vector_wrap(a: vec3f, b: vec3f, c: vec3f) -> vec3f {
330
+ return vec3f(math_wrap(a.x, b.x, c.x), math_wrap(a.y, b.y, c.y), math_wrap(a.z, b.z, c.z));
331
+ }
332
+
333
+ /** Vector Rotate, axis-angle form Rodrigues about an arbitrary axis. */
334
+ fn vector_rotate_axis(v: vec3f, center: vec3f, axis: vec3f, angle: f32) -> vec3f {
335
+ let n = safe_normal(axis);
336
+ let p = v - center;
337
+ let c = cos(angle);
338
+ return center + p * c + cross(n, p) * sin(angle) + n * dot(n, p) * (1.0 - c);
339
+ }
340
+ fn vector_rotate_euler(v: vec3f, center: vec3f, rot: vec3f) -> vec3f {
341
+ let p = v - center;
342
+ let cx = cos(rot.x); let sx = sin(rot.x);
343
+ let cy = cos(rot.y); let sy = sin(rot.y);
344
+ let cz = cos(rot.z); let sz = sin(rot.z);
345
+ // XYZ order, matching Blender's default euler.
346
+ let rx = vec3f(p.x, p.y * cx - p.z * sx, p.y * sx + p.z * cx);
347
+ let ry = vec3f(rx.x * cy + rx.z * sy, rx.y, -rx.x * sy + rx.z * cy);
348
+ return center + vec3f(ry.x * cz - ry.y * sz, ry.x * sz + ry.y * cz, ry.z);
349
+ }
350
+
351
+ // Blender's implicit Color Float socket conversion uses BT.601 grayscale
352
+ // (rgb_to_grayscale in blenkernel/intern/node.cc). When a material graph plugs a
353
+ // Color output into a Math node's Value input, this is the scalar it actually sees.
354
+ fn color_to_value(c: vec3f) -> f32 {
355
+ return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;
356
+ }
357
+
358
+ // ─── MIX node (blend_type variants) ────────────────────────────────
359
+
360
+ fn mix_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {
361
+ return mix(a, b, fac);
362
+ }
363
+
364
+ fn mix_overlay(fac: f32, a: vec3f, b: vec3f) -> vec3f {
365
+ let lo = 2.0 * a * b;
366
+ let hi = vec3f(1.0) - 2.0 * (vec3f(1.0) - a) * (vec3f(1.0) - b);
367
+ let overlay = select(hi, lo, a < vec3f(0.5));
368
+ return mix(a, overlay, fac);
369
+ }
370
+
371
+ fn mix_multiply(fac: f32, a: vec3f, b: vec3f) -> vec3f {
372
+ return mix(a, a * b, fac);
373
+ }
374
+
375
+ fn mix_lighten(fac: f32, a: vec3f, b: vec3f) -> vec3f {
376
+ return mix(a, max(a, b), fac);
377
+ }
378
+
379
+ // Blender Mix (Color) blend LINEAR_LIGHT: result = mix(A, A + 2*B - 1, Fac)
380
+ fn mix_linear_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {
381
+ return mix(a, a + 2.0 * b - vec3f(1.0), fac);
382
+ }
383
+
384
+ // The rest of Blender's blend set, transcribed from
385
+ // gpu_shader_material_mix_color.glsl. Note DODGE, BURN and DIVIDE are
386
+ // per-channel with their own guards there, not vector expressions.
387
+ fn mix_add(fac: f32, a: vec3f, b: vec3f) -> vec3f { return mix(a, a + b, fac); }
388
+ fn mix_subtract(fac: f32, a: vec3f, b: vec3f) -> vec3f { return mix(a, a - b, fac); }
389
+ fn mix_darken(fac: f32, a: vec3f, b: vec3f) -> vec3f { return mix(a, min(a, b), fac); }
390
+ fn mix_difference(fac: f32, a: vec3f, b: vec3f) -> vec3f { return mix(a, abs(a - b), fac); }
391
+ fn mix_exclusion(fac: f32, a: vec3f, b: vec3f) -> vec3f {
392
+ return max(mix(a, a + b - 2.0 * a * b, fac), vec3f(0.0));
393
+ }
394
+ fn mix_screen(fac: f32, a: vec3f, b: vec3f) -> vec3f {
395
+ let facm = 1.0 - fac;
396
+ return vec3f(1.0) - (vec3f(facm) + fac * (vec3f(1.0) - b)) * (vec3f(1.0) - a);
397
+ }
398
+ fn mix_soft_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {
399
+ let facm = 1.0 - fac;
400
+ let scr = vec3f(1.0) - (vec3f(1.0) - b) * (vec3f(1.0) - a);
401
+ return facm * a + fac * ((vec3f(1.0) - a) * b * a + a * scr);
402
+ }
403
+ fn mix_dodge_1(fac: f32, a: f32, b: f32) -> f32 {
404
+ if (a == 0.0) { return 0.0; }
405
+ let t = 1.0 - fac * b;
406
+ if (t <= 0.0) { return 1.0; }
407
+ return min(a / t, 1.0);
408
+ }
409
+ fn mix_dodge(fac: f32, a: vec3f, b: vec3f) -> vec3f {
410
+ return vec3f(mix_dodge_1(fac, a.r, b.r), mix_dodge_1(fac, a.g, b.g), mix_dodge_1(fac, a.b, b.b));
411
+ }
412
+ fn mix_burn_1(fac: f32, a: f32, b: f32) -> f32 {
413
+ let facm = 1.0 - fac;
414
+ let t = facm + fac * b;
415
+ if (t <= 0.0) { return 0.0; }
416
+ return clamp(1.0 - (1.0 - a) / t, 0.0, 1.0);
417
+ }
418
+ fn mix_burn(fac: f32, a: vec3f, b: vec3f) -> vec3f {
419
+ return vec3f(mix_burn_1(fac, a.r, b.r), mix_burn_1(fac, a.g, b.g), mix_burn_1(fac, a.b, b.b));
420
+ }
421
+ fn mix_divide_1(fac: f32, a: f32, b: f32) -> f32 {
422
+ return select(a, (1.0 - fac) * a + fac * a / b, b != 0.0);
423
+ }
424
+ fn mix_divide(fac: f32, a: vec3f, b: vec3f) -> vec3f {
425
+ return vec3f(mix_divide_1(fac, a.r, b.r), mix_divide_1(fac, a.g, b.g), mix_divide_1(fac, a.b, b.b));
426
+ }
427
+ // The HSV-routed blends: take one channel from B, the rest from A.
428
+ fn mix_hue(fac: f32, a: vec3f, b: vec3f) -> vec3f {
429
+ let hsvB = rgb_to_hsv(b);
430
+ if (hsvB.y == 0.0) { return a; }
431
+ var hsv = rgb_to_hsv(a);
432
+ hsv.x = hsvB.x;
433
+ return mix(a, hsv_to_rgb(hsv), fac);
434
+ }
435
+ fn mix_saturation(fac: f32, a: vec3f, b: vec3f) -> vec3f {
436
+ var hsv = rgb_to_hsv(a);
437
+ if (hsv.y == 0.0) { return a; }
438
+ hsv.y = mix(hsv.y, rgb_to_hsv(b).y, fac);
439
+ return hsv_to_rgb(hsv);
440
+ }
441
+ fn mix_color_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {
442
+ let hsvB = rgb_to_hsv(b);
443
+ if (hsvB.y == 0.0) { return a; }
444
+ var hsv = rgb_to_hsv(a);
445
+ hsv.x = hsvB.x;
446
+ hsv.y = hsvB.y;
447
+ return mix(a, hsv_to_rgb(hsv), fac);
448
+ }
449
+ fn mix_value(fac: f32, a: vec3f, b: vec3f) -> vec3f {
450
+ var hsv = rgb_to_hsv(a);
451
+ hsv.z = mix(hsv.z, rgb_to_hsv(b).z, fac);
452
+ return hsv_to_rgb(hsv);
453
+ }
454
+
455
+ // ─── Colour utility nodes ──────────────────────────────────────────
456
+
457
+ /** Gamma node Blender guards the zero case rather than emitting NaN. */
458
+ fn node_gamma(c: vec3f, g: f32) -> vec3f {
459
+ return vec3f(
460
+ select(0.0, pow(c.r, g), c.r > 0.0),
461
+ select(0.0, pow(c.g, g), c.g > 0.0),
462
+ select(0.0, pow(c.b, g), c.b > 0.0),
463
+ );
464
+ }
465
+
466
+ /** Map Range, linear, with the clamp Blender applies when the toggle is on. */
467
+ fn map_range_linear(v: f32, fromMin: f32, fromMax: f32, toMin: f32, toMax: f32) -> f32 {
468
+ let d = fromMax - fromMin;
469
+ return select(0.0, toMin + ((v - fromMin) / d) * (toMax - toMin), d != 0.0);
470
+ }
471
+ fn map_range_clamped(v: f32, fromMin: f32, fromMax: f32, toMin: f32, toMax: f32) -> f32 {
472
+ let r = map_range_linear(v, fromMin, fromMax, toMin, toMax);
473
+ return clamp(r, min(toMin, toMax), max(toMin, toMax));
474
+ }
475
+ /** Map Range, smoothstep interpolation. */
476
+ fn map_range_smooth(v: f32, fromMin: f32, fromMax: f32, toMin: f32, toMax: f32) -> f32 {
477
+ let d = fromMax - fromMin;
478
+ if (d == 0.0) { return 0.0; }
479
+ let t = clamp((v - fromMin) / d, 0.0, 1.0);
480
+ return toMin + (t * t * (3.0 - 2.0 * t)) * (toMax - toMin);
481
+ }
482
+
483
+ /** RGB→HSL and back, for Separate/Combine Color's HSL mode. */
484
+ fn rgb_to_hsl(c: vec3f) -> vec3f {
485
+ let mx = max(c.r, max(c.g, c.b));
486
+ let mn = min(c.r, min(c.g, c.b));
487
+ let l = (mx + mn) * 0.5;
488
+ if (mx == mn) { return vec3f(0.0, 0.0, l); }
489
+ let d = mx - mn;
490
+ let s = select(d / (2.0 - mx - mn), d / (mx + mn), l <= 0.5);
491
+ var h: f32;
492
+ if (mx == c.r) { h = (c.g - c.b) / d + select(0.0, 6.0, c.g < c.b); }
493
+ else if (mx == c.g) { h = (c.b - c.r) / d + 2.0; }
494
+ else { h = (c.r - c.g) / d + 4.0; }
495
+ return vec3f(h / 6.0, s, l);
496
+ }
497
+ fn hsl_channel(p: f32, q: f32, t0: f32) -> f32 {
498
+ var t = fract(t0);
499
+ if (t < 1.0 / 6.0) { return p + (q - p) * 6.0 * t; }
500
+ if (t < 0.5) { return q; }
501
+ if (t < 2.0 / 3.0) { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }
502
+ return p;
503
+ }
504
+ fn hsl_to_rgb(hsl: vec3f) -> vec3f {
505
+ if (hsl.y == 0.0) { return vec3f(hsl.z); }
506
+ let q = select(hsl.z + hsl.y - hsl.z * hsl.y, hsl.z * (1.0 + hsl.y), hsl.z < 0.5);
507
+ let p = 2.0 * hsl.z - q;
508
+ return vec3f(
509
+ hsl_channel(p, q, hsl.x + 1.0 / 3.0),
510
+ hsl_channel(p, q, hsl.x),
511
+ hsl_channel(p, q, hsl.x - 1.0 / 3.0),
512
+ );
513
+ }
514
+
515
+ // Luminance for Shader→RGB scalar gates (linear RGB, Rec.709 weights)
516
+ fn luminance_rec709_linear(c: vec3f) -> f32 {
517
+ return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
518
+ }
519
+
520
+ // ─── FRESNEL node ───────────────────────────────────────────────────
521
+ // Schlick approximation matching Blender's Fresnel node
522
+
523
+ fn fresnel(ior: f32, n: vec3f, v: vec3f) -> f32 {
524
+ let r = (ior - 1.0) / (ior + 1.0);
525
+ let f0 = r * r;
526
+ let cos_theta = clamp(dot(n, v), 0.0, 1.0);
527
+ let m = 1.0 - cos_theta;
528
+ let m2 = m * m;
529
+ let m5 = m2 * m2 * m;
530
+ return f0 + (1.0 - f0) * m5;
531
+ }
532
+
533
+ // ─── LAYER_WEIGHT node ──────────────────────────────────────────────
534
+
535
+ fn layer_weight_fresnel(blend: f32, n: vec3f, v: vec3f) -> f32 {
536
+ let eta = max(1.0 - blend, 1e-4);
537
+ let r = (1.0 - eta) / (1.0 + eta);
538
+ let f0 = r * r;
539
+ let cos_theta = clamp(abs(dot(n, v)), 0.0, 1.0);
540
+ let m = 1.0 - cos_theta;
541
+ let m2 = m * m;
542
+ let m5 = m2 * m2 * m;
543
+ return f0 + (1.0 - f0) * m5;
544
+ }
545
+
546
+ fn layer_weight_facing(blend: f32, n: vec3f, v: vec3f) -> f32 {
547
+ var facing = abs(dot(n, v));
548
+ let b = clamp(blend, 0.0, 0.99999);
549
+ if (b != 0.5) {
550
+ let exponent = select(2.0 * b, 0.5 / (1.0 - b), b >= 0.5);
551
+ facing = pow(facing, exponent);
552
+ }
553
+ return 1.0 - facing;
554
+ }
555
+
556
+ // ─── SHADER_TO_RGB (white DiffuseBSDF) ──────────────────────────────
557
+ // Eevee captures lit diffuse: (albedo/π)*sun*N·L*shadow + ambient (linear). Albedo=1.
558
+ // Matches default.ts direct term scale so VALTORGB thresholds from Blender JSON stay valid.
559
+
560
+ /**
561
+ * Shader RGB on a white diffuse closure, as a COLOUR.
562
+ *
563
+ * Blender's ShaderToRGB hands back what the closure actually radiates, and that
564
+ * is coloured: a warm sun against a cool ambient gives warm light and cool
565
+ * shadow, which is most of what an NPR ramp is reading. Collapsing it to
566
+ * luminance first — which this used to do — throws that away and leaves every
567
+ * ramp working on grey.
568
+ *
569
+ * Feeding this into a scalar socket still yields a float, because Blender's own
570
+ * implicit Color Value conversion (BT.601, see color_to_value) does it at the
571
+ * link. So a graph that wants the scalar gets Blender's scalar, and one that
572
+ * wants the colour now gets that too.
573
+ *
574
+ * The maths matches 5.2's bxdf_diffuse_eval, which is saturate(dot(N,L))
575
+ * integrated against a cosine distribution: radiance = E·N·L/π for the sun, plus
576
+ * the world's own irradiance. EEVEE Next's extra transport — probe GI, virtual
577
+ * shadow maps, ray-traced bounce — is machinery rather than a term, and is not
578
+ * here.
579
+ */
580
+ fn shader_to_rgb_lit(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> vec3f {
581
+ const PI_S: f32 = 3.141592653589793;
582
+ let ndotl = max(dot(n, l), 0.0);
583
+ return sun_rgb * (ndotl * shadow / PI_S) + ambient_rgb;
584
+ }
585
+
586
+ fn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> f32 {
587
+ return luminance_rec709_linear(shader_to_rgb_lit(n, l, sun_rgb, ambient_rgb, shadow));
588
+ }
589
+
590
+ // ─── BUMP node ──────────────────────────────────────────────────────
591
+ // Screen-space bump from a scalar height field. Needs dFdx/dFdy which
592
+ // WGSL provides as dpdx/dpdy.
593
+
594
+ fn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {
595
+ let dhdx = dpdx(height);
596
+ let dhdy = dpdy(height);
597
+ let dpdx_pos = dpdx(world_pos);
598
+ let dpdy_pos = dpdy(world_pos);
599
+ let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) + dhdy * normalize(cross(normal, dpdx_pos)));
600
+ return normalize(perturbed);
601
+ }
602
+
603
+ // LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference
604
+ fn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {
605
+ let dhdx = dpdx(height);
606
+ let dhdy = dpdy(height);
607
+ let dpdx_pos = dpdx(world_pos);
608
+ let dpdy_pos = dpdy(world_pos);
609
+ let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));
610
+ return normalize(perturbed);
611
+ }
612
+
613
+ // ─── NOISE texture (Perlin-style) ───────────────────────────────────
614
+ // Simplified gradient noise matching Blender's default noise output.
615
+
616
+ // PCG-style integer hash. Replaces the classic 'fract(sin(q) * LARGE)' trick because
617
+ // WebKit's Metal backend compiles 'sin' to a full transcendental op (slow), while
618
+ // Safari's Apple-GPU scalar ALU handles int muls/xors near free. Inputs arrive as
619
+ // integer-valued floats (floor(p) + unit offsets) from _noise3, so vec3i cast is exact.
620
+ fn _hash33(p: vec3f) -> vec3f {
621
+ var h = vec3u(vec3i(p) + vec3i(32768));
622
+ h = h * vec3u(1664525u, 1013904223u, 2654435761u);
623
+ h = (h.yzx ^ h) * vec3u(2246822519u, 3266489917u, 668265263u);
624
+ h = h ^ (h >> vec3u(16u));
625
+ // Mask to 24 bits — above that f32 loses precision on the u32→f32 convert.
626
+ let hm = h & vec3u(16777215u);
627
+ return vec3f(hm) * (2.0 / 16777216.0) - 1.0;
628
+ }
629
+
630
+ fn _noise3(p: vec3f) -> f32 {
631
+ let i = floor(p);
632
+ let f = fract(p);
633
+ let u = f * f * (3.0 - 2.0 * f);
634
+
635
+ return mix(
636
+ mix(
637
+ mix(dot(_hash33(i + vec3f(0,0,0)), f - vec3f(0,0,0)),
638
+ dot(_hash33(i + vec3f(1,0,0)), f - vec3f(1,0,0)), u.x),
639
+ mix(dot(_hash33(i + vec3f(0,1,0)), f - vec3f(0,1,0)),
640
+ dot(_hash33(i + vec3f(1,1,0)), f - vec3f(1,1,0)), u.x), u.y),
641
+ mix(
642
+ mix(dot(_hash33(i + vec3f(0,0,1)), f - vec3f(0,0,1)),
643
+ dot(_hash33(i + vec3f(1,0,1)), f - vec3f(1,0,1)), u.x),
644
+ mix(dot(_hash33(i + vec3f(0,1,1)), f - vec3f(0,1,1)),
645
+ dot(_hash33(i + vec3f(1,1,1)), f - vec3f(1,1,1)), u.x), u.y),
646
+ u.z);
647
+ }
648
+
649
+ fn tex_noise(p: vec3f, scale: f32, detail: f32, roughness: f32, distortion: f32) -> f32 {
650
+ var q = p;
651
+ if (abs(distortion) > 1e-6) {
652
+ let w = _noise3(p * scale * 1.37 + vec3f(2.31, 5.17, 8.09));
653
+ q = p + (w * 2.0 - 1.0) * distortion;
654
+ }
655
+ let coords = q * scale;
656
+ var value = 0.0;
657
+ var amplitude = 1.0;
658
+ var frequency = 1.0;
659
+ var total_amp = 0.0;
660
+ let octaves = i32(clamp(detail, 0.0, 15.0)) + 1;
661
+ for (var i = 0; i < octaves; i++) {
662
+ value += amplitude * _noise3(coords * frequency);
663
+ total_amp += amplitude;
664
+ amplitude *= roughness;
665
+ frequency *= 2.0;
666
+ }
667
+ return value / max(total_amp, 1e-6) * 0.5 + 0.5;
668
+ }
669
+
670
+ // tex_noise specialization: detail=2.0 (3 octaves), roughness=0.5, distortion=0.
671
+ // WebKit can't unroll tex_noise's for-loop because 'octaves' is a runtime value;
672
+ // this variant is fully unrolled with constants folded (total_amp = 1.75).
673
+ fn tex_noise_d2(p: vec3f, scale: f32) -> f32 {
674
+ let c = p * scale;
675
+ let v = _noise3(c) + 0.5 * _noise3(c * 2.0) + 0.25 * _noise3(c * 4.0);
676
+ return v * (1.0 / 1.75) * 0.5 + 0.5;
677
+ }
678
+
679
+ // ─── TEX_GRADIENT (linear) ──────────────────────────────────────────
680
+ // Used by Stockings preset. Maps the input vector's X to a 0–1 gradient.
681
+
682
+ fn tex_gradient_linear(uv: vec3f) -> f32 {
683
+ return clamp(uv.x, 0.0, 1.0);
684
+ }
685
+
686
+ // ─── TEX_VORONOI ────────────────────────────────────────────────────
687
+ // 3D F1 voronoi. _f1 returns Distance; _color returns per-cell hash color
688
+ // (matches Blender voronoi.cc: outColor = hash_int3_to_float3(cell + targetOffset)).
689
+
690
+ fn tex_voronoi_f1(p: vec3f, scale: f32) -> f32 {
691
+ let coords = p * scale;
692
+ let i = floor(coords);
693
+ let f = fract(coords);
694
+ var min_dist = 1e10;
695
+ for (var z = -1; z <= 1; z++) {
696
+ for (var y = -1; y <= 1; y++) {
697
+ for (var x = -1; x <= 1; x++) {
698
+ let neighbor = vec3f(f32(x), f32(y), f32(z));
699
+ let point = _hash33(i + neighbor) * 0.5 + 0.5;
700
+ let diff = neighbor + point - f;
701
+ min_dist = min(min_dist, dot(diff, diff));
702
+ }
703
+ }
704
+ }
705
+ return sqrt(min_dist);
706
+ }
707
+
708
+ // The per-cell jitter hash IS the Color output in Blender — reuse the same hash
709
+ // tap for jitter + color instead of computing two.
710
+ fn tex_voronoi_color(p: vec3f, scale: f32) -> vec3f {
711
+ let coords = p * scale;
712
+ let i = floor(coords);
713
+ let f = fract(coords);
714
+ var min_dist = 1e10;
715
+ var min_hash = vec3f(0.5);
716
+ for (var z = -1; z <= 1; z++) {
717
+ for (var y = -1; y <= 1; y++) {
718
+ for (var x = -1; x <= 1; x++) {
719
+ let neighbor = vec3f(f32(x), f32(y), f32(z));
720
+ let h = _hash33(i + neighbor) * 0.5 + 0.5;
721
+ let diff = neighbor + h - f;
722
+ let d = dot(diff, diff);
723
+ if (d < min_dist) {
724
+ min_dist = d;
725
+ min_hash = h;
726
+ }
727
+ }
728
+ }
729
+ }
730
+ return min_hash;
731
+ }
732
+
733
+ // ─── SEPXYZ node ────────────────────────────────────────────────────
734
+
735
+ fn separate_xyz(v: vec3f) -> vec3f { return v; }
736
+
737
+ // ─── VECT_MATH (cross product) ──────────────────────────────────────
738
+
739
+ fn vect_math_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }
740
+
741
+ // ─── MAPPING node ───────────────────────────────────────────────────
742
+ // Point-type mapping: scale, rotate (euler XYZ), translate.
743
+
744
+ fn mapping_point(v: vec3f, loc: vec3f, rot: vec3f, scl: vec3f) -> vec3f {
745
+ var p = v * scl;
746
+ // simplified: skip rotation when all angles are zero (common case)
747
+ if (abs(rot.x) + abs(rot.y) + abs(rot.z) > 1e-6) {
748
+ let cx = cos(rot.x); let sx = sin(rot.x);
749
+ let cy = cos(rot.y); let sy = sin(rot.y);
750
+ let cz = cos(rot.z); let sz = sin(rot.z);
751
+ let rx = vec3f(p.x, cx*p.y - sx*p.z, sx*p.y + cx*p.z);
752
+ let ry = vec3f(cy*rx.x + sy*rx.z, rx.y, -sy*rx.x + cy*rx.z);
753
+ p = vec3f(cz*ry.x - sz*ry.y, sz*ry.x + cz*ry.y, ry.z);
754
+ }
755
+ return p + loc;
756
+ }
757
+
758
+ // ─── NORMAL_MAP node (tangent-space) ────────────────────────────────
759
+ // Applies a tangent-space normal map. Requires TBN from vertex stage.
760
+
761
+ fn normal_map(strength: f32, map_color: vec3f, normal: vec3f, tangent: vec3f, bitangent: vec3f) -> vec3f {
762
+ let ts = map_color * 2.0 - 1.0;
763
+ let perturbed = normalize(tangent * ts.x + bitangent * ts.y + normal * ts.z);
764
+ return normalize(mix(normal, perturbed, strength));
765
+ }
766
+
767
+ // ─── EEVEE Principled BSDF primitives ───────────────────────────────
768
+ // Ports from Blender 3.6 source/blender/draw/engines/eevee/shaders/
769
+ // bsdf_common_lib.glsl + gpu_shader_material_principled.glsl.
770
+ // Usage pattern (see material shaders): direct spec = bsdf_ggx × sun × shadow
771
+ // (NL baked in, no F yet); ambient spec = probe_radiance; tint both with
772
+ // reflection_color = F_brdf_multi_scatter(f0, f90, split_sum) AFTER summing.
773
+
774
+ const EEVEE_PI: f32 = 3.141592653589793;
775
+
776
+ // Fused analytic GGX specular (direct lights). Returns BRDF × NL.
777
+ // 4·NL·NV is cancelled via G1_Smith reciprocal form — see bsdf_common_lib.glsl:115.
778
+ // Caller passes NL, NV (already computed for diffuse + brdf_lut_sample) so WebKit
779
+ // can reuse them instead of recomputing dot products across the function boundary.
780
+ fn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32) -> f32 {
781
+ let a = max(roughness, 1e-4);
782
+ let a2 = a * a;
783
+ let H = normalize(L + V);
784
+ let NH = max(dot(N, H), 1e-8);
785
+ let NL = max(NL_in, 1e-8);
786
+ let NV = max(NV_in, 1e-8);
787
+ // G1_Smith_GGX_opti reciprocal form — denominator piece only.
788
+ let G1L = NL + sqrt(NL * (NL - NL * a2) + a2);
789
+ let G1V = NV + sqrt(NV * (NV - NV * a2) + a2);
790
+ let G = G1L * G1V;
791
+ // D_ggx_opti = pi * denom² — reciprocal D × a².
792
+ let tmp = (NH * a2 - NH) * NH + 1.0;
793
+ let D_opti = EEVEE_PI * tmp * tmp;
794
+ return NL * a2 / (D_opti * G);
795
+ }
796
+
797
+ // Split-sum DFG LUT — Karis 2013 curve fit stand-in for the 64×64 baked LUT.
798
+ // Returns (lut.x, lut.y) in Blender convention: tint = f0·lut.x + f90·lut.y.
799
+ fn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {
800
+ let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);
801
+ let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);
802
+ let r = roughness * c0 + c1;
803
+ let a004 = min(r.x * r.x, exp2(-9.28 * NV)) * r.x + r.y;
804
+ return vec2f(-1.04, 1.04) * a004 + r.zw;
805
+ }
806
+
807
+ // Baked combined BRDF LUT — exact port of Blender bsdf_lut_frag.glsl packed with
808
+ // ltc_mag_ggx from eevee_lut.c. Single sample returns DFG (.rg) and LTC mag (.ba).
809
+ // Addressed as Blender's common_utiltex_lib.glsl:lut_coords:
810
+ // coords = (roughness, sqrt(1 - NV)), then half-texel bias for filtering.
811
+ // Requires group(0) binding(9) brdfLut + binding(2) diffuseSampler in the host shader.
812
+ fn brdf_lut_sample(NV: f32, roughness: f32) -> vec4f {
813
+ let LUT_SIZE: f32 = 64.0;
814
+ var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));
815
+ uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;
816
+ return textureSampleLevel(brdfLut, diffuseSampler, uv, 0.0);
817
+ }
818
+
819
+ fn F_brdf_single_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {
820
+ return lut.y * f90 + lut.x * f0;
821
+ }
822
+
823
+ // Fdez-Agüera 2019 multi-scatter compensation (EEVEE do_multiscatter=1).
824
+ fn F_brdf_multi_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {
825
+ let FssEss = lut.y * f90 + lut.x * f0;
826
+ let Ess = lut.x + lut.y;
827
+ let Ems = 1.0 - Ess;
828
+ let Favg = f0 + (1.0 - f0) / 21.0;
829
+ let Fms = FssEss * Favg / (1.0 - (1.0 - Ess) * Favg);
830
+ return FssEss + Fms * Ems;
831
+ }
832
+
833
+ // EEVEE direct-specular energy compensation factor — closure_eval_glossy_lib.glsl:79-81:
834
+ // ltc_brdf_scale = (ltc.x + ltc.y) / (split_sum.x + split_sum.y)
835
+ // Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum;
836
+ // direct radiance is rescaled so total-energy matches the split-sum LUT.
837
+ // Takes a pre-sampled vec4f from brdf_lut_sample() to share the fetch with
838
+ // F_brdf_multi_scatter on the same fragment.
839
+ fn ltc_brdf_scale_from_lut(lut: vec4f) -> f32 {
840
+ return (lut.z + lut.w) / max(lut.x + lut.y, 1e-6);
841
+ }
842
+
843
+ // Luminance-normalized hue extraction — Blender tint_from_color (isolates hue+sat).
844
+ fn tint_from_color(color: vec3f) -> vec3f {
845
+ let lum = dot(color, vec3f(0.3, 0.6, 0.1));
846
+ return select(vec3f(1.0), color / lum, lum > 0.0);
847
+ }
848
+
849
+ // ─── Principled sheen (gpu_shader_material_principled.glsl:8-14) ────
850
+ // Empirical NV-only curve that approximates grazing retroreflection on cloth/velvet.
851
+ // Scales the sheen layer's diffuse contribution; no sheen call site has sheen=0
852
+ // shortcut because the multiplier is tiny at normal view angles anyway.
853
+ fn principled_sheen(NV: f32) -> f32 {
854
+ let f = 1.0 - NV;
855
+ return f * f * f * 0.077 + f * 0.01 + 0.00026;
856
+ }
857
+
858
+ // ─── Principled BSDF eval ───────────────────────────────────────────
859
+ // Shared EEVEE Principled path used by every material in the engine — metallic,
860
+ // dielectric, and sheen variants all fold into these ~15 lines via the struct
861
+ // fields. NPR materials still compute a separate toon/rim/warm stack on top and
862
+ // mix(npr_stack, eval_principled(...), fac); see body.ts / face.ts / etc.
863
+ //
864
+ // Field conventions:
865
+ // base — diffuse albedo. Mixed into f0 only when metallic > 0.
866
+ // metallic — 0 = dielectric (f0 from specular), 1 = pure metal (f0 = base).
867
+ // specular — Principled Specular input (0.5 default → f0 = 0.04). sqrt for f90.
868
+ // roughness — GGX roughness; drives BRDF LUT coord + bsdf_ggx.
869
+ // spec_clamp — EEVEE Light Clamp equivalent. Caps firefly spec from noise-bumped
870
+ // NDF aliasing (Blender hides this via TAA which we don't have).
871
+ // Pass 1e30 (effectively disabled) for materials that don't bump.
872
+ // sheen — 0 disables. Scales the sheen diffuse add; cloth/stockings use ~0.7.
873
+ // sheen_tint — 0 = white sheen, 1 = fully tinted by base. Multiplied by sheen,
874
+ // so value is don't-care when sheen=0.
875
+ // The PMX sphere map, applied the way MMD applies it: the texture is a
876
+ // VIEW-SPACE lighting mask, sampled by the camera-space normal rather than by
877
+ // any UV the mesh carries, which is why it tracks the viewer and reads as a
878
+ // highlight. The material's own mode picks the operator — .sph (1) multiplies
879
+ // the shaded base, .spa (2) adds — so a graph asks for the effect and the model
880
+ // decides which it meant. Mode 0, or a material with no sphere texture (the 1×1
881
+ // white fallback is bound then), is an exact no-op.
882
+ fn pmx_sphere_map(base: vec3f, strength: f32, N: vec3f) -> vec3f {
883
+ if (material.sphereMode < 0.5) {
884
+ return base;
885
+ }
886
+ let camera_n = safe_normal((camera.view * vec4f(N, 0.0)).xyz);
887
+ let sphere_uv = clamp(camera_n.xy * 0.5 + vec2f(0.5), vec2f(0.0), vec2f(1.0));
888
+ let sphere_rgb = textureSample(sphereTexture, diffuseSampler, sphere_uv).rgb;
889
+ let amount = max(strength, 0.0);
890
+ if (material.sphereMode < 1.5) {
891
+ return mix(base, base * sphere_rgb, min(amount, 1.0));
892
+ }
893
+ return base + sphere_rgb * amount;
894
+ }
895
+
896
+ /**
897
+ * Principled v2 reflectance: IOR sets it, the level scales it.
898
+ *
899
+ * Blender 3.6 took Specular directly, where 0.5 meant f0 = 0.04. v2 computes
900
+ * f0 from IOR — ((ior-1)/(ior+1))² — and Specular IOR Level scales that, with
901
+ * 0.5 meaning "the IOR's own value". Returned in the 3.6 convention the BSDF
902
+ * below still speaks (f0 = 0.08 · specular), so the two agree at their defaults
903
+ * and v2 wins whenever a preset moves either socket.
904
+ */
905
+ fn principled_specular(ior: f32, level: f32) -> f32 {
906
+ let r = (ior - 1.0) / max(ior + 1.0, 1e-6);
907
+ return (r * r * 2.0 * max(level, 0.0)) / 0.08;
908
+ }
909
+
910
+ struct PrincipledIn {
911
+ base: vec3f,
912
+ metallic: f32,
913
+ specular: f32,
914
+ roughness: f32,
915
+ spec_clamp: f32,
916
+ sheen: f32,
917
+ sheen_tint: f32,
918
+ };
919
+
920
+ fn eval_principled(
921
+ p: PrincipledIn,
922
+ N: vec3f, L: vec3f, V: vec3f,
923
+ sun_rgb: vec3f, amb_rgb: vec3f, shadow: f32
924
+ ) -> vec3f {
925
+ let NL = max(dot(N, L), 0.0);
926
+ let NV = max(dot(N, V), 1e-4);
927
+
928
+ // f0/f90 per gpu_shader_material_principled.glsl. specular_tint=0 is assumed
929
+ // (all presets in this engine use the default white dielectric tint).
930
+ let dielectric_f0 = vec3f(0.08 * p.specular);
931
+ let f0 = mix(dielectric_f0, p.base, p.metallic);
932
+ let f90 = mix(f0, vec3f(1.0), sqrt(p.specular));
933
+
934
+ // Single LUT tap feeds both F_brdf_multi_scatter (split-sum DFG) and
935
+ // ltc_brdf_scale_from_lut (LTC mag in .ba). See nodes.ts brdf_lut_sample.
936
+ let lut = brdf_lut_sample(NV, p.roughness);
937
+ let reflection_color = F_brdf_multi_scatter(f0, f90, lut.xy);
938
+
939
+ // Direct glossy — bsdf_ggx already includes NL; no F applied here (tinted after
940
+ // accum with reflection_color). ltc_brdf_scale rescales direct to match the
941
+ // split-sum indirect path, matching EEVEE closure_eval_glossy_lib behavior.
942
+ let spec_direct_raw = bsdf_ggx(N, L, V, NL, NV, p.roughness)
943
+ * sun_rgb * shadow * ltc_brdf_scale_from_lut(lut);
944
+ let spec_direct = min(spec_direct_raw, vec3f(p.spec_clamp));
945
+ let spec_indirect = amb_rgb;
946
+ let spec_radiance = (spec_direct + spec_indirect) * reflection_color;
947
+
948
+ // Sheen add — when p.sheen=0 the whole term collapses, leaving diffuse_color=base.
949
+ let base_tint = tint_from_color(p.base);
950
+ let sheen_color = mix(vec3f(1.0), base_tint, p.sheen_tint);
951
+ let diffuse_color = p.base + p.sheen * sheen_color * principled_sheen(NV);
952
+
953
+ // diffuse_weight = (1-metallic). Indirect diffuse uses amb (L_w) with no π factor
954
+ // (probe_evaluate_world_diff returns SH-projected radiance, not cosine-convolved).
955
+ let diffuse_weight = 1.0 - p.metallic;
956
+ let diffuse_radiance = diffuse_color * (sun_rgb * NL * shadow / EEVEE_PI + amb_rgb) * diffuse_weight;
957
+
958
+ return diffuse_radiance + spec_radiance;
959
+ }
960
+
580
961
  `;