reze-engine 0.11.0 → 0.11.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 (50) hide show
  1. package/README.md +40 -22
  2. package/dist/engine.d.ts +14 -7
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +206 -77
  5. package/dist/shaders/body.d.ts +1 -1
  6. package/dist/shaders/body.d.ts.map +1 -1
  7. package/dist/shaders/body.js +58 -47
  8. package/dist/shaders/cloth_rough.d.ts +1 -1
  9. package/dist/shaders/cloth_rough.d.ts.map +1 -1
  10. package/dist/shaders/cloth_rough.js +38 -20
  11. package/dist/shaders/cloth_smooth.d.ts +1 -1
  12. package/dist/shaders/cloth_smooth.d.ts.map +1 -1
  13. package/dist/shaders/cloth_smooth.js +33 -18
  14. package/dist/shaders/default.d.ts +1 -1
  15. package/dist/shaders/default.d.ts.map +1 -1
  16. package/dist/shaders/default.js +45 -42
  17. package/dist/shaders/dfg_lut.d.ts +2 -3
  18. package/dist/shaders/dfg_lut.d.ts.map +1 -1
  19. package/dist/shaders/dfg_lut.js +30 -26
  20. package/dist/shaders/eye.d.ts +1 -1
  21. package/dist/shaders/eye.d.ts.map +1 -1
  22. package/dist/shaders/eye.js +47 -43
  23. package/dist/shaders/face.d.ts +1 -1
  24. package/dist/shaders/face.d.ts.map +1 -1
  25. package/dist/shaders/face.js +47 -23
  26. package/dist/shaders/hair.d.ts +1 -1
  27. package/dist/shaders/hair.d.ts.map +1 -1
  28. package/dist/shaders/hair.js +42 -32
  29. package/dist/shaders/metal.d.ts +1 -1
  30. package/dist/shaders/metal.d.ts.map +1 -1
  31. package/dist/shaders/metal.js +35 -19
  32. package/dist/shaders/nodes.d.ts +1 -1
  33. package/dist/shaders/nodes.d.ts.map +1 -1
  34. package/dist/shaders/nodes.js +79 -37
  35. package/dist/shaders/stockings.d.ts +1 -1
  36. package/dist/shaders/stockings.d.ts.map +1 -1
  37. package/dist/shaders/stockings.js +30 -15
  38. package/package.json +2 -2
  39. package/src/engine.ts +227 -97
  40. package/src/shaders/body.ts +58 -47
  41. package/src/shaders/cloth_rough.ts +38 -20
  42. package/src/shaders/cloth_smooth.ts +33 -18
  43. package/src/shaders/default.ts +46 -42
  44. package/src/shaders/dfg_lut.ts +32 -28
  45. package/src/shaders/eye.ts +48 -43
  46. package/src/shaders/face.ts +47 -23
  47. package/src/shaders/hair.ts +42 -32
  48. package/src/shaders/metal.ts +35 -19
  49. package/src/shaders/nodes.ts +79 -37
  50. package/src/shaders/stockings.ts +30 -15
@@ -51,19 +51,25 @@ struct LightVP { viewProj: mat4x4f, };
51
51
  @group(2) @binding(1) var<uniform> material: MaterialUniforms;
52
52
 
53
53
  fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
54
+ // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.
55
+ if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
54
56
  let biasedPos = worldPos + n * 0.08;
55
57
  let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);
56
58
  let ndc = lclip.xyz / max(lclip.w, 1e-6);
57
59
  let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
58
60
  let cmpZ = ndc.z - 0.001;
59
- let ts = 1.0 / 4096.0;
60
- var vis = 0.0;
61
- for (var y = -1; y <= 1; y++) {
62
- for (var x = -1; x <= 1; x++) {
63
- vis += textureSampleCompare(shadowMap, shadowSampler, suv + vec2f(f32(x), f32(y)) * ts, cmpZ);
64
- }
65
- }
66
- return vis / 9.0;
61
+ let ts = 1.0 / 2048.0;
62
+ // 3x3 PCF unrolled — Safari's Metal backend doesn't unroll nested shadow loops reliably.
63
+ let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);
64
+ let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);
65
+ let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);
66
+ let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);
67
+ let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);
68
+ let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);
69
+ let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);
70
+ let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);
71
+ let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);
72
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
67
73
  }
