reze-engine 0.11.0 → 0.11.1

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 +13 -6
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +184 -70
  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 +44 -21
  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 +29 -12
  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 +29 -25
  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 +29 -12
  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 +1 -1
  39. package/src/engine.ts +200 -78
  40. package/src/shaders/body.ts +44 -21
  41. package/src/shaders/cloth_rough.ts +38 -20
  42. package/src/shaders/cloth_smooth.ts +33 -18
  43. package/src/shaders/default.ts +29 -12
  44. package/src/shaders/dfg_lut.ts +31 -27
  45. package/src/shaders/eye.ts +29 -12
  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
@@ -48,19 +48,25 @@ struct LightVP { viewProj: mat4x4f, };
48
48
  @group(2) @binding(1) var<uniform> material: MaterialUniforms;
49
49
 
50
50
  fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
51
+ // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.
52
+ if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
51
53
  let biasedPos = worldPos + n * 0.08;
52
54
  let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);
53
55
  let ndc = lclip.xyz / max(lclip.w, 1e-6);
54
56
  let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
55
57
  let cmpZ = ndc.z - 0.001;
56
- let ts = 1.0 / 4096.0;
57
- var vis = 0.0;
58
- for (var y = -1; y <= 1; y++) {
59
- for (var x = -1; x <= 1; x++) {
60
- vis += textureSampleCompare(shadowMap, shadowSampler, suv + vec2f(f32(x), f32(y)) * ts, cmpZ);
61
- }
62
- }
63
- return vis / 9.0;
58
+ let ts = 1.0 / 2048.0;
59
+ // 3x3 PCF unrolled — Safari's Metal backend doesn't unroll nested shadow loops reliably.
60
+ let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);
61
+ let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);
62
+ let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);
63
+ let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);
64
+ let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);
65
+ let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);
66
+ let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);
67
+ let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);
68
+ let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);
69
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
64
70
  }
65
71
 
66
72
  const PI_B: f32 = 3.141592653589793;
@@ -72,6 +78,8 @@ const BODY_RIM2_POW: f32 = 1.4300000667572021;
72
78
  const BODY_RIM2_BG: vec3f = vec3f(1.0, 0.4303792119026184, 0.3315804898738861);
73
79
  const BODY_WARM_AO_MUL: f32 = 0.30000001192092896;
74
80
  const BODY_MIX_NPR: f32 = 0.5;
81
+ // EEVEE Light Clamp equivalent — caps firefly specular from noise-bumped NDF aliasing.
82
+ const BODY_SPEC_CLAMP: f32 = 10.0;
75
83
 
76
84
  fn ggx_d_body(ndoth: f32, a2: f32) -> f32 {
77
85
  let denom = ndoth * ndoth * (a2 - 1.0) + 1.0;
@@ -83,7 +91,9 @@ fn smith_g1_body(ndotx: f32, a2: f32) -> f32 {
83
91
  }
84
92
 
85
93
  fn fresnel_schlick_body(cosTheta: f32, f0: f32) -> f32 {
86
- return f0 + (1.0 - f0) * pow(1.0 - cosTheta, 5.0);
94
+ let m = 1.0 - cosTheta;
95
+ let m2 = m * m;
96
+ return f0 + (1.0 - f0) * (m2 * m2 * m);
87
97
  }
88
98
 
89
99
  // smoothstep-based ramp: t*t*(3-2*t) between two color stops
@@ -114,13 +124,19 @@ fn ramp_ease(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {
114
124
  skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;
115
125
  }
116
126
  output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);
117
- output.normal = normalize(skinnedNrm);
127
+ // Skip VS normalize — interpolation denormalizes anyway, and FS always does normalize(input.normal).
128
+ output.normal = skinnedNrm;
118
129
  output.uv = uv;
119
130
  output.worldPos = skinnedPos.xyz;
120
131
  return output;
121
132
  }
122
133
 
