reze-engine 0.12.2 → 0.12.3

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 (116) hide show
  1. package/README.md +20 -20
  2. package/dist/engine.d.ts +9 -4
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +62 -18
  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 +7 -28
  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 +4 -16
  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 +5 -17
  14. package/dist/shaders/default.d.ts +1 -1
  15. package/dist/shaders/default.d.ts.map +1 -1
  16. package/dist/shaders/eye.d.ts +1 -1
  17. package/dist/shaders/eye.d.ts.map +1 -1
  18. package/dist/shaders/face.d.ts +1 -1
  19. package/dist/shaders/face.d.ts.map +1 -1
  20. package/dist/shaders/face.js +21 -57
  21. package/dist/shaders/hair.d.ts +1 -1
  22. package/dist/shaders/hair.d.ts.map +1 -1
  23. package/dist/shaders/hair.js +7 -27
  24. package/dist/shaders/materials/body.d.ts +1 -1
  25. package/dist/shaders/materials/body.d.ts.map +1 -1
  26. package/dist/shaders/materials/body.js +1 -1
  27. package/dist/shaders/materials/cloth_rough.d.ts +1 -1
  28. package/dist/shaders/materials/cloth_rough.d.ts.map +1 -1
  29. package/dist/shaders/materials/cloth_rough.js +1 -1
  30. package/dist/shaders/materials/cloth_smooth.d.ts +1 -1
  31. package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -1
  32. package/dist/shaders/materials/cloth_smooth.js +1 -1
  33. package/dist/shaders/materials/common.d.ts +1 -1
  34. package/dist/shaders/materials/common.d.ts.map +1 -1
  35. package/dist/shaders/materials/common.js +19 -3
  36. package/dist/shaders/materials/default.d.ts +1 -1
  37. package/dist/shaders/materials/default.d.ts.map +1 -1
  38. package/dist/shaders/materials/default.js +1 -1
  39. package/dist/shaders/materials/eye.d.ts +1 -1
  40. package/dist/shaders/materials/eye.d.ts.map +1 -1
  41. package/dist/shaders/materials/eye.js +1 -1
  42. package/dist/shaders/materials/face.d.ts +1 -1
  43. package/dist/shaders/materials/face.d.ts.map +1 -1
  44. package/dist/shaders/materials/face.js +1 -1
  45. package/dist/shaders/materials/hair.d.ts +1 -1
  46. package/dist/shaders/materials/hair.d.ts.map +1 -1
  47. package/dist/shaders/materials/hair.js +1 -1
  48. package/dist/shaders/materials/metal.d.ts +1 -1
  49. package/dist/shaders/materials/metal.d.ts.map +1 -1
  50. package/dist/shaders/materials/metal.js +1 -1
  51. package/dist/shaders/materials/stockings.d.ts +1 -1
  52. package/dist/shaders/materials/stockings.d.ts.map +1 -1
  53. package/dist/shaders/materials/stockings.js +1 -1
  54. package/dist/shaders/metal.d.ts +1 -1
  55. package/dist/shaders/metal.d.ts.map +1 -1
  56. package/dist/shaders/metal.js +4 -17
  57. package/dist/shaders/nodes.d.ts +1 -1
  58. package/dist/shaders/nodes.d.ts.map +1 -1
  59. package/dist/shaders/nodes.js +0 -9
  60. package/dist/shaders/passes/bloom.d.ts +1 -1
  61. package/dist/shaders/passes/bloom.d.ts.map +1 -1
  62. package/dist/shaders/passes/bloom.js +7 -4
  63. package/dist/shaders/passes/composite.d.ts +1 -1
  64. package/dist/shaders/passes/composite.d.ts.map +1 -1
  65. package/dist/shaders/passes/composite.js +11 -4
  66. package/dist/shaders/passes/ground.d.ts +1 -1
  67. package/dist/shaders/passes/ground.d.ts.map +1 -1
  68. package/dist/shaders/passes/ground.js +6 -2
  69. package/dist/shaders/passes/outline.d.ts +1 -1
  70. package/dist/shaders/passes/outline.d.ts.map +1 -1
  71. package/dist/shaders/passes/outline.js +2 -2
  72. package/dist/shaders/stockings.d.ts +1 -1
  73. package/dist/shaders/stockings.d.ts.map +1 -1
  74. package/package.json +1 -1
  75. package/src/engine.ts +60 -18
  76. package/src/shaders/materials/body.ts +1 -1
  77. package/src/shaders/materials/cloth_rough.ts +1 -1
  78. package/src/shaders/materials/cloth_smooth.ts +1 -1
  79. package/src/shaders/materials/common.ts +19 -3
  80. package/src/shaders/materials/default.ts +1 -1
  81. package/src/shaders/materials/eye.ts +1 -1
  82. package/src/shaders/materials/face.ts +1 -1
  83. package/src/shaders/materials/hair.ts +1 -1
  84. package/src/shaders/materials/metal.ts +1 -1
  85. package/src/shaders/materials/stockings.ts +1 -1
  86. package/src/shaders/passes/bloom.ts +7 -4
  87. package/src/shaders/passes/composite.ts +11 -4
  88. package/src/shaders/passes/ground.ts +6 -2
  89. package/src/shaders/passes/outline.ts +2 -2
  90. package/dist/bezier-interpolate.d.ts +0 -15
  91. package/dist/bezier-interpolate.d.ts.map +0 -1
  92. package/dist/bezier-interpolate.js +0 -40
  93. package/dist/engine_ts.d.ts +0 -143
  94. package/dist/engine_ts.d.ts.map +0 -1
  95. package/dist/engine_ts.js +0 -1575
  96. package/dist/ik.d.ts +0 -32
  97. package/dist/ik.d.ts.map +0 -1
  98. package/dist/ik.js +0 -337
  99. package/dist/player.d.ts +0 -64
  100. package/dist/player.d.ts.map +0 -1
  101. package/dist/player.js +0 -220
  102. package/dist/pool-scene.d.ts +0 -52
  103. package/dist/pool-scene.d.ts.map +0 -1
  104. package/dist/pool-scene.js +0 -1122
  105. package/dist/pool.d.ts +0 -38
  106. package/dist/pool.d.ts.map +0 -1
  107. package/dist/pool.js +0 -422
  108. package/dist/rzm-converter.d.ts +0 -12
  109. package/dist/rzm-converter.d.ts.map +0 -1
  110. package/dist/rzm-converter.js +0 -40
  111. package/dist/rzm-loader.d.ts +0 -24
  112. package/dist/rzm-loader.d.ts.map +0 -1
  113. package/dist/rzm-loader.js +0 -488
  114. package/dist/rzm-writer.d.ts +0 -27
  115. package/dist/rzm-writer.d.ts.map +0 -1
  116. package/dist/rzm-writer.js +0 -701
