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
@@ -60,23 +60,31 @@ fn smith_g1(ndotx: f32, a2: f32) -> f32 {
60
60
  }
61
61
 
62
62
  fn fresnel_schlick(cosTheta: f32, f0: f32) -> f32 {
63
- return f0 + (1.0 - f0) * pow(1.0 - cosTheta, 5.0);
63
+ let m = 1.0 - cosTheta;
64
+ let m2 = m * m;
65
+ return f0 + (1.0 - f0) * (m2 * m2 * m);
64
66
  }
65
67
 
66
68
  fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
69
+ // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.
70
+ if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
67
71
  let biasedPos = worldPos + n * 0.08;
68
72
  let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);
69
73
  let ndc = lclip.xyz / max(lclip.w, 1e-6);
70
74
  let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
71
75
  let cmpZ = ndc.z - 0.001;
72
- let ts = 1.0 / 4096.0;
73
- var vis = 0.0;
74
- for (var y = -1; y <= 1; y++) {
75
- for (var x = -1; x <= 1; x++) {
76
- vis += textureSampleCompare(shadowMap, shadowSampler, suv + vec2f(f32(x), f32(y)) * ts, cmpZ);
77
- }
78
- }
79
- return vis / 9.0;
76
+ let ts = 1.0 / 2048.0;
77
+ // 3x3 PCF unrolled — Safari's Metal backend doesn't unroll nested shadow loops reliably.
78
+ let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);
79
+ let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);
80
+ let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);
81
+ let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);
82
+ let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);
83
+ let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);
84
+ let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);
85
+ let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);
86
+ let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);
87
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
80
88
  }
81
89
 
82
90
  @vertex fn vs(
@@ -100,13 +108,19 @@ fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
100
108
  skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;
101
109
  }
102
110
  output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);
103
- output.normal = normalize(skinnedNrm);
111
+ // Skip VS normalize — interpolation denormalizes anyway, and FS always does normalize(input.normal).
112
+ output.normal = skinnedNrm;
104
113
  output.uv = uv;
105
114
  output.worldPos = skinnedPos.xyz;
106
115
  return output;
107
116
  }
108
117
 
109
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
118
+ struct FSOut {
119
+ @location(0) color: vec4f,
120
+ @location(1) mask: f32,
121
+ };
122
+
123
+ @fragment fn fs(input: VertexOutput) -> FSOut {
110
124
  let alpha = material.alpha;
111
125
  if (alpha < 0.001) { discard; }
112
126
 
@@ -137,7 +151,10 @@ fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
137
151
  // Principled Emission socket: emissive = emission_color × strength, added on top of shading.
138
152
  let emission = albedo * EYE_EMISSION_STRENGTH;
139
153
 
140
- return vec4f(ambient + direct + emission, alpha);
154
+ var out: FSOut;
155
+ out.color = vec4f(ambient + direct + emission, alpha);
156
+ out.mask = 1.0;
157
+ return out;
141
158
  }
142
159
 