123
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
134
+ struct FSOut {
135
+ @location(0) color: vec4f,
136
+ @location(1) mask: f32,
137
+ };
138
+
139
+ @fragment fn fs(input: VertexOutput) -> FSOut {
124
140
  let alpha = material.alpha;
125
141
  if (alpha < 0.001) { discard; }
126
142
 
@@ -137,13 +153,13 @@ fn ramp_ease(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {
137
153
  let toon = ramp_constant(ndotl_raw, 0.0, vec4f(0,0,0,1), 0.2966, vec4f(1,1,1,1)).r;
138
154
 
139
155
  // ═══ TOON COLOR: Mix.004 A=HueSat, B=HueSat.001, Fac=ramp.008 (R) ═══
140
- let shadow_tint = hue_sat(0.5, 2.0, 0.3499999940395355, 1.0, tex_color);
141
- let lit_tint = hue_sat(0.5, 1.5, 1.0, 1.0, tex_color);
156
+ let shadow_tint = hue_sat_id(2.0, 0.3499999940395355, 1.0, tex_color);
157
+ let lit_tint = hue_sat_id(1.5, 1.0, 1.0, tex_color);
142
158
  let toon_color = mix_blend(toon, shadow_tint, lit_tint);
143
159
  let bc = bright_contrast(toon_color, 0.1, 0.2);
144
160
 
145
161
  // ═══ AO CHAIN: AO → ramp CONSTANT [0→white, 0.5995→black] → Mix.003 ═══
146
- let ao = ao_fake(n, v);
162
+ let ao = 1.0; // ao_fake(n, v) — no SSAO yet; inline 1.0 so the ramp/mix chain folds at compile time.
147
163
  let ao_ramp = ramp_constant(ao, 0.0, vec4f(1,1,1,1), 0.5995, vec4f(0,0,0,1)).r;
148
164
  let ao_mixed = mix_blend(ao_ramp, bc, vec3f(0.8301780223846436, 0.3345769941806793, 0.27946099638938904));
149
165
 
@@ -174,16 +190,16 @@ fn ramp_ease(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {
174
190
  let npr_stack = add0 + warm_emission;
175
191
 
176
192
  // ═══ PRINCIPLED BSDF: noise bump, GGX specular, SSS from AO ═══
177
- let gen = mapping_point(input.worldPos, vec3f(0.0), vec3f(0.0), vec3f(1.0, 1.0, 1.5));
178
- let noise_val = tex_noise(gen, 1.0, 2.0, 0.5, 0.0);
193
+ // Mapping loc=rot=0 plain scale multiply, inline.
194
+ let noise_val = tex_noise_d2(input.worldPos * vec3f(1.0, 1.0, 1.5), 1.0);
179
195
  let noise_ramp = ramp_linear(noise_val, 0.0, vec4f(0,0,0,1), 1.0, vec4f(1,1,1,1)).r;
180
196
  let bumped_n = bump_lh(0.324644535779953, noise_ramp, n, input.worldPos);
181
197
 
182
198
  let principled_base = mix_blend(noise_ramp, bc, vec3f(0.6831911206245422, 0.19474034011363983, 0.13732507824897766));
183
199
  let p_emission = bc * 0.2;
184
200
 
185
- let ao2 = ao_fake(n, v);
186
- let sss = ramp_linear(ao2, 0.003, vec4f(0,0,0,1), 1.0, vec4f(0.0786, 0.0786, 0.0786, 1.0)).r;
201
+ // Reuse 'ao' (ao_fake(n, v) above) — identical inputs, avoid a second procedural AO pass.
202
+ let sss = ramp_linear(ao, 0.003, vec4f(0,0,0,1), 1.0, vec4f(0.0786, 0.0786, 0.0786, 1.0)).r;
187
203
 
188
204
  let p_ndotl = max(dot(bumped_n, l), 0.0);
189
205
  let p_ndotv = max(dot(bumped_n, v), 0.001);
@@ -194,9 +210,13 @@ fn ramp_ease(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {
194
210
  let D = ggx_d_body(p_ndoth, a2);
195
211
  let G = smith_g1_body(p_ndotl, a2) * smith_g1_body(p_ndotv, a2);
196
212
  let F = fresnel_schlick_body(p_vdoth, F0_BODY);
197
- let spec = (D * G * F) / max(4.0 * p_ndotl * p_ndotv, 0.001) * ltc_brdf_scale(p_ndotv, BODY_ROUGHNESS);
213
+ let brdf_lut = brdf_lut_sample(p_ndotv, BODY_ROUGHNESS);
214
+ let spec = (D * G * F) / max(4.0 * p_ndotl * p_ndotv, 0.001) * ltc_brdf_scale_from_lut(brdf_lut);
198
215
  let kd = (1.0 - F) * principled_base / PI_B;
199
- let direct = (kd + spec) * sun * p_ndotl * shadow;
216
+ // Split so we can clamp only the spec firefly contribution (EEVEE Light Clamp).
217
+ let spec_radiance = vec3f(spec) * sun * p_ndotl * shadow;
218
+ let spec_clamped = min(spec_radiance, vec3f(BODY_SPEC_CLAMP));
219
+ let direct = kd * sun * p_ndotl * shadow + spec_clamped;
200
220
  // Indirect diffuse = base_color × L_w per Blender closure_eval_surface_lib.glsl line 302;
201
221
  // probe_evaluate_world_diff returns radiance (SH-projected, not cosine-convolved).
202
222
  let ambient = principled_base * light.ambientColor.xyz;
@@ -205,7 +225,10 @@ fn ramp_ease(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {
205
225
  // 混合着色器.001: Shader=相加着色器.001, Shader_001=原理化BSDF
206
226
  let final_color = mix(npr_stack, principled, BODY_MIX_NPR);
207
227
 
208
- return vec4f(final_color, alpha);
228
+ var out: FSOut;
229
+ out.color = vec4f(final_color, alpha);
230
+ out.mask = 1.0;
231
+ return out;
209
232
  }
210
233
 
211
234
  `
@@ -49,19 +49,25 @@ struct LightVP { viewProj: mat4x4f, };
49
49
  @group(2) @binding(1) var<uniform> material: MaterialUniforms;
50
50
 
51
51
  fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
52
+ // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.
53
+ if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
52
54
  let biasedPos = worldPos + n * 0.08;
53
55
  let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);
54
56
  let ndc = lclip.xyz / max(lclip.w, 1e-6);
55
57
  let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
56
58
  let cmpZ = ndc.z - 0.001;
57
- let ts = 1.0 / 4096.0;
58
- var vis = 0.0;
59
- for (var y = -1; y <= 1; y++) {
60
- for (var x = -1; x <= 1; x++) {
61
- vis += textureSampleCompare(shadowMap, shadowSampler, suv + vec2f(f32(x), f32(y)) * ts, cmpZ);
62
- }
63
- }
64
- return vis / 9.0;
59
+ let ts = 1.0 / 2048.0;
60
+ // 3x3 PCF unrolled — Safari's Metal backend doesn't unroll nested shadow loops reliably.
61
+ let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);
62
+ let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);
63
+ let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);
64
+ let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);
65
+ let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);
66
+ let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);
67
+ let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);
68
+ let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);
69
+ let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);
70
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
65
71
  }
66
72
 
67
73
  const PI_CR: f32 = 3.141592653589793;
@@ -73,6 +79,8 @@ const CLOTH_R_EMIT_STR: f32 = 18.200000762939453;
73
79
  const CLOTH_R_MIX_SHADER_FAC: f32 = 0.8999999761581421;
74
80
  const CLOTH_R_NOISE_SCALE: f32 = 17.7;
75
81
  const CLOTH_R_BUMP_STR: f32 = 1.0;
82
+ // EEVEE Light Clamp equivalent — caps firefly specular from noise-bumped NDF aliasing.
83
+ const CLOTH_R_SPEC_CLAMP: f32 = 10.0;
76
84
 
77
85
  @vertex fn vs(
78
86
  @location(0) position: vec3f,
@@ -95,13 +103,19 @@ const CLOTH_R_BUMP_STR: f32 = 1.0;
95
103
  skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;
96
104
  }
97
105
  output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);
98
- output.normal = normalize(skinnedNrm);
106
+ // Skip VS normalize — interpolation denormalizes anyway, and FS always does normalize(input.normal).
107
+ output.normal = skinnedNrm;
99
108
  output.uv = uv;
100
109
  output.worldPos = skinnedPos.xyz;
101
110
  return output;
102
111
  }
103
112
 
104
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
113
+ struct FSOut {
114
+ @location(0) color: vec4f,
115
+ @location(1) mask: f32,
116
+ };
117
+
118
+ @fragment fn fs(input: VertexOutput) -> FSOut {
105
119
  let n = normalize(input.normal);
106
120
  let v = normalize(camera.viewPos - input.worldPos);
107
121
  let l = -light.lights[0].direction.xyz;
@@ -121,7 +135,7 @@ const CLOTH_R_BUMP_STR: f32 = 1.0;
121
135
  let mix04_fac = math_multiply(toon_r, CLOTH_R_MIX04_MUL);
122
136
 
123
137
  // 混合.004: A=色相/饱和度/明度.002(Hue=0.5 Sat=1.0 Val=0.2), B=纹理
124
- let dark_tex = hue_sat(0.5, 1.0, 0.19999998807907104, 1.0, tex_rgb);
138
+ let dark_tex = hue_sat_id(1.0, 0.19999998807907104, 1.0, tex_rgb);
125
139
  let mix04 = mix_blend(mix04_fac, dark_tex, tex_rgb);
126
140
 
127
141
  // 倒角.001.Z → 混合.003: A=混合.004, B=色相/饱和度/明度.002
@@ -129,34 +143,35 @@ const CLOTH_R_BUMP_STR: f32 = 1.0;
129
143
  let mix03 = mix_blend(bevel_z, mix04, dark_tex);
130
144
 
131
145
  // 环境光遮蔽 → 颜色渐变.001 LINEAR → 混合.001 (white/black) → 混合.002 OVERLAY Fac
132
- let ao = ao_fake(n, v);
146
+ let ao = 1.0; // ao_fake(n, v) — no SSAO yet; inline 1.0 so the ramp/mix chain folds at compile time.
133
147
  let ao_ramp_c = ramp_linear(ao, 0.0, vec4f(1,1,1,1), 0.8808, vec4f(0,0,0,1));
134
148
  let mix01_fac = ao_ramp_c.r;
135
149
  let mix01_rgb = mix(vec3f(1.0), vec3f(0.0), mix01_fac);
136
150
 
137
151
  // 混合.002 OVERLAY: Fac=混合.001, A=混合.003, B=色相/饱和度/明度.004
138
- let hue004 = hue_sat(0.5, 0.800000011920929, 2.0, 1.0, mix03);
152
+ let hue004 = hue_sat_id(0.800000011920929, 2.0, 1.0, mix03);
139
153
  let overlay_fac = mix01_rgb.r;
140
154
  let npr_rgb = mix_overlay(overlay_fac, mix03, hue004);
141
155
  let npr_emission = npr_rgb * CLOTH_R_EMIT_STR;
142
156
 
143
157
  // 噪波→渐变→凹凸 (LIVE in M_Rough_Cloth — unlike Smooth Cloth): Strength=1.0, noise Scale=17.7.
144
- let noise_uv = mapping_point(input.worldPos, vec3f(0.0), vec3f(0.0), vec3f(1.0));
145
- let noise_val = tex_noise(noise_uv, CLOTH_R_NOISE_SCALE, 2.0, 0.5, 0.0);
158
+ // mapping scale=(1,1,1), loc=rot=0 → identity; use worldPos directly.
159
+ let noise_val = tex_noise_d2(input.worldPos, CLOTH_R_NOISE_SCALE);
146
160
  let noise_ramp = ramp_linear(noise_val, 0.0, vec4f(0,0,0,1), 1.0, vec4f(1,1,1,1)).r;
147
161
  let bumped_n = bump_lh(CLOTH_R_BUMP_STR, noise_ramp, n, input.worldPos);
148
162
 
149
163
  // 原理化BSDF (EEVEE port): metallic=0, specular=0.8, roughness=0.8187, specular_tint=0.
150
- let principled_base = hue_sat(0.5, 1.0, 0.800000011920929, 1.0, tex_rgb);
164
+ let principled_base = hue_sat_id(1.0, 0.800000011920929, 1.0, tex_rgb);
151
165
  let NL = max(dot(bumped_n, l), 0.0);
152
166
  let NV = max(dot(bumped_n, v), 1e-4);
153
167
 
154
168
  let f0 = vec3f(0.08 * CLOTH_R_SPECULAR);
155
169
  let f90 = mix(f0, vec3f(1.0), sqrt(CLOTH_R_SPECULAR));
156
- let split_sum = brdf_lut_baked(NV, CLOTH_R_ROUGHNESS);
157
- let reflection_color = F_brdf_multi_scatter(f0, f90, split_sum);
170
+ let brdf_lut = brdf_lut_sample(NV, CLOTH_R_ROUGHNESS);
171
+ let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);
158
172
 
159
- let spec_direct = bsdf_ggx(bumped_n, l, v, CLOTH_R_ROUGHNESS) * sun * shadow * ltc_brdf_scale(NV, CLOTH_R_ROUGHNESS);
173
+ let spec_direct_raw = bsdf_ggx(bumped_n, l, v, NL, NV, CLOTH_R_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);
174
+ let spec_direct = min(spec_direct_raw, vec3f(CLOTH_R_SPEC_CLAMP));
160
175
  let spec_indirect = amb;
161
176
  let spec_radiance = (spec_direct + spec_indirect) * reflection_color;
162
177
 
@@ -168,7 +183,10 @@ const CLOTH_R_BUMP_STR: f32 = 1.0;
168
183
  // 混合着色器.001 Fac=0.9: Shader=自发光.005, Shader_001=原理化BSDF
169
184
  let final_color = mix(npr_emission, principled, CLOTH_R_MIX_SHADER_FAC);
170
185
 
171
- return vec4f(final_color, out_alpha);
186
+ var out: FSOut;
187
+ out.color = vec4f(final_color, out_alpha);
188
+ out.mask = 1.0;
189
+ return out;
172
190
  }
173
191
 
174
192
  `
@@ -48,19 +48,25 @@ struct LightVP { viewProj: mat4x4f, };
48
48
  @group(2) @binding(1) var<uniform> material: MaterialUniforms;
49
49
 
50
50
  fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
51
+ // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.
52
+ if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
51
53
  let biasedPos = worldPos + n * 0.08;
52
54
  let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);
53
55
  let ndc = lclip.xyz / max(lclip.w, 1e-6);
54
56
  let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
55
57
  let cmpZ = ndc.z - 0.001;
56
- let ts = 1.0 / 4096.0;
57
- var vis = 0.0;
58
- for (var y = -1; y <= 1; y++) {
59
- for (var x = -1; x <= 1; x++) {
60
- vis += textureSampleCompare(shadowMap, shadowSampler, suv + vec2f(f32(x), f32(y)) * ts, cmpZ);
61
- }
62
- }
63
- return vis / 9.0;
58
+ let ts = 1.0 / 2048.0;
59
+ // 3x3 PCF unrolled — Safari's Metal backend doesn't unroll nested shadow loops reliably.
60
+ let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);
61
+ let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);
62
+ let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);
63
+ let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);
64
+ let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);
65
+ let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);
66
+ let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);
67
+ let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);
68
+ let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);
69
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
64
70
  }