@@ -1,2 +1,2 @@
1
- export declare const EYE_SHADER_WGSL = "\n\n\n\n// Baked 64\u00D764 rgba8unorm combined BRDF LUT \u2014 created once at engine init by dfg_lut.ts.\n// .rg = split-sum DFG (Karis: tint = f0\u00B7x + f90\u00B7y) \u2192 F_brdf_*_scatter\n// .ba = Heitz 2016 LTC magnitude (ltc_mag_ggx) \u2192 ltc_brdf_scale_from_lut\n// Paired with group(0) binding(2) diffuseSampler (linear filter). Sample once per\n// fragment via brdf_lut_sample() \u2014 callers feed .rg and the whole vec4 into the\n// helpers below, halving LUT taps on the default Principled path.\n@group(0) @binding(9) var brdfLut: texture_2d<f32>;\n\n// \u2500\u2500\u2500 RGB \u2194 HSV \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn rgb_to_hsv(rgb: vec3f) -> vec3f {\n let c_max = max(rgb.r, max(rgb.g, rgb.b));\n let c_min = min(rgb.r, min(rgb.g, rgb.b));\n let delta = c_max - c_min;\n\n var h = 0.0;\n if (delta > 1e-6) {\n if (c_max == rgb.r) {\n h = (rgb.g - rgb.b) / delta;\n if (h < 0.0) { h += 6.0; }\n } else if (c_max == rgb.g) {\n h = 2.0 + (rgb.b - rgb.r) / delta;\n } else {\n h = 4.0 + (rgb.r - rgb.g) / delta;\n }\n h /= 6.0;\n }\n let s = select(0.0, delta / c_max, c_max > 1e-6);\n return vec3f(h, s, c_max);\n}\n\nfn hsv_to_rgb(hsv: vec3f) -> vec3f {\n let h = hsv.x;\n let s = hsv.y;\n let v = hsv.z;\n if (s < 1e-6) { return vec3f(v); }\n\n let hh = fract(h) * 6.0;\n let sector = u32(hh);\n let f = hh - f32(sector);\n let p = v * (1.0 - s);\n let q = v * (1.0 - s * f);\n let t = v * (1.0 - s * (1.0 - f));\n\n switch (sector) {\n case 0u: { return vec3f(v, t, p); }\n case 1u: { return vec3f(q, v, p); }\n case 2u: { return vec3f(p, v, t); }\n case 3u: { return vec3f(p, q, v); }\n case 4u: { return vec3f(t, p, v); }\n default: { return vec3f(v, p, q); }\n }\n}\n\n// \u2500\u2500\u2500 HUE_SAT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn hue_sat(hue: f32, saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n var hsv = rgb_to_hsv(color);\n hsv.x = fract(hsv.x + hue - 0.5);\n hsv.y = clamp(hsv.y * saturation, 0.0, 1.0);\n hsv.z *= value;\n return mix(color, hsv_to_rgb(hsv), fac);\n}\n\n// hue_sat specialization for hue=0.5 (identity hue shift \u2014 fract(h + 0.5 - 0.5) = h).\n// Branchless equivalent that skips the rgb_to_hsv \u2192 hsv_to_rgb roundtrip: WebKit's\n// Metal backend serializes the 3-way if chain in rgb_to_hsv and the 6-way switch in\n// hsv_to_rgb, where this form compiles to linear SIMD ops + a single select.\nfn hue_sat_id(saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n let m = max(max(color.r, color.g), color.b);\n let n = min(min(color.r, color.g), color.b);\n // Unclamped (sat*old_s \u2264 1): reproj = mix(vec3f(m), color, saturation).\n // Clamped (saturated to 1): reproj = (color - n) * m / (m - n).\n let range = max(m - n, 1e-6);\n let unclamped = mix(vec3f(m), color, saturation);\n let clamped = (color - vec3f(n)) * m / range;\n let needs_clamp = (m - n) * saturation >= m;\n let reproj = select(unclamped, clamped, needs_clamp);\n return mix(color, reproj * value, fac);\n}\n\n// \u2500\u2500\u2500 BRIGHTCONTRAST node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn bright_contrast(color: vec3f, bright: f32, contrast: f32) -> vec3f {\n let a = 1.0 + contrast;\n let b = bright - contrast * 0.5;\n return max(vec3f(0.0), color * a + vec3f(b));\n}\n\n// \u2500\u2500\u2500 INVERT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn invert(fac: f32, color: vec3f) -> vec3f {\n return mix(color, vec3f(1.0) - color, fac);\n}\n\nfn invert_f(fac: f32, val: f32) -> f32 {\n return mix(val, 1.0 - val, fac);\n}\n\n// \u2500\u2500\u2500 Color ramp (VALTORGB) \u2014 2-stop variants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// All 7 presets use exclusively 2-stop ramps.\n\nfn ramp_constant(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n return select(c0, c1, f >= p1);\n}\n\n// CONSTANT ramp with screen-space edge AA \u2014 kills sparkle where fwidth(f) straddles a hard step (NPR terminator)\nfn ramp_constant_edge_aa(f: f32, edge: f32, c0: vec4f, c1: vec4f) -> vec4f {\n let w = max(fwidth(f) * 1.75, 6e-6);\n let t = smoothstep(edge - w, edge + w, f);\n return mix(c0, c1, t);\n}\n\nfn ramp_linear(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n return mix(c0, c1, t);\n}\n\nfn ramp_cardinal(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n // cardinal spline with 2 stops degrades to smoothstep\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n let ss = t * t * (3.0 - 2.0 * t);\n return mix(c0, c1, ss);\n}\n\n// \u2500\u2500\u2500 MATH node operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn math_add(a: f32, b: f32) -> f32 { return a + b; }\nfn math_multiply(a: f32, b: f32) -> f32 { return a * b; }\nfn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }\nfn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }\n\n// Blender's implicit Color \u2192 Float socket conversion uses BT.601 grayscale\n// (rgb_to_grayscale in blenkernel/intern/node.cc). When a material graph plugs a\n// Color output into a Math node's Value input, this is the scalar it actually sees.\nfn color_to_value(c: vec3f) -> f32 {\n return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;\n}\n\n// \u2500\u2500\u2500 MIX node (blend_type variants) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn mix_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, b, fac);\n}\n\nfn mix_overlay(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let lo = 2.0 * a * b;\n let hi = vec3f(1.0) - 2.0 * (vec3f(1.0) - a) * (vec3f(1.0) - b);\n let overlay = select(hi, lo, a < vec3f(0.5));\n return mix(a, overlay, fac);\n}\n\nfn mix_multiply(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a * b, fac);\n}\n\nfn mix_lighten(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, max(a, b), fac);\n}\n\n// Blender Mix (Color) blend LINEAR_LIGHT: result = mix(A, A + 2*B - 1, Fac)\nfn mix_linear_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a + 2.0 * b - vec3f(1.0), fac);\n}\n\n// Luminance for Shader\u2192RGB scalar gates (linear RGB, Rec.709 weights)\nfn luminance_rec709_linear(c: vec3f) -> f32 {\n return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));\n}\n\n// \u2500\u2500\u2500 FRESNEL node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Schlick approximation matching Blender's Fresnel node\n\nfn fresnel(ior: f32, n: vec3f, v: vec3f) -> f32 {\n let r = (ior - 1.0) / (ior + 1.0);\n let f0 = r * r;\n let cos_theta = clamp(dot(n, v), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\n// \u2500\u2500\u2500 LAYER_WEIGHT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn layer_weight_fresnel(blend: f32, n: vec3f, v: vec3f) -> f32 {\n let eta = max(1.0 - blend, 1e-4);\n let r = (1.0 - eta) / (1.0 + eta);\n let f0 = r * r;\n let cos_theta = clamp(abs(dot(n, v)), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\nfn layer_weight_facing(blend: f32, n: vec3f, v: vec3f) -> f32 {\n var facing = abs(dot(n, v));\n let b = clamp(blend, 0.0, 0.99999);\n if (b != 0.5) {\n let exponent = select(2.0 * b, 0.5 / (1.0 - b), b >= 0.5);\n facing = pow(facing, exponent);\n }\n return 1.0 - facing;\n}\n\n// \u2500\u2500\u2500 SHADER_TO_RGB (white DiffuseBSDF) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Eevee captures lit diffuse: (albedo/\u03C0)*sun*N\u00B7L*shadow + ambient (linear). Albedo=1.\n// Matches default.ts direct term scale so VALTORGB thresholds from Blender JSON stay valid.\n\nfn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> f32 {\n const PI_S: f32 = 3.141592653589793;\n let ndotl = max(dot(n, l), 0.0);\n let rgb = sun_rgb * (ndotl * shadow / PI_S) + ambient_rgb;\n return luminance_rec709_linear(rgb);\n}\n\n// \u2500\u2500\u2500 AMBIENT_OCCLUSION node (faked) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Real SSAO is a non-goal. We approximate: use the \"inside\" value from\n// concavity heuristic: 1.0 = fully lit, lower = occluded.\n// For now returns 1.0 (no darkening). Individual presets can override.\n\nfn ao_fake(n: vec3f, v: vec3f) -> f32 {\n return 1.0;\n}\n\n// \u2500\u2500\u2500 BUMP node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Screen-space bump from a scalar height field. Needs dFdx/dFdy which\n// WGSL provides as dpdx/dpdy.\n\nfn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) + dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference\nfn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// \u2500\u2500\u2500 NOISE texture (Perlin-style) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Simplified gradient noise matching Blender's default noise output.\n\n// PCG-style integer hash. Replaces the classic 'fract(sin(q) * LARGE)' trick because\n// WebKit's Metal backend compiles 'sin' to a full transcendental op (slow), while\n// Safari's Apple-GPU scalar ALU handles int muls/xors near free. Inputs arrive as\n// integer-valued floats (floor(p) + unit offsets) from _noise3, so vec3i cast is exact.\nfn _hash33(p: vec3f) -> vec3f {\n var h = vec3u(vec3i(p) + vec3i(32768));\n h = h * vec3u(1664525u, 1013904223u, 2654435761u);\n h = (h.yzx ^ h) * vec3u(2246822519u, 3266489917u, 668265263u);\n h = h ^ (h >> vec3u(16u));\n // Mask to 24 bits \u2014 above that f32 loses precision on the u32\u2192f32 convert.\n let hm = h & vec3u(16777215u);\n return vec3f(hm) * (2.0 / 16777216.0) - 1.0;\n}\n\nfn _noise3(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return mix(\n mix(\n mix(dot(_hash33(i + vec3f(0,0,0)), f - vec3f(0,0,0)),\n dot(_hash33(i + vec3f(1,0,0)), f - vec3f(1,0,0)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,0)), f - vec3f(0,1,0)),\n dot(_hash33(i + vec3f(1,1,0)), f - vec3f(1,1,0)), u.x), u.y),\n mix(\n mix(dot(_hash33(i + vec3f(0,0,1)), f - vec3f(0,0,1)),\n dot(_hash33(i + vec3f(1,0,1)), f - vec3f(1,0,1)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,1)), f - vec3f(0,1,1)),\n dot(_hash33(i + vec3f(1,1,1)), f - vec3f(1,1,1)), u.x), u.y),\n u.z);\n}\n\nfn tex_noise(p: vec3f, scale: f32, detail: f32, roughness: f32, distortion: f32) -> f32 {\n var q = p;\n if (abs(distortion) > 1e-6) {\n let w = _noise3(p * scale * 1.37 + vec3f(2.31, 5.17, 8.09));\n q = p + (w * 2.0 - 1.0) * distortion;\n }\n let coords = q * scale;\n var value = 0.0;\n var amplitude = 1.0;\n var frequency = 1.0;\n var total_amp = 0.0;\n let octaves = i32(clamp(detail, 0.0, 15.0)) + 1;\n for (var i = 0; i < octaves; i++) {\n value += amplitude * _noise3(coords * frequency);\n total_amp += amplitude;\n amplitude *= roughness;\n frequency *= 2.0;\n }\n return value / max(total_amp, 1e-6) * 0.5 + 0.5;\n}\n\n// tex_noise specialization: detail=2.0 (3 octaves), roughness=0.5, distortion=0.\n// WebKit can't unroll tex_noise's for-loop because 'octaves' is a runtime value;\n// this variant is fully unrolled with constants folded (total_amp = 1.75).\nfn tex_noise_d2(p: vec3f, scale: f32) -> f32 {\n let c = p * scale;\n let v = _noise3(c) + 0.5 * _noise3(c * 2.0) + 0.25 * _noise3(c * 4.0);\n return v * (1.0 / 1.75) * 0.5 + 0.5;\n}\n\n// \u2500\u2500\u2500 TEX_GRADIENT (linear) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Stockings preset. Maps the input vector's X to a 0\u20131 gradient.\n\nfn tex_gradient_linear(uv: vec3f) -> f32 {\n return clamp(uv.x, 0.0, 1.0);\n}\n\n// \u2500\u2500\u2500 TEX_VORONOI (distance only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Metal preset. Simplified F1 cell noise.\n\nfn tex_voronoi_f1(p: vec3f, scale: f32) -> f32 {\n let coords = p * scale;\n let i = floor(coords);\n let f = fract(coords);\n var min_dist = 1e10;\n for (var z = -1; z <= 1; z++) {\n for (var y = -1; y <= 1; y++) {\n for (var x = -1; x <= 1; x++) {\n let neighbor = vec3f(f32(x), f32(y), f32(z));\n let point = _hash33(i + neighbor) * 0.5 + 0.5;\n let diff = neighbor + point - f;\n min_dist = min(min_dist, dot(diff, diff));\n }\n }\n }\n return sqrt(min_dist);\n}\n\n// \u2500\u2500\u2500 SEPXYZ node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn separate_xyz(v: vec3f) -> vec3f { return v; }\n\n// \u2500\u2500\u2500 VECT_MATH (cross product) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn vect_math_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }\n\n// \u2500\u2500\u2500 MAPPING node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Point-type mapping: scale, rotate (euler XYZ), translate.\n\nfn mapping_point(v: vec3f, loc: vec3f, rot: vec3f, scl: vec3f) -> vec3f {\n var p = v * scl;\n // simplified: skip rotation when all angles are zero (common case)\n if (abs(rot.x) + abs(rot.y) + abs(rot.z) > 1e-6) {\n let cx = cos(rot.x); let sx = sin(rot.x);\n let cy = cos(rot.y); let sy = sin(rot.y);\n let cz = cos(rot.z); let sz = sin(rot.z);\n let rx = vec3f(p.x, cx*p.y - sx*p.z, sx*p.y + cx*p.z);\n let ry = vec3f(cy*rx.x + sy*rx.z, rx.y, -sy*rx.x + cy*rx.z);\n p = vec3f(cz*ry.x - sz*ry.y, sz*ry.x + cz*ry.y, ry.z);\n }\n return p + loc;\n}\n\n// \u2500\u2500\u2500 NORMAL_MAP node (tangent-space) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Applies a tangent-space normal map. Requires TBN from vertex stage.\n\nfn normal_map(strength: f32, map_color: vec3f, normal: vec3f, tangent: vec3f, bitangent: vec3f) -> vec3f {\n let ts = map_color * 2.0 - 1.0;\n let perturbed = normalize(tangent * ts.x + bitangent * ts.y + normal * ts.z);\n return normalize(mix(normal, perturbed, strength));\n}\n\n// \u2500\u2500\u2500 EEVEE Principled BSDF primitives \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Ports from Blender 3.6 source/blender/draw/engines/eevee/shaders/\n// bsdf_common_lib.glsl + gpu_shader_material_principled.glsl.\n// Usage pattern (see material shaders): direct spec = bsdf_ggx \u00D7 sun \u00D7 shadow\n// (NL baked in, no F yet); ambient spec = probe_radiance; tint both with\n// reflection_color = F_brdf_multi_scatter(f0, f90, split_sum) AFTER summing.\n\nconst EEVEE_PI: f32 = 3.141592653589793;\n\n// Fused analytic GGX specular (direct lights). Returns BRDF \u00D7 NL.\n// 4\u00B7NL\u00B7NV is cancelled via G1_Smith reciprocal form \u2014 see bsdf_common_lib.glsl:115.\n// Caller passes NL, NV (already computed for diffuse + brdf_lut_sample) so WebKit\n// can reuse them instead of recomputing dot products across the function boundary.\nfn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32) -> f32 {\n let a = max(roughness, 1e-4);\n let a2 = a * a;\n let H = normalize(L + V);\n let NH = max(dot(N, H), 1e-8);\n let NL = max(NL_in, 1e-8);\n let NV = max(NV_in, 1e-8);\n // G1_Smith_GGX_opti reciprocal form \u2014 denominator piece only.\n let G1L = NL + sqrt(NL * (NL - NL * a2) + a2);\n let G1V = NV + sqrt(NV * (NV - NV * a2) + a2);\n let G = G1L * G1V;\n // D_ggx_opti = pi * denom\u00B2 \u2014 reciprocal D \u00D7 a\u00B2.\n let tmp = (NH * a2 - NH) * NH + 1.0;\n let D_opti = EEVEE_PI * tmp * tmp;\n return NL * a2 / (D_opti * G);\n}\n\n// Split-sum DFG LUT \u2014 Karis 2013 curve fit stand-in for the 64\u00D764 baked LUT.\n// Returns (lut.x, lut.y) in Blender convention: tint = f0\u00B7lut.x + f90\u00B7lut.y.\nfn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {\n let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);\n let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);\n let r = roughness * c0 + c1;\n let a004 = min(r.x * r.x, exp2(-9.28 * NV)) * r.x + r.y;\n return vec2f(-1.04, 1.04) * a004 + r.zw;\n}\n\n// Baked combined BRDF LUT \u2014 exact port of Blender bsdf_lut_frag.glsl packed with\n// ltc_mag_ggx from eevee_lut.c. Single sample returns DFG (.rg) and LTC mag (.ba).\n// Addressed as Blender's common_utiltex_lib.glsl:lut_coords:\n// coords = (roughness, sqrt(1 - NV)), then half-texel bias for filtering.\n// Requires group(0) binding(9) brdfLut + binding(2) diffuseSampler in the host shader.\nfn brdf_lut_sample(NV: f32, roughness: f32) -> vec4f {\n let LUT_SIZE: f32 = 64.0;\n var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));\n uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;\n return textureSampleLevel(brdfLut, diffuseSampler, uv, 0.0);\n}\n\nfn F_brdf_single_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n return lut.y * f90 + lut.x * f0;\n}\n\n// Fdez-Ag\u00FCera 2019 multi-scatter compensation (EEVEE do_multiscatter=1).\nfn F_brdf_multi_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n let FssEss = lut.y * f90 + lut.x * f0;\n let Ess = lut.x + lut.y;\n let Ems = 1.0 - Ess;\n let Favg = f0 + (1.0 - f0) / 21.0;\n let Fms = FssEss * Favg / (1.0 - (1.0 - Ess) * Favg);\n return FssEss + Fms * Ems;\n}\n\n// EEVEE direct-specular energy compensation factor \u2014 closure_eval_glossy_lib.glsl:79-81:\n// ltc_brdf_scale = (ltc.x + ltc.y) / (split_sum.x + split_sum.y)\n// Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum;\n// direct radiance is rescaled so total-energy matches the split-sum LUT.\n// Takes a pre-sampled vec4f from brdf_lut_sample() to share the fetch with\n// F_brdf_multi_scatter on the same fragment.\nfn ltc_brdf_scale_from_lut(lut: vec4f) -> f32 {\n return (lut.z + lut.w) / max(lut.x + lut.y, 1e-6);\n}\n\n// Luminance-normalized hue extraction \u2014 Blender tint_from_color (isolates hue+sat).\nfn tint_from_color(color: vec3f) -> vec3f {\n let lum = dot(color, vec3f(0.3, 0.6, 0.1));\n return select(vec3f(1.0), color / lum, lum > 0.0);\n}\n\n\n\nconst PI_E: f32 = 3.141592653589793;\nconst EYE_SPECULAR: f32 = 0.5;\nconst EYE_ROUGHNESS: f32 = 0.5;\nconst EYE_EMISSION_STRENGTH: f32 = 1.5;\n\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct Light {\n direction: vec4f,\n color: vec4f,\n};\n\nstruct LightUniforms {\n ambientColor: vec4f,\n lights: array<Light, 4>,\n};\n\nstruct MaterialUniforms {\n diffuseColor: vec3f,\n alpha: f32,\n};\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) normal: vec3f,\n @location(1) uv: vec2f,\n @location(2) worldPos: vec3f,\n};\n\nstruct LightVP { viewProj: mat4x4f, };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var diffuseSampler: sampler;\n@group(0) @binding(3) var shadowMap: texture_depth_2d;\n@group(0) @binding(4) var shadowSampler: sampler_comparison;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;\n@group(2) @binding(1) var<uniform> material: MaterialUniforms;\n\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\n // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.\n if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }\n let biasedPos = worldPos + n * 0.08;\n let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let cmpZ = ndc.z - 0.001;\n let ts = 1.0 / 2048.0;\n // 3x3 PCF unrolled \u2014 Safari's Metal backend doesn't unroll nested shadow loops reliably.\n let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);\n let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);\n let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);\n let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);\n let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);\n let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);\n let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);\n let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);\n let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);\n return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);\n}\n\n@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\n @location(2) uv: vec2f,\n @location(3) joints0: vec4<u32>,\n @location(4) weights0: vec4<f32>\n) -> VertexOutput {\n var output: VertexOutput;\n let pos4 = vec4f(position, 1.0);\n let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n var skinnedPos = vec4f(0.0);\n var skinnedNrm = vec3f(0.0);\n for (var i = 0u; i < 4u; i++) {\n let m = skinMats[joints0[i]];\n let w = nw[i];\n skinnedPos += (m * pos4) * w;\n skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;\n }\n output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);\n // Skip VS normalize \u2014 interpolation denormalizes anyway, and FS always does normalize(input.normal).\n output.normal = skinnedNrm;\n output.uv = uv;\n output.worldPos = skinnedPos.xyz;\n return output;\n}\n\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) mask: f32,\n};\n\n@fragment fn fs(input: VertexOutput) -> FSOut {\n let alpha = material.alpha;\n if (alpha < 0.001) { discard; }\n\n let n = normalize(input.normal);\n let v = normalize(camera.viewPos - input.worldPos);\n let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;\n\n let l = -light.lights[0].direction.xyz;\n let sun = light.lights[0].color.xyz * light.lights[0].color.w;\n let amb = light.ambientColor.xyz;\n let shadow = sampleShadow(input.worldPos, n);\n\n // \u539F\u7406\u5316BSDF (EEVEE port): metallic=0, specular=0.5, roughness=0.5, specular_tint=0.\n let NL = max(dot(n, l), 0.0);\n let NV = max(dot(n, v), 1e-4);\n\n let f0 = vec3f(0.08 * EYE_SPECULAR);\n let f90 = mix(f0, vec3f(1.0), sqrt(EYE_SPECULAR));\n let brdf_lut = brdf_lut_sample(NV, EYE_ROUGHNESS);\n let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);\n\n let spec_direct = bsdf_ggx(n, l, v, NL, NV, EYE_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);\n let spec_indirect = amb;\n let spec_radiance = (spec_direct + spec_indirect) * reflection_color;\n\n let diffuse_radiance = albedo * (sun * NL * shadow / PI_E + amb);\n // Principled Emission socket: emissive = emission_color \u00D7 strength, added on top of shading.\n let emission = albedo * EYE_EMISSION_STRENGTH;\n\n var out: FSOut;\n out.color = vec4f(diffuse_radiance + spec_radiance + emission, alpha);\n out.mask = 1.0;\n return out;\n}\n\n";
1
+ export declare const EYE_SHADER_WGSL = "\n\n\n\n// Baked 64\u00D764 rgba8unorm combined BRDF LUT \u2014 created once at engine init by dfg_lut.ts.\n// .rg = split-sum DFG (Karis: tint = f0\u00B7x + f90\u00B7y) \u2192 F_brdf_*_scatter\n// .ba = Heitz 2016 LTC magnitude (ltc_mag_ggx) \u2192 ltc_brdf_scale_from_lut\n// Paired with group(0) binding(2) diffuseSampler (linear filter). Sample once per\n// fragment via brdf_lut_sample() \u2014 callers feed .rg and the whole vec4 into the\n// helpers below, halving LUT taps on the default Principled path.\n@group(0) @binding(9) var brdfLut: texture_2d<f32>;\n\n// \u2500\u2500\u2500 RGB \u2194 HSV \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn rgb_to_hsv(rgb: vec3f) -> vec3f {\n let c_max = max(rgb.r, max(rgb.g, rgb.b));\n let c_min = min(rgb.r, min(rgb.g, rgb.b));\n let delta = c_max - c_min;\n\n var h = 0.0;\n if (delta > 1e-6) {\n if (c_max == rgb.r) {\n h = (rgb.g - rgb.b) / delta;\n if (h < 0.0) { h += 6.0; }\n } else if (c_max == rgb.g) {\n h = 2.0 + (rgb.b - rgb.r) / delta;\n } else {\n h = 4.0 + (rgb.r - rgb.g) / delta;\n }\n h /= 6.0;\n }\n let s = select(0.0, delta / c_max, c_max > 1e-6);\n return vec3f(h, s, c_max);\n}\n\nfn hsv_to_rgb(hsv: vec3f) -> vec3f {\n let h = hsv.x;\n let s = hsv.y;\n let v = hsv.z;\n if (s < 1e-6) { return vec3f(v); }\n\n let hh = fract(h) * 6.0;\n let sector = u32(hh);\n let f = hh - f32(sector);\n let p = v * (1.0 - s);\n let q = v * (1.0 - s * f);\n let t = v * (1.0 - s * (1.0 - f));\n\n switch (sector) {\n case 0u: { return vec3f(v, t, p); }\n case 1u: { return vec3f(q, v, p); }\n case 2u: { return vec3f(p, v, t); }\n case 3u: { return vec3f(p, q, v); }\n case 4u: { return vec3f(t, p, v); }\n default: { return vec3f(v, p, q); }\n }\n}\n\n// \u2500\u2500\u2500 HUE_SAT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn hue_sat(hue: f32, saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n var hsv = rgb_to_hsv(color);\n hsv.x = fract(hsv.x + hue - 0.5);\n hsv.y = clamp(hsv.y * saturation, 0.0, 1.0);\n hsv.z *= value;\n return mix(color, hsv_to_rgb(hsv), fac);\n}\n\n// hue_sat specialization for hue=0.5 (identity hue shift \u2014 fract(h + 0.5 - 0.5) = h).\n// Branchless equivalent that skips the rgb_to_hsv \u2192 hsv_to_rgb roundtrip: WebKit's\n// Metal backend serializes the 3-way if chain in rgb_to_hsv and the 6-way switch in\n// hsv_to_rgb, where this form compiles to linear SIMD ops + a single select.\nfn hue_sat_id(saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n let m = max(max(color.r, color.g), color.b);\n let n = min(min(color.r, color.g), color.b);\n // Unclamped (sat*old_s \u2264 1): reproj = mix(vec3f(m), color, saturation).\n // Clamped (saturated to 1): reproj = (color - n) * m / (m - n).\n let range = max(m - n, 1e-6);\n let unclamped = mix(vec3f(m), color, saturation);\n let clamped = (color - vec3f(n)) * m / range;\n let needs_clamp = (m - n) * saturation >= m;\n let reproj = select(unclamped, clamped, needs_clamp);\n return mix(color, reproj * value, fac);\n}\n\n// \u2500\u2500\u2500 BRIGHTCONTRAST node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn bright_contrast(color: vec3f, bright: f32, contrast: f32) -> vec3f {\n let a = 1.0 + contrast;\n let b = bright - contrast * 0.5;\n return max(vec3f(0.0), color * a + vec3f(b));\n}\n\n// \u2500\u2500\u2500 INVERT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn invert(fac: f32, color: vec3f) -> vec3f {\n return mix(color, vec3f(1.0) - color, fac);\n}\n\nfn invert_f(fac: f32, val: f32) -> f32 {\n return mix(val, 1.0 - val, fac);\n}\n\n// \u2500\u2500\u2500 Color ramp (VALTORGB) \u2014 2-stop variants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// All 7 presets use exclusively 2-stop ramps.\n\nfn ramp_constant(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n return select(c0, c1, f >= p1);\n}\n\n// CONSTANT ramp with screen-space edge AA \u2014 kills sparkle where fwidth(f) straddles a hard step (NPR terminator)\nfn ramp_constant_edge_aa(f: f32, edge: f32, c0: vec4f, c1: vec4f) -> vec4f {\n let w = max(fwidth(f) * 1.75, 6e-6);\n let t = smoothstep(edge - w, edge + w, f);\n return mix(c0, c1, t);\n}\n\nfn ramp_linear(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n return mix(c0, c1, t);\n}\n\nfn ramp_cardinal(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n // cardinal spline with 2 stops degrades to smoothstep\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n let ss = t * t * (3.0 - 2.0 * t);\n return mix(c0, c1, ss);\n}\n\n// \u2500\u2500\u2500 MATH node operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn math_add(a: f32, b: f32) -> f32 { return a + b; }\nfn math_multiply(a: f32, b: f32) -> f32 { return a * b; }\nfn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }\nfn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }\n\n// Blender's implicit Color \u2192 Float socket conversion uses BT.601 grayscale\n// (rgb_to_grayscale in blenkernel/intern/node.cc). When a material graph plugs a\n// Color output into a Math node's Value input, this is the scalar it actually sees.\nfn color_to_value(c: vec3f) -> f32 {\n return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;\n}\n\n// \u2500\u2500\u2500 MIX node (blend_type variants) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn mix_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, b, fac);\n}\n\nfn mix_overlay(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let lo = 2.0 * a * b;\n let hi = vec3f(1.0) - 2.0 * (vec3f(1.0) - a) * (vec3f(1.0) - b);\n let overlay = select(hi, lo, a < vec3f(0.5));\n return mix(a, overlay, fac);\n}\n\nfn mix_multiply(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a * b, fac);\n}\n\nfn mix_lighten(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, max(a, b), fac);\n}\n\n// Blender Mix (Color) blend LINEAR_LIGHT: result = mix(A, A + 2*B - 1, Fac)\nfn mix_linear_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a + 2.0 * b - vec3f(1.0), fac);\n}\n\n// Luminance for Shader\u2192RGB scalar gates (linear RGB, Rec.709 weights)\nfn luminance_rec709_linear(c: vec3f) -> f32 {\n return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));\n}\n\n// \u2500\u2500\u2500 FRESNEL node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Schlick approximation matching Blender's Fresnel node\n\nfn fresnel(ior: f32, n: vec3f, v: vec3f) -> f32 {\n let r = (ior - 1.0) / (ior + 1.0);\n let f0 = r * r;\n let cos_theta = clamp(dot(n, v), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\n// \u2500\u2500\u2500 LAYER_WEIGHT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn layer_weight_fresnel(blend: f32, n: vec3f, v: vec3f) -> f32 {\n let eta = max(1.0 - blend, 1e-4);\n let r = (1.0 - eta) / (1.0 + eta);\n let f0 = r * r;\n let cos_theta = clamp(abs(dot(n, v)), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\nfn layer_weight_facing(blend: f32, n: vec3f, v: vec3f) -> f32 {\n var facing = abs(dot(n, v));\n let b = clamp(blend, 0.0, 0.99999);\n if (b != 0.5) {\n let exponent = select(2.0 * b, 0.5 / (1.0 - b), b >= 0.5);\n facing = pow(facing, exponent);\n }\n return 1.0 - facing;\n}\n\n// \u2500\u2500\u2500 SHADER_TO_RGB (white DiffuseBSDF) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Eevee captures lit diffuse: (albedo/\u03C0)*sun*N\u00B7L*shadow + ambient (linear). Albedo=1.\n// Matches default.ts direct term scale so VALTORGB thresholds from Blender JSON stay valid.\n\nfn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> f32 {\n const PI_S: f32 = 3.141592653589793;\n let ndotl = max(dot(n, l), 0.0);\n let rgb = sun_rgb * (ndotl * shadow / PI_S) + ambient_rgb;\n return luminance_rec709_linear(rgb);\n}\n\n// \u2500\u2500\u2500 BUMP node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Screen-space bump from a scalar height field. Needs dFdx/dFdy which\n// WGSL provides as dpdx/dpdy.\n\nfn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) + dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference\nfn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// \u2500\u2500\u2500 NOISE texture (Perlin-style) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Simplified gradient noise matching Blender's default noise output.\n\n// PCG-style integer hash. Replaces the classic 'fract(sin(q) * LARGE)' trick because\n// WebKit's Metal backend compiles 'sin' to a full transcendental op (slow), while\n// Safari's Apple-GPU scalar ALU handles int muls/xors near free. Inputs arrive as\n// integer-valued floats (floor(p) + unit offsets) from _noise3, so vec3i cast is exact.\nfn _hash33(p: vec3f) -> vec3f {\n var h = vec3u(vec3i(p) + vec3i(32768));\n h = h * vec3u(1664525u, 1013904223u, 2654435761u);\n h = (h.yzx ^ h) * vec3u(2246822519u, 3266489917u, 668265263u);\n h = h ^ (h >> vec3u(16u));\n // Mask to 24 bits \u2014 above that f32 loses precision on the u32\u2192f32 convert.\n let hm = h & vec3u(16777215u);\n return vec3f(hm) * (2.0 / 16777216.0) - 1.0;\n}\n\nfn _noise3(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return mix(\n mix(\n mix(dot(_hash33(i + vec3f(0,0,0)), f - vec3f(0,0,0)),\n dot(_hash33(i + vec3f(1,0,0)), f - vec3f(1,0,0)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,0)), f - vec3f(0,1,0)),\n dot(_hash33(i + vec3f(1,1,0)), f - vec3f(1,1,0)), u.x), u.y),\n mix(\n mix(dot(_hash33(i + vec3f(0,0,1)), f - vec3f(0,0,1)),\n dot(_hash33(i + vec3f(1,0,1)), f - vec3f(1,0,1)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,1)), f - vec3f(0,1,1)),\n dot(_hash33(i + vec3f(1,1,1)), f - vec3f(1,1,1)), u.x), u.y),\n u.z);\n}\n\nfn tex_noise(p: vec3f, scale: f32, detail: f32, roughness: f32, distortion: f32) -> f32 {\n var q = p;\n if (abs(distortion) > 1e-6) {\n let w = _noise3(p * scale * 1.37 + vec3f(2.31, 5.17, 8.09));\n q = p + (w * 2.0 - 1.0) * distortion;\n }\n let coords = q * scale;\n var value = 0.0;\n var amplitude = 1.0;\n var frequency = 1.0;\n var total_amp = 0.0;\n let octaves = i32(clamp(detail, 0.0, 15.0)) + 1;\n for (var i = 0; i < octaves; i++) {\n value += amplitude * _noise3(coords * frequency);\n total_amp += amplitude;\n amplitude *= roughness;\n frequency *= 2.0;\n }\n return value / max(total_amp, 1e-6) * 0.5 + 0.5;\n}\n\n// tex_noise specialization: detail=2.0 (3 octaves), roughness=0.5, distortion=0.\n// WebKit can't unroll tex_noise's for-loop because 'octaves' is a runtime value;\n// this variant is fully unrolled with constants folded (total_amp = 1.75).\nfn tex_noise_d2(p: vec3f, scale: f32) -> f32 {\n let c = p * scale;\n let v = _noise3(c) + 0.5 * _noise3(c * 2.0) + 0.25 * _noise3(c * 4.0);\n return v * (1.0 / 1.75) * 0.5 + 0.5;\n}\n\n// \u2500\u2500\u2500 TEX_GRADIENT (linear) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Stockings preset. Maps the input vector's X to a 0\u20131 gradient.\n\nfn tex_gradient_linear(uv: vec3f) -> f32 {\n return clamp(uv.x, 0.0, 1.0);\n}\n\n// \u2500\u2500\u2500 TEX_VORONOI (distance only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Metal preset. Simplified F1 cell noise.\n\nfn tex_voronoi_f1(p: vec3f, scale: f32) -> f32 {\n let coords = p * scale;\n let i = floor(coords);\n let f = fract(coords);\n var min_dist = 1e10;\n for (var z = -1; z <= 1; z++) {\n for (var y = -1; y <= 1; y++) {\n for (var x = -1; x <= 1; x++) {\n let neighbor = vec3f(f32(x), f32(y), f32(z));\n let point = _hash33(i + neighbor) * 0.5 + 0.5;\n let diff = neighbor + point - f;\n min_dist = min(min_dist, dot(diff, diff));\n }\n }\n }\n return sqrt(min_dist);\n}\n\n// \u2500\u2500\u2500 SEPXYZ node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn separate_xyz(v: vec3f) -> vec3f { return v; }\n\n// \u2500\u2500\u2500 VECT_MATH (cross product) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn vect_math_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }\n\n// \u2500\u2500\u2500 MAPPING node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Point-type mapping: scale, rotate (euler XYZ), translate.\n\nfn mapping_point(v: vec3f, loc: vec3f, rot: vec3f, scl: vec3f) -> vec3f {\n var p = v * scl;\n // simplified: skip rotation when all angles are zero (common case)\n if (abs(rot.x) + abs(rot.y) + abs(rot.z) > 1e-6) {\n let cx = cos(rot.x); let sx = sin(rot.x);\n let cy = cos(rot.y); let sy = sin(rot.y);\n let cz = cos(rot.z); let sz = sin(rot.z);\n let rx = vec3f(p.x, cx*p.y - sx*p.z, sx*p.y + cx*p.z);\n let ry = vec3f(cy*rx.x + sy*rx.z, rx.y, -sy*rx.x + cy*rx.z);\n p = vec3f(cz*ry.x - sz*ry.y, sz*ry.x + cz*ry.y, ry.z);\n }\n return p + loc;\n}\n\n// \u2500\u2500\u2500 NORMAL_MAP node (tangent-space) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Applies a tangent-space normal map. Requires TBN from vertex stage.\n\nfn normal_map(strength: f32, map_color: vec3f, normal: vec3f, tangent: vec3f, bitangent: vec3f) -> vec3f {\n let ts = map_color * 2.0 - 1.0;\n let perturbed = normalize(tangent * ts.x + bitangent * ts.y + normal * ts.z);\n return normalize(mix(normal, perturbed, strength));\n}\n\n// \u2500\u2500\u2500 EEVEE Principled BSDF primitives \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Ports from Blender 3.6 source/blender/draw/engines/eevee/shaders/\n// bsdf_common_lib.glsl + gpu_shader_material_principled.glsl.\n// Usage pattern (see material shaders): direct spec = bsdf_ggx \u00D7 sun \u00D7 shadow\n// (NL baked in, no F yet); ambient spec = probe_radiance; tint both with\n// reflection_color = F_brdf_multi_scatter(f0, f90, split_sum) AFTER summing.\n\nconst EEVEE_PI: f32 = 3.141592653589793;\n\n// Fused analytic GGX specular (direct lights). Returns BRDF \u00D7 NL.\n// 4\u00B7NL\u00B7NV is cancelled via G1_Smith reciprocal form \u2014 see bsdf_common_lib.glsl:115.\n// Caller passes NL, NV (already computed for diffuse + brdf_lut_sample) so WebKit\n// can reuse them instead of recomputing dot products across the function boundary.\nfn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32) -> f32 {\n let a = max(roughness, 1e-4);\n let a2 = a * a;\n let H = normalize(L + V);\n let NH = max(dot(N, H), 1e-8);\n let NL = max(NL_in, 1e-8);\n let NV = max(NV_in, 1e-8);\n // G1_Smith_GGX_opti reciprocal form \u2014 denominator piece only.\n let G1L = NL + sqrt(NL * (NL - NL * a2) + a2);\n let G1V = NV + sqrt(NV * (NV - NV * a2) + a2);\n let G = G1L * G1V;\n // D_ggx_opti = pi * denom\u00B2 \u2014 reciprocal D \u00D7 a\u00B2.\n let tmp = (NH * a2 - NH) * NH + 1.0;\n let D_opti = EEVEE_PI * tmp * tmp;\n return NL * a2 / (D_opti * G);\n}\n\n// Split-sum DFG LUT \u2014 Karis 2013 curve fit stand-in for the 64\u00D764 baked LUT.\n// Returns (lut.x, lut.y) in Blender convention: tint = f0\u00B7lut.x + f90\u00B7lut.y.\nfn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {\n let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);\n let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);\n let r = roughness * c0 + c1;\n let a004 = min(r.x * r.x, exp2(-9.28 * NV)) * r.x + r.y;\n return vec2f(-1.04, 1.04) * a004 + r.zw;\n}\n\n// Baked combined BRDF LUT \u2014 exact port of Blender bsdf_lut_frag.glsl packed with\n// ltc_mag_ggx from eevee_lut.c. Single sample returns DFG (.rg) and LTC mag (.ba).\n// Addressed as Blender's common_utiltex_lib.glsl:lut_coords:\n// coords = (roughness, sqrt(1 - NV)), then half-texel bias for filtering.\n// Requires group(0) binding(9) brdfLut + binding(2) diffuseSampler in the host shader.\nfn brdf_lut_sample(NV: f32, roughness: f32) -> vec4f {\n let LUT_SIZE: f32 = 64.0;\n var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));\n uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;\n return textureSampleLevel(brdfLut, diffuseSampler, uv, 0.0);\n}\n\nfn F_brdf_single_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n return lut.y * f90 + lut.x * f0;\n}\n\n// Fdez-Ag\u00FCera 2019 multi-scatter compensation (EEVEE do_multiscatter=1).\nfn F_brdf_multi_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n let FssEss = lut.y * f90 + lut.x * f0;\n let Ess = lut.x + lut.y;\n let Ems = 1.0 - Ess;\n let Favg = f0 + (1.0 - f0) / 21.0;\n let Fms = FssEss * Favg / (1.0 - (1.0 - Ess) * Favg);\n return FssEss + Fms * Ems;\n}\n\n// EEVEE direct-specular energy compensation factor \u2014 closure_eval_glossy_lib.glsl:79-81:\n// ltc_brdf_scale = (ltc.x + ltc.y) / (split_sum.x + split_sum.y)\n// Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum;\n// direct radiance is rescaled so total-energy matches the split-sum LUT.\n// Takes a pre-sampled vec4f from brdf_lut_sample() to share the fetch with\n// F_brdf_multi_scatter on the same fragment.\nfn ltc_brdf_scale_from_lut(lut: vec4f) -> f32 {\n return (lut.z + lut.w) / max(lut.x + lut.y, 1e-6);\n}\n\n// Luminance-normalized hue extraction \u2014 Blender tint_from_color (isolates hue+sat).\nfn tint_from_color(color: vec3f) -> vec3f {\n let lum = dot(color, vec3f(0.3, 0.6, 0.1));\n return select(vec3f(1.0), color / lum, lum > 0.0);\n}\n\n\n\nconst PI_E: f32 = 3.141592653589793;\nconst EYE_SPECULAR: f32 = 0.5;\nconst EYE_ROUGHNESS: f32 = 0.5;\nconst EYE_EMISSION_STRENGTH: f32 = 1.5;\n\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct Light {\n direction: vec4f,\n color: vec4f,\n};\n\nstruct LightUniforms {\n ambientColor: vec4f,\n lights: array<Light, 4>,\n};\n\nstruct MaterialUniforms {\n diffuseColor: vec3f,\n alpha: f32,\n};\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) normal: vec3f,\n @location(1) uv: vec2f,\n @location(2) worldPos: vec3f,\n};\n\nstruct LightVP { viewProj: mat4x4f, };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var diffuseSampler: sampler;\n@group(0) @binding(3) var shadowMap: texture_depth_2d;\n@group(0) @binding(4) var shadowSampler: sampler_comparison;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;\n@group(2) @binding(1) var<uniform> material: MaterialUniforms;\n\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\n // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.\n if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }\n let biasedPos = worldPos + n * 0.08;\n let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let cmpZ = ndc.z - 0.001;\n let ts = 1.0 / 2048.0;\n // 3x3 PCF unrolled \u2014 Safari's Metal backend doesn't unroll nested shadow loops reliably.\n let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);\n let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);\n let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);\n let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);\n let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);\n let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);\n let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);\n let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);\n let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);\n return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);\n}\n\n@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\n @location(2) uv: vec2f,\n @location(3) joints0: vec4<u32>,\n @location(4) weights0: vec4<f32>\n) -> VertexOutput {\n var output: VertexOutput;\n let pos4 = vec4f(position, 1.0);\n let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n var skinnedPos = vec4f(0.0);\n var skinnedNrm = vec3f(0.0);\n for (var i = 0u; i < 4u; i++) {\n let m = skinMats[joints0[i]];\n let w = nw[i];\n skinnedPos += (m * pos4) * w;\n skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;\n }\n output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);\n // Skip VS normalize \u2014 interpolation denormalizes anyway, and FS always does normalize(input.normal).\n output.normal = skinnedNrm;\n output.uv = uv;\n output.worldPos = skinnedPos.xyz;\n return output;\n}\n\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) mask: f32,\n};\n\n@fragment fn fs(input: VertexOutput) -> FSOut {\n let alpha = material.alpha;\n if (alpha < 0.001) { discard; }\n\n let n = normalize(input.normal);\n let v = normalize(camera.viewPos - input.worldPos);\n let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;\n\n let l = -light.lights[0].direction.xyz;\n let sun = light.lights[0].color.xyz * light.lights[0].color.w;\n let amb = light.ambientColor.xyz;\n let shadow = sampleShadow(input.worldPos, n);\n\n // \u539F\u7406\u5316BSDF (EEVEE port): metallic=0, specular=0.5, roughness=0.5, specular_tint=0.\n let NL = max(dot(n, l), 0.0);\n let NV = max(dot(n, v), 1e-4);\n\n let f0 = vec3f(0.08 * EYE_SPECULAR);\n let f90 = mix(f0, vec3f(1.0), sqrt(EYE_SPECULAR));\n let brdf_lut = brdf_lut_sample(NV, EYE_ROUGHNESS);\n let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);\n\n let spec_direct = bsdf_ggx(n, l, v, NL, NV, EYE_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);\n let spec_indirect = amb;\n let spec_radiance = (spec_direct + spec_indirect) * reflection_color;\n\n let diffuse_radiance = albedo * (sun * NL * shadow / PI_E + amb);\n // Principled Emission socket: emissive = emission_color \u00D7 strength, added on top of shading.\n let emission = albedo * EYE_EMISSION_STRENGTH;\n\n var out: FSOut;\n out.color = vec4f(diffuse_radiance + spec_radiance + emission, alpha);\n out.mask = 1.0;\n return out;\n}\n\n";
2
2
  //# sourceMappingURL=eye.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"eye.d.ts","sourceRoot":"","sources":["../../src/shaders/eye.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,eAAe,qz2BA6I3B,CAAA"}