68
74
 
69
75
  const PI_M: f32 = 3.141592653589793;
@@ -98,13 +104,19 @@ const METAL_VORONOI_SCALE: f32 = 4.3;
98
104
  skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;
99
105
  }
100
106
  output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);
101
- output.normal = normalize(skinnedNrm);
107
+ // Skip VS normalize — interpolation denormalizes anyway, and FS always does normalize(input.normal).
108
+ output.normal = skinnedNrm;
102
109
  output.uv = uv;
103
110
  output.worldPos = skinnedPos.xyz;
104
111
  return output;
105
112
  }
106
113
 
107
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
114
+ struct FSOut {
115
+ @location(0) color: vec4f,
116
+ @location(1) mask: f32,
117
+ };
118
+
119
+ @fragment fn fs(input: VertexOutput) -> FSOut {
108
120
  let n = normalize(input.normal);
109
121
  let v = normalize(camera.viewPos - input.worldPos);
110
122
  let l = -light.lights[0].direction.xyz;
@@ -118,23 +130,23 @@ const METAL_VORONOI_SCALE: f32 = 4.3;
118
130
  if (out_alpha < 0.001) { discard; }
119
131
 
120
132
  // ═══ NPR toon stack (图像 → HSV.007 Val=0.8 → 转接点.001) ═══
121
- let tex_tint = hue_sat(0.5, 1.0, 0.800000011920929, 1.0, tex_rgb);
133
+ let tex_tint = hue_sat_id(1.0, 0.800000011920929, 1.0, tex_rgb);
122
134
  let lum_shade = shader_to_rgb_diffuse(n, l, sun, amb, shadow);
123
135
  let ramp008 = ramp_constant_edge_aa(lum_shade, METAL_TOON_EDGE, vec4f(0,0,0,1), vec4f(1,1,1,1));
124
136
  let mix04_fac = math_multiply(ramp008.r, METAL_MIX04_MUL);
125
137
 
126
138
  // 混合.004: A=HSV.002(Val=0.2 dark), B=tex_tint
127
- let dark_tex = hue_sat(0.5, 1.0, 0.19999998807907104, 1.0, tex_tint);
139
+ let dark_tex = hue_sat_id(1.0, 0.19999998807907104, 1.0, tex_tint);
128
140
  let mix04 = mix_blend(mix04_fac, dark_tex, tex_tint);
129
141
 
130
142
  // AO white/black ramp → 混合.002 factor
131
- let ao = ao_fake(n, v);
143
+ let ao = 1.0; // ao_fake(n, v) — no SSAO yet; inline 1.0 so the ramp/mix chain folds at compile time.
132
144
  let ao_ramp_c = ramp_linear(ao, 0.0, vec4f(1,1,1,1), 0.8808, vec4f(0,0,0,1));
133
145
  let overlay_fac = mix(1.0, 0.0, ao_ramp_c.r);
134
146
 
135
147
  // 混合.002 OVERLAY: A=HSV.008(Val=1.0 identity) ← mix04, B=HSV.004(Val=2.0 bright) ← mix04
136
148
  let hue008 = mix04; // identity HSV
137
- let hue004 = hue_sat(0.5, 1.0, 2.0, 1.0, mix04);
149
+ let hue004 = hue_sat_id(1.0, 2.0, 1.0, mix04);
138
150
  let npr_rgb = mix_overlay(overlay_fac, hue008, hue004);
139
151
  let npr_emission = npr_rgb * METAL_EMIT_STR;
140
152
 
@@ -145,7 +157,7 @@ const METAL_VORONOI_SCALE: f32 = 4.3;
145
157
  let voro = tex_voronoi_f1(refl_dir, METAL_VORONOI_SCALE);
146
158
  let voro_ramp = ramp_linear(voro, 0.0, vec4f(0,0,0,1), 1.0, vec4f(1,1,1,1)).r;
147
159
  // 混合.005: Fac=voro_ramp, A=voro_color(grayscale), B=HSV.006(Hue=0.5 Sat=1.5 Val=1.3)
148
- let hue006 = hue_sat(0.5, 1.5, 1.2999999523162842, 1.0, tex_tint);
160
+ let hue006 = hue_sat_id(1.5, 1.2999999523162842, 1.0, tex_tint);
149
161
  let albedo = mix_blend(voro_ramp, vec3f(voro_ramp), hue006);
150
162
 
151
163
  // 原理化BSDF (EEVEE port): metallic=1.0, specular=1.0, roughness=0.3.
@@ -153,11 +165,12 @@ const METAL_VORONOI_SCALE: f32 = 4.3;
153
165
  // metallic=1 this is just albedo (specular_tint is dielectric-only and ignored here).
154
166
  let f0 = albedo;
155
167
  let f90 = mix(f0, vec3f(1.0), sqrt(METAL_SPECULAR));
168
+ let NL = max(dot(n, l), 0.0);
156
169
  let NV = max(dot(n, v), 1e-4);
157
- let split_sum = brdf_lut_baked(NV, METAL_ROUGHNESS);
158
- let reflection_color = F_brdf_multi_scatter(f0, f90, split_sum);
170
+ let brdf_lut = brdf_lut_sample(NV, METAL_ROUGHNESS);
171
+ let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);
159
172
 