143
160
  `
@@ -47,21 +47,27 @@ struct LightVP { viewProj: mat4x4f, };
47
47
  @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
48
48
  @group(2) @binding(1) var<uniform> material: MaterialUniforms;
49
49
 
50
- // 3x3 PCF shadow sampling, 4096 map, normal-bias 0.08, depth-bias 0.001
50
+ // 3x3 PCF shadow sampling, 2048 map, normal-bias 0.08, depth-bias 0.001
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_F: f32 = 3.141592653589793;
@@ -73,6 +79,10 @@ const FACE_RIM2_BG: vec3f = vec3f(1.0, 0.4684903025627136, 0.3698573112487793);
73
79
  const FACE_WARM_AO_MUL: f32 = 0.30000001192092896; // 运算.004 MULTIPLY after invert (was 0.5 in older trace)
74
80
  const FACE_BRIGHT_TEX_THRESH: f32 = 0.9300000071525574; // 运算.005 GREATER_THAN Value_001
75
81
  const FACE_MIX_NPR: f32 = 0.5; // 混合着色器.001 Fac
82
+ // EEVEE Light Clamp equivalent (Render Props → Sampling → Clamping). Caps direct
83
+ // specular firefly from the noise-bumped normal's NDF aliasing — Blender hides this
84
+ // via TAA, which we don't have. Value mirrors EEVEE's default Clamp Indirect=10.0.
85
+ const FACE_SPEC_CLAMP: f32 = 10.0;
76
86
 
77
87
  @vertex fn vs(
78
88
  @location(0) position: vec3f,
@@ -95,7 +105,8 @@ const FACE_MIX_NPR: f32 = 0.5; // 混合着色器.001 Fac
95
105
  skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;
96
106
  }
97
107
  output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);
98
- output.normal = normalize(skinnedNrm);
108
+ // Skip VS normalize — interpolation denormalizes anyway, and FS always does normalize(input.normal).
109
+ output.normal = skinnedNrm;
99
110
  output.uv = uv;
100
111
  output.worldPos = skinnedPos.xyz;
101
112
  return output;
@@ -105,7 +116,12 @@ const FACE_MIX_NPR: f32 = 0.5; // 混合着色器.001 Fac
105
116
  // TEX → HueSat shadow/lit → toon gate → BrightContrast → AO chain → emission stack
106
117
  // Fresnel rims, warm AO emission, bright-texture gate, noise-bumped Principled
107
118
  // Final = mix(Principled, NPR, 0.5)
108
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
119
+ struct FSOut {
120
+ @location(0) color: vec4f,
121
+ @location(1) mask: f32,
122
+ };
123
+
124
+ @fragment fn fs(input: VertexOutput) -> FSOut {
109
125
  let alpha = material.alpha;
110
126
  if (alpha < 0.001) { discard; }
111
127
 
@@ -123,7 +139,7 @@ const FACE_MIX_NPR: f32 = 0.5; // 混合着色器.001 Fac
123
139
  let ndotl_raw = shader_to_rgb_diffuse(n, l, sun, light.ambientColor.xyz, shadow);
124
140
  // ramp.008 CONSTANT — edge AA avoids binary fac shimmer / white specks on terminator (fwidth + smoothstep)
125
141
  let toon = ramp_constant_edge_aa(ndotl_raw, 0.2966, vec4f(0,0,0,1), vec4f(1,1,1,1)).r;
126
- 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.
127
143
 
128
144
  // ═══ TOON COLOR ═══
129
145
  let shadow_tint = hue_sat(0.46000000834465027, 2.0, 0.3499999940395355, 1.0, tex_color); // HueSat.002
@@ -163,7 +179,12 @@ const FACE_MIX_NPR: f32 = 0.5; // 混合着色器.001 Fac
163
179
  let rim2_mixed = mix(emission3, FACE_RIM2_BG, rim2_fac);
164
180
 
165
181
  // 转接点.005(tex) → 运算.005 GREATER_THAN Value_001
166
- let tex_gate = math_greater_than(tex_color.r, FACE_BRIGHT_TEX_THRESH);
182
+ // Blender implicitly converts Color → Float via BT.601 grayscale when plugging a
183
+ // color output into a Math node's Value input. Our earlier trace used tex_color.r,
184
+ // which fires aggressively on R-dominant skin — single near-white R pixels produced
185
+ // firefly speckles. color_to_value matches the actual Blender socket semantic and
186
+ // only fires on genuinely near-white painted features (the author's intent).
187
+ let tex_gate = math_greater_than(color_to_value(tex_color), FACE_BRIGHT_TEX_THRESH);
167
188
  let bright_emit = vec3f(tex_gate) * 3.0; // Emission.002(Strength=3.0)
168
189
 
169
190
  // ═══ NPR STACK (AddShader chain) ═══
@@ -172,9 +193,8 @@ const FACE_MIX_NPR: f32 = 0.5; // 混合着色器.001 Fac
172
193
  let npr_stack = add0 + warm_emission; // AddShader.001
173
194
 
174
195
  // ═══ PRINCIPLED BSDF ═══
175
- // Noise-based bump normal
176
- let gen = mapping_point(input.worldPos, vec3f(0.0), vec3f(0.0), vec3f(1.0, 1.0, 1.5));
177
- let noise_val = tex_noise(gen, 1.0, 2.0, 0.5, 0.0);
196
+ // Noise-based bump normal. Mapping loc=rot=0 → plain scale multiply, inline.
197
+ let noise_val = tex_noise_d2(input.worldPos * vec3f(1.0, 1.0, 1.5), 1.0);
178
198
  let noise_ramp = ramp_linear(noise_val, 0.0, vec4f(0,0,0,1), 1.0, vec4f(1,1,1,1)).r;
179
199
  let bumped_n = bump_lh(0.324644535779953, noise_ramp, n, input.worldPos); // 凹凸 Strength; LH bump
180
200
 
@@ -182,9 +202,9 @@ const FACE_MIX_NPR: f32 = 0.5; // 混合着色器.001 Fac
182
202
  let principled_base = mix_blend(noise_ramp, bc, vec3f(0.6832, 0.1947, 0.1373));
183
203
  // Emission input from reroute.011 (bc), Strength=0.2
184
204
  let p_emission = bc * 0.2;
185
- // AO.002 → ramp.005 LINEAR [0.003→black, 1.0→gray] for subsurface approx
186
- let ao2 = ao_fake(n, v);
187
- let sss = ramp_linear(ao2, 0.003, vec4f(0,0,0,1), 1.0, vec4f(0.0786, 0.0786, 0.0786, 1.0)).r;
205
+ // AO.002 → ramp.005 LINEAR [0.003→black, 1.0→gray] for subsurface approx.
206
+ // Reuse 'ao' (ao_fake(n, v) above) — identical inputs, avoid a second procedural AO pass.
207
+ let sss = ramp_linear(ao, 0.003, vec4f(0,0,0,1), 1.0, vec4f(0.0786, 0.0786, 0.0786, 1.0)).r;
188
208
 
189
209
  // 原理化BSDF (EEVEE port): metallic=0, specular=0.5, roughness=0.3, specular_tint=0.
190
210
  let NL = max(dot(bumped_n, l), 0.0);
@@ -192,10 +212,11 @@ const FACE_MIX_NPR: f32 = 0.5; // 混合着色器.001 Fac
192
212
 
193
213
  let f0 = vec3f(0.08 * FACE_SPECULAR);
194
214
  let f90 = mix(f0, vec3f(1.0), sqrt(FACE_SPECULAR));
195
- let split_sum = brdf_lut_baked(NV, FACE_ROUGHNESS);
196
- let reflection_color = F_brdf_multi_scatter(f0, f90, split_sum);
215
+ let brdf_lut = brdf_lut_sample(NV, FACE_ROUGHNESS);
216
+ let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);
197
217
 
198
- let spec_direct = bsdf_ggx(bumped_n, l, v, FACE_ROUGHNESS) * sun * shadow * ltc_brdf_scale(NV, FACE_ROUGHNESS);
218
+ let spec_direct_raw = bsdf_ggx(bumped_n, l, v, NL, NV, FACE_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);
219
+ let spec_direct = min(spec_direct_raw, vec3f(FACE_SPEC_CLAMP));
199
220
  let spec_indirect = light.ambientColor.xyz;
200
221
  let spec_radiance = (spec_direct + spec_indirect) * reflection_color;
201
222
 
@@ -207,7 +228,10 @@ const FACE_MIX_NPR: f32 = 0.5; // 混合着色器.001 Fac
207
228
  // 混合着色器.001: Shader=相加着色器.001, Shader_001=原理化BSDF — Fac blends toward second
208
229
  let final_color = mix(npr_stack, principled, FACE_MIX_NPR);
209
230
 
210
- return vec4f(final_color, alpha);
231
+ var out: FSOut;
232
+ out.color = vec4f(final_color, alpha);
233
+ out.mask = 1.0;
234
+ return out;
211
235
  }
212
236
 
213
237
  `