65
71
 
66
72
  const PI_C: f32 = 3.141592653589793;
@@ -92,13 +98,19 @@ const NPR_MIX_SHADER_FAC: f32 = 0.8999999761581421;
92
98
  skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;
93
99
  }
94
100
  output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);
95
- output.normal = normalize(skinnedNrm);
101
+ // Skip VS normalize — interpolation denormalizes anyway, and FS always does normalize(input.normal).
102
+ output.normal = skinnedNrm;
96
103
  output.uv = uv;
97
104
  output.worldPos = skinnedPos.xyz;
98
105
  return output;
99
106
  }
100
107
 
101
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
108
+ struct FSOut {
109
+ @location(0) color: vec4f,
110
+ @location(1) mask: f32,
111
+ };
112
+
113
+ @fragment fn fs(input: VertexOutput) -> FSOut {
102
114
  let n = normalize(input.normal);
103
115
  let v = normalize(camera.viewPos - input.worldPos);
104
116
  let l = -light.lights[0].direction.xyz;
@@ -119,7 +131,7 @@ const NPR_MIX_SHADER_FAC: f32 = 0.8999999761581421;
119
131
  let mix04_fac = math_multiply(toon_r, CLOTH_MIX04_MUL);
120
132
 
121
133
  // 混合.004: A=色相/饱和度/明度.002, B=纹理
122
- let dark_tex = hue_sat(0.5, 1.0, 0.19999998807907104, 1.0, tex_rgb);
134
+ let dark_tex = hue_sat_id(1.0, 0.19999998807907104, 1.0, tex_rgb);
123
135
  let mix04 = mix_blend(mix04_fac, dark_tex, tex_rgb);
124
136
 
125
137
  // 倒角.001→Z → 混合.003 Factor; A=混合.004, B=色相/饱和度/明度.002
@@ -127,32 +139,32 @@ const NPR_MIX_SHADER_FAC: f32 = 0.8999999761581421;
127
139
  let mix03 = mix_blend(bevel_z, mix04, dark_tex);
128
140
 
129
141
  // 环境光遮蔽 → 颜色渐变.001 LINEAR → 混合.001 (白/黑) → 混合.002 OVERLAY Fac
130
- let ao = ao_fake(n, v);
142
+ let ao = 1.0; // ao_fake(n, v) — no SSAO yet; inline 1.0 so the ramp/mix chain folds at compile time.
131
143
  let ao_ramp_c = ramp_linear(ao, 0.0, vec4f(1,1,1,1), 0.8808, vec4f(0,0,0,1));
132
144
  let mix01_fac = ao_ramp_c.r;
133
145
  let mix01_rgb = mix(vec3f(1.0), vec3f(0.0), mix01_fac);
134
146
 
135
147
  // 混合.002 OVERLAY: Fac=混合.001, A=混合.003, B=色相/饱和度/明度.004
136
- let hue004 = hue_sat(0.5, 0.800000011920929, 2.0, 1.0, mix03);
148
+ let hue004 = hue_sat_id(0.800000011920929, 2.0, 1.0, mix03);
137
149
  let overlay_fac = mix01_rgb.r;
138
150
  let npr_rgb = mix_overlay(overlay_fac, mix03, hue004);
139
151
  let npr_emission = npr_rgb * NPR_EMIT_STR;
140
152
 
141
153
  // 原理化BSDF (EEVEE port): metallic=0, specular=0.8, roughness=0.5, specular_tint=0.
142
154
  // Bump subtree is dead in the Blender graph (噪波→凹凸 not linked to Principled.Normal).
143
- let principled_base = hue_sat(0.5, 1.0, 0.800000011920929, 1.0, tex_rgb);
155
+ let principled_base = hue_sat_id(1.0, 0.800000011920929, 1.0, tex_rgb);
144
156
  let NL = max(dot(n, l), 0.0);
145
157
  let NV = max(dot(n, v), 1e-4);
146
158
 
147
159
  // f0/f90 per gpu_shader_material_principled.glsl — specular_tint=0 → dielectric_f0_color=white.
148
160
  let f0 = vec3f(0.08 * CLOTH_SPECULAR);
149
161
  let f90 = mix(f0, vec3f(1.0), sqrt(CLOTH_SPECULAR));
150
- let split_sum = brdf_lut_baked(NV, CLOTH_ROUGHNESS);
151
- let reflection_color = F_brdf_multi_scatter(f0, f90, split_sum);
162
+ let brdf_lut = brdf_lut_sample(NV, CLOTH_ROUGHNESS);
163
+ let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);
152
164
 