160
- let spec_direct = bsdf_ggx(n, l, v, METAL_ROUGHNESS) * sun * shadow * ltc_brdf_scale(NV, METAL_ROUGHNESS);
173
+ let spec_direct = bsdf_ggx(n, l, v, NL, NV, METAL_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);
161
174
  let spec_indirect = amb;
162
175
  let spec_radiance = (spec_direct + spec_indirect) * reflection_color;
163
176
 
@@ -167,7 +180,10 @@ const METAL_VORONOI_SCALE: f32 = 4.3;
167
180
  // 混合着色器.001 Fac=0.6967: Shader=npr_emission, Shader_001=principled
168
181
  let final_color = mix(npr_emission, principled, METAL_MIX_SHADER_FAC);
169
182
 
170
- return vec4f(final_color, out_alpha);
183
+ var out: FSOut;
184
+ out.color = vec4f(final_color, out_alpha);
185
+ out.mask = 1.0;
186
+ return out;
171
187
  }
172
188
 
173
189
  `
@@ -4,16 +4,13 @@
4
4
 
5
5
  export const NODES_WGSL = /* wgsl */ `
6
6
 
7
- // Baked 64×64 rg16float DFG LUT — created once at engine init by dfg_lut.ts.
8
- // Paired with group(0) binding(2) diffuseSampler (linear filter, clamp implicit
9
- // via the half-texel bias inside brdf_lut_baked). Bound by the main per-frame
10
- // bind group to every material pipeline that includes this module.
11
- @group(0) @binding(9) var dfgLut: texture_2d<f32>;
12
-
13
- // Baked 64×64 rg16float LTC GGX magnitude LUT — ltc_mag_ggx from Blender eevee_lut.c.
14
- // Heitz 2016 LTC fit amplitude — same UV addressing as dfgLut.
15
- // Used for direct-specular energy compensation (ltc_brdf_scale in closure_eval_glossy_lib.glsl).
16
- @group(0) @binding(10) var ltcMag: texture_2d<f32>;
7
+ // Baked 64×64 rgba8unorm combined BRDF LUT — created once at engine init by dfg_lut.ts.
8
+ // .rg = split-sum DFG (Karis: tint = f0·x + f90·y) → F_brdf_*_scatter
9
+ // .ba = Heitz 2016 LTC magnitude (ltc_mag_ggx) ltc_brdf_scale_from_lut
10
+ // Paired with group(0) binding(2) diffuseSampler (linear filter). Sample once per
11
+ // fragment via brdf_lut_sample() callers feed .rg and the whole vec4 into the
12
+ // helpers below, halving LUT taps on the default Principled path.
13
+ @group(0) @binding(9) var brdfLut: texture_2d<f32>;
17
14
 