@@ -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_H: f32 = 3.141592653589793;
@@ -93,13 +99,19 @@ const HAIR_MIX_BG: vec3f = vec3f(0.1673291176557541);
93
99
  skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;
94
100
  }
95
101
  output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);
96
- output.normal = normalize(skinnedNrm);
102
+ // Skip VS normalize — interpolation denormalizes anyway, and FS always does normalize(input.normal).
103
+ output.normal = skinnedNrm;
97
104
  output.uv = uv;
98
105
  output.worldPos = skinnedPos.xyz;
99
106
  return output;
100
107
  }
101
108
 
102
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
109
+ struct FSOut {
110
+ @location(0) color: vec4f,
111
+ @location(1) mask: f32,
112
+ };
113
+
114
+ @fragment fn fs(input: VertexOutput) -> FSOut {
103
115
  let alpha = material.alpha;
104
116
  if (alpha < 0.001) { discard; }
105
117
 
@@ -113,11 +125,11 @@ const HAIR_MIX_BG: vec3f = vec3f(0.1673291176557541);
113
125
  let shadow = sampleShadow(input.worldPos, n);
114
126
 
115
127
  // 色相/饱和度/明度 (Hue=0.5 Sat=1.2 Val=0.5 Fac=1) ← reroute from image
116
- let hue_sat_shadow = hue_sat(0.5, 1.2, 0.5, 1.0, tex_color);
128
+ let hue_sat_shadow = hue_sat_id(1.2, 0.5, 1.0, tex_color);
117
129
  // 色相/饱和度/明度.002 (0.48, 1.2, 0.7, 1) ← previous
118
130
  let hue_sat_002 = hue_sat(0.48, 1.2, 0.7, 1.0, hue_sat_shadow);
119
131
  // 色相/饱和度/明度.001 (0.5, 1.5, 1.0, 1) ← image reroute (lit path)
120
- let hue_sat_001 = hue_sat(0.5, 1.5, 1.0, 1.0, tex_color);
132
+ let hue_sat_001 = hue_sat_id(1.5, 1.0, 1.0, tex_color);
121
133
 
122
134
  // 漫射 BSDF.002 → Shader --> RGB → 颜色渐变.008 CONSTANT [0→0, 0.2966→1]
123
135
  let ndotl_raw = shader_to_rgb_diffuse(n, l, sun, light.ambientColor.xyz, shadow);
@@ -133,19 +145,10 @@ const HAIR_MIX_BG: vec3f = vec3f(0.1673291176557541);
133
145
  let bevel_z = clamp(n.y, 0.0, 1.0);
134
146
  let mix_003 = mix_blend(bevel_z, bc, hue_sat_002);
135
147
 
136
- // 环境光遮蔽 (AO).001 → 颜色渐变.001 CONSTANT [01, 0.3756→0] → 混合.001 ao_factor
137
- let ao = ao_fake(n, v);
138
- let ramp_001 = ramp_constant(ao, 0.0, vec4f(1,1,1,1), 0.3756, vec4f(0,0,0,1)).r;
139
- let ao_factor = mix(1.0, 0.0, ramp_001);
140
-
141
- // 色相/饱和度/明度.004 (0.5, 0.8, 0.1, 1) ← mix_003
142
- let hue_sat_004 = hue_sat(0.5, 0.8, 0.1, 1.0, mix_003);
143
-
144
- // 混合.002 MIX Fac=ao_factor, A=hue_sat_004, B=mix_003
145
- let mix_002 = mix_blend(ao_factor, hue_sat_004, mix_003);
146
-
147
- // 自发光(发射).003 Strength=1.0 ← mix_002
148
- let emission3 = mix_002 * 1.0;
148
+ // 环境光遮蔽 (AO).001 → 颜色渐变.001 → 混合.001 → 混合.002 chain collapses with fake AO=1:
149
+ // ramp_constant(1, 0→white, 0.3756→black).r = 0 → ao_factor = mix(1,0,0) = 1 → mix_002 = mix_003.
150
+ // hue_sat_004 becomes unreachable. When real SSAO lands, restore the original 5-line port.
151
+ let emission3 = mix_003; // Emission.003 Strength=1.0 (×1 omitted)
149
152
 
150
153
  // 菲涅尔.001 × 层权重.002 → 运算.003 MULTIPLY → 运算.007 POWER(exponent Value_001) → MixShader.002 Fac
151
154
  let rim2_raw = fresnel(1.45, n, v) * layer_weight_fresnel(0.61, n, v);
@@ -153,24 +156,28 @@ const HAIR_MIX_BG: vec3f = vec3f(0.1673291176557541);
153
156
  // MixShader.002: Shader=Emission.003, Shader_001=背景 — (1-Fac)*emission + Fac*bg
154
157
  let mix_shader_002 = mix(emission3, HAIR_MIX_BG, rim2_fac);
155
158
 
156
- // 运算.004 GREATER_THAN: 图像→Value, threshold Value_001 (R when Color plugs float socket)
157
- let tex_gate = math_greater_than(tex_color.r, HAIR_TEX_GATE_THRESH);
159
+ // 运算.004 GREATER_THAN: 图像→Value, threshold Value_001. Blender converts Color→Float
160
+ // via BT.601 luminance, not raw R — same socket-semantic fix as M_Face.
161
+ let tex_gate = math_greater_than(color_to_value(tex_color), HAIR_TEX_GATE_THRESH);
158
162
  let gate_emit = vec3f(tex_gate) * 0.1;
159
163
 
160
164
  // 相加着色器: MixShader.002 + gate emission (color sum in linear space)
161
165
  let add_shader = mix_shader_002 + gate_emit;
162
166
 
163
167
  // 原理化BSDF (EEVEE port): metallic=0, specular=1.0, roughness=0.3, specular_tint=0.
164
- // Graph's 噪波→法线贴图 Strength=0.1 is near-identity; plain n matches visually.
168
+ // Blender graph has 噪波→法线贴图 Strength=0.1 on Principled.Normal, but MixShader.001
169
+ // weights Principled at only 0.2; spec contribution × that weight is imperceptible in
170
+ // A/B with the noise-bump port enabled, so we drop it and keep plain n — saves a full
171
+ // tex_noise + bump_lh per hair fragment.
165
172
  let NL = max(dot(n, l), 0.0);
166
173
  let NV = max(dot(n, v), 1e-4);
167
174
 
168
175
  let f0 = vec3f(0.08 * HAIR_SPECULAR);
169
176
  let f90 = mix(f0, vec3f(1.0), sqrt(HAIR_SPECULAR));
170
- let split_sum = brdf_lut_baked(NV, HAIR_ROUGHNESS);
171
- let reflection_color = F_brdf_multi_scatter(f0, f90, split_sum);
177
+ let brdf_lut = brdf_lut_sample(NV, HAIR_ROUGHNESS);
178
+ let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);
172
179
 
173
- let spec_direct = bsdf_ggx(n, l, v, HAIR_ROUGHNESS) * sun * shadow * ltc_brdf_scale(NV, HAIR_ROUGHNESS);
180
+ let spec_direct = bsdf_ggx(n, l, v, NL, NV, HAIR_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);
174
181
  let spec_indirect = light.ambientColor.xyz;
175
182
  let spec_radiance = (spec_direct + spec_indirect) * reflection_color;
176
183
 
@@ -182,7 +189,10 @@ const HAIR_MIX_BG: vec3f = vec3f(0.1673291176557541);
182
189
  // 混合着色器.001 Fac=0.2: first socket=相加着色器, second=原理化BSDF
183
190
  let final_color = mix(add_shader, principled, 0.2);
184
191
 
185
- return vec4f(final_color, alpha);
192
+ var out: FSOut;
193
+ out.color = vec4f(final_color, alpha);
194
+ out.mask = 1.0;
195
+ return out;
186
196
  }
187
197
 
188
198
  `
@@ -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
  `