153
165
  // Direct glossy — bsdf_ggx already includes NL; no F applied here (tinted after accum).
154
166
  // ltc_brdf_scale: EEVEE direct path uses LTC; split-sum LUT path is rescaled to match.
155
- let spec_direct = bsdf_ggx(n, l, v, CLOTH_ROUGHNESS) * sun * shadow * ltc_brdf_scale(NV, CLOTH_ROUGHNESS);
167
+ let spec_direct = bsdf_ggx(n, l, v, NL, NV, CLOTH_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);
156
168
  // Indirect glossy — flat world probe (solid color). Phase 2 adds cubemap.
157
169
  let spec_indirect = amb;
158
170
  let spec_radiance = (spec_direct + spec_indirect) * reflection_color;
@@ -167,7 +179,10 @@ const NPR_MIX_SHADER_FAC: f32 = 0.8999999761581421;
167
179
  // 混合着色器.001: Shader=自发光.005, Shader_001=原理化BSDF, Fac=0.9
168
180
  let final_color = mix(npr_emission, principled, NPR_MIX_SHADER_FAC);
169
181
 
170
- return vec4f(final_color, out_alpha);
182
+ var out: FSOut;
183
+ out.color = vec4f(final_color, out_alpha);
184
+ out.mask = 1.0;
185
+ return out;
171
186
  }