18
15
  // ─── RGB ↔ HSV ──────────────────────────────────────────────────────
19
16
 
@@ -71,6 +68,23 @@ fn hue_sat(hue: f32, saturation: f32, value: f32, fac: f32, color: vec3f) -> vec
71
68
  return mix(color, hsv_to_rgb(hsv), fac);
72
69
  }
73
70
 
71
+ // hue_sat specialization for hue=0.5 (identity hue shift — fract(h + 0.5 - 0.5) = h).
72
+ // Branchless equivalent that skips the rgb_to_hsv → hsv_to_rgb roundtrip: WebKit's
73
+ // Metal backend serializes the 3-way if chain in rgb_to_hsv and the 6-way switch in
74
+ // hsv_to_rgb, where this form compiles to linear SIMD ops + a single select.
75
+ fn hue_sat_id(saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {
76
+ let m = max(max(color.r, color.g), color.b);
77
+ let n = min(min(color.r, color.g), color.b);
78
+ // Unclamped (sat*old_s ≤ 1): reproj = mix(vec3f(m), color, saturation).
79
+ // Clamped (saturated to 1): reproj = (color - n) * m / (m - n).
80
+ let range = max(m - n, 1e-6);
81
+ let unclamped = mix(vec3f(m), color, saturation);
82
+ let clamped = (color - vec3f(n)) * m / range;
83
+ let needs_clamp = (m - n) * saturation >= m;
84
+ let reproj = select(unclamped, clamped, needs_clamp);
85
+ return mix(color, reproj * value, fac);
86
+ }
87
+
74
88
  // ─── BRIGHTCONTRAST node ────────────────────────────────────────────
75
89
 
76
90
  fn bright_contrast(color: vec3f, bright: f32, contrast: f32) -> vec3f {
@@ -122,6 +136,13 @@ fn math_multiply(a: f32, b: f32) -> f32 { return a * b; }
122
136
  fn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }
123
137
  fn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }
124
138
 
139
+ // Blender's implicit Color → Float socket conversion uses BT.601 grayscale
140
+ // (rgb_to_grayscale in blenkernel/intern/node.cc). When a material graph plugs a
141
+ // Color output into a Math node's Value input, this is the scalar it actually sees.
142
+ fn color_to_value(c: vec3f) -> f32 {
143
+ return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;
144
+ }
145
+
125
146
  // ─── MIX node (blend_type variants) ────────────────────────────────
126
147
 
