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
@@ -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,23 +75,21 @@ 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
 
83
87
  let NV = clamp(1.0 - y_uv * y_uv, 1e-4, 0.9999);
84
- let a = x_uv * x_uv;
88
+ let a = max(x_uv, 1e-4);
85
89
  let a2 = clamp(a * a, 1e-4, 0.9999);
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
  `
@@ -1,12 +1,16 @@
1
- // Eye preset — default Principled BSDF (F0=0.04, Roughness=0.5) + Emission socket set to albedo × 1.5.
1
+ // Eye preset — default Principled BSDF (Specular=0.5, Roughness=0.5) + Emission socket set to albedo × 1.5.
2
2
  // Matches the published preset's instruction: "keep eyes in the default nodegraph, add emission 1.5".
3
3
  // Blender's Principled BSDF Emission socket is added on top of the shaded output (pre-tonemap, feeds bloom).
4
4
 
5
+ import { NODES_WGSL } from "./nodes"
6
+
5
7
  export const EYE_SHADER_WGSL = /* wgsl */ `
6
8
 
7
- const PI: f32 = 3.141592653589793;
8
- const F0_DIELECTRIC: f32 = 0.04;
9
- const ROUGHNESS: f32 = 0.5;
9
+ ${NODES_WGSL}
10
+
11
+ const PI_E: f32 = 3.141592653589793;
12
+ const EYE_SPECULAR: f32 = 0.5;
13
+ const EYE_ROUGHNESS: f32 = 0.5;
10
14
  const EYE_EMISSION_STRENGTH: f32 = 1.5;
11
15
 
12
16
  struct CameraUniforms {
@@ -50,33 +54,26 @@ struct LightVP { viewProj: mat4x4f, };
50
54
  @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
51
55
  @group(2) @binding(1) var<uniform> material: MaterialUniforms;
52
56
 
53
- fn ggx_d(ndoth: f32, a2: f32) -> f32 {
54
- let denom = ndoth * ndoth * (a2 - 1.0) + 1.0;
55
- return a2 / (PI * denom * denom);
56
- }
57
-
58
- fn smith_g1(ndotx: f32, a2: f32) -> f32 {
59
- return 2.0 * ndotx / (ndotx + sqrt(a2 + (1.0 - a2) * ndotx * ndotx));
60
- }
61
-
62
- fn fresnel_schlick(cosTheta: f32, f0: f32) -> f32 {
63
- return f0 + (1.0 - f0) * pow(1.0 - cosTheta, 5.0);
64
- }
65
-
66
57
  fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
58
+ // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.
59
+ if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
67
60
  let biasedPos = worldPos + n * 0.08;
68
61
  let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);
69
62
  let ndc = lclip.xyz / max(lclip.w, 1e-6);
70
63
  let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
71
64
  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;
65
+ let ts = 1.0 / 2048.0;
66
+ // 3x3 PCF unrolled — Safari's Metal backend doesn't unroll nested shadow loops reliably.
67
+ let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);
68
+ let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);
69
+ let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);
70
+ let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);
71
+ let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);
72
+ let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);
73
+ let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);
74
+ let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);
75
+ let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);
76
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
80
77
  }
81
78
 
82
79
  @vertex fn vs(
@@ -100,13 +97,19 @@ fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
100
97
  skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;
101
98
  }
102
99
  output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);
103
- output.normal = normalize(skinnedNrm);
100
+ // Skip VS normalize — interpolation denormalizes anyway, and FS always does normalize(input.normal).
101
+ output.normal = skinnedNrm;
104
102
  output.uv = uv;
105
103
  output.worldPos = skinnedPos.xyz;
106
104
  return output;
107
105
  }
108
106
 
109
- @fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
107
+ struct FSOut {
108
+ @location(0) color: vec4f,
109
+ @location(1) mask: f32,
110
+ };
111
+
112
+ @fragment fn fs(input: VertexOutput) -> FSOut {
110
113
  let alpha = material.alpha;
111
114
  if (alpha < 0.001) { discard; }
112
115
 
@@ -115,29 +118,31 @@ fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
115
118
  let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
116
119
 
117
120
  let l = -light.lights[0].direction.xyz;
118
- let sunColor = light.lights[0].color.xyz * light.lights[0].color.w;
119
- let h = normalize(l + v);
121
+ let sun = light.lights[0].color.xyz * light.lights[0].color.w;
122
+ let amb = light.ambientColor.xyz;
123
+ let shadow = sampleShadow(input.worldPos, n);
120
124
 
121
- let ndotl = max(dot(n, l), 0.0);
122
- let ndotv = max(dot(n, v), 0.001);
123
- let ndoth = max(dot(n, h), 0.0);
124
- let vdoth = max(dot(v, h), 0.0);
125
+ // 原理化BSDF (EEVEE port): metallic=0, specular=0.5, roughness=0.5, specular_tint=0.
126
+ let NL = max(dot(n, l), 0.0);
127
+ let NV = max(dot(n, v), 1e-4);
125
128
 
126
- let a2 = ROUGHNESS * ROUGHNESS;
127
- let D = ggx_d(ndoth, a2);
128
- let G = smith_g1(ndotl, a2) * smith_g1(ndotv, a2);
129
- let F = fresnel_schlick(vdoth, F0_DIELECTRIC);
130
- let spec = (D * G * F) / max(4.0 * ndotl * ndotv, 0.001);
129
+ let f0 = vec3f(0.08 * EYE_SPECULAR);
130
+ let f90 = mix(f0, vec3f(1.0), sqrt(EYE_SPECULAR));
131
+ let brdf_lut = brdf_lut_sample(NV, EYE_ROUGHNESS);
132
+ let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);
131
133
 
132
- let shadow = sampleShadow(input.worldPos, n);
133
- let kd = (1.0 - F) * albedo / PI;
134
- let direct = (kd + spec) * sunColor * ndotl * shadow;
135
- let ambient = albedo * light.ambientColor.xyz;
134
+ let spec_direct = bsdf_ggx(n, l, v, NL, NV, EYE_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);
135
+ let spec_indirect = amb;
136
+ let spec_radiance = (spec_direct + spec_indirect) * reflection_color;
136
137
 
138
+ let diffuse_radiance = albedo * (sun * NL * shadow / PI_E + amb);
137
139
  // Principled Emission socket: emissive = emission_color × strength, added on top of shading.
138
140
  let emission = albedo * EYE_EMISSION_STRENGTH;
139
141
 
140
- return vec4f(ambient + direct + emission, alpha);
142
+ var out: FSOut;
143
+ out.color = vec4f(diffuse_radiance + spec_radiance + emission, alpha);
144
+ out.mask = 1.0;
145
+ return out;
141
146
  }
142
147
 
143
148
  `
@@ -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
  `