172
187
 
173
188
  `
@@ -64,7 +64,9 @@ fn smith_g1(ndotx: f32, a2: f32) -> f32 {
64
64
  }
65
65
 
66
66
  fn fresnel_schlick(cosTheta: f32, f0: f32) -> f32 {
67
- return f0 + (1.0 - f0) * pow(1.0 - cosTheta, 5.0);
67
+ let m = 1.0 - cosTheta;
68
+ let m2 = m * m;
69
+ return f0 + (1.0 - f0) * (m2 * m2 * m);
68
70
  }
69
71
 
70
72
  // ─── Filmic tone mapping (LUT extracted from Blender 3.6 OCIO) ─────
@@ -91,19 +93,25 @@ fn tonemap(hdr: vec3f) -> vec3f {
91
93
  // ─── Shadow sampling (3×3 PCF) ──────────────────────────────────────
92
94
 
93
95
  fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
96
+ // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.
97
+ if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
94
98
  let biasedPos = worldPos + n * 0.08;
95
99
  let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);
96
100
  let ndc = lclip.xyz / max(lclip.w, 1e-6);
97
101
  let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
98
102
  let cmpZ = ndc.z - 0.001;
99
- let ts = 1.0 / 4096.0;
100
- var vis = 0.0;
101
- for (var y = -1; y <= 1; y++) {
102
- for (var x = -1; x <= 1; x++) {
103
- vis += textureSampleCompare(shadowMap, shadowSampler, suv + vec2f(f32(x), f32(y)) * ts, cmpZ);
104
- }
105
- }
106
- return vis / 9.0;
103
+ let ts = 1.0 / 2048.0;
104
+ // 3x3 PCF unrolled — Safari's Metal backend doesn't unroll nested shadow loops reliably.
105
+ let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);
106
+ let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);
107
+ let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);
108
+ let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);
109
+ let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);
110
+ let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);
111
+ let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);
112
+ let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);
113
+ let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);
114
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
107
115
  }
108
116
 
109
117
  // ─── Vertex / Fragment ──────────────────────────────────────────────
@@ -129,13 +137,19 @@ fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
129
137
  skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;
130
138
  }
131
139
  output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);
132
- output.normal = normalize(skinnedNrm);
140
+ // Skip VS normalize — interpolation denormalizes anyway, and FS always does normalize(input.normal).
141
+ output.normal = skinnedNrm;
133
142
  output.uv = uv;
134
143
  output.worldPos = skinnedPos.xyz;
135
144
  return output;
136
145
  }
137
146
 
138
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
147
+ struct FSOut {
148
+ @location(0) color: vec4f,
149
+ @location(1) mask: f32,
150
+ };
151
+
152
+ @fragment fn fs(input: VertexOutput) -> FSOut {
139
153
  let alpha = material.alpha;
140
154
  if (alpha < 0.001) { discard; }
141
155
 
@@ -163,7 +177,10 @@ fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
163
177
  let direct = (kd + spec) * sunColor * ndotl * shadow;
164
178
  let ambient = albedo * light.ambientColor.xyz;
165
179
 
166
- return vec4f(ambient + direct, alpha);
180
+ var out: FSOut;
181
+ out.color = vec4f(ambient + direct, alpha);
182
+ out.mask = 1.0;
183
+ return out;
167
184
  }
168
185
 
169
186
  `