127
148
  fn mix_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {
@@ -157,18 +178,26 @@ fn luminance_rec709_linear(c: vec3f) -> f32 {
157
178
  // Schlick approximation matching Blender's Fresnel node
158
179
 
159
180
  fn fresnel(ior: f32, n: vec3f, v: vec3f) -> f32 {
160
- let f0 = pow((ior - 1.0) / (ior + 1.0), 2.0);
181
+ let r = (ior - 1.0) / (ior + 1.0);
182
+ let f0 = r * r;
161
183
  let cos_theta = clamp(dot(n, v), 0.0, 1.0);
162
- return f0 + (1.0 - f0) * pow(1.0 - cos_theta, 5.0);
184
+ let m = 1.0 - cos_theta;
185
+ let m2 = m * m;
186
+ let m5 = m2 * m2 * m;
187
+ return f0 + (1.0 - f0) * m5;
163
188
  }
164
189
 
165
190
  // ─── LAYER_WEIGHT node ──────────────────────────────────────────────
166
191
 
167
192
  fn layer_weight_fresnel(blend: f32, n: vec3f, v: vec3f) -> f32 {
168
193
  let eta = max(1.0 - blend, 1e-4);
169
- let f0 = pow((1.0 - eta) / (1.0 + eta), 2.0);
194
+ let r = (1.0 - eta) / (1.0 + eta);
195
+ let f0 = r * r;
170
196
  let cos_theta = clamp(abs(dot(n, v)), 0.0, 1.0);
171
- return f0 + (1.0 - f0) * pow(1.0 - cos_theta, 5.0);
197
+ let m = 1.0 - cos_theta;
198
+ let m2 = m * m;
199
+ let m5 = m2 * m2 * m;
200
+ return f0 + (1.0 - f0) * m5;
172
201
  }
173
202
 
174
203
  fn layer_weight_facing(blend: f32, n: vec3f, v: vec3f) -> f32 {
@@ -227,13 +256,18 @@ fn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f
227
256
  // ─── NOISE texture (Perlin-style) ───────────────────────────────────
228
257
  // Simplified gradient noise matching Blender's default noise output.
229
258
 
259
+ // PCG-style integer hash. Replaces the classic 'fract(sin(q) * LARGE)' trick because
260
+ // WebKit's Metal backend compiles 'sin' to a full transcendental op (slow), while
261
+ // Safari's Apple-GPU scalar ALU handles int muls/xors near free. Inputs arrive as
262
+ // integer-valued floats (floor(p) + unit offsets) from _noise3, so vec3i cast is exact.
230
263
  fn _hash33(p: vec3f) -> vec3f {
231
- var q = vec3f(
232
- dot(p, vec3f(127.1, 311.7, 74.7)),
233
- dot(p, vec3f(269.5, 183.3, 246.1)),
234
- dot(p, vec3f(113.5, 271.9, 124.6))
235
- );
236
- return fract(sin(q) * 43758.5453123) * 2.0 - 1.0;
264
+ var h = vec3u(vec3i(p) + vec3i(32768));
265
+ h = h * vec3u(1664525u, 1013904223u, 2654435761u);
266
+ h = (h.yzx ^ h) * vec3u(2246822519u, 3266489917u, 668265263u);
267
+ h = h ^ (h >> vec3u(16u));
268
+ // Mask to 24 bits — above that f32 loses precision on the u32→f32 convert.
269
+ let hm = h & vec3u(16777215u);
270
+ return vec3f(hm) * (2.0 / 16777216.0) - 1.0;
237
271
  }
238
272
 
239
273
  fn _noise3(p: vec3f) -> f32 {
@@ -276,6 +310,15 @@ fn tex_noise(p: vec3f, scale: f32, detail: f32, roughness: f32, distortion: f32)
276
310
  return value / max(total_amp, 1e-6) * 0.5 + 0.5;
277
311
  }
278
312
 
313
+ // tex_noise specialization: detail=2.0 (3 octaves), roughness=0.5, distortion=0.
314
+ // WebKit can't unroll tex_noise's for-loop because 'octaves' is a runtime value;
315
+ // this variant is fully unrolled with constants folded (total_amp = 1.75).
316
+ fn tex_noise_d2(p: vec3f, scale: f32) -> f32 {
317
+ let c = p * scale;
318
+ let v = _noise3(c) + 0.5 * _noise3(c * 2.0) + 0.25 * _noise3(c * 4.0);
319
+ return v * (1.0 / 1.75) * 0.5 + 0.5;
320
+ }
321
+
279
322
  // ─── TEX_GRADIENT (linear) ──────────────────────────────────────────
280
323
  // Used by Stockings preset. Maps the input vector's X to a 0–1 gradient.
281
324
 
@@ -349,13 +392,15 @@ const EEVEE_PI: f32 = 3.141592653589793;
349
392
 
350
393
  // Fused analytic GGX specular (direct lights). Returns BRDF × NL.
351
394
  // 4·NL·NV is cancelled via G1_Smith reciprocal form — see bsdf_common_lib.glsl:115.
352
- fn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, roughness: f32) -> f32 {
395
+ // Caller passes NL, NV (already computed for diffuse + brdf_lut_sample) so WebKit
396
+ // can reuse them instead of recomputing dot products across the function boundary.
397
+ fn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32) -> f32 {
353
398
  let a = max(roughness, 1e-4);
354
399
  let a2 = a * a;
355
400
  let H = normalize(L + V);
356
401
  let NH = max(dot(N, H), 1e-8);
357
- let NL = max(dot(N, L), 1e-8);
358
- let NV = max(dot(N, V), 1e-8);
402
+ let NL = max(NL_in, 1e-8);
403
+ let NV = max(NV_in, 1e-8);
359
404
  // G1_Smith_GGX_opti reciprocal form — denominator piece only.
360
405
  let G1L = NL + sqrt(NL * (NL - NL * a2) + a2);
361
406
  let G1V = NV + sqrt(NV * (NV - NV * a2) + a2);
@@ -376,15 +421,16 @@ fn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {
376
421
  return vec2f(-1.04, 1.04) * a004 + r.zw;
377
422
  }
378
423
 
379
- // Baked 64×64 EEVEE split-sum LUT — exact port of bsdf_lut_frag.glsl.
424
+ // Baked combined BRDF LUT — exact port of Blender bsdf_lut_frag.glsl packed with
425
+ // ltc_mag_ggx from eevee_lut.c. Single sample returns DFG (.rg) and LTC mag (.ba).
380
426
  // Addressed as Blender's common_utiltex_lib.glsl:lut_coords:
381
427
  // coords = (roughness, sqrt(1 - NV)), then half-texel bias for filtering.
382
- // Requires group(0) binding(9) dfgLut + binding(2) diffuseSampler in the host shader.
383
- fn brdf_lut_baked(NV: f32, roughness: f32) -> vec2f {
428
+ // Requires group(0) binding(9) brdfLut + binding(2) diffuseSampler in the host shader.
429
+ fn brdf_lut_sample(NV: f32, roughness: f32) -> vec4f {
384
430
  let LUT_SIZE: f32 = 64.0;
385
431
  var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));
386
432
  uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;
387
- return textureSampleLevel(dfgLut, diffuseSampler, uv, 0.0).rg;
433
+ return textureSampleLevel(brdfLut, diffuseSampler, uv, 0.0);
388
434
  }
389
435
 
390
436
  fn F_brdf_single_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {
@@ -403,16 +449,12 @@ fn F_brdf_multi_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {
403
449
 
404
450
  // EEVEE direct-specular energy compensation factor — closure_eval_glossy_lib.glsl:79-81:
405
451
  // ltc_brdf_scale = (ltc.x + ltc.y) / (split_sum.x + split_sum.y)
406
- // Because Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum,
407
- // direct radiance is rescaled so total-energy matches what the split-sum LUT expects.
408
- // Sample both LUTs at identical lut_coords and return the ratio.
409
- fn ltc_brdf_scale(NV: f32, roughness: f32) -> f32 {
410
- let LUT_SIZE: f32 = 64.0;
411
- var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));
412
- uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;
413
- let ltc = textureSampleLevel(ltcMag, diffuseSampler, uv, 0.0).rg;
414
- let dfg = textureSampleLevel(dfgLut, diffuseSampler, uv, 0.0).rg;
415
- return (ltc.x + ltc.y) / max(dfg.x + dfg.y, 1e-6);
452
+ // Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum;
453
+ // direct radiance is rescaled so total-energy matches the split-sum LUT.
454
+ // Takes a pre-sampled vec4f from brdf_lut_sample() to share the fetch with
455
+ // F_brdf_multi_scatter on the same fragment.
456
+ fn ltc_brdf_scale_from_lut(lut: vec4f) -> f32 {
457
+ return (lut.z + lut.w) / max(lut.x + lut.y, 1e-6);
416
458
  }
417
459
 
418
460
  // Luminance-normalized hue extraction — Blender tint_from_color (isolates hue+sat).
@@ -51,19 +51,25 @@ struct LightVP { viewProj: mat4x4f, };
51
51
  @group(2) @binding(1) var<uniform> material: MaterialUniforms;
52
52
 
53
53
  fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
54
+ // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.
55
+ if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
54
56
  let biasedPos = worldPos + n * 0.08;
55
57
  let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);
56
58
  let ndc = lclip.xyz / max(lclip.w, 1e-6);
57
59
  let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
58
60
  let cmpZ = ndc.z - 0.001;
59
- let ts = 1.0 / 4096.0;
60
- var vis = 0.0;
61
- for (var y = -1; y <= 1; y++) {
62
- for (var x = -1; x <= 1; x++) {
63
- vis += textureSampleCompare(shadowMap, shadowSampler, suv + vec2f(f32(x), f32(y)) * ts, cmpZ);
64
- }
65
- }
66
- return vis / 9.0;
61
+ let ts = 1.0 / 2048.0;
62
+ // 3x3 PCF unrolled — Safari's Metal backend doesn't unroll nested shadow loops reliably.
63
+ let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);
64
+ let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);
65
+ let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);
66
+ let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);
67
+ let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);
68
+ let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);
69
+ let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);
70
+ let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);
71
+ let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);
72
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
67
73
  }