1
+ {"version":3,"file":"eye.d.ts","sourceRoot":"","sources":["../../src/shaders/eye.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,eAAe,yy1BA6I3B,CAAA"}
@@ -1,2 +1,2 @@
1
- export declare const FACE_SHADER_WGSL = "\n\n\n\n// Baked 64\u00D764 rgba8unorm combined BRDF LUT \u2014 created once at engine init by dfg_lut.ts.\n// .rg = split-sum DFG (Karis: tint = f0\u00B7x + f90\u00B7y) \u2192 F_brdf_*_scatter\n// .ba = Heitz 2016 LTC magnitude (ltc_mag_ggx) \u2192 ltc_brdf_scale_from_lut\n// Paired with group(0) binding(2) diffuseSampler (linear filter). Sample once per\n// fragment via brdf_lut_sample() \u2014 callers feed .rg and the whole vec4 into the\n// helpers below, halving LUT taps on the default Principled path.\n@group(0) @binding(9) var brdfLut: texture_2d<f32>;\n\n// \u2500\u2500\u2500 RGB \u2194 HSV \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn rgb_to_hsv(rgb: vec3f) -> vec3f {\n let c_max = max(rgb.r, max(rgb.g, rgb.b));\n let c_min = min(rgb.r, min(rgb.g, rgb.b));\n let delta = c_max - c_min;\n\n var h = 0.0;\n if (delta > 1e-6) {\n if (c_max == rgb.r) {\n h = (rgb.g - rgb.b) / delta;\n if (h < 0.0) { h += 6.0; }\n } else if (c_max == rgb.g) {\n h = 2.0 + (rgb.b - rgb.r) / delta;\n } else {\n h = 4.0 + (rgb.r - rgb.g) / delta;\n }\n h /= 6.0;\n }\n let s = select(0.0, delta / c_max, c_max > 1e-6);\n return vec3f(h, s, c_max);\n}\n\nfn hsv_to_rgb(hsv: vec3f) -> vec3f {\n let h = hsv.x;\n let s = hsv.y;\n let v = hsv.z;\n if (s < 1e-6) { return vec3f(v); }\n\n let hh = fract(h) * 6.0;\n let sector = u32(hh);\n let f = hh - f32(sector);\n let p = v * (1.0 - s);\n let q = v * (1.0 - s * f);\n let t = v * (1.0 - s * (1.0 - f));\n\n switch (sector) {\n case 0u: { return vec3f(v, t, p); }\n case 1u: { return vec3f(q, v, p); }\n case 2u: { return vec3f(p, v, t); }\n case 3u: { return vec3f(p, q, v); }\n case 4u: { return vec3f(t, p, v); }\n default: { return vec3f(v, p, q); }\n }\n}\n\n// \u2500\u2500\u2500 HUE_SAT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn hue_sat(hue: f32, saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n var hsv = rgb_to_hsv(color);\n hsv.x = fract(hsv.x + hue - 0.5);\n hsv.y = clamp(hsv.y * saturation, 0.0, 1.0);\n hsv.z *= value;\n return mix(color, hsv_to_rgb(hsv), fac);\n}\n\n// hue_sat specialization for hue=0.5 (identity hue shift \u2014 fract(h + 0.5 - 0.5) = h).\n// Branchless equivalent that skips the rgb_to_hsv \u2192 hsv_to_rgb roundtrip: WebKit's\n// Metal backend serializes the 3-way if chain in rgb_to_hsv and the 6-way switch in\n// hsv_to_rgb, where this form compiles to linear SIMD ops + a single select.\nfn hue_sat_id(saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n let m = max(max(color.r, color.g), color.b);\n let n = min(min(color.r, color.g), color.b);\n // Unclamped (sat*old_s \u2264 1): reproj = mix(vec3f(m), color, saturation).\n // Clamped (saturated to 1): reproj = (color - n) * m / (m - n).\n let range = max(m - n, 1e-6);\n let unclamped = mix(vec3f(m), color, saturation);\n let clamped = (color - vec3f(n)) * m / range;\n let needs_clamp = (m - n) * saturation >= m;\n let reproj = select(unclamped, clamped, needs_clamp);\n return mix(color, reproj * value, fac);\n}\n\n// \u2500\u2500\u2500 BRIGHTCONTRAST node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn bright_contrast(color: vec3f, bright: f32, contrast: f32) -> vec3f {\n let a = 1.0 + contrast;\n let b = bright - contrast * 0.5;\n return max(vec3f(0.0), color * a + vec3f(b));\n}\n\n// \u2500\u2500\u2500 INVERT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn invert(fac: f32, color: vec3f) -> vec3f {\n return mix(color, vec3f(1.0) - color, fac);\n}\n\nfn invert_f(fac: f32, val: f32) -> f32 {\n return mix(val, 1.0 - val, fac);\n}\n\n// \u2500\u2500\u2500 Color ramp (VALTORGB) \u2014 2-stop variants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// All 7 presets use exclusively 2-stop ramps.\n\nfn ramp_constant(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n return select(c0, c1, f >= p1);\n}\n\n// CONSTANT ramp with screen-space edge AA \u2014 kills sparkle where fwidth(f) straddles a hard step (NPR terminator)\nfn ramp_constant_edge_aa(f: f32, edge: f32, c0: vec4f, c1: vec4f) -> vec4f {\n let w = max(fwidth(f) * 1.75, 6e-6);\n let t = smoothstep(edge - w, edge + w, f);\n return mix(c0, c1, t);\n}\n\nfn ramp_linear(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n return mix(c0, c1, t);\n}\n\nfn ramp_cardinal(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n // cardinal spline with 2 stops degrades to smoothstep\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n let ss = t * t * (3.0 - 2.0 * t);\n return mix(c0, c1, ss);\n}\n\n// \u2500\u2500\u2500 MATH node operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn math_add(a: f32, b: f32) -> f32 { return a + b; }\nfn math_multiply(a: f32, b: f32) -> f32 { return a * b; }\nfn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }\nfn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }\n\n// Blender's implicit Color \u2192 Float socket conversion uses BT.601 grayscale\n// (rgb_to_grayscale in blenkernel/intern/node.cc). When a material graph plugs a\n// Color output into a Math node's Value input, this is the scalar it actually sees.\nfn color_to_value(c: vec3f) -> f32 {\n return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;\n}\n\n// \u2500\u2500\u2500 MIX node (blend_type variants) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn mix_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, b, fac);\n}\n\nfn mix_overlay(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let lo = 2.0 * a * b;\n let hi = vec3f(1.0) - 2.0 * (vec3f(1.0) - a) * (vec3f(1.0) - b);\n let overlay = select(hi, lo, a < vec3f(0.5));\n return mix(a, overlay, fac);\n}\n\nfn mix_multiply(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a * b, fac);\n}\n\nfn mix_lighten(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, max(a, b), fac);\n}\n\n// Blender Mix (Color) blend LINEAR_LIGHT: result = mix(A, A + 2*B - 1, Fac)\nfn mix_linear_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a + 2.0 * b - vec3f(1.0), fac);\n}\n\n// Luminance for Shader\u2192RGB scalar gates (linear RGB, Rec.709 weights)\nfn luminance_rec709_linear(c: vec3f) -> f32 {\n return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));\n}\n\n// \u2500\u2500\u2500 FRESNEL node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Schlick approximation matching Blender's Fresnel node\n\nfn fresnel(ior: f32, n: vec3f, v: vec3f) -> f32 {\n let r = (ior - 1.0) / (ior + 1.0);\n let f0 = r * r;\n let cos_theta = clamp(dot(n, v), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\n// \u2500\u2500\u2500 LAYER_WEIGHT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn layer_weight_fresnel(blend: f32, n: vec3f, v: vec3f) -> f32 {\n let eta = max(1.0 - blend, 1e-4);\n let r = (1.0 - eta) / (1.0 + eta);\n let f0 = r * r;\n let cos_theta = clamp(abs(dot(n, v)), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\nfn layer_weight_facing(blend: f32, n: vec3f, v: vec3f) -> f32 {\n var facing = abs(dot(n, v));\n let b = clamp(blend, 0.0, 0.99999);\n if (b != 0.5) {\n let exponent = select(2.0 * b, 0.5 / (1.0 - b), b >= 0.5);\n facing = pow(facing, exponent);\n }\n return 1.0 - facing;\n}\n\n// \u2500\u2500\u2500 SHADER_TO_RGB (white DiffuseBSDF) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Eevee captures lit diffuse: (albedo/\u03C0)*sun*N\u00B7L*shadow + ambient (linear). Albedo=1.\n// Matches default.ts direct term scale so VALTORGB thresholds from Blender JSON stay valid.\n\nfn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> f32 {\n const PI_S: f32 = 3.141592653589793;\n let ndotl = max(dot(n, l), 0.0);\n let rgb = sun_rgb * (ndotl * shadow / PI_S) + ambient_rgb;\n return luminance_rec709_linear(rgb);\n}\n\n// \u2500\u2500\u2500 AMBIENT_OCCLUSION node (faked) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Real SSAO is a non-goal. We approximate: use the \"inside\" value from\n// concavity heuristic: 1.0 = fully lit, lower = occluded.\n// For now returns 1.0 (no darkening). Individual presets can override.\n\nfn ao_fake(n: vec3f, v: vec3f) -> f32 {\n return 1.0;\n}\n\n// \u2500\u2500\u2500 BUMP node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Screen-space bump from a scalar height field. Needs dFdx/dFdy which\n// WGSL provides as dpdx/dpdy.\n\nfn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) + dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference\nfn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// \u2500\u2500\u2500 NOISE texture (Perlin-style) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Simplified gradient noise matching Blender's default noise output.\n\n// PCG-style integer hash. Replaces the classic 'fract(sin(q) * LARGE)' trick because\n// WebKit's Metal backend compiles 'sin' to a full transcendental op (slow), while\n// Safari's Apple-GPU scalar ALU handles int muls/xors near free. Inputs arrive as\n// integer-valued floats (floor(p) + unit offsets) from _noise3, so vec3i cast is exact.\nfn _hash33(p: vec3f) -> vec3f {\n var h = vec3u(vec3i(p) + vec3i(32768));\n h = h * vec3u(1664525u, 1013904223u, 2654435761u);\n h = (h.yzx ^ h) * vec3u(2246822519u, 3266489917u, 668265263u);\n h = h ^ (h >> vec3u(16u));\n // Mask to 24 bits \u2014 above that f32 loses precision on the u32\u2192f32 convert.\n let hm = h & vec3u(16777215u);\n return vec3f(hm) * (2.0 / 16777216.0) - 1.0;\n}\n\nfn _noise3(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return mix(\n mix(\n mix(dot(_hash33(i + vec3f(0,0,0)), f - vec3f(0,0,0)),\n dot(_hash33(i + vec3f(1,0,0)), f - vec3f(1,0,0)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,0)), f - vec3f(0,1,0)),\n dot(_hash33(i + vec3f(1,1,0)), f - vec3f(1,1,0)), u.x), u.y),\n mix(\n mix(dot(_hash33(i + vec3f(0,0,1)), f - vec3f(0,0,1)),\n dot(_hash33(i + vec3f(1,0,1)), f - vec3f(1,0,1)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,1)), f - vec3f(0,1,1)),\n dot(_hash33(i + vec3f(1,1,1)), f - vec3f(1,1,1)), u.x), u.y),\n u.z);\n}\n\nfn tex_noise(p: vec3f, scale: f32, detail: f32, roughness: f32, distortion: f32) -> f32 {\n var q = p;\n if (abs(distortion) > 1e-6) {\n let w = _noise3(p * scale * 1.37 + vec3f(2.31, 5.17, 8.09));\n q = p + (w * 2.0 - 1.0) * distortion;\n }\n let coords = q * scale;\n var value = 0.0;\n var amplitude = 1.0;\n var frequency = 1.0;\n var total_amp = 0.0;\n let octaves = i32(clamp(detail, 0.0, 15.0)) + 1;\n for (var i = 0; i < octaves; i++) {\n value += amplitude * _noise3(coords * frequency);\n total_amp += amplitude;\n amplitude *= roughness;\n frequency *= 2.0;\n }\n return value / max(total_amp, 1e-6) * 0.5 + 0.5;\n}\n\n// tex_noise specialization: detail=2.0 (3 octaves), roughness=0.5, distortion=0.\n// WebKit can't unroll tex_noise's for-loop because 'octaves' is a runtime value;\n// this variant is fully unrolled with constants folded (total_amp = 1.75).\nfn tex_noise_d2(p: vec3f, scale: f32) -> f32 {\n let c = p * scale;\n let v = _noise3(c) + 0.5 * _noise3(c * 2.0) + 0.25 * _noise3(c * 4.0);\n return v * (1.0 / 1.75) * 0.5 + 0.5;\n}\n\n// \u2500\u2500\u2500 TEX_GRADIENT (linear) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Stockings preset. Maps the input vector's X to a 0\u20131 gradient.\n\nfn tex_gradient_linear(uv: vec3f) -> f32 {\n return clamp(uv.x, 0.0, 1.0);\n}\n\n// \u2500\u2500\u2500 TEX_VORONOI (distance only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Metal preset. Simplified F1 cell noise.\n\nfn tex_voronoi_f1(p: vec3f, scale: f32) -> f32 {\n let coords = p * scale;\n let i = floor(coords);\n let f = fract(coords);\n var min_dist = 1e10;\n for (var z = -1; z <= 1; z++) {\n for (var y = -1; y <= 1; y++) {\n for (var x = -1; x <= 1; x++) {\n let neighbor = vec3f(f32(x), f32(y), f32(z));\n let point = _hash33(i + neighbor) * 0.5 + 0.5;\n let diff = neighbor + point - f;\n min_dist = min(min_dist, dot(diff, diff));\n }\n }\n }\n return sqrt(min_dist);\n}\n\n// \u2500\u2500\u2500 SEPXYZ node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn separate_xyz(v: vec3f) -> vec3f { return v; }\n\n// \u2500\u2500\u2500 VECT_MATH (cross product) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn vect_math_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }\n\n// \u2500\u2500\u2500 MAPPING node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Point-type mapping: scale, rotate (euler XYZ), translate.\n\nfn mapping_point(v: vec3f, loc: vec3f, rot: vec3f, scl: vec3f) -> vec3f {\n var p = v * scl;\n // simplified: skip rotation when all angles are zero (common case)\n if (abs(rot.x) + abs(rot.y) + abs(rot.z) > 1e-6) {\n let cx = cos(rot.x); let sx = sin(rot.x);\n let cy = cos(rot.y); let sy = sin(rot.y);\n let cz = cos(rot.z); let sz = sin(rot.z);\n let rx = vec3f(p.x, cx*p.y - sx*p.z, sx*p.y + cx*p.z);\n let ry = vec3f(cy*rx.x + sy*rx.z, rx.y, -sy*rx.x + cy*rx.z);\n p = vec3f(cz*ry.x - sz*ry.y, sz*ry.x + cz*ry.y, ry.z);\n }\n return p + loc;\n}\n\n// \u2500\u2500\u2500 NORMAL_MAP node (tangent-space) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Applies a tangent-space normal map. Requires TBN from vertex stage.\n\nfn normal_map(strength: f32, map_color: vec3f, normal: vec3f, tangent: vec3f, bitangent: vec3f) -> vec3f {\n let ts = map_color * 2.0 - 1.0;\n let perturbed = normalize(tangent * ts.x + bitangent * ts.y + normal * ts.z);\n return normalize(mix(normal, perturbed, strength));\n}\n\n// \u2500\u2500\u2500 EEVEE Principled BSDF primitives \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Ports from Blender 3.6 source/blender/draw/engines/eevee/shaders/\n// bsdf_common_lib.glsl + gpu_shader_material_principled.glsl.\n// Usage pattern (see material shaders): direct spec = bsdf_ggx \u00D7 sun \u00D7 shadow\n// (NL baked in, no F yet); ambient spec = probe_radiance; tint both with\n// reflection_color = F_brdf_multi_scatter(f0, f90, split_sum) AFTER summing.\n\nconst EEVEE_PI: f32 = 3.141592653589793;\n\n// Fused analytic GGX specular (direct lights). Returns BRDF \u00D7 NL.\n// 4\u00B7NL\u00B7NV is cancelled via G1_Smith reciprocal form \u2014 see bsdf_common_lib.glsl:115.\n// Caller passes NL, NV (already computed for diffuse + brdf_lut_sample) so WebKit\n// can reuse them instead of recomputing dot products across the function boundary.\nfn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32) -> f32 {\n let a = max(roughness, 1e-4);\n let a2 = a * a;\n let H = normalize(L + V);\n let NH = max(dot(N, H), 1e-8);\n let NL = max(NL_in, 1e-8);\n let NV = max(NV_in, 1e-8);\n // G1_Smith_GGX_opti reciprocal form \u2014 denominator piece only.\n let G1L = NL + sqrt(NL * (NL - NL * a2) + a2);\n let G1V = NV + sqrt(NV * (NV - NV * a2) + a2);\n let G = G1L * G1V;\n // D_ggx_opti = pi * denom\u00B2 \u2014 reciprocal D \u00D7 a\u00B2.\n let tmp = (NH * a2 - NH) * NH + 1.0;\n let D_opti = EEVEE_PI * tmp * tmp;\n return NL * a2 / (D_opti * G);\n}\n\n// Split-sum DFG LUT \u2014 Karis 2013 curve fit stand-in for the 64\u00D764 baked LUT.\n// Returns (lut.x, lut.y) in Blender convention: tint = f0\u00B7lut.x + f90\u00B7lut.y.\nfn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {\n let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);\n let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);\n let r = roughness * c0 + c1;\n let a004 = min(r.x * r.x, exp2(-9.28 * NV)) * r.x + r.y;\n return vec2f(-1.04, 1.04) * a004 + r.zw;\n}\n\n// Baked combined BRDF LUT \u2014 exact port of Blender bsdf_lut_frag.glsl packed with\n// ltc_mag_ggx from eevee_lut.c. Single sample returns DFG (.rg) and LTC mag (.ba).\n// Addressed as Blender's common_utiltex_lib.glsl:lut_coords:\n// coords = (roughness, sqrt(1 - NV)), then half-texel bias for filtering.\n// Requires group(0) binding(9) brdfLut + binding(2) diffuseSampler in the host shader.\nfn brdf_lut_sample(NV: f32, roughness: f32) -> vec4f {\n let LUT_SIZE: f32 = 64.0;\n var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));\n uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;\n return textureSampleLevel(brdfLut, diffuseSampler, uv, 0.0);\n}\n\nfn F_brdf_single_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n return lut.y * f90 + lut.x * f0;\n}\n\n// Fdez-Ag\u00FCera 2019 multi-scatter compensation (EEVEE do_multiscatter=1).\nfn F_brdf_multi_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n let FssEss = lut.y * f90 + lut.x * f0;\n let Ess = lut.x + lut.y;\n let Ems = 1.0 - Ess;\n let Favg = f0 + (1.0 - f0) / 21.0;\n let Fms = FssEss * Favg / (1.0 - (1.0 - Ess) * Favg);\n return FssEss + Fms * Ems;\n}\n\n// EEVEE direct-specular energy compensation factor \u2014 closure_eval_glossy_lib.glsl:79-81:\n// ltc_brdf_scale = (ltc.x + ltc.y) / (split_sum.x + split_sum.y)\n// Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum;\n// direct radiance is rescaled so total-energy matches the split-sum LUT.\n// Takes a pre-sampled vec4f from brdf_lut_sample() to share the fetch with\n// F_brdf_multi_scatter on the same fragment.\nfn ltc_brdf_scale_from_lut(lut: vec4f) -> f32 {\n return (lut.z + lut.w) / max(lut.x + lut.y, 1e-6);\n}\n\n// Luminance-normalized hue extraction \u2014 Blender tint_from_color (isolates hue+sat).\nfn tint_from_color(color: vec3f) -> vec3f {\n let lum = dot(color, vec3f(0.3, 0.6, 0.1));\n return select(vec3f(1.0), color / lum, lum > 0.0);\n}\n\n\n\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct Light {\n direction: vec4f,\n color: vec4f,\n};\n\nstruct LightUniforms {\n ambientColor: vec4f,\n lights: array<Light, 4>,\n};\n\nstruct MaterialUniforms {\n diffuseColor: vec3f,\n alpha: f32,\n};\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) normal: vec3f,\n @location(1) uv: vec2f,\n @location(2) worldPos: vec3f,\n};\n\nstruct LightVP { viewProj: mat4x4f, };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var diffuseSampler: sampler;\n@group(0) @binding(3) var shadowMap: texture_depth_2d;\n@group(0) @binding(4) var shadowSampler: sampler_comparison;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;\n@group(2) @binding(1) var<uniform> material: MaterialUniforms;\n\n// 3x3 PCF shadow sampling, 2048 map, normal-bias 0.08, depth-bias 0.001\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\n // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.\n if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }\n let biasedPos = worldPos + n * 0.08;\n let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let cmpZ = ndc.z - 0.001;\n let ts = 1.0 / 2048.0;\n // 3x3 PCF unrolled \u2014 Safari's Metal backend doesn't unroll nested shadow loops reliably.\n let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);\n let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);\n let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);\n let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);\n let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);\n let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);\n let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);\n let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);\n let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);\n return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);\n}\n\nconst PI_F: f32 = 3.141592653589793;\nconst FACE_SPECULAR: f32 = 0.5;\nconst FACE_ROUGHNESS: f32 = 0.3;\n// Dump M_Face unlinked defaults (math op enum not serialized \u2014 warm clamp chain still from m_graphs)\nconst FACE_RIM2_POW: f32 = 0.6300000548362732;\nconst FACE_RIM2_BG: vec3f = vec3f(1.0, 0.4684903025627136, 0.3698573112487793);\nconst FACE_WARM_AO_MUL: f32 = 0.30000001192092896; // \u8FD0\u7B97.004 MULTIPLY after invert (was 0.5 in older trace)\nconst FACE_BRIGHT_TEX_THRESH: f32 = 0.9300000071525574; // \u8FD0\u7B97.005 GREATER_THAN Value_001\nconst FACE_MIX_NPR: f32 = 0.5; // \u6DF7\u5408\u7740\u8272\u5668.001 Fac\n// EEVEE Light Clamp equivalent (Render Props \u2192 Sampling \u2192 Clamping). Caps direct\n// specular firefly from the noise-bumped normal's NDF aliasing \u2014 Blender hides this\n// via TAA, which we don't have. Value mirrors EEVEE's default Clamp Indirect=10.0.\nconst FACE_SPEC_CLAMP: f32 = 10.0;\n\n@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\n @location(2) uv: vec2f,\n @location(3) joints0: vec4<u32>,\n @location(4) weights0: vec4<f32>\n) -> VertexOutput {\n var output: VertexOutput;\n let pos4 = vec4f(position, 1.0);\n let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n var skinnedPos = vec4f(0.0);\n var skinnedNrm = vec3f(0.0);\n for (var i = 0u; i < 4u; i++) {\n let m = skinMats[joints0[i]];\n let w = nw[i];\n skinnedPos += (m * pos4) * w;\n skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;\n }\n output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);\n // Skip VS normalize \u2014 interpolation denormalizes anyway, and FS always does normalize(input.normal).\n output.normal = skinnedNrm;\n output.uv = uv;\n output.worldPos = skinnedPos.xyz;\n return output;\n}\n\n// Fragment: M_Face NPR + Principled hybrid\n// TEX \u2192 HueSat shadow/lit \u2192 toon gate \u2192 BrightContrast \u2192 AO chain \u2192 emission stack\n// Fresnel rims, warm AO emission, bright-texture gate, noise-bumped Principled\n// Final = mix(Principled, NPR, 0.5)\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) mask: f32,\n};\n\n@fragment fn fs(input: VertexOutput) -> FSOut {\n let alpha = material.alpha;\n if (alpha < 0.001) { discard; }\n\n let n = normalize(input.normal);\n let v = normalize(camera.viewPos - input.worldPos);\n let l = -light.lights[0].direction.xyz;\n let intensity = light.lights[0].color.w;\n let sun = light.lights[0].color.xyz * intensity;\n\n let tex_color = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;\n let shadow = sampleShadow(input.worldPos, n);\n\n // \u2550\u2550\u2550 SOURCES \u2550\u2550\u2550\n // DiffuseBSDF(white) \u2192 ShaderToRGB (energy-matched); shadow on direct only\n let ndotl_raw = shader_to_rgb_diffuse(n, l, sun, light.ambientColor.xyz, shadow);\n // ramp.008 CONSTANT \u2014 edge AA avoids binary fac shimmer / white specks on terminator (fwidth + smoothstep)\n let toon = ramp_constant_edge_aa(ndotl_raw, 0.2966, vec4f(0,0,0,1), vec4f(1,1,1,1)).r;\n let ao = 1.0; // ao_fake(n, v) \u2014 no SSAO yet; inline 1.0 so the ramp/mix chain folds at compile time.\n\n // \u2550\u2550\u2550 TOON COLOR \u2550\u2550\u2550\n let shadow_tint = hue_sat(0.46000000834465027, 2.0, 0.3499999940395355, 1.0, tex_color); // HueSat.002\n let lit_tint = hue_sat(0.46000000834465027, 1.600000023841858, 1.5, 1.0, tex_color); // HueSat.001\n let toon_color = mix_blend(toon, shadow_tint, lit_tint); // Mix.004\n let bc = bright_contrast(toon_color, 0.1, 0.2);\n\n // \u2550\u2550\u2550 AO CHAIN \u2550\u2550\u2550\n // ramp CONSTANT [0\u2192white, 0.5995\u2192black]\n let ao_ramp = ramp_constant(ao, 0.0, vec4f(1,1,1,1), 0.5995, vec4f(0,0,0,1)).r;\n // Mix.003(Factor=ao_ramp, A=bc, B=reddish tint)\n let ao_mixed = mix_blend(ao_ramp, bc, vec3f(0.8302, 0.3346, 0.2795));\n\n // \u2550\u2550\u2550 EMISSION 3 \u2550\u2550\u2550\n let emission3 = ao_mixed * 2.5; // Emission.003(Strength=2.5)\n\n // \u2550\u2550\u2550 WARM EMISSION \u2550\u2550\u2550\n let ao_inv = invert_f(1.0, ao_ramp);\n let warm_str = ao_inv * FACE_WARM_AO_MUL; // \u53CD\u8F6C \u2192 \u8FD0\u7B97.004 MULTIPLY Value_001\n let warm_input = clamp(toon * 0.5 + 0.5, 0.0, 1.0); // \u8FD0\u7B97.001\u2192\u8FD0\u7B97.006\u2192Clamp\n // ramp.003 CARDINAL [0.2409\u2192warm dark, 0.4663\u2192warm light]\n let warm_color = ramp_cardinal(warm_input, 0.2409,\n vec4f(0.2426, 0.068, 0.0588, 1.0), 0.4663,\n vec4f(0.6677, 0.5024, 0.5126, 1.0)).rgb;\n let warm_emission = warm_color * warm_str; // Emission.001\n\n // \u2550\u2550\u2550 RIM 1 \u2550\u2550\u2550\n // Fresnel(IOR=2.0) \u00D7 LayerWeight.001(Facing, Blend=0.24)\n let rim1_str = fresnel(2.0, n, v) * layer_weight_facing(0.24, n, v);\n let rim1 = vec3f(0.984157919883728, 0.6110184788703918, 0.5736401677131653) * rim1_str;\n\n // \u2550\u2550\u2550 RIM 2 \u2550\u2550\u2550\n // Fresnel.001(IOR=1.45) \u00D7 LayerWeight.002(Fresnel output, Blend=0.61)\n let rim2_raw = fresnel(1.45, n, v) * layer_weight_fresnel(0.61, n, v);\n let rim2_fac = math_power(rim2_raw, FACE_RIM2_POW);\n // MixShader.002: Shader=Emission.003, Shader_001=\u80CC\u666F\n let rim2_mixed = mix(emission3, FACE_RIM2_BG, rim2_fac);\n\n // \u8F6C\u63A5\u70B9.005(tex) \u2192 \u8FD0\u7B97.005 GREATER_THAN Value_001\n // Blender implicitly converts Color \u2192 Float via BT.601 grayscale when plugging a\n // color output into a Math node's Value input. Our earlier trace used tex_color.r,\n // which fires aggressively on R-dominant skin \u2014 single near-white R pixels produced\n // firefly speckles. color_to_value matches the actual Blender socket semantic and\n // only fires on genuinely near-white painted features (the author's intent).\n let tex_gate = math_greater_than(color_to_value(tex_color), FACE_BRIGHT_TEX_THRESH);\n let bright_emit = vec3f(tex_gate) * 3.0; // Emission.002(Strength=3.0)\n\n // \u2550\u2550\u2550 NPR STACK (AddShader chain) \u2550\u2550\u2550\n let add2 = rim2_mixed + bright_emit; // AddShader.002\n let add0 = rim1 + add2; // AddShader\n let npr_stack = add0 + warm_emission; // AddShader.001\n\n // \u2550\u2550\u2550 PRINCIPLED BSDF \u2550\u2550\u2550\n // Noise-based bump normal. Mapping loc=rot=0 \u2192 plain scale multiply, inline.\n let noise_val = tex_noise_d2(input.worldPos * vec3f(1.0, 1.0, 1.5), 1.0);\n let noise_ramp = ramp_linear(noise_val, 0.0, vec4f(0,0,0,1), 1.0, vec4f(1,1,1,1)).r;\n let bumped_n = bump_lh(0.324644535779953, noise_ramp, n, input.worldPos); // \u51F9\u51F8 Strength; LH bump\n\n // Mix.001(Factor=noise_ramp, A=bc, B=dark red)\n let principled_base = mix_blend(noise_ramp, bc, vec3f(0.6832, 0.1947, 0.1373));\n // Emission input from reroute.011 (bc), Strength=0.2\n let p_emission = bc * 0.2;\n // AO.002 \u2192 ramp.005 LINEAR [0.003\u2192black, 1.0\u2192gray] for subsurface approx.\n // Reuse 'ao' (ao_fake(n, v) above) \u2014 identical inputs, avoid a second procedural AO pass.\n let sss = ramp_linear(ao, 0.003, vec4f(0,0,0,1), 1.0, vec4f(0.0786, 0.0786, 0.0786, 1.0)).r;\n\n // \u539F\u7406\u5316BSDF (EEVEE port): metallic=0, specular=0.5, roughness=0.3, specular_tint=0.\n let NL = max(dot(bumped_n, l), 0.0);\n let NV = max(dot(bumped_n, v), 1e-4);\n\n let f0 = vec3f(0.08 * FACE_SPECULAR);\n let f90 = mix(f0, vec3f(1.0), sqrt(FACE_SPECULAR));\n let brdf_lut = brdf_lut_sample(NV, FACE_ROUGHNESS);\n let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);\n\n let spec_direct_raw = bsdf_ggx(bumped_n, l, v, NL, NV, FACE_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);\n let spec_direct = min(spec_direct_raw, vec3f(FACE_SPEC_CLAMP));\n let spec_indirect = light.ambientColor.xyz;\n let spec_radiance = (spec_direct + spec_indirect) * reflection_color;\n\n // Indirect diffuse = base_color \u00D7 L_w per Blender closure_eval_surface_lib.glsl line 302;\n // probe_evaluate_world_diff returns radiance (SH-projected, not cosine-convolved).\n let diffuse_radiance = principled_base * (sun * NL * shadow / PI_F + light.ambientColor.xyz);\n let principled = diffuse_radiance + spec_radiance + p_emission + vec3f(sss);\n\n // \u6DF7\u5408\u7740\u8272\u5668.001: Shader=\u76F8\u52A0\u7740\u8272\u5668.001, Shader_001=\u539F\u7406\u5316BSDF \u2014 Fac blends toward second\n let final_color = mix(npr_stack, principled, FACE_MIX_NPR);\n\n var out: FSOut;\n out.color = vec4f(final_color, alpha);\n out.mask = 1.0;\n return out;\n}\n\n";
1
+ export declare const FACE_SHADER_WGSL = "\n\n\n\n// Baked 64\u00D764 rgba8unorm combined BRDF LUT \u2014 created once at engine init by dfg_lut.ts.\n// .rg = split-sum DFG (Karis: tint = f0\u00B7x + f90\u00B7y) \u2192 F_brdf_*_scatter\n// .ba = Heitz 2016 LTC magnitude (ltc_mag_ggx) \u2192 ltc_brdf_scale_from_lut\n// Paired with group(0) binding(2) diffuseSampler (linear filter). Sample once per\n// fragment via brdf_lut_sample() \u2014 callers feed .rg and the whole vec4 into the\n// helpers below, halving LUT taps on the default Principled path.\n@group(0) @binding(9) var brdfLut: texture_2d<f32>;\n\n// \u2500\u2500\u2500 RGB \u2194 HSV \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn rgb_to_hsv(rgb: vec3f) -> vec3f {\n let c_max = max(rgb.r, max(rgb.g, rgb.b));\n let c_min = min(rgb.r, min(rgb.g, rgb.b));\n let delta = c_max - c_min;\n\n var h = 0.0;\n if (delta > 1e-6) {\n if (c_max == rgb.r) {\n h = (rgb.g - rgb.b) / delta;\n if (h < 0.0) { h += 6.0; }\n } else if (c_max == rgb.g) {\n h = 2.0 + (rgb.b - rgb.r) / delta;\n } else {\n h = 4.0 + (rgb.r - rgb.g) / delta;\n }\n h /= 6.0;\n }\n let s = select(0.0, delta / c_max, c_max > 1e-6);\n return vec3f(h, s, c_max);\n}\n\nfn hsv_to_rgb(hsv: vec3f) -> vec3f {\n let h = hsv.x;\n let s = hsv.y;\n let v = hsv.z;\n if (s < 1e-6) { return vec3f(v); }\n\n let hh = fract(h) * 6.0;\n let sector = u32(hh);\n let f = hh - f32(sector);\n let p = v * (1.0 - s);\n let q = v * (1.0 - s * f);\n let t = v * (1.0 - s * (1.0 - f));\n\n switch (sector) {\n case 0u: { return vec3f(v, t, p); }\n case 1u: { return vec3f(q, v, p); }\n case 2u: { return vec3f(p, v, t); }\n case 3u: { return vec3f(p, q, v); }\n case 4u: { return vec3f(t, p, v); }\n default: { return vec3f(v, p, q); }\n }\n}\n\n// \u2500\u2500\u2500 HUE_SAT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn hue_sat(hue: f32, saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n var hsv = rgb_to_hsv(color);\n hsv.x = fract(hsv.x + hue - 0.5);\n hsv.y = clamp(hsv.y * saturation, 0.0, 1.0);\n hsv.z *= value;\n return mix(color, hsv_to_rgb(hsv), fac);\n}\n\n// hue_sat specialization for hue=0.5 (identity hue shift \u2014 fract(h + 0.5 - 0.5) = h).\n// Branchless equivalent that skips the rgb_to_hsv \u2192 hsv_to_rgb roundtrip: WebKit's\n// Metal backend serializes the 3-way if chain in rgb_to_hsv and the 6-way switch in\n// hsv_to_rgb, where this form compiles to linear SIMD ops + a single select.\nfn hue_sat_id(saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n let m = max(max(color.r, color.g), color.b);\n let n = min(min(color.r, color.g), color.b);\n // Unclamped (sat*old_s \u2264 1): reproj = mix(vec3f(m), color, saturation).\n // Clamped (saturated to 1): reproj = (color - n) * m / (m - n).\n let range = max(m - n, 1e-6);\n let unclamped = mix(vec3f(m), color, saturation);\n let clamped = (color - vec3f(n)) * m / range;\n let needs_clamp = (m - n) * saturation >= m;\n let reproj = select(unclamped, clamped, needs_clamp);\n return mix(color, reproj * value, fac);\n}\n\n// \u2500\u2500\u2500 BRIGHTCONTRAST node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn bright_contrast(color: vec3f, bright: f32, contrast: f32) -> vec3f {\n let a = 1.0 + contrast;\n let b = bright - contrast * 0.5;\n return max(vec3f(0.0), color * a + vec3f(b));\n}\n\n// \u2500\u2500\u2500 INVERT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn invert(fac: f32, color: vec3f) -> vec3f {\n return mix(color, vec3f(1.0) - color, fac);\n}\n\nfn invert_f(fac: f32, val: f32) -> f32 {\n return mix(val, 1.0 - val, fac);\n}\n\n// \u2500\u2500\u2500 Color ramp (VALTORGB) \u2014 2-stop variants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// All 7 presets use exclusively 2-stop ramps.\n\nfn ramp_constant(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n return select(c0, c1, f >= p1);\n}\n\n// CONSTANT ramp with screen-space edge AA \u2014 kills sparkle where fwidth(f) straddles a hard step (NPR terminator)\nfn ramp_constant_edge_aa(f: f32, edge: f32, c0: vec4f, c1: vec4f) -> vec4f {\n let w = max(fwidth(f) * 1.75, 6e-6);\n let t = smoothstep(edge - w, edge + w, f);\n return mix(c0, c1, t);\n}\n\nfn ramp_linear(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n return mix(c0, c1, t);\n}\n\nfn ramp_cardinal(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n // cardinal spline with 2 stops degrades to smoothstep\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n let ss = t * t * (3.0 - 2.0 * t);\n return mix(c0, c1, ss);\n}\n\n// \u2500\u2500\u2500 MATH node operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn math_add(a: f32, b: f32) -> f32 { return a + b; }\nfn math_multiply(a: f32, b: f32) -> f32 { return a * b; }\nfn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }\nfn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }\n\n// Blender's implicit Color \u2192 Float socket conversion uses BT.601 grayscale\n// (rgb_to_grayscale in blenkernel/intern/node.cc). When a material graph plugs a\n// Color output into a Math node's Value input, this is the scalar it actually sees.\nfn color_to_value(c: vec3f) -> f32 {\n return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;\n}\n\n// \u2500\u2500\u2500 MIX node (blend_type variants) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn mix_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, b, fac);\n}\n\nfn mix_overlay(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let lo = 2.0 * a * b;\n let hi = vec3f(1.0) - 2.0 * (vec3f(1.0) - a) * (vec3f(1.0) - b);\n let overlay = select(hi, lo, a < vec3f(0.5));\n return mix(a, overlay, fac);\n}\n\nfn mix_multiply(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a * b, fac);\n}\n\nfn mix_lighten(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, max(a, b), fac);\n}\n\n// Blender Mix (Color) blend LINEAR_LIGHT: result = mix(A, A + 2*B - 1, Fac)\nfn mix_linear_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a + 2.0 * b - vec3f(1.0), fac);\n}\n\n// Luminance for Shader\u2192RGB scalar gates (linear RGB, Rec.709 weights)\nfn luminance_rec709_linear(c: vec3f) -> f32 {\n return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));\n}\n\n// \u2500\u2500\u2500 FRESNEL node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Schlick approximation matching Blender's Fresnel node\n\nfn fresnel(ior: f32, n: vec3f, v: vec3f) -> f32 {\n let r = (ior - 1.0) / (ior + 1.0);\n let f0 = r * r;\n let cos_theta = clamp(dot(n, v), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\n// \u2500\u2500\u2500 LAYER_WEIGHT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn layer_weight_fresnel(blend: f32, n: vec3f, v: vec3f) -> f32 {\n let eta = max(1.0 - blend, 1e-4);\n let r = (1.0 - eta) / (1.0 + eta);\n let f0 = r * r;\n let cos_theta = clamp(abs(dot(n, v)), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\nfn layer_weight_facing(blend: f32, n: vec3f, v: vec3f) -> f32 {\n var facing = abs(dot(n, v));\n let b = clamp(blend, 0.0, 0.99999);\n if (b != 0.5) {\n let exponent = select(2.0 * b, 0.5 / (1.0 - b), b >= 0.5);\n facing = pow(facing, exponent);\n }\n return 1.0 - facing;\n}\n\n// \u2500\u2500\u2500 SHADER_TO_RGB (white DiffuseBSDF) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Eevee captures lit diffuse: (albedo/\u03C0)*sun*N\u00B7L*shadow + ambient (linear). Albedo=1.\n// Matches default.ts direct term scale so VALTORGB thresholds from Blender JSON stay valid.\n\nfn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> f32 {\n const PI_S: f32 = 3.141592653589793;\n let ndotl = max(dot(n, l), 0.0);\n let rgb = sun_rgb * (ndotl * shadow / PI_S) + ambient_rgb;\n return luminance_rec709_linear(rgb);\n}\n\n// \u2500\u2500\u2500 BUMP node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Screen-space bump from a scalar height field. Needs dFdx/dFdy which\n// WGSL provides as dpdx/dpdy.\n\nfn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) + dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference\nfn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// \u2500\u2500\u2500 NOISE texture (Perlin-style) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Simplified gradient noise matching Blender's default noise output.\n\n// PCG-style integer hash. Replaces the classic 'fract(sin(q) * LARGE)' trick because\n// WebKit's Metal backend compiles 'sin' to a full transcendental op (slow), while\n// Safari's Apple-GPU scalar ALU handles int muls/xors near free. Inputs arrive as\n// integer-valued floats (floor(p) + unit offsets) from _noise3, so vec3i cast is exact.\nfn _hash33(p: vec3f) -> vec3f {\n var h = vec3u(vec3i(p) + vec3i(32768));\n h = h * vec3u(1664525u, 1013904223u, 2654435761u);\n h = (h.yzx ^ h) * vec3u(2246822519u, 3266489917u, 668265263u);\n h = h ^ (h >> vec3u(16u));\n // Mask to 24 bits \u2014 above that f32 loses precision on the u32\u2192f32 convert.\n let hm = h & vec3u(16777215u);\n return vec3f(hm) * (2.0 / 16777216.0) - 1.0;\n}\n\nfn _noise3(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return mix(\n mix(\n mix(dot(_hash33(i + vec3f(0,0,0)), f - vec3f(0,0,0)),\n dot(_hash33(i + vec3f(1,0,0)), f - vec3f(1,0,0)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,0)), f - vec3f(0,1,0)),\n dot(_hash33(i + vec3f(1,1,0)), f - vec3f(1,1,0)), u.x), u.y),\n mix(\n mix(dot(_hash33(i + vec3f(0,0,1)), f - vec3f(0,0,1)),\n dot(_hash33(i + vec3f(1,0,1)), f - vec3f(1,0,1)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,1)), f - vec3f(0,1,1)),\n dot(_hash33(i + vec3f(1,1,1)), f - vec3f(1,1,1)), u.x), u.y),\n u.z);\n}\n\nfn tex_noise(p: vec3f, scale: f32, detail: f32, roughness: f32, distortion: f32) -> f32 {\n var q = p;\n if (abs(distortion) > 1e-6) {\n let w = _noise3(p * scale * 1.37 + vec3f(2.31, 5.17, 8.09));\n q = p + (w * 2.0 - 1.0) * distortion;\n }\n let coords = q * scale;\n var value = 0.0;\n var amplitude = 1.0;\n var frequency = 1.0;\n var total_amp = 0.0;\n let octaves = i32(clamp(detail, 0.0, 15.0)) + 1;\n for (var i = 0; i < octaves; i++) {\n value += amplitude * _noise3(coords * frequency);\n total_amp += amplitude;\n amplitude *= roughness;\n frequency *= 2.0;\n }\n return value / max(total_amp, 1e-6) * 0.5 + 0.5;\n}\n\n// tex_noise specialization: detail=2.0 (3 octaves), roughness=0.5, distortion=0.\n// WebKit can't unroll tex_noise's for-loop because 'octaves' is a runtime value;\n// this variant is fully unrolled with constants folded (total_amp = 1.75).\nfn tex_noise_d2(p: vec3f, scale: f32) -> f32 {\n let c = p * scale;\n let v = _noise3(c) + 0.5 * _noise3(c * 2.0) + 0.25 * _noise3(c * 4.0);\n return v * (1.0 / 1.75) * 0.5 + 0.5;\n}\n\n// \u2500\u2500\u2500 TEX_GRADIENT (linear) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Stockings preset. Maps the input vector's X to a 0\u20131 gradient.\n\nfn tex_gradient_linear(uv: vec3f) -> f32 {\n return clamp(uv.x, 0.0, 1.0);\n}\n\n// \u2500\u2500\u2500 TEX_VORONOI (distance only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Metal preset. Simplified F1 cell noise.\n\nfn tex_voronoi_f1(p: vec3f, scale: f32) -> f32 {\n let coords = p * scale;\n let i = floor(coords);\n let f = fract(coords);\n var min_dist = 1e10;\n for (var z = -1; z <= 1; z++) {\n for (var y = -1; y <= 1; y++) {\n for (var x = -1; x <= 1; x++) {\n let neighbor = vec3f(f32(x), f32(y), f32(z));\n let point = _hash33(i + neighbor) * 0.5 + 0.5;\n let diff = neighbor + point - f;\n min_dist = min(min_dist, dot(diff, diff));\n }\n }\n }\n return sqrt(min_dist);\n}\n\n// \u2500\u2500\u2500 SEPXYZ node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn separate_xyz(v: vec3f) -> vec3f { return v; }\n\n// \u2500\u2500\u2500 VECT_MATH (cross product) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn vect_math_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }\n\n// \u2500\u2500\u2500 MAPPING node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Point-type mapping: scale, rotate (euler XYZ), translate.\n\nfn mapping_point(v: vec3f, loc: vec3f, rot: vec3f, scl: vec3f) -> vec3f {\n var p = v * scl;\n // simplified: skip rotation when all angles are zero (common case)\n if (abs(rot.x) + abs(rot.y) + abs(rot.z) > 1e-6) {\n let cx = cos(rot.x); let sx = sin(rot.x);\n let cy = cos(rot.y); let sy = sin(rot.y);\n let cz = cos(rot.z); let sz = sin(rot.z);\n let rx = vec3f(p.x, cx*p.y - sx*p.z, sx*p.y + cx*p.z);\n let ry = vec3f(cy*rx.x + sy*rx.z, rx.y, -sy*rx.x + cy*rx.z);\n p = vec3f(cz*ry.x - sz*ry.y, sz*ry.x + cz*ry.y, ry.z);\n }\n return p + loc;\n}\n\n// \u2500\u2500\u2500 NORMAL_MAP node (tangent-space) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Applies a tangent-space normal map. Requires TBN from vertex stage.\n\nfn normal_map(strength: f32, map_color: vec3f, normal: vec3f, tangent: vec3f, bitangent: vec3f) -> vec3f {\n let ts = map_color * 2.0 - 1.0;\n let perturbed = normalize(tangent * ts.x + bitangent * ts.y + normal * ts.z);\n return normalize(mix(normal, perturbed, strength));\n}\n\n// \u2500\u2500\u2500 EEVEE Principled BSDF primitives \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Ports from Blender 3.6 source/blender/draw/engines/eevee/shaders/\n// bsdf_common_lib.glsl + gpu_shader_material_principled.glsl.\n// Usage pattern (see material shaders): direct spec = bsdf_ggx \u00D7 sun \u00D7 shadow\n// (NL baked in, no F yet); ambient spec = probe_radiance; tint both with\n// reflection_color = F_brdf_multi_scatter(f0, f90, split_sum) AFTER summing.\n\nconst EEVEE_PI: f32 = 3.141592653589793;\n\n// Fused analytic GGX specular (direct lights). Returns BRDF \u00D7 NL.\n// 4\u00B7NL\u00B7NV is cancelled via G1_Smith reciprocal form \u2014 see bsdf_common_lib.glsl:115.\n// Caller passes NL, NV (already computed for diffuse + brdf_lut_sample) so WebKit\n// can reuse them instead of recomputing dot products across the function boundary.\nfn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32) -> f32 {\n let a = max(roughness, 1e-4);\n let a2 = a * a;\n let H = normalize(L + V);\n let NH = max(dot(N, H), 1e-8);\n let NL = max(NL_in, 1e-8);\n let NV = max(NV_in, 1e-8);\n // G1_Smith_GGX_opti reciprocal form \u2014 denominator piece only.\n let G1L = NL + sqrt(NL * (NL - NL * a2) + a2);\n let G1V = NV + sqrt(NV * (NV - NV * a2) + a2);\n let G = G1L * G1V;\n // D_ggx_opti = pi * denom\u00B2 \u2014 reciprocal D \u00D7 a\u00B2.\n let tmp = (NH * a2 - NH) * NH + 1.0;\n let D_opti = EEVEE_PI * tmp * tmp;\n return NL * a2 / (D_opti * G);\n}\n\n// Split-sum DFG LUT \u2014 Karis 2013 curve fit stand-in for the 64\u00D764 baked LUT.\n// Returns (lut.x, lut.y) in Blender convention: tint = f0\u00B7lut.x + f90\u00B7lut.y.\nfn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {\n let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);\n let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);\n let r = roughness * c0 + c1;\n let a004 = min(r.x * r.x, exp2(-9.28 * NV)) * r.x + r.y;\n return vec2f(-1.04, 1.04) * a004 + r.zw;\n}\n\n// Baked combined BRDF LUT \u2014 exact port of Blender bsdf_lut_frag.glsl packed with\n// ltc_mag_ggx from eevee_lut.c. Single sample returns DFG (.rg) and LTC mag (.ba).\n// Addressed as Blender's common_utiltex_lib.glsl:lut_coords:\n// coords = (roughness, sqrt(1 - NV)), then half-texel bias for filtering.\n// Requires group(0) binding(9) brdfLut + binding(2) diffuseSampler in the host shader.\nfn brdf_lut_sample(NV: f32, roughness: f32) -> vec4f {\n let LUT_SIZE: f32 = 64.0;\n var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));\n uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;\n return textureSampleLevel(brdfLut, diffuseSampler, uv, 0.0);\n}\n\nfn F_brdf_single_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n return lut.y * f90 + lut.x * f0;\n}\n\n// Fdez-Ag\u00FCera 2019 multi-scatter compensation (EEVEE do_multiscatter=1).\nfn F_brdf_multi_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n let FssEss = lut.y * f90 + lut.x * f0;\n let Ess = lut.x + lut.y;\n let Ems = 1.0 - Ess;\n let Favg = f0 + (1.0 - f0) / 21.0;\n let Fms = FssEss * Favg / (1.0 - (1.0 - Ess) * Favg);\n return FssEss + Fms * Ems;\n}\n\n// EEVEE direct-specular energy compensation factor \u2014 closure_eval_glossy_lib.glsl:79-81:\n// ltc_brdf_scale = (ltc.x + ltc.y) / (split_sum.x + split_sum.y)\n// Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum;\n// direct radiance is rescaled so total-energy matches the split-sum LUT.\n// Takes a pre-sampled vec4f from brdf_lut_sample() to share the fetch with\n// F_brdf_multi_scatter on the same fragment.\nfn ltc_brdf_scale_from_lut(lut: vec4f) -> f32 {\n return (lut.z + lut.w) / max(lut.x + lut.y, 1e-6);\n}\n\n// Luminance-normalized hue extraction \u2014 Blender tint_from_color (isolates hue+sat).\nfn tint_from_color(color: vec3f) -> vec3f {\n let lum = dot(color, vec3f(0.3, 0.6, 0.1));\n return select(vec3f(1.0), color / lum, lum > 0.0);\n}\n\n\n\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct Light {\n direction: vec4f,\n color: vec4f,\n};\n\nstruct LightUniforms {\n ambientColor: vec4f,\n lights: array<Light, 4>,\n};\n\nstruct MaterialUniforms {\n diffuseColor: vec3f,\n alpha: f32,\n};\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) normal: vec3f,\n @location(1) uv: vec2f,\n @location(2) worldPos: vec3f,\n};\n\nstruct LightVP { viewProj: mat4x4f, };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var diffuseSampler: sampler;\n@group(0) @binding(3) var shadowMap: texture_depth_2d;\n@group(0) @binding(4) var shadowSampler: sampler_comparison;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;\n@group(2) @binding(1) var<uniform> material: MaterialUniforms;\n\n// 3x3 PCF shadow sampling, 2048 map, normal-bias 0.08, depth-bias 0.001\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\n // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.\n if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }\n let biasedPos = worldPos + n * 0.08;\n let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let cmpZ = ndc.z - 0.001;\n let ts = 1.0 / 2048.0;\n // 3x3 PCF unrolled \u2014 Safari's Metal backend doesn't unroll nested shadow loops reliably.\n let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);\n let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);\n let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);\n let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);\n let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);\n let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);\n let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);\n let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);\n let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);\n return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);\n}\n\nconst PI_F: f32 = 3.141592653589793;\nconst FACE_SPECULAR: f32 = 0.5;\nconst FACE_ROUGHNESS: f32 = 0.3;\nconst FACE_RIM2_POW: f32 = 0.6300000548362732;\nconst FACE_RIM2_BG: vec3f = vec3f(1.0, 0.4684903025627136, 0.3698573112487793);\nconst FACE_WARM_STR: f32 = 0.30000001192092896;\nconst FACE_BRIGHT_TEX_THRESH: f32 = 0.9300000071525574;\nconst FACE_MIX_NPR: f32 = 0.5;\n// EEVEE Light Clamp equivalent (Render Props \u2192 Sampling \u2192 Clamping). Caps direct\n// specular firefly from the noise-bumped normal's NDF aliasing \u2014 Blender hides this\n// via TAA, which we don't have. Value mirrors EEVEE's default Clamp Indirect=10.0.\nconst FACE_SPEC_CLAMP: f32 = 10.0;\n\n@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\n @location(2) uv: vec2f,\n @location(3) joints0: vec4<u32>,\n @location(4) weights0: vec4<f32>\n) -> VertexOutput {\n var output: VertexOutput;\n let pos4 = vec4f(position, 1.0);\n let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n var skinnedPos = vec4f(0.0);\n var skinnedNrm = vec3f(0.0);\n for (var i = 0u; i < 4u; i++) {\n let m = skinMats[joints0[i]];\n let w = nw[i];\n skinnedPos += (m * pos4) * w;\n skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;\n }\n output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);\n // Skip VS normalize \u2014 interpolation denormalizes anyway, and FS always does normalize(input.normal).\n output.normal = skinnedNrm;\n output.uv = uv;\n output.worldPos = skinnedPos.xyz;\n return output;\n}\n\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) mask: f32,\n};\n\n@fragment fn fs(input: VertexOutput) -> FSOut {\n let alpha = material.alpha;\n if (alpha < 0.001) { discard; }\n\n let n = normalize(input.normal);\n let v = normalize(camera.viewPos - input.worldPos);\n let l = -light.lights[0].direction.xyz;\n let intensity = light.lights[0].color.w;\n let sun = light.lights[0].color.xyz * intensity;\n\n let tex_color = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;\n let shadow = sampleShadow(input.worldPos, n);\n\n let ndotl_raw = shader_to_rgb_diffuse(n, l, sun, light.ambientColor.xyz, shadow);\n // ramp_constant_edge_aa: avoids binary fac shimmer on terminator (fwidth + smoothstep).\n let toon = ramp_constant_edge_aa(ndotl_raw, 0.2966, vec4f(0,0,0,1), vec4f(1,1,1,1)).r;\n\n let shadow_tint = hue_sat(0.46000000834465027, 2.0, 0.3499999940395355, 1.0, tex_color);\n let lit_tint = hue_sat(0.46000000834465027, 1.600000023841858, 1.5, 1.0, tex_color);\n let toon_color = mix_blend(toon, shadow_tint, lit_tint);\n let bc = bright_contrast(toon_color, 0.1, 0.2);\n\n let emission3 = bc * 2.5;\n\n let warm_input = clamp(toon * 0.5 + 0.5, 0.0, 1.0);\n let warm_color = ramp_cardinal(warm_input, 0.2409,\n vec4f(0.2426, 0.068, 0.0588, 1.0), 0.4663,\n vec4f(0.6677, 0.5024, 0.5126, 1.0)).rgb;\n let warm_emission = warm_color * FACE_WARM_STR;\n\n let rim1_str = fresnel(2.0, n, v) * layer_weight_facing(0.24, n, v);\n let rim1 = vec3f(0.984157919883728, 0.6110184788703918, 0.5736401677131653) * rim1_str;\n\n let rim2_raw = fresnel(1.45, n, v) * layer_weight_fresnel(0.61, n, v);\n let rim2_fac = math_power(rim2_raw, FACE_RIM2_POW);\n let rim2_mixed = mix(emission3, FACE_RIM2_BG, rim2_fac);\n\n // Blender implicitly converts Color \u2192 Float via BT.601 grayscale when plugging a color\n // output into a Math node's Value input. An earlier trace used tex_color.r, which fires\n // aggressively on R-dominant skin \u2014 single near-white R pixels produced firefly speckles.\n // color_to_value matches the Blender socket semantic and only fires on genuinely near-\n // white painted features (the author's intent).\n let tex_gate = math_greater_than(color_to_value(tex_color), FACE_BRIGHT_TEX_THRESH);\n let bright_emit = vec3f(tex_gate) * 3.0;\n\n let npr_stack = rim1 + rim2_mixed + bright_emit + warm_emission;\n\n // Noise bump \u2014 Mapping loc=rot=0 folds to a plain scale multiply.\n let noise_val = tex_noise_d2(input.worldPos * vec3f(1.0, 1.0, 1.5), 1.0);\n let noise_ramp = ramp_linear(noise_val, 0.0, vec4f(0,0,0,1), 1.0, vec4f(1,1,1,1)).r;\n let bumped_n = bump_lh(0.324644535779953, noise_ramp, n, input.worldPos);\n\n let principled_base = mix_blend(noise_ramp, bc, vec3f(0.6832, 0.1947, 0.1373));\n let p_emission = bc * 0.2;\n\n // Principled BSDF (EEVEE port): metallic=0, specular=0.5, roughness=0.3, specular_tint=0.\n let NL = max(dot(bumped_n, l), 0.0);\n let NV = max(dot(bumped_n, v), 1e-4);\n\n let f0 = vec3f(0.08 * FACE_SPECULAR);\n let f90 = mix(f0, vec3f(1.0), sqrt(FACE_SPECULAR));\n let brdf_lut = brdf_lut_sample(NV, FACE_ROUGHNESS);\n let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);\n\n let spec_direct_raw = bsdf_ggx(bumped_n, l, v, NL, NV, FACE_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);\n let spec_direct = min(spec_direct_raw, vec3f(FACE_SPEC_CLAMP));\n let spec_indirect = light.ambientColor.xyz;\n let spec_radiance = (spec_direct + spec_indirect) * reflection_color;\n\n // Indirect diffuse = base_color \u00D7 L_w per Blender closure_eval_surface_lib.glsl line 302;\n // probe_evaluate_world_diff returns radiance (SH-projected, not cosine-convolved).\n let diffuse_radiance = principled_base * (sun * NL * shadow / PI_F + light.ambientColor.xyz);\n let principled = diffuse_radiance + spec_radiance + p_emission;\n\n let final_color = mix(npr_stack, principled, FACE_MIX_NPR);\n\n var out: FSOut;\n out.color = vec4f(final_color, alpha);\n out.mask = 1.0;\n return out;\n}\n\n";
2
2
  //# sourceMappingURL=face.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"face.d.ts","sourceRoot":"","sources":["../../src/shaders/face.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,gBAAgB,0tiCAwO5B,CAAA"}
1
+ {"version":3,"file":"face.d.ts","sourceRoot":"","sources":["../../src/shaders/face.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,gBAAgB,m37BAoM5B,CAAA"}
@@ -71,12 +71,11 @@ fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
71
71
  const PI_F: f32 = 3.141592653589793;
72
72
  const FACE_SPECULAR: f32 = 0.5;
73
73
  const FACE_ROUGHNESS: f32 = 0.3;
74
- // Dump M_Face unlinked defaults (math op enum not serialized — warm clamp chain still from m_graphs)
75
74
  const FACE_RIM2_POW: f32 = 0.6300000548362732;
76
75
  const FACE_RIM2_BG: vec3f = vec3f(1.0, 0.4684903025627136, 0.3698573112487793);
77
- const FACE_WARM_AO_MUL: f32 = 0.30000001192092896; // 运算.004 MULTIPLY after invert (was 0.5 in older trace)
78
- const FACE_BRIGHT_TEX_THRESH: f32 = 0.9300000071525574; // 运算.005 GREATER_THAN Value_001
79
- const FACE_MIX_NPR: f32 = 0.5; // 混合着色器.001 Fac
76
+ const FACE_WARM_STR: f32 = 0.30000001192092896;
77
+ const FACE_BRIGHT_TEX_THRESH: f32 = 0.9300000071525574;
78
+ const FACE_MIX_NPR: f32 = 0.5;
80
79
  // EEVEE Light Clamp equivalent (Render Props → Sampling → Clamping). Caps direct
81
80
  // specular firefly from the noise-bumped normal's NDF aliasing — Blender hides this
82
81
  // via TAA, which we don't have. Value mirrors EEVEE's default Clamp Indirect=10.0.
@@ -110,10 +109,6 @@ const FACE_SPEC_CLAMP: f32 = 10.0;
110
109
  return output;
111
110
  }
112
111
 
113
- // Fragment: M_Face NPR + Principled hybrid
114
- // TEX → HueSat shadow/lit → toon gate → BrightContrast → AO chain → emission stack
115
- // Fresnel rims, warm AO emission, bright-texture gate, noise-bumped Principled
116
- // Final = mix(Principled, NPR, 0.5)
117
112
  struct FSOut {
118
113
  @location(0) color: vec4f,
119
114
  @location(1) mask: f32,
@@ -132,79 +127,49 @@ struct FSOut {
132
127
  let tex_color = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
133
128
  let shadow = sampleShadow(input.worldPos, n);
134
129
 
135
- // ═══ SOURCES ═══
136
- // DiffuseBSDF(white) → ShaderToRGB (energy-matched); shadow on direct only
137
130
  let ndotl_raw = shader_to_rgb_diffuse(n, l, sun, light.ambientColor.xyz, shadow);
138
- // ramp.008 CONSTANT — edge AA avoids binary fac shimmer / white specks on terminator (fwidth + smoothstep)
131
+ // ramp_constant_edge_aa: avoids binary fac shimmer on terminator (fwidth + smoothstep).
139
132
  let toon = ramp_constant_edge_aa(ndotl_raw, 0.2966, vec4f(0,0,0,1), vec4f(1,1,1,1)).r;
140
- let ao = 1.0; // ao_fake(n, v) — no SSAO yet; inline 1.0 so the ramp/mix chain folds at compile time.
141
133
 
142
- // ═══ TOON COLOR ═══
143
- let shadow_tint = hue_sat(0.46000000834465027, 2.0, 0.3499999940395355, 1.0, tex_color); // HueSat.002
144
- let lit_tint = hue_sat(0.46000000834465027, 1.600000023841858, 1.5, 1.0, tex_color); // HueSat.001
145
- let toon_color = mix_blend(toon, shadow_tint, lit_tint); // Mix.004
134
+ let shadow_tint = hue_sat(0.46000000834465027, 2.0, 0.3499999940395355, 1.0, tex_color);
135
+ let lit_tint = hue_sat(0.46000000834465027, 1.600000023841858, 1.5, 1.0, tex_color);
136
+ let toon_color = mix_blend(toon, shadow_tint, lit_tint);
146
137
  let bc = bright_contrast(toon_color, 0.1, 0.2);
147
138
 
148
- // ═══ AO CHAIN ═══
149
- // ramp CONSTANT [0→white, 0.5995→black]
150
- let ao_ramp = ramp_constant(ao, 0.0, vec4f(1,1,1,1), 0.5995, vec4f(0,0,0,1)).r;
151
- // Mix.003(Factor=ao_ramp, A=bc, B=reddish tint)
152
- let ao_mixed = mix_blend(ao_ramp, bc, vec3f(0.8302, 0.3346, 0.2795));
139
+ let emission3 = bc * 2.5;
153
140
 
154
- // ═══ EMISSION 3 ═══
155
- let emission3 = ao_mixed * 2.5; // Emission.003(Strength=2.5)
156
-
157
- // ═══ WARM EMISSION ═══
158
- let ao_inv = invert_f(1.0, ao_ramp);
159
- let warm_str = ao_inv * FACE_WARM_AO_MUL; // 反转 → 运算.004 MULTIPLY Value_001
160
- let warm_input = clamp(toon * 0.5 + 0.5, 0.0, 1.0); // 运算.001→运算.006→Clamp
161
- // ramp.003 CARDINAL [0.2409→warm dark, 0.4663→warm light]
141
+ let warm_input = clamp(toon * 0.5 + 0.5, 0.0, 1.0);
162
142
  let warm_color = ramp_cardinal(warm_input, 0.2409,
163
143
  vec4f(0.2426, 0.068, 0.0588, 1.0), 0.4663,
164
144
  vec4f(0.6677, 0.5024, 0.5126, 1.0)).rgb;
165
- let warm_emission = warm_color * warm_str; // Emission.001
145
+ let warm_emission = warm_color * FACE_WARM_STR;
166
146
 
167
- // ═══ RIM 1 ═══
168
- // Fresnel(IOR=2.0) × LayerWeight.001(Facing, Blend=0.24)
169
147
  let rim1_str = fresnel(2.0, n, v) * layer_weight_facing(0.24, n, v);
170
148
  let rim1 = vec3f(0.984157919883728, 0.6110184788703918, 0.5736401677131653) * rim1_str;
171
149
 
172
- // ═══ RIM 2 ═══
173
- // Fresnel.001(IOR=1.45) × LayerWeight.002(Fresnel output, Blend=0.61)
174
150
  let rim2_raw = fresnel(1.45, n, v) * layer_weight_fresnel(0.61, n, v);
175
151
  let rim2_fac = math_power(rim2_raw, FACE_RIM2_POW);
176
- // MixShader.002: Shader=Emission.003, Shader_001=背景
177
152
  let rim2_mixed = mix(emission3, FACE_RIM2_BG, rim2_fac);
178
153
 
179
- // 转接点.005(tex)运算.005 GREATER_THAN Value_001
180
- // Blender implicitly converts Color Float via BT.601 grayscale when plugging a
181
- // color output into a Math node's Value input. Our earlier trace used tex_color.r,
182
- // which fires aggressively on R-dominant skin single near-white R pixels produced
183
- // firefly speckles. color_to_value matches the actual Blender socket semantic and
184
- // only fires on genuinely near-white painted features (the author's intent).
154
+ // Blender implicitly converts Color Float via BT.601 grayscale when plugging a color
155
+ // output into a Math node's Value input. An earlier trace used tex_color.r, which fires
156
+ // aggressively on R-dominant skin single near-white R pixels produced firefly speckles.
157
+ // color_to_value matches the Blender socket semantic and only fires on genuinely near-
158
+ // white painted features (the author's intent).
185
159
  let tex_gate = math_greater_than(color_to_value(tex_color), FACE_BRIGHT_TEX_THRESH);
186
- let bright_emit = vec3f(tex_gate) * 3.0; // Emission.002(Strength=3.0)
160
+ let bright_emit = vec3f(tex_gate) * 3.0;
187
161
 
188
- // ═══ NPR STACK (AddShader chain) ═══
189
- let add2 = rim2_mixed + bright_emit; // AddShader.002
190
- let add0 = rim1 + add2; // AddShader
191
- let npr_stack = add0 + warm_emission; // AddShader.001
162
+ let npr_stack = rim1 + rim2_mixed + bright_emit + warm_emission;
192
163
 
193
- // ═══ PRINCIPLED BSDF ═══
194
- // Noise-based bump normal. Mapping loc=rot=0 → plain scale multiply, inline.
164
+ // Noise bump Mapping loc=rot=0 folds to a plain scale multiply.
195
165
  let noise_val = tex_noise_d2(input.worldPos * vec3f(1.0, 1.0, 1.5), 1.0);
196
166
  let noise_ramp = ramp_linear(noise_val, 0.0, vec4f(0,0,0,1), 1.0, vec4f(1,1,1,1)).r;
197
- let bumped_n = bump_lh(0.324644535779953, noise_ramp, n, input.worldPos); // 凹凸 Strength; LH bump
167
+ let bumped_n = bump_lh(0.324644535779953, noise_ramp, n, input.worldPos);
198
168
 
199
- // Mix.001(Factor=noise_ramp, A=bc, B=dark red)
200
169
  let principled_base = mix_blend(noise_ramp, bc, vec3f(0.6832, 0.1947, 0.1373));
201
- // Emission input from reroute.011 (bc), Strength=0.2
202
170
  let p_emission = bc * 0.2;
203
- // AO.002 → ramp.005 LINEAR [0.003→black, 1.0→gray] for subsurface approx.
204
- // Reuse 'ao' (ao_fake(n, v) above) — identical inputs, avoid a second procedural AO pass.
205
- let sss = ramp_linear(ao, 0.003, vec4f(0,0,0,1), 1.0, vec4f(0.0786, 0.0786, 0.0786, 1.0)).r;
206
171
 
207
- // 原理化BSDF (EEVEE port): metallic=0, specular=0.5, roughness=0.3, specular_tint=0.
172
+ // Principled BSDF (EEVEE port): metallic=0, specular=0.5, roughness=0.3, specular_tint=0.
208
173
  let NL = max(dot(bumped_n, l), 0.0);
209
174
  let NV = max(dot(bumped_n, v), 1e-4);
210
175
 
@@ -221,9 +186,8 @@ struct FSOut {
221
186
  // Indirect diffuse = base_color × L_w per Blender closure_eval_surface_lib.glsl line 302;
222
187
  // probe_evaluate_world_diff returns radiance (SH-projected, not cosine-convolved).
223
188
  let diffuse_radiance = principled_base * (sun * NL * shadow / PI_F + light.ambientColor.xyz);
224
- let principled = diffuse_radiance + spec_radiance + p_emission + vec3f(sss);
189
+ let principled = diffuse_radiance + spec_radiance + p_emission;
225
190
 
226
- // 混合着色器.001: Shader=相加着色器.001, Shader_001=原理化BSDF — Fac blends toward second
227
191
  let final_color = mix(npr_stack, principled, FACE_MIX_NPR);
228
192
 
229
193
  var out: FSOut;
@@ -1,2 +1,2 @@
1
- export declare const HAIR_SHADER_WGSL = "\n\n\n\n// Baked 64\u00D764 rgba8unorm combined BRDF LUT \u2014 created once at engine init by dfg_lut.ts.\n// .rg = split-sum DFG (Karis: tint = f0\u00B7x + f90\u00B7y) \u2192 F_brdf_*_scatter\n// .ba = Heitz 2016 LTC magnitude (ltc_mag_ggx) \u2192 ltc_brdf_scale_from_lut\n// Paired with group(0) binding(2) diffuseSampler (linear filter). Sample once per\n// fragment via brdf_lut_sample() \u2014 callers feed .rg and the whole vec4 into the\n// helpers below, halving LUT taps on the default Principled path.\n@group(0) @binding(9) var brdfLut: texture_2d<f32>;\n\n// \u2500\u2500\u2500 RGB \u2194 HSV \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn rgb_to_hsv(rgb: vec3f) -> vec3f {\n let c_max = max(rgb.r, max(rgb.g, rgb.b));\n let c_min = min(rgb.r, min(rgb.g, rgb.b));\n let delta = c_max - c_min;\n\n var h = 0.0;\n if (delta > 1e-6) {\n if (c_max == rgb.r) {\n h = (rgb.g - rgb.b) / delta;\n if (h < 0.0) { h += 6.0; }\n } else if (c_max == rgb.g) {\n h = 2.0 + (rgb.b - rgb.r) / delta;\n } else {\n h = 4.0 + (rgb.r - rgb.g) / delta;\n }\n h /= 6.0;\n }\n let s = select(0.0, delta / c_max, c_max > 1e-6);\n return vec3f(h, s, c_max);\n}\n\nfn hsv_to_rgb(hsv: vec3f) -> vec3f {\n let h = hsv.x;\n let s = hsv.y;\n let v = hsv.z;\n if (s < 1e-6) { return vec3f(v); }\n\n let hh = fract(h) * 6.0;\n let sector = u32(hh);\n let f = hh - f32(sector);\n let p = v * (1.0 - s);\n let q = v * (1.0 - s * f);\n let t = v * (1.0 - s * (1.0 - f));\n\n switch (sector) {\n case 0u: { return vec3f(v, t, p); }\n case 1u: { return vec3f(q, v, p); }\n case 2u: { return vec3f(p, v, t); }\n case 3u: { return vec3f(p, q, v); }\n case 4u: { return vec3f(t, p, v); }\n default: { return vec3f(v, p, q); }\n }\n}\n\n// \u2500\u2500\u2500 HUE_SAT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn hue_sat(hue: f32, saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n var hsv = rgb_to_hsv(color);\n hsv.x = fract(hsv.x + hue - 0.5);\n hsv.y = clamp(hsv.y * saturation, 0.0, 1.0);\n hsv.z *= value;\n return mix(color, hsv_to_rgb(hsv), fac);\n}\n\n// hue_sat specialization for hue=0.5 (identity hue shift \u2014 fract(h + 0.5 - 0.5) = h).\n// Branchless equivalent that skips the rgb_to_hsv \u2192 hsv_to_rgb roundtrip: WebKit's\n// Metal backend serializes the 3-way if chain in rgb_to_hsv and the 6-way switch in\n// hsv_to_rgb, where this form compiles to linear SIMD ops + a single select.\nfn hue_sat_id(saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n let m = max(max(color.r, color.g), color.b);\n let n = min(min(color.r, color.g), color.b);\n // Unclamped (sat*old_s \u2264 1): reproj = mix(vec3f(m), color, saturation).\n // Clamped (saturated to 1): reproj = (color - n) * m / (m - n).\n let range = max(m - n, 1e-6);\n let unclamped = mix(vec3f(m), color, saturation);\n let clamped = (color - vec3f(n)) * m / range;\n let needs_clamp = (m - n) * saturation >= m;\n let reproj = select(unclamped, clamped, needs_clamp);\n return mix(color, reproj * value, fac);\n}\n\n// \u2500\u2500\u2500 BRIGHTCONTRAST node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn bright_contrast(color: vec3f, bright: f32, contrast: f32) -> vec3f {\n let a = 1.0 + contrast;\n let b = bright - contrast * 0.5;\n return max(vec3f(0.0), color * a + vec3f(b));\n}\n\n// \u2500\u2500\u2500 INVERT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn invert(fac: f32, color: vec3f) -> vec3f {\n return mix(color, vec3f(1.0) - color, fac);\n}\n\nfn invert_f(fac: f32, val: f32) -> f32 {\n return mix(val, 1.0 - val, fac);\n}\n\n// \u2500\u2500\u2500 Color ramp (VALTORGB) \u2014 2-stop variants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// All 7 presets use exclusively 2-stop ramps.\n\nfn ramp_constant(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n return select(c0, c1, f >= p1);\n}\n\n// CONSTANT ramp with screen-space edge AA \u2014 kills sparkle where fwidth(f) straddles a hard step (NPR terminator)\nfn ramp_constant_edge_aa(f: f32, edge: f32, c0: vec4f, c1: vec4f) -> vec4f {\n let w = max(fwidth(f) * 1.75, 6e-6);\n let t = smoothstep(edge - w, edge + w, f);\n return mix(c0, c1, t);\n}\n\nfn ramp_linear(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n return mix(c0, c1, t);\n}\n\nfn ramp_cardinal(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n // cardinal spline with 2 stops degrades to smoothstep\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n let ss = t * t * (3.0 - 2.0 * t);\n return mix(c0, c1, ss);\n}\n\n// \u2500\u2500\u2500 MATH node operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn math_add(a: f32, b: f32) -> f32 { return a + b; }\nfn math_multiply(a: f32, b: f32) -> f32 { return a * b; }\nfn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }\nfn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }\n\n// Blender's implicit Color \u2192 Float socket conversion uses BT.601 grayscale\n// (rgb_to_grayscale in blenkernel/intern/node.cc). When a material graph plugs a\n// Color output into a Math node's Value input, this is the scalar it actually sees.\nfn color_to_value(c: vec3f) -> f32 {\n return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;\n}\n\n// \u2500\u2500\u2500 MIX node (blend_type variants) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn mix_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, b, fac);\n}\n\nfn mix_overlay(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let lo = 2.0 * a * b;\n let hi = vec3f(1.0) - 2.0 * (vec3f(1.0) - a) * (vec3f(1.0) - b);\n let overlay = select(hi, lo, a < vec3f(0.5));\n return mix(a, overlay, fac);\n}\n\nfn mix_multiply(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a * b, fac);\n}\n\nfn mix_lighten(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, max(a, b), fac);\n}\n\n// Blender Mix (Color) blend LINEAR_LIGHT: result = mix(A, A + 2*B - 1, Fac)\nfn mix_linear_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a + 2.0 * b - vec3f(1.0), fac);\n}\n\n// Luminance for Shader\u2192RGB scalar gates (linear RGB, Rec.709 weights)\nfn luminance_rec709_linear(c: vec3f) -> f32 {\n return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));\n}\n\n// \u2500\u2500\u2500 FRESNEL node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Schlick approximation matching Blender's Fresnel node\n\nfn fresnel(ior: f32, n: vec3f, v: vec3f) -> f32 {\n let r = (ior - 1.0) / (ior + 1.0);\n let f0 = r * r;\n let cos_theta = clamp(dot(n, v), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\n// \u2500\u2500\u2500 LAYER_WEIGHT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn layer_weight_fresnel(blend: f32, n: vec3f, v: vec3f) -> f32 {\n let eta = max(1.0 - blend, 1e-4);\n let r = (1.0 - eta) / (1.0 + eta);\n let f0 = r * r;\n let cos_theta = clamp(abs(dot(n, v)), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\nfn layer_weight_facing(blend: f32, n: vec3f, v: vec3f) -> f32 {\n var facing = abs(dot(n, v));\n let b = clamp(blend, 0.0, 0.99999);\n if (b != 0.5) {\n let exponent = select(2.0 * b, 0.5 / (1.0 - b), b >= 0.5);\n facing = pow(facing, exponent);\n }\n return 1.0 - facing;\n}\n\n// \u2500\u2500\u2500 SHADER_TO_RGB (white DiffuseBSDF) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Eevee captures lit diffuse: (albedo/\u03C0)*sun*N\u00B7L*shadow + ambient (linear). Albedo=1.\n// Matches default.ts direct term scale so VALTORGB thresholds from Blender JSON stay valid.\n\nfn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> f32 {\n const PI_S: f32 = 3.141592653589793;\n let ndotl = max(dot(n, l), 0.0);\n let rgb = sun_rgb * (ndotl * shadow / PI_S) + ambient_rgb;\n return luminance_rec709_linear(rgb);\n}\n\n// \u2500\u2500\u2500 AMBIENT_OCCLUSION node (faked) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Real SSAO is a non-goal. We approximate: use the \"inside\" value from\n// concavity heuristic: 1.0 = fully lit, lower = occluded.\n// For now returns 1.0 (no darkening). Individual presets can override.\n\nfn ao_fake(n: vec3f, v: vec3f) -> f32 {\n return 1.0;\n}\n\n// \u2500\u2500\u2500 BUMP node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Screen-space bump from a scalar height field. Needs dFdx/dFdy which\n// WGSL provides as dpdx/dpdy.\n\nfn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) + dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference\nfn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// \u2500\u2500\u2500 NOISE texture (Perlin-style) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Simplified gradient noise matching Blender's default noise output.\n\n// PCG-style integer hash. Replaces the classic 'fract(sin(q) * LARGE)' trick because\n// WebKit's Metal backend compiles 'sin' to a full transcendental op (slow), while\n// Safari's Apple-GPU scalar ALU handles int muls/xors near free. Inputs arrive as\n// integer-valued floats (floor(p) + unit offsets) from _noise3, so vec3i cast is exact.\nfn _hash33(p: vec3f) -> vec3f {\n var h = vec3u(vec3i(p) + vec3i(32768));\n h = h * vec3u(1664525u, 1013904223u, 2654435761u);\n h = (h.yzx ^ h) * vec3u(2246822519u, 3266489917u, 668265263u);\n h = h ^ (h >> vec3u(16u));\n // Mask to 24 bits \u2014 above that f32 loses precision on the u32\u2192f32 convert.\n let hm = h & vec3u(16777215u);\n return vec3f(hm) * (2.0 / 16777216.0) - 1.0;\n}\n\nfn _noise3(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return mix(\n mix(\n mix(dot(_hash33(i + vec3f(0,0,0)), f - vec3f(0,0,0)),\n dot(_hash33(i + vec3f(1,0,0)), f - vec3f(1,0,0)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,0)), f - vec3f(0,1,0)),\n dot(_hash33(i + vec3f(1,1,0)), f - vec3f(1,1,0)), u.x), u.y),\n mix(\n mix(dot(_hash33(i + vec3f(0,0,1)), f - vec3f(0,0,1)),\n dot(_hash33(i + vec3f(1,0,1)), f - vec3f(1,0,1)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,1)), f - vec3f(0,1,1)),\n dot(_hash33(i + vec3f(1,1,1)), f - vec3f(1,1,1)), u.x), u.y),\n u.z);\n}\n\nfn tex_noise(p: vec3f, scale: f32, detail: f32, roughness: f32, distortion: f32) -> f32 {\n var q = p;\n if (abs(distortion) > 1e-6) {\n let w = _noise3(p * scale * 1.37 + vec3f(2.31, 5.17, 8.09));\n q = p + (w * 2.0 - 1.0) * distortion;\n }\n let coords = q * scale;\n var value = 0.0;\n var amplitude = 1.0;\n var frequency = 1.0;\n var total_amp = 0.0;\n let octaves = i32(clamp(detail, 0.0, 15.0)) + 1;\n for (var i = 0; i < octaves; i++) {\n value += amplitude * _noise3(coords * frequency);\n total_amp += amplitude;\n amplitude *= roughness;\n frequency *= 2.0;\n }\n return value / max(total_amp, 1e-6) * 0.5 + 0.5;\n}\n\n// tex_noise specialization: detail=2.0 (3 octaves), roughness=0.5, distortion=0.\n// WebKit can't unroll tex_noise's for-loop because 'octaves' is a runtime value;\n// this variant is fully unrolled with constants folded (total_amp = 1.75).\nfn tex_noise_d2(p: vec3f, scale: f32) -> f32 {\n let c = p * scale;\n let v = _noise3(c) + 0.5 * _noise3(c * 2.0) + 0.25 * _noise3(c * 4.0);\n return v * (1.0 / 1.75) * 0.5 + 0.5;\n}\n\n// \u2500\u2500\u2500 TEX_GRADIENT (linear) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Stockings preset. Maps the input vector's X to a 0\u20131 gradient.\n\nfn tex_gradient_linear(uv: vec3f) -> f32 {\n return clamp(uv.x, 0.0, 1.0);\n}\n\n// \u2500\u2500\u2500 TEX_VORONOI (distance only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Metal preset. Simplified F1 cell noise.\n\nfn tex_voronoi_f1(p: vec3f, scale: f32) -> f32 {\n let coords = p * scale;\n let i = floor(coords);\n let f = fract(coords);\n var min_dist = 1e10;\n for (var z = -1; z <= 1; z++) {\n for (var y = -1; y <= 1; y++) {\n for (var x = -1; x <= 1; x++) {\n let neighbor = vec3f(f32(x), f32(y), f32(z));\n let point = _hash33(i + neighbor) * 0.5 + 0.5;\n let diff = neighbor + point - f;\n min_dist = min(min_dist, dot(diff, diff));\n }\n }\n }\n return sqrt(min_dist);\n}\n\n// \u2500\u2500\u2500 SEPXYZ node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn separate_xyz(v: vec3f) -> vec3f { return v; }\n\n// \u2500\u2500\u2500 VECT_MATH (cross product) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn vect_math_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }\n\n// \u2500\u2500\u2500 MAPPING node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Point-type mapping: scale, rotate (euler XYZ), translate.\n\nfn mapping_point(v: vec3f, loc: vec3f, rot: vec3f, scl: vec3f) -> vec3f {\n var p = v * scl;\n // simplified: skip rotation when all angles are zero (common case)\n if (abs(rot.x) + abs(rot.y) + abs(rot.z) > 1e-6) {\n let cx = cos(rot.x); let sx = sin(rot.x);\n let cy = cos(rot.y); let sy = sin(rot.y);\n let cz = cos(rot.z); let sz = sin(rot.z);\n let rx = vec3f(p.x, cx*p.y - sx*p.z, sx*p.y + cx*p.z);\n let ry = vec3f(cy*rx.x + sy*rx.z, rx.y, -sy*rx.x + cy*rx.z);\n p = vec3f(cz*ry.x - sz*ry.y, sz*ry.x + cz*ry.y, ry.z);\n }\n return p + loc;\n}\n\n// \u2500\u2500\u2500 NORMAL_MAP node (tangent-space) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Applies a tangent-space normal map. Requires TBN from vertex stage.\n\nfn normal_map(strength: f32, map_color: vec3f, normal: vec3f, tangent: vec3f, bitangent: vec3f) -> vec3f {\n let ts = map_color * 2.0 - 1.0;\n let perturbed = normalize(tangent * ts.x + bitangent * ts.y + normal * ts.z);\n return normalize(mix(normal, perturbed, strength));\n}\n\n// \u2500\u2500\u2500 EEVEE Principled BSDF primitives \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Ports from Blender 3.6 source/blender/draw/engines/eevee/shaders/\n// bsdf_common_lib.glsl + gpu_shader_material_principled.glsl.\n// Usage pattern (see material shaders): direct spec = bsdf_ggx \u00D7 sun \u00D7 shadow\n// (NL baked in, no F yet); ambient spec = probe_radiance; tint both with\n// reflection_color = F_brdf_multi_scatter(f0, f90, split_sum) AFTER summing.\n\nconst EEVEE_PI: f32 = 3.141592653589793;\n\n// Fused analytic GGX specular (direct lights). Returns BRDF \u00D7 NL.\n// 4\u00B7NL\u00B7NV is cancelled via G1_Smith reciprocal form \u2014 see bsdf_common_lib.glsl:115.\n// Caller passes NL, NV (already computed for diffuse + brdf_lut_sample) so WebKit\n// can reuse them instead of recomputing dot products across the function boundary.\nfn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32) -> f32 {\n let a = max(roughness, 1e-4);\n let a2 = a * a;\n let H = normalize(L + V);\n let NH = max(dot(N, H), 1e-8);\n let NL = max(NL_in, 1e-8);\n let NV = max(NV_in, 1e-8);\n // G1_Smith_GGX_opti reciprocal form \u2014 denominator piece only.\n let G1L = NL + sqrt(NL * (NL - NL * a2) + a2);\n let G1V = NV + sqrt(NV * (NV - NV * a2) + a2);\n let G = G1L * G1V;\n // D_ggx_opti = pi * denom\u00B2 \u2014 reciprocal D \u00D7 a\u00B2.\n let tmp = (NH * a2 - NH) * NH + 1.0;\n let D_opti = EEVEE_PI * tmp * tmp;\n return NL * a2 / (D_opti * G);\n}\n\n// Split-sum DFG LUT \u2014 Karis 2013 curve fit stand-in for the 64\u00D764 baked LUT.\n// Returns (lut.x, lut.y) in Blender convention: tint = f0\u00B7lut.x + f90\u00B7lut.y.\nfn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {\n let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);\n let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);\n let r = roughness * c0 + c1;\n let a004 = min(r.x * r.x, exp2(-9.28 * NV)) * r.x + r.y;\n return vec2f(-1.04, 1.04) * a004 + r.zw;\n}\n\n// Baked combined BRDF LUT \u2014 exact port of Blender bsdf_lut_frag.glsl packed with\n// ltc_mag_ggx from eevee_lut.c. Single sample returns DFG (.rg) and LTC mag (.ba).\n// Addressed as Blender's common_utiltex_lib.glsl:lut_coords:\n// coords = (roughness, sqrt(1 - NV)), then half-texel bias for filtering.\n// Requires group(0) binding(9) brdfLut + binding(2) diffuseSampler in the host shader.\nfn brdf_lut_sample(NV: f32, roughness: f32) -> vec4f {\n let LUT_SIZE: f32 = 64.0;\n var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));\n uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;\n return textureSampleLevel(brdfLut, diffuseSampler, uv, 0.0);\n}\n\nfn F_brdf_single_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n return lut.y * f90 + lut.x * f0;\n}\n\n// Fdez-Ag\u00FCera 2019 multi-scatter compensation (EEVEE do_multiscatter=1).\nfn F_brdf_multi_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n let FssEss = lut.y * f90 + lut.x * f0;\n let Ess = lut.x + lut.y;\n let Ems = 1.0 - Ess;\n let Favg = f0 + (1.0 - f0) / 21.0;\n let Fms = FssEss * Favg / (1.0 - (1.0 - Ess) * Favg);\n return FssEss + Fms * Ems;\n}\n\n// EEVEE direct-specular energy compensation factor \u2014 closure_eval_glossy_lib.glsl:79-81:\n// ltc_brdf_scale = (ltc.x + ltc.y) / (split_sum.x + split_sum.y)\n// Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum;\n// direct radiance is rescaled so total-energy matches the split-sum LUT.\n// Takes a pre-sampled vec4f from brdf_lut_sample() to share the fetch with\n// F_brdf_multi_scatter on the same fragment.\nfn ltc_brdf_scale_from_lut(lut: vec4f) -> f32 {\n return (lut.z + lut.w) / max(lut.x + lut.y, 1e-6);\n}\n\n// Luminance-normalized hue extraction \u2014 Blender tint_from_color (isolates hue+sat).\nfn tint_from_color(color: vec3f) -> vec3f {\n let lum = dot(color, vec3f(0.3, 0.6, 0.1));\n return select(vec3f(1.0), color / lum, lum > 0.0);\n}\n\n\n\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct Light {\n direction: vec4f,\n color: vec4f,\n};\n\nstruct LightUniforms {\n ambientColor: vec4f,\n lights: array<Light, 4>,\n};\n\nstruct MaterialUniforms {\n diffuseColor: vec3f,\n alpha: f32,\n};\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) normal: vec3f,\n @location(1) uv: vec2f,\n @location(2) worldPos: vec3f,\n};\n\nstruct LightVP { viewProj: mat4x4f, };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var diffuseSampler: sampler;\n@group(0) @binding(3) var shadowMap: texture_depth_2d;\n@group(0) @binding(4) var shadowSampler: sampler_comparison;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;\n@group(2) @binding(1) var<uniform> material: MaterialUniforms;\n\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\n // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.\n if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }\n let biasedPos = worldPos + n * 0.08;\n let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let cmpZ = ndc.z - 0.001;\n let ts = 1.0 / 2048.0;\n // 3x3 PCF unrolled \u2014 Safari's Metal backend doesn't unroll nested shadow loops reliably.\n let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);\n let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);\n let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);\n let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);\n let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);\n let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);\n let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);\n let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);\n let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);\n return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);\n}\n\nconst PI_H: f32 = 3.141592653589793;\nconst HAIR_SPECULAR: f32 = 1.0;\nconst HAIR_ROUGHNESS: f32 = 0.3;\n// Dump M_Hair: \u8FD0\u7B97.004 GREATER_THAN second operand Value_001; \u8FD0\u7B97.007 POWER exponent Value_001; \u80CC\u666F Color\nconst HAIR_TEX_GATE_THRESH: f32 = 0.15000000596046448;\nconst HAIR_RIM2_POW: f32 = 0.6300000548362732;\nconst HAIR_MIX_BG: vec3f = vec3f(0.1673291176557541);\n\n@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\n @location(2) uv: vec2f,\n @location(3) joints0: vec4<u32>,\n @location(4) weights0: vec4<f32>\n) -> VertexOutput {\n var output: VertexOutput;\n let pos4 = vec4f(position, 1.0);\n let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n var skinnedPos = vec4f(0.0);\n var skinnedNrm = vec3f(0.0);\n for (var i = 0u; i < 4u; i++) {\n let m = skinMats[joints0[i]];\n let w = nw[i];\n skinnedPos += (m * pos4) * w;\n skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;\n }\n output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);\n // Skip VS normalize \u2014 interpolation denormalizes anyway, and FS always does normalize(input.normal).\n output.normal = skinnedNrm;\n output.uv = uv;\n output.worldPos = skinnedPos.xyz;\n return output;\n}\n\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) mask: f32,\n};\n\n@fragment fn fs(input: VertexOutput) -> FSOut {\n let alpha = material.alpha;\n if (alpha < 0.001) { discard; }\n\n let n = normalize(input.normal);\n let v = normalize(camera.viewPos - input.worldPos);\n let l = -light.lights[0].direction.xyz;\n let sun = light.lights[0].color.xyz * light.lights[0].color.w;\n\n // \u56FE\u50CF\u7EB9\u7406 \u2190 \u7EB9\u7406\u5750\u6807.UV \u2192 \u6620\u5C04 (default 1,1,1 scale per JSON)\n let tex_color = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;\n let shadow = sampleShadow(input.worldPos, n);\n\n // \u8272\u76F8/\u9971\u548C\u5EA6/\u660E\u5EA6 (Hue=0.5 Sat=1.2 Val=0.5 Fac=1) \u2190 reroute from image\n let hue_sat_shadow = hue_sat_id(1.2, 0.5, 1.0, tex_color);\n // \u8272\u76F8/\u9971\u548C\u5EA6/\u660E\u5EA6.002 (0.48, 1.2, 0.7, 1) \u2190 previous\n let hue_sat_002 = hue_sat(0.48, 1.2, 0.7, 1.0, hue_sat_shadow);\n // \u8272\u76F8/\u9971\u548C\u5EA6/\u660E\u5EA6.001 (0.5, 1.5, 1.0, 1) \u2190 image reroute (lit path)\n let hue_sat_001 = hue_sat_id(1.5, 1.0, 1.0, tex_color);\n\n // \u6F2B\u5C04 BSDF.002 \u2192 Shader --> RGB \u2192 \u989C\u8272\u6E10\u53D8.008 CONSTANT [0\u21920, 0.2966\u21921]\n let ndotl_raw = shader_to_rgb_diffuse(n, l, sun, light.ambientColor.xyz, shadow);\n let ramp_008 = ramp_constant(ndotl_raw, 0.0, vec4f(0,0,0,1), 0.2966, vec4f(1,1,1,1)).r;\n\n // \u6DF7\u5408.004 MIX Fac=ramp_008, A=hue_sat_002, B=hue_sat_001\n let mix_004 = mix_blend(ramp_008, hue_sat_002, hue_sat_001);\n\n // \u4EAE\u5EA6/\u5BF9\u6BD4\u5EA6 (Bright=0.1 Contrast=0.2) \u2190 mix_004 only (links: not bevel path)\n let bc = bright_contrast(mix_004, 0.1, 0.2);\n\n // \u5012\u89D2.001 \u2192 \u5206\u79BB XYZ.001 \u2192 Z \u2192 \u6DF7\u5408.003 Factor; A=bc, B=hue_sat_002\n let bevel_z = clamp(n.y, 0.0, 1.0);\n let mix_003 = mix_blend(bevel_z, bc, hue_sat_002);\n\n // \u73AF\u5883\u5149\u906E\u853D (AO).001 \u2192 \u989C\u8272\u6E10\u53D8.001 \u2192 \u6DF7\u5408.001 \u2192 \u6DF7\u5408.002 chain collapses with fake AO=1:\n // ramp_constant(1, 0\u2192white, 0.3756\u2192black).r = 0 \u2192 ao_factor = mix(1,0,0) = 1 \u2192 mix_002 = mix_003.\n // hue_sat_004 becomes unreachable. When real SSAO lands, restore the original 5-line port.\n let emission3 = mix_003; // Emission.003 Strength=1.0 (\u00D71 omitted)\n\n // \u83F2\u6D85\u5C14.001 \u00D7 \u5C42\u6743\u91CD.002 \u2192 \u8FD0\u7B97.003 MULTIPLY \u2192 \u8FD0\u7B97.007 POWER(exponent Value_001) \u2192 MixShader.002 Fac\n let rim2_raw = fresnel(1.45, n, v) * layer_weight_fresnel(0.61, n, v);\n let rim2_fac = math_power(rim2_raw, HAIR_RIM2_POW);\n // MixShader.002: Shader=Emission.003, Shader_001=\u80CC\u666F \u2014 (1-Fac)*emission + Fac*bg\n let mix_shader_002 = mix(emission3, HAIR_MIX_BG, rim2_fac);\n\n // \u8FD0\u7B97.004 GREATER_THAN: \u56FE\u50CF\u2192Value, threshold Value_001. Blender converts Color\u2192Float\n // via BT.601 luminance, not raw R \u2014 same socket-semantic fix as M_Face.\n let tex_gate = math_greater_than(color_to_value(tex_color), HAIR_TEX_GATE_THRESH);\n let gate_emit = vec3f(tex_gate) * 0.1;\n\n // \u76F8\u52A0\u7740\u8272\u5668: MixShader.002 + gate emission (color sum in linear space)\n let add_shader = mix_shader_002 + gate_emit;\n\n // \u539F\u7406\u5316BSDF (EEVEE port): metallic=0, specular=1.0, roughness=0.3, specular_tint=0.\n // Blender graph has \u566A\u6CE2\u2192\u6CD5\u7EBF\u8D34\u56FE Strength=0.1 on Principled.Normal, but MixShader.001\n // weights Principled at only 0.2; spec contribution \u00D7 that weight is imperceptible in\n // A/B with the noise-bump port enabled, so we drop it and keep plain n \u2014 saves a full\n // tex_noise + bump_lh per hair fragment.\n let NL = max(dot(n, l), 0.0);\n let NV = max(dot(n, v), 1e-4);\n\n let f0 = vec3f(0.08 * HAIR_SPECULAR);\n let f90 = mix(f0, vec3f(1.0), sqrt(HAIR_SPECULAR));\n let brdf_lut = brdf_lut_sample(NV, HAIR_ROUGHNESS);\n let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);\n\n let spec_direct = bsdf_ggx(n, l, v, NL, NV, HAIR_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);\n let spec_indirect = light.ambientColor.xyz;\n let spec_radiance = (spec_direct + spec_indirect) * reflection_color;\n\n // Indirect diffuse = base_color \u00D7 L_w per Blender closure_eval_surface_lib.glsl line 302;\n // probe_evaluate_world_diff returns radiance (SH-projected, not cosine-convolved).\n let diffuse_radiance = bc * (sun * NL * shadow / PI_H + light.ambientColor.xyz);\n let principled = diffuse_radiance + spec_radiance;\n\n // \u6DF7\u5408\u7740\u8272\u5668.001 Fac=0.2: first socket=\u76F8\u52A0\u7740\u8272\u5668, second=\u539F\u7406\u5316BSDF\n let final_color = mix(add_shader, principled, 0.2);\n\n var out: FSOut;\n out.color = vec4f(final_color, alpha);\n out.mask = 1.0;\n return out;\n}\n\n";
1
+ export declare const HAIR_SHADER_WGSL = "\n\n\n\n// Baked 64\u00D764 rgba8unorm combined BRDF LUT \u2014 created once at engine init by dfg_lut.ts.\n// .rg = split-sum DFG (Karis: tint = f0\u00B7x + f90\u00B7y) \u2192 F_brdf_*_scatter\n// .ba = Heitz 2016 LTC magnitude (ltc_mag_ggx) \u2192 ltc_brdf_scale_from_lut\n// Paired with group(0) binding(2) diffuseSampler (linear filter). Sample once per\n// fragment via brdf_lut_sample() \u2014 callers feed .rg and the whole vec4 into the\n// helpers below, halving LUT taps on the default Principled path.\n@group(0) @binding(9) var brdfLut: texture_2d<f32>;\n\n// \u2500\u2500\u2500 RGB \u2194 HSV \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn rgb_to_hsv(rgb: vec3f) -> vec3f {\n let c_max = max(rgb.r, max(rgb.g, rgb.b));\n let c_min = min(rgb.r, min(rgb.g, rgb.b));\n let delta = c_max - c_min;\n\n var h = 0.0;\n if (delta > 1e-6) {\n if (c_max == rgb.r) {\n h = (rgb.g - rgb.b) / delta;\n if (h < 0.0) { h += 6.0; }\n } else if (c_max == rgb.g) {\n h = 2.0 + (rgb.b - rgb.r) / delta;\n } else {\n h = 4.0 + (rgb.r - rgb.g) / delta;\n }\n h /= 6.0;\n }\n let s = select(0.0, delta / c_max, c_max > 1e-6);\n return vec3f(h, s, c_max);\n}\n\nfn hsv_to_rgb(hsv: vec3f) -> vec3f {\n let h = hsv.x;\n let s = hsv.y;\n let v = hsv.z;\n if (s < 1e-6) { return vec3f(v); }\n\n let hh = fract(h) * 6.0;\n let sector = u32(hh);\n let f = hh - f32(sector);\n let p = v * (1.0 - s);\n let q = v * (1.0 - s * f);\n let t = v * (1.0 - s * (1.0 - f));\n\n switch (sector) {\n case 0u: { return vec3f(v, t, p); }\n case 1u: { return vec3f(q, v, p); }\n case 2u: { return vec3f(p, v, t); }\n case 3u: { return vec3f(p, q, v); }\n case 4u: { return vec3f(t, p, v); }\n default: { return vec3f(v, p, q); }\n }\n}\n\n// \u2500\u2500\u2500 HUE_SAT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn hue_sat(hue: f32, saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n var hsv = rgb_to_hsv(color);\n hsv.x = fract(hsv.x + hue - 0.5);\n hsv.y = clamp(hsv.y * saturation, 0.0, 1.0);\n hsv.z *= value;\n return mix(color, hsv_to_rgb(hsv), fac);\n}\n\n// hue_sat specialization for hue=0.5 (identity hue shift \u2014 fract(h + 0.5 - 0.5) = h).\n// Branchless equivalent that skips the rgb_to_hsv \u2192 hsv_to_rgb roundtrip: WebKit's\n// Metal backend serializes the 3-way if chain in rgb_to_hsv and the 6-way switch in\n// hsv_to_rgb, where this form compiles to linear SIMD ops + a single select.\nfn hue_sat_id(saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n let m = max(max(color.r, color.g), color.b);\n let n = min(min(color.r, color.g), color.b);\n // Unclamped (sat*old_s \u2264 1): reproj = mix(vec3f(m), color, saturation).\n // Clamped (saturated to 1): reproj = (color - n) * m / (m - n).\n let range = max(m - n, 1e-6);\n let unclamped = mix(vec3f(m), color, saturation);\n let clamped = (color - vec3f(n)) * m / range;\n let needs_clamp = (m - n) * saturation >= m;\n let reproj = select(unclamped, clamped, needs_clamp);\n return mix(color, reproj * value, fac);\n}\n\n// \u2500\u2500\u2500 BRIGHTCONTRAST node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn bright_contrast(color: vec3f, bright: f32, contrast: f32) -> vec3f {\n let a = 1.0 + contrast;\n let b = bright - contrast * 0.5;\n return max(vec3f(0.0), color * a + vec3f(b));\n}\n\n// \u2500\u2500\u2500 INVERT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn invert(fac: f32, color: vec3f) -> vec3f {\n return mix(color, vec3f(1.0) - color, fac);\n}\n\nfn invert_f(fac: f32, val: f32) -> f32 {\n return mix(val, 1.0 - val, fac);\n}\n\n// \u2500\u2500\u2500 Color ramp (VALTORGB) \u2014 2-stop variants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// All 7 presets use exclusively 2-stop ramps.\n\nfn ramp_constant(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n return select(c0, c1, f >= p1);\n}\n\n// CONSTANT ramp with screen-space edge AA \u2014 kills sparkle where fwidth(f) straddles a hard step (NPR terminator)\nfn ramp_constant_edge_aa(f: f32, edge: f32, c0: vec4f, c1: vec4f) -> vec4f {\n let w = max(fwidth(f) * 1.75, 6e-6);\n let t = smoothstep(edge - w, edge + w, f);\n return mix(c0, c1, t);\n}\n\nfn ramp_linear(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n return mix(c0, c1, t);\n}\n\nfn ramp_cardinal(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n // cardinal spline with 2 stops degrades to smoothstep\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n let ss = t * t * (3.0 - 2.0 * t);\n return mix(c0, c1, ss);\n}\n\n// \u2500\u2500\u2500 MATH node operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn math_add(a: f32, b: f32) -> f32 { return a + b; }\nfn math_multiply(a: f32, b: f32) -> f32 { return a * b; }\nfn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }\nfn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }\n\n// Blender's implicit Color \u2192 Float socket conversion uses BT.601 grayscale\n// (rgb_to_grayscale in blenkernel/intern/node.cc). When a material graph plugs a\n// Color output into a Math node's Value input, this is the scalar it actually sees.\nfn color_to_value(c: vec3f) -> f32 {\n return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;\n}\n\n// \u2500\u2500\u2500 MIX node (blend_type variants) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn mix_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, b, fac);\n}\n\nfn mix_overlay(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let lo = 2.0 * a * b;\n let hi = vec3f(1.0) - 2.0 * (vec3f(1.0) - a) * (vec3f(1.0) - b);\n let overlay = select(hi, lo, a < vec3f(0.5));\n return mix(a, overlay, fac);\n}\n\nfn mix_multiply(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a * b, fac);\n}\n\nfn mix_lighten(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, max(a, b), fac);\n}\n\n// Blender Mix (Color) blend LINEAR_LIGHT: result = mix(A, A + 2*B - 1, Fac)\nfn mix_linear_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a + 2.0 * b - vec3f(1.0), fac);\n}\n\n// Luminance for Shader\u2192RGB scalar gates (linear RGB, Rec.709 weights)\nfn luminance_rec709_linear(c: vec3f) -> f32 {\n return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));\n}\n\n// \u2500\u2500\u2500 FRESNEL node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Schlick approximation matching Blender's Fresnel node\n\nfn fresnel(ior: f32, n: vec3f, v: vec3f) -> f32 {\n let r = (ior - 1.0) / (ior + 1.0);\n let f0 = r * r;\n let cos_theta = clamp(dot(n, v), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\n// \u2500\u2500\u2500 LAYER_WEIGHT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn layer_weight_fresnel(blend: f32, n: vec3f, v: vec3f) -> f32 {\n let eta = max(1.0 - blend, 1e-4);\n let r = (1.0 - eta) / (1.0 + eta);\n let f0 = r * r;\n let cos_theta = clamp(abs(dot(n, v)), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\nfn layer_weight_facing(blend: f32, n: vec3f, v: vec3f) -> f32 {\n var facing = abs(dot(n, v));\n let b = clamp(blend, 0.0, 0.99999);\n if (b != 0.5) {\n let exponent = select(2.0 * b, 0.5 / (1.0 - b), b >= 0.5);\n facing = pow(facing, exponent);\n }\n return 1.0 - facing;\n}\n\n// \u2500\u2500\u2500 SHADER_TO_RGB (white DiffuseBSDF) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Eevee captures lit diffuse: (albedo/\u03C0)*sun*N\u00B7L*shadow + ambient (linear). Albedo=1.\n// Matches default.ts direct term scale so VALTORGB thresholds from Blender JSON stay valid.\n\nfn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> f32 {\n const PI_S: f32 = 3.141592653589793;\n let ndotl = max(dot(n, l), 0.0);\n let rgb = sun_rgb * (ndotl * shadow / PI_S) + ambient_rgb;\n return luminance_rec709_linear(rgb);\n}\n\n// \u2500\u2500\u2500 BUMP node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Screen-space bump from a scalar height field. Needs dFdx/dFdy which\n// WGSL provides as dpdx/dpdy.\n\nfn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) + dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference\nfn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// \u2500\u2500\u2500 NOISE texture (Perlin-style) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Simplified gradient noise matching Blender's default noise output.\n\n// PCG-style integer hash. Replaces the classic 'fract(sin(q) * LARGE)' trick because\n// WebKit's Metal backend compiles 'sin' to a full transcendental op (slow), while\n// Safari's Apple-GPU scalar ALU handles int muls/xors near free. Inputs arrive as\n// integer-valued floats (floor(p) + unit offsets) from _noise3, so vec3i cast is exact.\nfn _hash33(p: vec3f) -> vec3f {\n var h = vec3u(vec3i(p) + vec3i(32768));\n h = h * vec3u(1664525u, 1013904223u, 2654435761u);\n h = (h.yzx ^ h) * vec3u(2246822519u, 3266489917u, 668265263u);\n h = h ^ (h >> vec3u(16u));\n // Mask to 24 bits \u2014 above that f32 loses precision on the u32\u2192f32 convert.\n let hm = h & vec3u(16777215u);\n return vec3f(hm) * (2.0 / 16777216.0) - 1.0;\n}\n\nfn _noise3(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return mix(\n mix(\n mix(dot(_hash33(i + vec3f(0,0,0)), f - vec3f(0,0,0)),\n dot(_hash33(i + vec3f(1,0,0)), f - vec3f(1,0,0)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,0)), f - vec3f(0,1,0)),\n dot(_hash33(i + vec3f(1,1,0)), f - vec3f(1,1,0)), u.x), u.y),\n mix(\n mix(dot(_hash33(i + vec3f(0,0,1)), f - vec3f(0,0,1)),\n dot(_hash33(i + vec3f(1,0,1)), f - vec3f(1,0,1)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,1)), f - vec3f(0,1,1)),\n dot(_hash33(i + vec3f(1,1,1)), f - vec3f(1,1,1)), u.x), u.y),\n u.z);\n}\n\nfn tex_noise(p: vec3f, scale: f32, detail: f32, roughness: f32, distortion: f32) -> f32 {\n var q = p;\n if (abs(distortion) > 1e-6) {\n let w = _noise3(p * scale * 1.37 + vec3f(2.31, 5.17, 8.09));\n q = p + (w * 2.0 - 1.0) * distortion;\n }\n let coords = q * scale;\n var value = 0.0;\n var amplitude = 1.0;\n var frequency = 1.0;\n var total_amp = 0.0;\n let octaves = i32(clamp(detail, 0.0, 15.0)) + 1;\n for (var i = 0; i < octaves; i++) {\n value += amplitude * _noise3(coords * frequency);\n total_amp += amplitude;\n amplitude *= roughness;\n frequency *= 2.0;\n }\n return value / max(total_amp, 1e-6) * 0.5 + 0.5;\n}\n\n// tex_noise specialization: detail=2.0 (3 octaves), roughness=0.5, distortion=0.\n// WebKit can't unroll tex_noise's for-loop because 'octaves' is a runtime value;\n// this variant is fully unrolled with constants folded (total_amp = 1.75).\nfn tex_noise_d2(p: vec3f, scale: f32) -> f32 {\n let c = p * scale;\n let v = _noise3(c) + 0.5 * _noise3(c * 2.0) + 0.25 * _noise3(c * 4.0);\n return v * (1.0 / 1.75) * 0.5 + 0.5;\n}\n\n// \u2500\u2500\u2500 TEX_GRADIENT (linear) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Stockings preset. Maps the input vector's X to a 0\u20131 gradient.\n\nfn tex_gradient_linear(uv: vec3f) -> f32 {\n return clamp(uv.x, 0.0, 1.0);\n}\n\n// \u2500\u2500\u2500 TEX_VORONOI (distance only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Metal preset. Simplified F1 cell noise.\n\nfn tex_voronoi_f1(p: vec3f, scale: f32) -> f32 {\n let coords = p * scale;\n let i = floor(coords);\n let f = fract(coords);\n var min_dist = 1e10;\n for (var z = -1; z <= 1; z++) {\n for (var y = -1; y <= 1; y++) {\n for (var x = -1; x <= 1; x++) {\n let neighbor = vec3f(f32(x), f32(y), f32(z));\n let point = _hash33(i + neighbor) * 0.5 + 0.5;\n let diff = neighbor + point - f;\n min_dist = min(min_dist, dot(diff, diff));\n }\n }\n }\n return sqrt(min_dist);\n}\n\n// \u2500\u2500\u2500 SEPXYZ node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn separate_xyz(v: vec3f) -> vec3f { return v; }\n\n// \u2500\u2500\u2500 VECT_MATH (cross product) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn vect_math_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }\n\n// \u2500\u2500\u2500 MAPPING node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Point-type mapping: scale, rotate (euler XYZ), translate.\n\nfn mapping_point(v: vec3f, loc: vec3f, rot: vec3f, scl: vec3f) -> vec3f {\n var p = v * scl;\n // simplified: skip rotation when all angles are zero (common case)\n if (abs(rot.x) + abs(rot.y) + abs(rot.z) > 1e-6) {\n let cx = cos(rot.x); let sx = sin(rot.x);\n let cy = cos(rot.y); let sy = sin(rot.y);\n let cz = cos(rot.z); let sz = sin(rot.z);\n let rx = vec3f(p.x, cx*p.y - sx*p.z, sx*p.y + cx*p.z);\n let ry = vec3f(cy*rx.x + sy*rx.z, rx.y, -sy*rx.x + cy*rx.z);\n p = vec3f(cz*ry.x - sz*ry.y, sz*ry.x + cz*ry.y, ry.z);\n }\n return p + loc;\n}\n\n// \u2500\u2500\u2500 NORMAL_MAP node (tangent-space) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Applies a tangent-space normal map. Requires TBN from vertex stage.\n\nfn normal_map(strength: f32, map_color: vec3f, normal: vec3f, tangent: vec3f, bitangent: vec3f) -> vec3f {\n let ts = map_color * 2.0 - 1.0;\n let perturbed = normalize(tangent * ts.x + bitangent * ts.y + normal * ts.z);\n return normalize(mix(normal, perturbed, strength));\n}\n\n// \u2500\u2500\u2500 EEVEE Principled BSDF primitives \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Ports from Blender 3.6 source/blender/draw/engines/eevee/shaders/\n// bsdf_common_lib.glsl + gpu_shader_material_principled.glsl.\n// Usage pattern (see material shaders): direct spec = bsdf_ggx \u00D7 sun \u00D7 shadow\n// (NL baked in, no F yet); ambient spec = probe_radiance; tint both with\n// reflection_color = F_brdf_multi_scatter(f0, f90, split_sum) AFTER summing.\n\nconst EEVEE_PI: f32 = 3.141592653589793;\n\n// Fused analytic GGX specular (direct lights). Returns BRDF \u00D7 NL.\n// 4\u00B7NL\u00B7NV is cancelled via G1_Smith reciprocal form \u2014 see bsdf_common_lib.glsl:115.\n// Caller passes NL, NV (already computed for diffuse + brdf_lut_sample) so WebKit\n// can reuse them instead of recomputing dot products across the function boundary.\nfn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32) -> f32 {\n let a = max(roughness, 1e-4);\n let a2 = a * a;\n let H = normalize(L + V);\n let NH = max(dot(N, H), 1e-8);\n let NL = max(NL_in, 1e-8);\n let NV = max(NV_in, 1e-8);\n // G1_Smith_GGX_opti reciprocal form \u2014 denominator piece only.\n let G1L = NL + sqrt(NL * (NL - NL * a2) + a2);\n let G1V = NV + sqrt(NV * (NV - NV * a2) + a2);\n let G = G1L * G1V;\n // D_ggx_opti = pi * denom\u00B2 \u2014 reciprocal D \u00D7 a\u00B2.\n let tmp = (NH * a2 - NH) * NH + 1.0;\n let D_opti = EEVEE_PI * tmp * tmp;\n return NL * a2 / (D_opti * G);\n}\n\n// Split-sum DFG LUT \u2014 Karis 2013 curve fit stand-in for the 64\u00D764 baked LUT.\n// Returns (lut.x, lut.y) in Blender convention: tint = f0\u00B7lut.x + f90\u00B7lut.y.\nfn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {\n let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);\n let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);\n let r = roughness * c0 + c1;\n let a004 = min(r.x * r.x, exp2(-9.28 * NV)) * r.x + r.y;\n return vec2f(-1.04, 1.04) * a004 + r.zw;\n}\n\n// Baked combined BRDF LUT \u2014 exact port of Blender bsdf_lut_frag.glsl packed with\n// ltc_mag_ggx from eevee_lut.c. Single sample returns DFG (.rg) and LTC mag (.ba).\n// Addressed as Blender's common_utiltex_lib.glsl:lut_coords:\n// coords = (roughness, sqrt(1 - NV)), then half-texel bias for filtering.\n// Requires group(0) binding(9) brdfLut + binding(2) diffuseSampler in the host shader.\nfn brdf_lut_sample(NV: f32, roughness: f32) -> vec4f {\n let LUT_SIZE: f32 = 64.0;\n var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));\n uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;\n return textureSampleLevel(brdfLut, diffuseSampler, uv, 0.0);\n}\n\nfn F_brdf_single_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n return lut.y * f90 + lut.x * f0;\n}\n\n// Fdez-Ag\u00FCera 2019 multi-scatter compensation (EEVEE do_multiscatter=1).\nfn F_brdf_multi_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n let FssEss = lut.y * f90 + lut.x * f0;\n let Ess = lut.x + lut.y;\n let Ems = 1.0 - Ess;\n let Favg = f0 + (1.0 - f0) / 21.0;\n let Fms = FssEss * Favg / (1.0 - (1.0 - Ess) * Favg);\n return FssEss + Fms * Ems;\n}\n\n// EEVEE direct-specular energy compensation factor \u2014 closure_eval_glossy_lib.glsl:79-81:\n// ltc_brdf_scale = (ltc.x + ltc.y) / (split_sum.x + split_sum.y)\n// Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum;\n// direct radiance is rescaled so total-energy matches the split-sum LUT.\n// Takes a pre-sampled vec4f from brdf_lut_sample() to share the fetch with\n// F_brdf_multi_scatter on the same fragment.\nfn ltc_brdf_scale_from_lut(lut: vec4f) -> f32 {\n return (lut.z + lut.w) / max(lut.x + lut.y, 1e-6);\n}\n\n// Luminance-normalized hue extraction \u2014 Blender tint_from_color (isolates hue+sat).\nfn tint_from_color(color: vec3f) -> vec3f {\n let lum = dot(color, vec3f(0.3, 0.6, 0.1));\n return select(vec3f(1.0), color / lum, lum > 0.0);\n}\n\n\n\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct Light {\n direction: vec4f,\n color: vec4f,\n};\n\nstruct LightUniforms {\n ambientColor: vec4f,\n lights: array<Light, 4>,\n};\n\nstruct MaterialUniforms {\n diffuseColor: vec3f,\n alpha: f32,\n};\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) normal: vec3f,\n @location(1) uv: vec2f,\n @location(2) worldPos: vec3f,\n};\n\nstruct LightVP { viewProj: mat4x4f, };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var diffuseSampler: sampler;\n@group(0) @binding(3) var shadowMap: texture_depth_2d;\n@group(0) @binding(4) var shadowSampler: sampler_comparison;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;\n@group(2) @binding(1) var<uniform> material: MaterialUniforms;\n\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\n // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.\n if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }\n let biasedPos = worldPos + n * 0.08;\n let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let cmpZ = ndc.z - 0.001;\n let ts = 1.0 / 2048.0;\n // 3x3 PCF unrolled \u2014 Safari's Metal backend doesn't unroll nested shadow loops reliably.\n let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);\n let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);\n let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);\n let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);\n let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);\n let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);\n let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);\n let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);\n let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);\n return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);\n}\n\nconst PI_H: f32 = 3.141592653589793;\nconst HAIR_SPECULAR: f32 = 1.0;\nconst HAIR_ROUGHNESS: f32 = 0.3;\nconst HAIR_TEX_GATE_THRESH: f32 = 0.15000000596046448;\nconst HAIR_RIM2_POW: f32 = 0.6300000548362732;\nconst HAIR_MIX_BG: vec3f = vec3f(0.1673291176557541);\n\n@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\n @location(2) uv: vec2f,\n @location(3) joints0: vec4<u32>,\n @location(4) weights0: vec4<f32>\n) -> VertexOutput {\n var output: VertexOutput;\n let pos4 = vec4f(position, 1.0);\n let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n var skinnedPos = vec4f(0.0);\n var skinnedNrm = vec3f(0.0);\n for (var i = 0u; i < 4u; i++) {\n let m = skinMats[joints0[i]];\n let w = nw[i];\n skinnedPos += (m * pos4) * w;\n skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;\n }\n output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);\n // Skip VS normalize \u2014 interpolation denormalizes anyway, and FS always does normalize(input.normal).\n output.normal = skinnedNrm;\n output.uv = uv;\n output.worldPos = skinnedPos.xyz;\n return output;\n}\n\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) mask: f32,\n};\n\n@fragment fn fs(input: VertexOutput) -> FSOut {\n let alpha = material.alpha;\n if (alpha < 0.001) { discard; }\n\n let n = normalize(input.normal);\n let v = normalize(camera.viewPos - input.worldPos);\n let l = -light.lights[0].direction.xyz;\n let sun = light.lights[0].color.xyz * light.lights[0].color.w;\n\n let tex_color = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;\n let shadow = sampleShadow(input.worldPos, n);\n\n let hue_sat_shadow = hue_sat_id(1.2, 0.5, 1.0, tex_color);\n let hue_sat_002 = hue_sat(0.48, 1.2, 0.7, 1.0, hue_sat_shadow);\n let hue_sat_001 = hue_sat_id(1.5, 1.0, 1.0, tex_color);\n\n let ndotl_raw = shader_to_rgb_diffuse(n, l, sun, light.ambientColor.xyz, shadow);\n let ramp_008 = ramp_constant(ndotl_raw, 0.0, vec4f(0,0,0,1), 0.2966, vec4f(1,1,1,1)).r;\n\n let mix_004 = mix_blend(ramp_008, hue_sat_002, hue_sat_001);\n let bc = bright_contrast(mix_004, 0.1, 0.2);\n\n let bevel_z = clamp(n.y, 0.0, 1.0);\n let mix_003 = mix_blend(bevel_z, bc, hue_sat_002);\n\n let rim2_raw = fresnel(1.45, n, v) * layer_weight_fresnel(0.61, n, v);\n let rim2_fac = math_power(rim2_raw, HAIR_RIM2_POW);\n let mix_shader_002 = mix(mix_003, HAIR_MIX_BG, rim2_fac);\n\n // Blender's GREATER_THAN converts Color\u2192Float via BT.601 luminance, not raw R \u2014 same\n // socket-semantic fix as M_Face.\n let tex_gate = math_greater_than(color_to_value(tex_color), HAIR_TEX_GATE_THRESH);\n let gate_emit = vec3f(tex_gate) * 0.1;\n\n let add_shader = mix_shader_002 + gate_emit;\n\n // Principled BSDF (EEVEE port): metallic=0, specular=1.0, roughness=0.3, specular_tint=0.\n // Graph has a noise\u2192normal_map bump (Strength=0.1) on Principled.Normal, but MixShader.001\n // weights Principled at only 0.2 \u2014 the bumped spec \u00D7 that weight is imperceptible, so we\n // drop the subtree and keep plain n (saves a tex_noise + bump_lh per hair fragment).\n let NL = max(dot(n, l), 0.0);\n let NV = max(dot(n, v), 1e-4);\n\n let f0 = vec3f(0.08 * HAIR_SPECULAR);\n let f90 = mix(f0, vec3f(1.0), sqrt(HAIR_SPECULAR));\n let brdf_lut = brdf_lut_sample(NV, HAIR_ROUGHNESS);\n let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);\n\n let spec_direct = bsdf_ggx(n, l, v, NL, NV, HAIR_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);\n let spec_indirect = light.ambientColor.xyz;\n let spec_radiance = (spec_direct + spec_indirect) * reflection_color;\n\n // Indirect diffuse = base_color \u00D7 L_w per Blender closure_eval_surface_lib.glsl line 302;\n // probe_evaluate_world_diff returns radiance (SH-projected, not cosine-convolved).\n let diffuse_radiance = bc * (sun * NL * shadow / PI_H + light.ambientColor.xyz);\n let principled = diffuse_radiance + spec_radiance;\n\n let final_color = mix(add_shader, principled, 0.2);\n\n var out: FSOut;\n out.color = vec4f(final_color, alpha);\n out.mask = 1.0;\n return out;\n}\n\n";
2
2
  //# sourceMappingURL=hair.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"hair.d.ts","sourceRoot":"","sources":["../../src/shaders/hair.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,gBAAgB,g49BAgM5B,CAAA"}
1
+ {"version":3,"file":"hair.d.ts","sourceRoot":"","sources":["../../src/shaders/hair.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,gBAAgB,g34BA4K5B,CAAA"}