@@ -1,29 +1,37 @@
1
- // One-shot bake pass that precomputes EEVEE's BRDF split-sum DFG LUT.
2
- // Direct WGSL port of Blender 3.6 source/blender/draw/engines/eevee/shaders/
3
- // bsdf_lut_frag.glsl + bsdf_sampling_lib.glsl (VNDF GGX branch).
1
+ // One-shot bake pass that produces the combined EEVEE BRDF LUT.
2
+ // Output: 64×64 rgba8unorm .rg = split-sum DFG (Blender bsdf_lut_frag.glsl,
3
+ // Karis convention: tint = f0·x + f90·y), .ba = Heitz 2016 LTC magnitude
4
+ // (ltc_mag_ggx from eevee_lut.c), sampled from a temp rg16float source texture
5
+ // passed in at bake time.
4
6
  //
5
- // Output texture: 64×64 rg16float, written once at engine init.
6
- // R = (1 - Fc) × BRDF integrated over GGX VNDF — scales f0
7
- // G = Fc × BRDF integrated over GGX VNDF scales f90
8
- // Runtime sampler: see brdf_lut_baked() in nodes.ts. Plug into
9
- // F_brdf_single_scatter / F_brdf_multi_scatter verbatim.
10
-
11
- export const DFG_LUT_SIZE = 64
12
- export const DFG_LUT_SAMPLE_COUNT = 32
13
-
14
- export const DFG_LUT_WGSL = /* wgsl */ `
15
- const LUT_SIZE: f32 = ${DFG_LUT_SIZE}.0;
16
- const SAMPLE_COUNT: u32 = ${DFG_LUT_SAMPLE_COUNT}u;
7
+ // Packing both LUTs into one texture lets runtime shaders do a SINGLE texture
8
+ // fetch per fragment to get everything needed for F_brdf_multi_scatter AND
9
+ // ltc_brdf_scale. Was 3 taps (dfg in brdf_lut_baked + dfg+ltc in ltc_brdf_scale);
10
+ // now 1. Big win on Apple GPUs where fragment-stage texture fetches are the
11
+ // dominant cost with MSAA.
12
+ //
13
+ // rgba8unorm (vs rgba16float) is a deliberate precision drop: DFG values live in
14
+ // [0,1], LTC magnitude in [0,1], 1/255 quantization is below the perceptual
15
+ // threshold for direct-spec energy compensation. Halves bandwidth per sample.
16
+
17
+ export const BRDF_LUT_SIZE = 64
18
+ const BAKE_SAMPLE_COUNT = 32
19
+
20
+ export const BRDF_LUT_BAKE_WGSL = /* wgsl */ `
21
+ const LUT_SIZE: f32 = ${BRDF_LUT_SIZE}.0;
22
+ const SAMPLE_COUNT: u32 = ${BAKE_SAMPLE_COUNT}u;
17
23
  const M_2PI: f32 = 6.283185307179586;