68
74
 
69
75
  const PI_S: f32 = 3.141592653589793;
@@ -144,13 +150,19 @@ fn ramp_ease_s(f: f32, p0: f32, p1: f32) -> f32 {
144
150
  skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;
145
151
  }
146
152
  output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);
147
- output.normal = normalize(skinnedNrm);
153
+ // Skip VS normalize — interpolation denormalizes anyway, and FS always does normalize(input.normal).
154
+ output.normal = skinnedNrm;
148
155
  output.uv = uv;
149
156
  output.worldPos = skinnedPos.xyz;
150
157
  return output;
151
158
  }
152
159
 
153
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
160
+ struct FSOut {
161
+ @location(0) color: vec4f,
162
+ @location(1) bloom_mask: f32,
163
+ };
164
+
165
+ @fragment fn fs(input: VertexOutput) -> FSOut {
154
166
  let n = normalize(input.normal);
155
167
  let v = normalize(camera.viewPos - input.worldPos);
156
168
  let l = -light.lights[0].direction.xyz;
@@ -194,7 +206,7 @@ fn ramp_ease_s(f: f32, p0: f32, p1: f32) -> f32 {
194
206
 
195
207
  // ═══ EMISSION SHADER ═══
196
208
  // Hue=0.5 (identity rotation), Sat=1.0, Val=5.0 (5× brightness boost), Fac=1; Strength=1
197
- let emission = hue_sat(0.5, 1.0, 5.0, 1.0, tex_rgb);
209
+ let emission = hue_sat_id(1.0, 5.0, 1.0, tex_rgb);
198
210
 
199
211
  // ═══ PRINCIPLED BSDF (EEVEE port) ═══
200
212
  // base_color_tint, metallic f0, sheen coarse approx (scales diffuse radiance).
@@ -205,10 +217,10 @@ fn ramp_ease_s(f: f32, p0: f32, p1: f32) -> f32 {
205
217
  let dielectric_f0 = vec3f(0.08 * STOCK_SPECULAR);
206
218
  let f0 = mix(dielectric_f0, tex_rgb, STOCK_METALLIC);
207
219
  let f90 = mix(f0, vec3f(1.0), sqrt(STOCK_SPECULAR));
208
- let split_sum = brdf_lut_baked(NV, STOCK_ROUGHNESS);
209
- let reflection_color = F_brdf_multi_scatter(f0, f90, split_sum);
220
+ let brdf_lut = brdf_lut_sample(NV, STOCK_ROUGHNESS);
221
+ let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);
210
222
 
211
- let spec_direct = bsdf_ggx(n, l, v, STOCK_ROUGHNESS) * sun * shadow * ltc_brdf_scale(NV, STOCK_ROUGHNESS);
223
+ let spec_direct = bsdf_ggx(n, l, v, NL, NV, STOCK_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);
212
224
  let spec_indirect = amb;
213
225
  let spec_radiance = (spec_direct + spec_indirect) * reflection_color;
214
226
 
@@ -225,7 +237,10 @@ fn ramp_ease_s(f: f32, p0: f32, p1: f32) -> f32 {
225
237
  // ═══ MIX SHADER: Shader=Emission, Shader_001=Principled, Fac=mask ═══
226
238
  let final_color = mix(emission, principled, mask);
227
239
 
228
- return vec4f(final_color, out_alpha);
240
+ var out: FSOut;
241
+ out.color = vec4f(final_color, out_alpha);
242
+ out.bloom_mask = 1.0;
243
+ return out;
229
244
  }
230
245
 
231
246
  `