18
24
 
25
+ // Temp LTC magnitude source (rg16float, uploaded from eevee_lut.c ltc_mag_ggx).
26
+ // Sampled 1:1 by pixel — bake coord mapping matches runtime sample coord mapping.
27
+ @group(0) @binding(0) var ltcSrc: texture_2d<f32>;
28
+
19
29
  @vertex fn vs(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4f {
20
- // Full-screen triangle covering [-1,1]² in NDC.
21
30
  let x = f32((vid << 1u) & 2u) * 2.0 - 1.0;
22
31
  let y = f32(vid & 2u) * 2.0 - 1.0;
23
32
  return vec4f(x, y, 0.0, 1.0);
24
33
  }
25
34
 
26
- // common_math_geom_lib.glsl:165 — make_orthonormal_basis.
27
35
  fn orthonormal_basis(N: vec3f) -> mat2x3f {
28
36
  let up = select(vec3f(1.0, 0.0, 0.0), vec3f(0.0, 0.0, 1.0), abs(N.z) < 0.99999);
29
37
  let T = normalize(cross(up, N));
@@ -31,7 +39,6 @@ fn orthonormal_basis(N: vec3f) -> mat2x3f {
31
39
  return mat2x3f(T, B);
32
40
  }
33
41
 
34
- // bsdf_sampling_lib.glsl:27 — Heitz 2018 VNDF sampling in tangent space.
35
42
  fn sample_ggx_vndf(rand: vec3f, alpha: f32, Vt: vec3f) -> vec3f {
36
43
  let Vh = normalize(vec3f(alpha * Vt.xy, Vt.z));
37
44
  let tb = orthonormal_basis(Vh);
@@ -47,12 +54,10 @@ fn sample_ggx_vndf(rand: vec3f, alpha: f32, Vt: vec3f) -> vec3f {
47
54
  return normalize(vec3f(alpha * Hh.xy, saturate(Hh.z)));
48
55
  }
49
56
 
50
- // bsdf_common_lib.glsl:105 — G1 Smith GGX (Brian Karis opti form).
51
57
  fn G1_Smith_GGX_opti(NX: f32, a2: f32) -> f32 {
52
58
  return NX + sqrt(NX * (NX - NX * a2) + a2);
53
59
  }
54
60
 
55
- // bsdf_common_lib.glsl:50 — exact dielectric Fresnel (monochromatic).
56
61
  fn F_eta(eta: f32, cos_theta: f32) -> f32 {
57
62
  let c = abs(cos_theta);
58
63
  var g = eta * eta - 1.0 + c * c;
@@ -62,7 +67,7 @@ fn F_eta(eta: f32, cos_theta: f32) -> f32 {
62
67
  let B = (c * (g + c) - 1.0) / (c * (g - c) + 1.0);
63
68
  return 0.5 * A * A * (1.0 + B * B);
64
69
  }
65
- return 1.0; // total internal reflection
70
+ return 1.0;
66
71
  }
67
72
 
68
73
  fn f0_from_ior(eta: f32) -> f32 {
@@ -70,13 +75,12 @@ fn f0_from_ior(eta: f32) -> f32 {
70
75
  return A * A;
71
76
  }
72
77
 
73
- // F_color_blend(eta, fresnel, vec3(0)).r — blend factor only.
74
78
  fn F_color_blend_zero(eta: f32, fresnel: f32) -> f32 {
75
79
  let f0 = f0_from_ior(eta);
76
80
  return saturate((fresnel - f0) / (1.0 - f0));
77
81
  }
78
82
 
79
- @fragment fn fs(@builtin(position) frag: vec4f) -> @location(0) vec2f {
83
+ @fragment fn fs(@builtin(position) frag: vec4f) -> @location(0) vec4f {
80
84
  let y_uv = floor(frag.y) / (LUT_SIZE - 1.0);
81
85
  let x_uv = floor(frag.x) / (LUT_SIZE - 1.0);
82
86
 
@@ -86,7 +90,6 @@ fn F_color_blend_zero(eta: f32, fresnel: f32) -> f32 {
86
90
 
87
91
  let V = vec3f(sqrt(1.0 - NV * NV), 0.0, NV);
88
92
 
89
- // principled specular=1.0 — max value, matches bsdf_lut_frag.glsl:41.
90
93
  let eta = (2.0 / (1.0 - sqrt(0.08 * 1.0))) - 1.0;
91
94
 
92
95
  var brdf_accum = 0.0;
@@ -96,7 +99,6 @@ fn F_color_blend_zero(eta: f32, fresnel: f32) -> f32 {
96
99
  for (var i: u32 = 0u; i < SAMPLE_COUNT; i = i + 1u) {
97
100
  let ix = (f32(i) + 0.5) / sc_f;
98
101
  let iy = (f32(j) + 0.5) / sc_f;
99
- // Xi.x = radial, Xi.yz = (cos, sin) of azimuth — bsdf_lut_frag.glsl:22.
100
102
  let Xi = vec3f(ix, cos(iy * M_2PI), sin(iy * M_2PI));
101
103
 
102
104
  let H = sample_ggx_vndf(Xi, a, V);
@@ -106,7 +108,6 @@ fn F_color_blend_zero(eta: f32, fresnel: f32) -> f32 {
106
108
  let NH = max(H.z, 0.0);
107
109
  let VH = max(dot(V, H), 0.0);
108
110
 
109
- // G_smith (divided form): 4·NV·NL / (G1_v·G1_l). See bsdf_common_lib.glsl:105.
110
111
  let G1v = G1_Smith_GGX_opti(NV, a2);
111
112
  let G1l = G1_Smith_GGX_opti(NL, a2);
112
113
  let G_smith = 4.0 * NV * NL / (G1v * G1l);
@@ -122,6 +123,9 @@ fn F_color_blend_zero(eta: f32, fresnel: f32) -> f32 {
122
123
  }
123
124
  }
124
125
  let n2 = sc_f * sc_f;
125
- return vec2f(brdf_accum / n2, fresnel_accum / n2);
126
+ let dfg = vec2f(brdf_accum / n2, fresnel_accum / n2);
127
+ // Pack preloaded LTC magnitude at matching (roughness, sqrt(1-NV)) pixel.
128
+ let ltc = textureLoad(ltcSrc, vec2i(i32(frag.x), i32(frag.y)), 0).rg;
129
+ return vec4f(dfg, ltc);
126
130
  }
127
131
  `