reze-engine 0.60.5 → 0.61.0

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 (95) hide show
  1. package/dist/_dbg-loader.mjs +1100 -0
  2. package/dist/engine.d.ts +110 -0
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +258 -7
  5. package/dist/graph/compile.d.ts.map +1 -1
  6. package/dist/graph/compile.js +4 -1
  7. package/dist/graph/registry.d.ts.map +1 -1
  8. package/dist/graph/registry.js +14 -2
  9. package/dist/graph/slots.d.ts +1 -1
  10. package/dist/graph/slots.d.ts.map +1 -1
  11. package/dist/graph/slots.js +16 -6
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/physics-debug.d.ts +30 -0
  15. package/dist/physics-debug.d.ts.map +1 -0
  16. package/dist/physics-debug.js +526 -0
  17. package/dist/shaders/lights.d.ts.map +1 -1
  18. package/dist/shaders/lights.js +4 -0
  19. package/dist/shaders/materials/body.d.ts +2 -0
  20. package/dist/shaders/materials/body.d.ts.map +1 -0
  21. package/dist/shaders/materials/body.js +95 -0
  22. package/dist/shaders/materials/cloth_rough.d.ts +2 -0
  23. package/dist/shaders/materials/cloth_rough.d.ts.map +1 -0
  24. package/dist/shaders/materials/cloth_rough.js +69 -0
  25. package/dist/shaders/materials/cloth_smooth.d.ts +2 -0
  26. package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -0
  27. package/dist/shaders/materials/cloth_smooth.js +61 -0
  28. package/dist/shaders/materials/common.d.ts.map +1 -1
  29. package/dist/shaders/materials/common.js +47 -0
  30. package/dist/shaders/materials/default.d.ts +2 -0
  31. package/dist/shaders/materials/default.d.ts.map +1 -0
  32. package/dist/shaders/materials/default.js +43 -0
  33. package/dist/shaders/materials/eye.d.ts +2 -0
  34. package/dist/shaders/materials/eye.d.ts.map +1 -0
  35. package/dist/shaders/materials/eye.js +60 -0
  36. package/dist/shaders/materials/face.d.ts +2 -0
  37. package/dist/shaders/materials/face.d.ts.map +1 -0
  38. package/dist/shaders/materials/face.js +95 -0
  39. package/dist/shaders/materials/hair.d.ts +2 -0
  40. package/dist/shaders/materials/hair.d.ts.map +1 -0
  41. package/dist/shaders/materials/hair.js +90 -0
  42. package/dist/shaders/materials/metal.d.ts +2 -0
  43. package/dist/shaders/materials/metal.d.ts.map +1 -0
  44. package/dist/shaders/materials/metal.js +77 -0
  45. package/dist/shaders/materials/mmd_classic.d.ts +2 -0
  46. package/dist/shaders/materials/mmd_classic.d.ts.map +1 -0
  47. package/dist/shaders/materials/mmd_classic.js +66 -0
  48. package/dist/shaders/materials/nodes.d.ts +1 -1
  49. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  50. package/dist/shaders/materials/nodes.js +81 -18
  51. package/dist/shaders/materials/stockings.d.ts +2 -0
  52. package/dist/shaders/materials/stockings.d.ts.map +1 -0
  53. package/dist/shaders/materials/stockings.js +122 -0
  54. package/dist/shaders/passes/composite.d.ts +1 -1
  55. package/dist/shaders/passes/composite.d.ts.map +1 -1
  56. package/dist/shaders/passes/composite.js +34 -2
  57. package/dist/shaders/passes/physics-debug.d.ts +2 -0
  58. package/dist/shaders/passes/physics-debug.d.ts.map +1 -0
  59. package/dist/shaders/passes/physics-debug.js +69 -0
  60. package/dist/shaders/scene-tap.js +1 -1
  61. package/package.json +1 -1
  62. package/src/engine.ts +288 -7
  63. package/src/graph/compile.ts +4 -1
  64. package/src/graph/registry.ts +14 -2
  65. package/src/graph/slots.ts +17 -5
  66. package/src/index.ts +4 -0
  67. package/src/shaders/lights.ts +4 -0
  68. package/src/shaders/materials/common.ts +47 -0
  69. package/src/shaders/materials/nodes.ts +81 -18
  70. package/src/shaders/passes/composite.ts +34 -2
  71. package/src/shaders/scene-tap.ts +1 -1
  72. package/dist/graph/presets/pool_floor.d.ts +0 -3
  73. package/dist/graph/presets/pool_floor.d.ts.map +0 -1
  74. package/dist/graph/presets/pool_floor.js +0 -42
  75. package/dist/graph/presets/water.d.ts +0 -3
  76. package/dist/graph/presets/water.d.ts.map +0 -1
  77. package/dist/graph/presets/water.js +0 -31
  78. package/dist/physics/autofit.d.ts +0 -147
  79. package/dist/physics/autofit.d.ts.map +0 -1
  80. package/dist/physics/autofit.js +0 -501
  81. package/dist/shaders/passes/field-blit.d.ts +0 -26
  82. package/dist/shaders/passes/field-blit.d.ts.map +0 -1
  83. package/dist/shaders/passes/field-blit.js +0 -65
  84. package/dist/shaders/passes/ground-noise.d.ts +0 -7
  85. package/dist/shaders/passes/ground-noise.d.ts.map +0 -1
  86. package/dist/shaders/passes/ground-noise.js +0 -88
  87. package/dist/shaders/passes/sim.d.ts +0 -34
  88. package/dist/shaders/passes/sim.d.ts.map +0 -1
  89. package/dist/shaders/passes/sim.js +0 -169
  90. package/dist/shaders/score-api.d.ts +0 -10
  91. package/dist/shaders/score-api.d.ts.map +0 -1
  92. package/dist/shaders/score-api.js +0 -114
  93. package/dist/shaders/shadow-api.d.ts +0 -29
  94. package/dist/shaders/shadow-api.d.ts.map +0 -1
  95. package/dist/shaders/shadow-api.js +0 -112
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metal.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/metal.ts"],"names":[],"mappings":"AAaA,eAAO,MAAM,iBAAiB,8ilCAiE7B,CAAA"}
@@ -0,0 +1,77 @@
1
+ // M_Metal — Metallic Principled (Metallic=1.0, Specular=1.0, Roughness=0.3) with a
2
+ // reflection-coord Voronoi pattern driving base color for metallic sparkle, plus an
3
+ // NPR toon/overlay emission stack mixed at MixShader Fac=0.6967.
4
+ //
5
+ // Graph's base color chain: 纹理坐标.Reflection → 矢量运算.007(CROSS, Vec2=(0,1,0)) →
6
+ // 沃罗诺伊纹理(F1, Color out) → 颜色渐变(linear) → 混合.005. The dumper did not capture
7
+ // the VectorMath op — CROSS is assumed based on the hardcoded (0,1,0) Vector_001
8
+ // constant (MULTIPLY would zero X/Z producing 1D bands; CROSS produces horizontal ring
9
+ // patterns consistent with metallic anisotropy).
10
+ import { NODES_WGSL } from "./nodes";
11
+ import { COMMON_MATERIAL_PRELUDE_WGSL } from "./common";
12
+ export const METAL_SHADER_WGSL = /* wgsl */ `
13
+
14
+ ${NODES_WGSL}
15
+ ${COMMON_MATERIAL_PRELUDE_WGSL}
16
+
17
+ const METAL_SPECULAR: f32 = 1.0;
18
+ const METAL_METALLIC: f32 = 1.0;
19
+ const METAL_ROUGHNESS: f32 = 0.3;
20
+ const METAL_TOON_EDGE: f32 = 0.2966;
21
+ const METAL_MIX04_MUL: f32 = 0.5;
22
+ const METAL_EMIT_STR: f32 = 8.100000381469727;
23
+ const METAL_MIX_SHADER_FAC: f32 = 0.6967;
24
+ const METAL_VORONOI_SCALE: f32 = 4.3;
25
+
26
+ @fragment fn fs(input: VertexOutput) -> FSOut {
27
+ let n = safe_normal(input.normal);
28
+ let v = normalize(camera.viewPos - input.worldPos);
29
+ let l = -light.lights[0].direction.xyz;
30
+ let sun = light.lights[0].color.xyz * light.lights[0].color.w;
31
+ let amb = light.ambientColor.xyz;
32
+ let shadow = sampleShadow(input.worldPos, n);
33
+
34
+ let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);
35
+ let tex_rgb = tex_s.rgb;
36
+ let out_alpha = material.alpha * tex_s.a;
37
+ if (out_alpha < 0.001) { discard; }
38
+
39
+ // ═══ NPR STACK ═══
40
+ let tex_tint = hue_sat_id(1.0, 0.800000011920929, 1.0, tex_rgb);
41
+ let lum_shade = shader_to_rgb_diffuse(n, l, sun, amb, shadow);
42
+ let ramp008 = ramp_constant_edge_aa(lum_shade, METAL_TOON_EDGE, vec4f(0,0,0,1), vec4f(1,1,1,1));
43
+ let mix04_fac = math_multiply(ramp008.r, METAL_MIX04_MUL);
44
+
45
+ let dark_tex = hue_sat_id(1.0, 0.19999998807907104, 1.0, tex_tint);
46
+ let mix04 = mix_blend(mix04_fac, dark_tex, tex_tint);
47
+
48
+ let hue004 = hue_sat_id(1.0, 2.0, 1.0, mix04);
49
+ let npr_rgb = mix_overlay(1.0, mix04, hue004);
50
+ let npr_emission = npr_rgb * METAL_EMIT_STR;
51
+
52
+ // ═══ PRINCIPLED BSDF (metallic=1, voronoi-driven base) ═══
53
+ // Reflection-coord Voronoi produces the metallic sparkle variation.
54
+ // VALTORGB takes Color → Fac via Blender's BT.601 implicit color_to_value.
55
+ let refl_dir = reflect(-v, n);
56
+ let voro_input = cross(refl_dir, vec3f(0.0, 1.0, 0.0));
57
+ let voro_rgb = tex_voronoi_color(voro_input, METAL_VORONOI_SCALE);
58
+ let voro_scalar = color_to_value(voro_rgb);
59
+ let voro_ramp = ramp_linear(voro_scalar, 0.0, vec4f(0,0,0,1), 1.0, vec4f(1,1,1,1)).r;
60
+ let hue006 = hue_sat_id(1.5, 1.2999999523162842, 1.0, tex_tint);
61
+ let albedo = mix_blend(voro_ramp, vec3f(voro_ramp), hue006);
62
+
63
+ // metallic=1 collapses f0 = mix(dielectric, albedo, 1) = albedo; diffuse_weight = 0.
64
+ let principled = eval_principled(
65
+ PrincipledIn(albedo, METAL_METALLIC, METAL_SPECULAR, METAL_ROUGHNESS, 1e30, 0.0, 0.0),
66
+ n, l, v, sun, amb, shadow
67
+ );
68
+
69
+ let final_color = mix(npr_emission, principled, METAL_MIX_SHADER_FAC);
70
+
71
+ var out: FSOut;
72
+ out.color = vec4f(final_color, out_alpha);
73
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
74
+ return out;
75
+ }
76
+
77
+ `;
@@ -0,0 +1,2 @@
1
+ export declare const MMD_CLASSIC_SHADER_WGSL = "\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\n// Per-material uniforms. Every material binds this layout even if it ignores fields;\n// the engine keeps one bind group layout across all material pipelines. The PMX\n// classic-material fields (ambient/specular/toon/sphere) are consumed by the\n// mmd_classic preset; NPR presets ignore them.\nstruct MaterialUniforms {\n diffuseColor: vec3f, // PMX diffuse rgb (mmd_classic); reserved for others\n alpha: f32, // 0 \u2192 discard; <1 \u2192 transparent draw call\n ambient: vec3f, // PMX ambient rgb\n shininess: f32, // PMX specular power\n specular: vec3f, // PMX specular rgb\n sphereMode: f32, // 0 none \u00B7 1 multiply (sph) \u00B7 2 add (spa)\n // Skeleton index of the \u982D (head) bone, or -1. Lets the eye shader gate\n // the post-alpha-eye stencil by camera-vs-face hemisphere.\n headBoneIndex: f32,\n _pad0: f32,\n _pad1: f32,\n _pad2: 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 // Bind-pose object-space position (the raw pre-skin vertex attribute). Procedural\n // textures (noise bump, sparkle, Generated-coord gradients) key off this instead of\n // worldPos so the pattern rides with the surface \u2014 otherwise the mesh swims through a\n // world-static noise field under any skinning deformation or root (\u30BB\u30F3\u30BF\u30FC) motion.\n // At rest skinMats are identity so restPos == worldPos, which is why existing noise-\n // scale constants stay valid without retuning.\n @location(3) restPos: 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// binding(9) brdfLut is declared inside NODES_WGSL (nodes.ts).\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// mmd_classic inputs; other presets leave them unread (fallback 1\u00D71 whites).\n@group(2) @binding(2) var toonTexture: texture_2d<f32>;\n@group(2) @binding(3) var sphereTexture: texture_2d<f32>;\n\n// Four-bone blended normals can cancel to ~zero on physics-driven parts\n// (opposing bone rotations at 50/50 weights) \u2014 normalize(0) is 0/0 = NaN,\n// which poisons the whole shading stack and flashes through bloom. Fall\n// back to up for degenerate normals instead.\nfn safe_normal(nIn: vec3f) -> vec3f {\n let l2 = dot(nIn, nIn);\n if (l2 < 1e-12) { return vec3f(0.0, 1.0, 0.0); }\n return nIn * inverseSqrt(l2);\n}\n\n\n\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\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 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\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 output.normal = skinnedNrm;\n output.uv = uv;\n output.worldPos = skinnedPos.xyz;\n output.restPos = position;\n return output;\n}\n\n\n\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) mask: vec4f,\n};\n\n\n\n@fragment fn fs(input: VertexOutput) -> FSOut {\n let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);\n // MMD alpha semantics: material alpha \u00D7 texture alpha.\n let alpha = material.alpha * tex_s.a;\n if (alpha < 0.001) { discard; }\n\n let n = safe_normal(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 let amb = light.ambientColor.xyz;\n let shadow = sampleShadow(input.worldPos, n);\n\n // Sphere-map UV from the view-space normal \u2014 sampled up front to keep the\n // texture reads in uniform control flow.\n let vn = normalize((camera.view * vec4f(n, 0.0)).xyz);\n let sph_uv = vec2f(vn.x * 0.5 + 0.5, 0.5 - vn.y * 0.5);\n let sphere_rgb = textureSample(sphereTexture, diffuseSampler, sph_uv).rgb;\n\n // Toon ramp: v runs light (0) \u2192 shadow (1); the ramp texture carries the\n // terminator shape and shadow tint. Self-shadow pushes toward the shadow\n // end by attenuating N\u00B7L. Sampled up front for the same uniformity reason.\n let ndl = dot(n, l) * shadow;\n let toon_v = clamp(0.5 - 0.5 * ndl, 0.0, 1.0);\n let toon_rgb = textureSample(toonTexture, diffuseSampler, vec2f(0.5, toon_v)).rgb;\n\n // MMD base: saturate(ambient + diffuse \u00D7 light color), modulated by texture.\n // The engine's ambient light scales the material-ambient floor so scene\n // lighting still has authority over overall exposure.\n let base = clamp(material.ambient * max(amb, vec3f(0.35)) + material.diffuseColor * sun, vec3f(0.0), vec3f(1.0));\n var color = base * tex_s.rgb * toon_rgb;\n\n // Sphere map: 1 = sph (multiply), 2 = spa (add). Mode 3 (sub-texture) and\n // 0 fall through unchanged.\n if (material.sphereMode == 1.0) {\n color *= sphere_rgb;\n } else if (material.sphereMode == 2.0) {\n color += sphere_rgb;\n }\n\n // Blinn-Phong specular with PMX shininess. shininess 0 disables (many\n // rigs author 0 to mean \"no highlight\").\n if (material.shininess > 0.0) {\n let h = normalize(l + v);\n let spec = pow(max(dot(n, h), 0.0), max(material.shininess, 1.0));\n color += material.specular * sun * (spec * shadow);\n }\n\n var out: FSOut;\n out.color = vec4f(color, alpha);\n out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);\n return out;\n}\n\n";
2
+ //# sourceMappingURL=mmd_classic.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mmd_classic.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/mmd_classic.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,uBAAuB,gvPA0DnC,CAAA"}
@@ -0,0 +1,66 @@
1
+ // M_MMDClassic — authentic MMD/MikuMikuDance fixed-pipeline material. Uses the
2
+ // PMX material data every model author actually tuned: ambient + diffuse×light
3
+ // base, toon-ramp shading (ramp carries the terminator, NOT an N·L multiply),
4
+ // sphere map (sph multiply / spa add), and Blinn-Phong specular with the PMX
5
+ // shininess. This is the universal fallback for materials no preset map or
6
+ // name heuristic covers — it renders what the author saw in MMD/PMXEditor.
7
+ import { COMMON_MATERIAL_PRELUDE_WGSL } from "./common";
8
+ export const MMD_CLASSIC_SHADER_WGSL = /* wgsl */ `
9
+
10
+ ${COMMON_MATERIAL_PRELUDE_WGSL}
11
+
12
+ @fragment fn fs(input: VertexOutput) -> FSOut {
13
+ let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);
14
+ // MMD alpha semantics: material alpha × texture alpha.
15
+ let alpha = material.alpha * tex_s.a;
16
+ if (alpha < 0.001) { discard; }
17
+
18
+ let n = safe_normal(input.normal);
19
+ let v = normalize(camera.viewPos - input.worldPos);
20
+ let l = -light.lights[0].direction.xyz;
21
+ let sun = light.lights[0].color.xyz * light.lights[0].color.w;
22
+ let amb = light.ambientColor.xyz;
23
+ let shadow = sampleShadow(input.worldPos, n);
24
+
25
+ // Sphere-map UV from the view-space normal — sampled up front to keep the
26
+ // texture reads in uniform control flow.
27
+ let vn = normalize((camera.view * vec4f(n, 0.0)).xyz);
28
+ let sph_uv = vec2f(vn.x * 0.5 + 0.5, 0.5 - vn.y * 0.5);
29
+ let sphere_rgb = textureSample(sphereTexture, diffuseSampler, sph_uv).rgb;
30
+
31
+ // Toon ramp: v runs light (0) → shadow (1); the ramp texture carries the
32
+ // terminator shape and shadow tint. Self-shadow pushes toward the shadow
33
+ // end by attenuating N·L. Sampled up front for the same uniformity reason.
34
+ let ndl = dot(n, l) * shadow;
35
+ let toon_v = clamp(0.5 - 0.5 * ndl, 0.0, 1.0);
36
+ let toon_rgb = textureSample(toonTexture, diffuseSampler, vec2f(0.5, toon_v)).rgb;
37
+
38
+ // MMD base: saturate(ambient + diffuse × light color), modulated by texture.
39
+ // The engine's ambient light scales the material-ambient floor so scene
40
+ // lighting still has authority over overall exposure.
41
+ let base = clamp(material.ambient * max(amb, vec3f(0.35)) + material.diffuseColor * sun, vec3f(0.0), vec3f(1.0));
42
+ var color = base * tex_s.rgb * toon_rgb;
43
+
44
+ // Sphere map: 1 = sph (multiply), 2 = spa (add). Mode 3 (sub-texture) and
45
+ // 0 fall through unchanged.
46
+ if (material.sphereMode == 1.0) {
47
+ color *= sphere_rgb;
48
+ } else if (material.sphereMode == 2.0) {
49
+ color += sphere_rgb;
50
+ }
51
+
52
+ // Blinn-Phong specular with PMX shininess. shininess 0 disables (many
53
+ // rigs author 0 to mean "no highlight").
54
+ if (material.shininess > 0.0) {
55
+ let h = normalize(l + v);
56
+ let spec = pow(max(dot(n, h), 0.0), max(material.shininess, 1.0));
57
+ color += material.specular * sun * (spec * shadow);
58
+ }
59
+
60
+ var out: FSOut;
61
+ out.color = vec4f(color, alpha);
62
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
63
+ return out;
64
+ }
65
+
66
+ `;
@@ -1,2 +1,2 @@
1
- export declare const NODES_WGSL = "\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\n// LINEAR ramp, three stops. Both halves are evaluated and selected between rather\n// than branched on, so there is no divergence; saturate outside each span already\n// gives Blender's clamp-to-end-stop behaviour.\nfn ramp_linear3(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f, p2: f32, c2: vec4f) -> vec4f {\n let lo = mix(c0, c1, saturate((f - p0) / max(p1 - p0, 1e-6)));\n let hi = mix(c1, c2, saturate((f - p1) / max(p2 - p1, 1e-6)));\n return select(lo, hi, f >= p1);\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\n// Transcribed from Blender 5.2's gpu_shader_common_math.glsl, including the\n// safe-guards \u2014 a Math node that divides by zero yields 0 there, not inf, and a\n// graph written against that behaviour relies on it.\nfn math_add(a: f32, b: f32) -> f32 { return a + b; }\nfn math_subtract(a: f32, b: f32) -> f32 { return a - b; }\nfn math_multiply(a: f32, b: f32) -> f32 { return a * b; }\nfn math_divide(a: f32, b: f32) -> f32 { return select(0.0, a / b, b != 0.0); }\nfn math_multiply_add(a: f32, b: f32, c: f32) -> f32 { return a * b + c; }\nfn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }\nfn math_logarithm(a: f32, b: f32) -> f32 { return select(0.0, log(a) / log(b), a > 0.0 && b > 0.0 && b != 1.0); }\nfn math_sqrt(a: f32) -> f32 { return sqrt(max(a, 0.0)); }\nfn math_inversesqrt(a: f32) -> f32 { return inverseSqrt(max(a, 1e-8)); }\nfn math_absolute(a: f32) -> f32 { return abs(a); }\nfn math_exponent(a: f32) -> f32 { return exp(a); }\nfn math_minimum(a: f32, b: f32) -> f32 { return min(a, b); }\nfn math_maximum(a: f32, b: f32) -> f32 { return max(a, b); }\nfn math_less_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a < b); }\nfn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }\nfn math_sign(a: f32) -> f32 { return sign(a); }\n// The tolerance floors at 1e-5 in Blender, so a zero third input still compares.\nfn math_compare(a: f32, b: f32, c: f32) -> f32 { return select(0.0, 1.0, abs(a - b) <= max(c, 1e-5)); }\nfn math_smooth_min(a: f32, b: f32, c: f32) -> f32 {\n if (c == 0.0) { return min(a, b); }\n let h = max(c - abs(a - b), 0.0) / c;\n return min(a, b) - h * h * h * c * (1.0 / 6.0);\n}\nfn math_smooth_max(a: f32, b: f32, c: f32) -> f32 { return -math_smooth_min(-a, -b, c); }\nfn math_round(a: f32) -> f32 { return floor(a + 0.5); }\nfn math_floor(a: f32) -> f32 { return floor(a); }\nfn math_ceil(a: f32) -> f32 { return ceil(a); }\nfn math_truncate(a: f32) -> f32 { return trunc(a); }\nfn math_fraction(a: f32) -> f32 { return a - floor(a); }\n// Blender's MODULO is fmod (truncated), not floorMod \u2014 the sign follows a.\nfn math_modulo(a: f32, b: f32) -> f32 { return select(0.0, a - b * trunc(a / b), b != 0.0); }\nfn math_floored_modulo(a: f32, b: f32) -> f32 { return select(0.0, a - floor(a / b) * b, b != 0.0); }\nfn math_snap(a: f32, b: f32) -> f32 { return floor(select(0.0, a / b, b != 0.0)) * b; }\nfn math_wrap(a: f32, b: f32, c: f32) -> f32 {\n let range = b - c;\n return select(c, a - range * floor((a - c) / range), range != 0.0);\n}\nfn math_pingpong(a: f32, b: f32) -> f32 {\n return select(0.0, abs(fract((a - b) / (b * 2.0)) * b * 2.0 - b), b != 0.0);\n}\nfn math_sine(a: f32) -> f32 { return sin(a); }\nfn math_cosine(a: f32) -> f32 { return cos(a); }\nfn math_tangent(a: f32) -> f32 { return tan(a); }\nfn math_arcsine(a: f32) -> f32 { return asin(clamp(a, -1.0, 1.0)); }\nfn math_arccosine(a: f32) -> f32 { return acos(clamp(a, -1.0, 1.0)); }\nfn math_arctangent(a: f32) -> f32 { return atan(a); }\nfn math_arctan2(a: f32, b: f32) -> f32 { return atan2(a, b); }\nfn math_radians(a: f32) -> f32 { return radians(a); }\nfn math_degrees(a: f32) -> f32 { return degrees(a); }\n\n// \u2500\u2500\u2500 RGB Curves, as five samples \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\n/**\n * Blender's RGB Curves holds arbitrary control points, which cannot cross into a\n * shader as sockets. So the curve arrives SAMPLED: five values at t = 0, .25,\n * .5, .75, 1, and this rebuilds it with the same monotone (Fritsch-Carlson)\n * interpolation the Filmic LUT uses \u2014 C1 continuous, and it cannot overshoot,\n * which matters because a curve that overshoots turns a highlight roll-off into\n * a bright ring.\n *\n * Five points captures the levels-and-contrast adjustments these curves are\n * actually used for. A curve with more structure than that will not survive, and\n * should be a ramp instead.\n */\nfn curve_slope(d0: f32, d1: f32) -> f32 {\n if (d0 * d1 <= 0.0) { return 0.0; }\n let m = (d0 + d1) * 0.5;\n return sign(d0) * min(abs(m), 2.0 * min(abs(d0), abs(d1)));\n}\nfn curve5(t0: f32, y0: f32, y1: f32, y2: f32, y3: f32, y4: f32) -> f32 {\n let t = clamp(t0, 0.0, 1.0) * 4.0;\n let i = min(floor(t), 3.0);\n let f = t - i;\n // NO local array, deliberately. This used to build array<f32, 5> and index it\n // with the runtime k \u2014 and a dynamic index on function-local memory is the one\n // construct this codebase has already caught Metal lowering to a\n // per-invocation copy plus a switch (the filmic LUT note in composite.ts).\n // rgb_curve calls this three times per node per pixel, so on a graph-heavy\n // close-up that lowering was paid in the hottest loop the frame has. Four\n // segments select cleanly: the values below are the SAME subtractions the\n // array indexing produced, chosen by k instead of loaded through it \u2014\n // bit-identical results, no local memory, nothing for the backend to spill.\n let k = i32(i);\n let s0 = y1 - y0;\n let s1 = y2 - y1;\n let s2 = y3 - y2;\n let s3 = y4 - y3;\n let pa = select(select(y0, y1, k == 1), select(y2, y3, k == 3), k >= 2);\n let pb = select(select(y1, y2, k == 1), select(y3, y4, k == 3), k >= 2);\n // Secants either side of each knot, clamped at the ends.\n let dHere = pb - pa;\n let dPrev = select(s0, select(s1, s2, k == 3), k >= 2);\n let dNext = select(select(s1, s2, k == 1), s3, k >= 2);\n let m0 = curve_slope(dPrev, dHere);\n let m1 = curve_slope(dHere, dNext);\n let f2 = f * f;\n let f3 = f2 * f;\n return (2.0 * f3 - 3.0 * f2 + 1.0) * pa + (f3 - 2.0 * f2 + f) * m0 +\n (-2.0 * f3 + 3.0 * f2) * pb + (f3 - f2) * m1;\n}\nfn rgb_curve(c: vec3f, y0: f32, y1: f32, y2: f32, y3: f32, y4: f32) -> vec3f {\n return vec3f(\n curve5(c.r, y0, y1, y2, y3, y4),\n curve5(c.g, y0, y1, y2, y3, y4),\n curve5(c.b, y0, y1, y2, y3, y4),\n );\n}\n\n// \u2500\u2500\u2500 Normal Map / Vector Transform \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\n/**\n * Normal Map, with the tangent frame derived rather than read.\n *\n * Blender reads a per-vertex tangent; PMX files do not carry one, so there is\n * nothing to read. The frame is rebuilt per-pixel from screen-space derivatives\n * of position and UV \u2014 the standard substitute, exact on flat-ish shading and\n * off only where UVs are severely sheared. Cheaper than the alternative (running\n * a tangent-generation pass over every imported mesh) and invisible on the\n * character work this is for.\n */\nfn node_normal_map(color: vec3f, strength: f32, N: vec3f, worldPos: vec3f, uv: vec2f) -> vec3f {\n let dp1 = dpdx(worldPos);\n let dp2 = dpdy(worldPos);\n let duv1 = dpdx(uv);\n let duv2 = dpdy(uv);\n let det = duv1.x * duv2.y - duv2.x * duv1.y;\n if (abs(det) < 1e-12) { return N; }\n let inv = 1.0 / det;\n let T = safe_normal((dp1 * duv2.y - dp2 * duv1.y) * inv);\n let B = safe_normal(cross(N, T));\n // Tangent-space [0,1] \u2192 [-1,1], then into world.\n let t = color * 2.0 - vec3f(1.0);\n let mapped = safe_normal(T * t.x + B * t.y + N * t.z);\n return safe_normal(mix(N, mapped, clamp(strength, 0.0, 1.0)));\n}\n\n/** Vector Transform, world \u2194 camera. Object space IS world here: a PMX model is\n * placed by its own transform and shaded in world space, so there is no\n * separate object frame to convert into. */\nfn vector_world_to_camera(v: vec3f) -> vec3f { return (camera.view * vec4f(v, 0.0)).xyz; }\nfn vector_camera_to_world(v: vec3f) -> vec3f { return (transpose(camera.view) * vec4f(v, 0.0)).xyz; }\nfn point_world_to_camera(p: vec3f) -> vec3f { return (camera.view * vec4f(p, 1.0)).xyz; }\n\n// \u2500\u2500\u2500 Style-group image maps (Image Texture on a non-PMX slot) \u2500\u2500\u2500\u2500\u2500\u2500\n// One function per slot because WGSL has no texture arrays-of-bindings here; the\n// slot is topology, exactly like a Math node's operation.\n\n/**\n * The world's radiance arriving from a direction \u2014 what a reflection sees.\n *\n * The engine's ambient is irradiance: the SH fit of the sky integrated over a\n * hemisphere, evaluated along the NORMAL. That is the right answer for diffuse\n * light and the wrong one for a mirror, which needs radiance along the\n * REFLECTION vector and needs it sharp. A stage ripped from a game asks for\n * this on nearly every surface \u2014 it is what a Unity material's DEFAULT_REFLECTION\n * keyword means \u2014 \n * and so does every metal the app has ever drawn.\n *\n * ROUGHNESS PICKS A MIP, which is the cheap prefilter: the chain is built by\n * successive halving at upload, so a level is a box average of the sky over a\n * widening solid angle. A cosine-lobe convolution would be more correct; the\n * visible difference is confined to the middle of the roughness range, and the\n * chain costs one downsample per level instead of a render pass per level.\n *\n * Without an HDRI there is nothing to reflect but the flat world colour, which\n * is what the ambient already is \u2014 so the two agree and a scene without a sky\n * texture sees no change at all.\n */\nfn rzWorldSpecular(dir: vec3f, roughness: f32) -> vec3f {\n // A SKY TEXTURE, or the best the scene has. The flag is 2 when an HDRI is\n // installed, 1 when the world is a gradient fitted to the same harmonics,\n // and 0 when it is one flat colour \u2014 and in the last two cases the ambient\n // already answers by direction, which is a blurred sky and exactly what a\n // world without a picture can honestly reflect.\n if (light.sh[0].w < 1.5) { return rzWorldAmbient(normalize(dir)); }\n let d = normalize(dir);\n // The composite's own convention, restated: LH world with +Z forward, which\n // is what makes a reflection line up with the dome the viewer can see.\n let u = 0.5 + atan2(d.x, d.z) * 0.15915494309;\n let v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) * 0.31830988618;\n let levels = f32(textureNumLevels(worldEnvTexture) - 1u);\n // sqrt, not linear: mip n covers roughly twice the angle of n-1, so a linear\n // ramp spends most of its travel in the blurry end and a mirror-to-satin\n // sweep happens entirely in the first tenth of the dial.\n let lod = clamp(sqrt(clamp(roughness, 0.0, 1.0)) * levels, 0.0, levels);\n return textureSampleLevel(worldEnvTexture, diffuseSampler, vec2f(u, v), lod).rgb * light.ambientColor.w;\n}\n\nfn group_tex0(uv: vec2f) -> vec4f { return textureSample(groupTexture0, diffuseSampler, uv); }\nfn group_tex1(uv: vec2f) -> vec4f { return textureSample(groupTexture1, diffuseSampler, uv); }\nfn group_tex2(uv: vec2f) -> vec4f { return textureSample(groupTexture2, diffuseSampler, uv); }\nfn group_tex3(uv: vec2f) -> vec4f { return textureSample(groupTexture3, diffuseSampler, uv); }\n\n// \u2500\u2500\u2500 VECTOR 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\n\nfn vector_add(a: vec3f, b: vec3f) -> vec3f { return a + b; }\nfn vector_subtract(a: vec3f, b: vec3f) -> vec3f { return a - b; }\nfn vector_multiply(a: vec3f, b: vec3f) -> vec3f { return a * b; }\nfn vector_divide(a: vec3f, b: vec3f) -> vec3f {\n return vec3f(math_divide(a.x, b.x), math_divide(a.y, b.y), math_divide(a.z, b.z));\n}\nfn vector_multiply_add(a: vec3f, b: vec3f, c: vec3f) -> vec3f { return a * b + c; }\nfn vector_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }\nfn vector_project(a: vec3f, b: vec3f) -> vec3f {\n let l = dot(b, b);\n return select(vec3f(0.0), b * (dot(a, b) / l), l != 0.0);\n}\nfn vector_reflect(a: vec3f, b: vec3f) -> vec3f { return reflect(a, safe_normal(b)); }\nfn vector_refract(a: vec3f, b: vec3f, ior: f32) -> vec3f { return refract(a, safe_normal(b), ior); }\nfn vector_faceforward(a: vec3f, b: vec3f, c: vec3f) -> vec3f {\n return select(a, -a, dot(b, c) < 0.0);\n}\nfn vector_dot(a: vec3f, b: vec3f) -> f32 { return dot(a, b); }\nfn vector_distance(a: vec3f, b: vec3f) -> f32 { return length(a - b); }\nfn vector_length(a: vec3f) -> f32 { return length(a); }\nfn vector_scale(a: vec3f, s: f32) -> vec3f { return a * s; }\n// Blender returns the zero vector rather than NaN for a degenerate input.\nfn vector_normalize(a: vec3f) -> vec3f {\n let l = length(a);\n return select(vec3f(0.0), a / l, l > 0.0);\n}\nfn vector_absolute(a: vec3f) -> vec3f { return abs(a); }\nfn vector_minimum(a: vec3f, b: vec3f) -> vec3f { return min(a, b); }\nfn vector_maximum(a: vec3f, b: vec3f) -> vec3f { return max(a, b); }\nfn vector_floor(a: vec3f) -> vec3f { return floor(a); }\nfn vector_ceil(a: vec3f) -> vec3f { return ceil(a); }\nfn vector_fraction(a: vec3f) -> vec3f { return a - floor(a); }\nfn vector_modulo(a: vec3f, b: vec3f) -> vec3f {\n return vec3f(math_modulo(a.x, b.x), math_modulo(a.y, b.y), math_modulo(a.z, b.z));\n}\nfn vector_snap(a: vec3f, b: vec3f) -> vec3f {\n return vec3f(math_snap(a.x, b.x), math_snap(a.y, b.y), math_snap(a.z, b.z));\n}\nfn vector_wrap(a: vec3f, b: vec3f, c: vec3f) -> vec3f {\n return vec3f(math_wrap(a.x, b.x, c.x), math_wrap(a.y, b.y, c.y), math_wrap(a.z, b.z, c.z));\n}\n\n/** Vector Rotate, axis-angle form \u2014 Rodrigues about an arbitrary axis. */\nfn vector_rotate_axis(v: vec3f, center: vec3f, axis: vec3f, angle: f32) -> vec3f {\n let n = safe_normal(axis);\n let p = v - center;\n let c = cos(angle);\n return center + p * c + cross(n, p) * sin(angle) + n * dot(n, p) * (1.0 - c);\n}\nfn vector_rotate_euler(v: vec3f, center: vec3f, rot: vec3f) -> vec3f {\n let p = v - center;\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 // XYZ order, matching Blender's default euler.\n let rx = vec3f(p.x, p.y * cx - p.z * sx, p.y * sx + p.z * cx);\n let ry = vec3f(rx.x * cy + rx.z * sy, rx.y, -rx.x * sy + rx.z * cy);\n return center + vec3f(ry.x * cz - ry.y * sz, ry.x * sz + ry.y * cz, ry.z);\n}\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// The rest of Blender's blend set, transcribed from\n// gpu_shader_material_mix_color.glsl. Note DODGE, BURN and DIVIDE are\n// per-channel with their own guards there, not vector expressions.\nfn mix_add(fac: f32, a: vec3f, b: vec3f) -> vec3f { return mix(a, a + b, fac); }\nfn mix_subtract(fac: f32, a: vec3f, b: vec3f) -> vec3f { return mix(a, a - b, fac); }\nfn mix_darken(fac: f32, a: vec3f, b: vec3f) -> vec3f { return mix(a, min(a, b), fac); }\nfn mix_difference(fac: f32, a: vec3f, b: vec3f) -> vec3f { return mix(a, abs(a - b), fac); }\nfn mix_exclusion(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return max(mix(a, a + b - 2.0 * a * b, fac), vec3f(0.0));\n}\nfn mix_screen(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let facm = 1.0 - fac;\n return vec3f(1.0) - (vec3f(facm) + fac * (vec3f(1.0) - b)) * (vec3f(1.0) - a);\n}\nfn mix_soft_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let facm = 1.0 - fac;\n let scr = vec3f(1.0) - (vec3f(1.0) - b) * (vec3f(1.0) - a);\n return facm * a + fac * ((vec3f(1.0) - a) * b * a + a * scr);\n}\nfn mix_dodge_1(fac: f32, a: f32, b: f32) -> f32 {\n if (a == 0.0) { return 0.0; }\n let t = 1.0 - fac * b;\n if (t <= 0.0) { return 1.0; }\n return min(a / t, 1.0);\n}\nfn mix_dodge(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return vec3f(mix_dodge_1(fac, a.r, b.r), mix_dodge_1(fac, a.g, b.g), mix_dodge_1(fac, a.b, b.b));\n}\nfn mix_burn_1(fac: f32, a: f32, b: f32) -> f32 {\n let facm = 1.0 - fac;\n let t = facm + fac * b;\n if (t <= 0.0) { return 0.0; }\n return clamp(1.0 - (1.0 - a) / t, 0.0, 1.0);\n}\nfn mix_burn(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return vec3f(mix_burn_1(fac, a.r, b.r), mix_burn_1(fac, a.g, b.g), mix_burn_1(fac, a.b, b.b));\n}\nfn mix_divide_1(fac: f32, a: f32, b: f32) -> f32 {\n return select(a, (1.0 - fac) * a + fac * a / b, b != 0.0);\n}\nfn mix_divide(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return vec3f(mix_divide_1(fac, a.r, b.r), mix_divide_1(fac, a.g, b.g), mix_divide_1(fac, a.b, b.b));\n}\n// The HSV-routed blends: take one channel from B, the rest from A.\nfn mix_hue(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let hsvB = rgb_to_hsv(b);\n if (hsvB.y == 0.0) { return a; }\n var hsv = rgb_to_hsv(a);\n hsv.x = hsvB.x;\n return mix(a, hsv_to_rgb(hsv), fac);\n}\nfn mix_saturation(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n var hsv = rgb_to_hsv(a);\n if (hsv.y == 0.0) { return a; }\n hsv.y = mix(hsv.y, rgb_to_hsv(b).y, fac);\n return hsv_to_rgb(hsv);\n}\nfn mix_color_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let hsvB = rgb_to_hsv(b);\n if (hsvB.y == 0.0) { return a; }\n var hsv = rgb_to_hsv(a);\n hsv.x = hsvB.x;\n hsv.y = hsvB.y;\n return mix(a, hsv_to_rgb(hsv), fac);\n}\nfn mix_value(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n var hsv = rgb_to_hsv(a);\n hsv.z = mix(hsv.z, rgb_to_hsv(b).z, fac);\n return hsv_to_rgb(hsv);\n}\n\n// \u2500\u2500\u2500 Colour utility nodes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\n/** Gamma node \u2014 Blender guards the zero case rather than emitting NaN. */\nfn node_gamma(c: vec3f, g: f32) -> vec3f {\n return vec3f(\n select(0.0, pow(c.r, g), c.r > 0.0),\n select(0.0, pow(c.g, g), c.g > 0.0),\n select(0.0, pow(c.b, g), c.b > 0.0),\n );\n}\n\n/** Map Range, linear, with the clamp Blender applies when the toggle is on. */\nfn map_range_linear(v: f32, fromMin: f32, fromMax: f32, toMin: f32, toMax: f32) -> f32 {\n let d = fromMax - fromMin;\n return select(0.0, toMin + ((v - fromMin) / d) * (toMax - toMin), d != 0.0);\n}\nfn map_range_clamped(v: f32, fromMin: f32, fromMax: f32, toMin: f32, toMax: f32) -> f32 {\n let r = map_range_linear(v, fromMin, fromMax, toMin, toMax);\n return clamp(r, min(toMin, toMax), max(toMin, toMax));\n}\n/** Map Range, smoothstep interpolation. */\nfn map_range_smooth(v: f32, fromMin: f32, fromMax: f32, toMin: f32, toMax: f32) -> f32 {\n let d = fromMax - fromMin;\n if (d == 0.0) { return 0.0; }\n let t = clamp((v - fromMin) / d, 0.0, 1.0);\n return toMin + (t * t * (3.0 - 2.0 * t)) * (toMax - toMin);\n}\n\n/** RGB\u2192HSL and back, for Separate/Combine Color's HSL mode. */\nfn rgb_to_hsl(c: vec3f) -> vec3f {\n let mx = max(c.r, max(c.g, c.b));\n let mn = min(c.r, min(c.g, c.b));\n let l = (mx + mn) * 0.5;\n if (mx == mn) { return vec3f(0.0, 0.0, l); }\n let d = mx - mn;\n let s = select(d / (2.0 - mx - mn), d / (mx + mn), l <= 0.5);\n var h: f32;\n if (mx == c.r) { h = (c.g - c.b) / d + select(0.0, 6.0, c.g < c.b); }\n else if (mx == c.g) { h = (c.b - c.r) / d + 2.0; }\n else { h = (c.r - c.g) / d + 4.0; }\n return vec3f(h / 6.0, s, l);\n}\nfn hsl_channel(p: f32, q: f32, t0: f32) -> f32 {\n var t = fract(t0);\n if (t < 1.0 / 6.0) { return p + (q - p) * 6.0 * t; }\n if (t < 0.5) { return q; }\n if (t < 2.0 / 3.0) { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }\n return p;\n}\nfn hsl_to_rgb(hsl: vec3f) -> vec3f {\n if (hsl.y == 0.0) { return vec3f(hsl.z); }\n let q = select(hsl.z + hsl.y - hsl.z * hsl.y, hsl.z * (1.0 + hsl.y), hsl.z < 0.5);\n let p = 2.0 * hsl.z - q;\n return vec3f(\n hsl_channel(p, q, hsl.x + 1.0 / 3.0),\n hsl_channel(p, q, hsl.x),\n hsl_channel(p, q, hsl.x - 1.0 / 3.0),\n );\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\n/**\n * Shader \u2192 RGB on a white diffuse closure, as a COLOUR.\n *\n * Blender's ShaderToRGB hands back what the closure actually radiates, and that\n * is coloured: a warm sun against a cool ambient gives warm light and cool\n * shadow, which is most of what an NPR ramp is reading. Collapsing it to\n * luminance first \u2014 which this used to do \u2014 throws that away and leaves every\n * ramp working on grey.\n *\n * Feeding this into a scalar socket still yields a float, because Blender's own\n * implicit Color \u2192 Value conversion (BT.601, see color_to_value) does it at the\n * link. So a graph that wants the scalar gets Blender's scalar, and one that\n * wants the colour now gets that too.\n *\n * The maths matches 5.2's bxdf_diffuse_eval, which is saturate(dot(N,L))\n * integrated against a cosine distribution: radiance = E\u00B7N\u00B7L/\u03C0 for the sun, plus\n * the world's own irradiance. EEVEE Next's extra transport \u2014 probe GI, virtual\n * shadow maps, ray-traced bounce \u2014 is machinery rather than a term, and is not\n * here.\n */\nfn shader_to_rgb_lit(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> vec3f {\n const PI_S: f32 = 3.141592653589793;\n let ndotl = max(dot(n, l), 0.0);\n return sun_rgb * (ndotl * shadow / PI_S) + ambient_rgb;\n}\n\nfn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> f32 {\n return luminance_rec709_linear(shader_to_rgb_lit(n, l, sun_rgb, ambient_rgb, shadow));\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\n// The same bump measured against the WORLD instead of the screen.\n//\n// bump_lh below takes dpdx(height) \u2014 the change across one PIXEL \u2014 and drops\n// the world size of that pixel on the floor when it normalizes the cross\n// products. Its slope is therefore per pixel, so the same surface ripples at a\n// distance, where a pixel spans metres, and goes glass-flat underfoot, where it\n// spans millimetres. For skin and cloth that reads as detail which holds its\n// size on screen, which is why it stays; for water it is the difference between\n// a pool and a mirror, because water is the thing you stand next to.\n//\n// Dividing each derivative by its own footprint gives the true world gradient:\n// view-independent, and strength becomes a real slope \u2014 0.25 means a quarter\n// rise over a run, whatever the camera does.\nfn bump_world(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let dhdx = dpdx(height) / max(length(dpdx_pos), 1e-8);\n let dhdy = dpdy(height) / max(length(dpdy_pos), 1e-8);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\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 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// 3D F1 voronoi. _f1 returns Distance; _color returns per-cell hash color\n// (matches Blender voronoi.cc: outColor = hash_int3_to_float3(cell + targetOffset)).\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// The per-cell jitter hash IS the Color output in Blender \u2014 reuse the same hash\n// tap for jitter + color instead of computing two.\nfn tex_voronoi_color(p: vec3f, scale: f32) -> vec3f {\n let coords = p * scale;\n let i = floor(coords);\n let f = fract(coords);\n var min_dist = 1e10;\n var min_hash = vec3f(0.5);\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 h = _hash33(i + neighbor) * 0.5 + 0.5;\n let diff = neighbor + h - f;\n let d = dot(diff, diff);\n if (d < min_dist) {\n min_dist = d;\n min_hash = h;\n }\n }\n }\n }\n return min_hash;\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// \u2500\u2500\u2500 Principled sheen (gpu_shader_material_principled.glsl:8-14) \u2500\u2500\u2500\u2500\n// Empirical NV-only curve that approximates grazing retroreflection on cloth/velvet.\n// Scales the sheen layer's diffuse contribution; no sheen call site has sheen=0\n// shortcut because the multiplier is tiny at normal view angles anyway.\nfn principled_sheen(NV: f32) -> f32 {\n let f = 1.0 - NV;\n return f * f * f * 0.077 + f * 0.01 + 0.00026;\n}\n\n// \u2500\u2500\u2500 Principled BSDF eval \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Shared EEVEE Principled path used by every material in the engine \u2014 metallic,\n// dielectric, and sheen variants all fold into these ~15 lines via the struct\n// fields. NPR materials still compute a separate toon/rim/warm stack on top and\n// mix(npr_stack, eval_principled(...), fac); see body.ts / face.ts / etc.\n//\n// Field conventions:\n// base \u2014 diffuse albedo. Mixed into f0 only when metallic > 0.\n// metallic \u2014 0 = dielectric (f0 from specular), 1 = pure metal (f0 = base).\n// specular \u2014 Principled Specular input (0.5 default \u2192 f0 = 0.04). sqrt for f90.\n// roughness \u2014 GGX roughness; drives BRDF LUT coord + bsdf_ggx.\n// spec_clamp \u2014 EEVEE Light Clamp equivalent. Caps firefly spec from noise-bumped\n// NDF aliasing (Blender hides this via TAA which we don't have).\n// Pass 1e30 (effectively disabled) for materials that don't bump.\n// sheen \u2014 0 disables. Scales the sheen diffuse add; cloth/stockings use ~0.7.\n// sheen_tint \u2014 0 = white sheen, 1 = fully tinted by base. Multiplied by sheen,\n// so value is don't-care when sheen=0.\n// The PMX sphere map, applied the way MMD applies it: the texture is a\n// VIEW-SPACE lighting mask, sampled by the camera-space normal rather than by\n// any UV the mesh carries, which is why it tracks the viewer and reads as a\n// highlight. The material's own mode picks the operator \u2014 .sph (1) multiplies\n// the shaded base, .spa (2) adds \u2014 so a graph asks for the effect and the model\n// decides which it meant. Mode 0, or a material with no sphere texture (the 1\u00D71\n// white fallback is bound then), is an exact no-op.\nfn pmx_sphere_map(base: vec3f, strength: f32, N: vec3f) -> vec3f {\n if (material.sphereMode < 0.5) {\n return base;\n }\n let camera_n = safe_normal((camera.view * vec4f(N, 0.0)).xyz);\n let sphere_uv = clamp(camera_n.xy * 0.5 + vec2f(0.5), vec2f(0.0), vec2f(1.0));\n let sphere_rgb = textureSample(sphereTexture, diffuseSampler, sphere_uv).rgb;\n let amount = max(strength, 0.0);\n if (material.sphereMode < 1.5) {\n return mix(base, base * sphere_rgb, min(amount, 1.0));\n }\n return base + sphere_rgb * amount;\n}\n\n/**\n * Principled v2 reflectance: IOR sets it, the level scales it.\n *\n * Blender 3.6 took Specular directly, where 0.5 meant f0 = 0.04. v2 computes\n * f0 from IOR \u2014 ((ior-1)/(ior+1))\u00B2 \u2014 and Specular IOR Level scales that, with\n * 0.5 meaning \"the IOR's own value\". Returned in the 3.6 convention the BSDF\n * below still speaks (f0 = 0.08 \u00B7 specular), so the two agree at their defaults\n * and v2 wins whenever a preset moves either socket.\n */\nfn principled_specular(ior: f32, level: f32) -> f32 {\n let r = (ior - 1.0) / max(ior + 1.0, 1e-6);\n return (r * r * 2.0 * max(level, 0.0)) / 0.08;\n}\n\n// \u2500\u2500\u2500 The scene's lamps, on a PBR closure \u2500\u2500\u2500\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//\n// A lamp reaches a material as rzLightsDiffuse, added outside the graph and\n// multiplied by the raw texture: Lambert and nothing else, because that layer\n// cannot know what the surface is. On a principled closure it can. A floor at\n// roughness 0.23 under forty candles carries forty glints, and a metal\n// REFLECTS a lamp instead of having it painted on \u2014 without this a ported\n// stage is a photograph of itself, lit but never shining.\n//\n// Same reach as _rzLightOne: the inverse square past RZ_LAMP_NEAR, the\n// (1 \u2212 (d/R)\u2074)\u00B2 window that makes the radius real, the squared cone. N\u00B7L is\n// inside bsdf_ggx. Declared here for bsdf_ggx and resolved against the lights\n// API the material prelude brings; the ground pass takes that API without\n// this file and is untouched.\n//\n// It walks the light grid a second time \u2014 the lamps THIS CELL can see, which\n// is what the grid is for \u2014 and with no lamps at all neither walk runs.\n\n// The walk also yields the DIFFUSE layer, against the prelude's normal\n// (_rzLampN, not the mapped N) and in the order rzLightsDiffuse sums it, so the\n// epilogue's rzLightsDiffuseOnce is that function's exact answer without a\n// second pass over the grid. See lights.ts.\nstruct _RzLampPair { d: vec3f, s: vec3f };\n\nfn _rzLampOne(i: u32, p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32) -> _RzLampPair {\n var out = _RzLampPair(vec3f(0.0), vec3f(0.0));\n let pr = _rzLightVec(i, 0u);\n let d = pr.xyz - p;\n let dist = length(d);\n if (dist >= pr.w) { return out; }\n let toLight = d / max(dist, 1e-4);\n let ndlD = max(dot(_rzLampN, toLight), 0.0);\n let ndl = dot(n, toLight);\n if (ndlD <= 0.0 && ndl <= 0.0) { return out; }\n let t = clamp(dist / max(pr.w, 1e-4), 0.0, 1.0);\n let t2 = t * t;\n let window = 1.0 - t2 * t2;\n let falloff = window * window / max(dist * dist, RZ_LAMP_NEAR * RZ_LAMP_NEAR);\n let cone = rzLightCone(i);\n let aim = clamp((dot(-toLight, rzLightAim(i)) - cone.x) / max(cone.y - cone.x, 1e-4), 0.0, 1.0);\n let c = rzLightColor(i);\n if (ndlD > 0.0) { out.d = c * (ndlD * falloff * aim * aim); }\n if (ndl > 0.0) { out.s = c * (bsdf_ggx(n, toLight, v, ndl, ndv, roughness) * falloff * aim * aim); }\n return out;\n}\n\nfn _rzLampWord(bits0: u32, base: u32, p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32) -> _RzLampPair {\n var acc = _RzLampPair(vec3f(0.0), vec3f(0.0));\n var bits = bits0;\n loop {\n if (bits == 0u) { break; }\n let i = base + firstTrailingBit(bits);\n bits = bits & (bits - 1u);\n let one = _rzLampOne(i, p, n, v, ndv, roughness);\n acc.d = acc.d + one.d;\n acc.s = acc.s + one.s;\n }\n return acc;\n}\n\nfn rzLampsSpecular(p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32) -> vec3f {\n var d = vec3f(0.0);\n var s = vec3f(0.0);\n let count = rzLightCount();\n let docs = _rzLightDocCount();\n if (docs > 0u) {\n let m = _rzLightCellMask(p);\n let wx = _rzLampWord(m.x, 0u, p, n, v, ndv, roughness);\n let wy = _rzLampWord(m.y, 32u, p, n, v, ndv, roughness);\n let wz = _rzLampWord(m.z, 64u, p, n, v, ndv, roughness);\n let ww = _rzLampWord(m.w, 96u, p, n, v, ndv, roughness);\n d = d + wx.d + wy.d + wz.d + ww.d;\n s = s + wx.s + wy.s + wz.s + ww.s;\n }\n for (var i = docs; i < count; i = i + 1u) {\n let one = _rzLampOne(i, p, n, v, ndv, roughness);\n d = d + one.d;\n s = s + one.s;\n }\n // The first closure's walk speaks for the fragment; a second principled in\n // the same graph shares p and the prelude normal, so its answer is the same.\n if (!_rzLampDiffuseSet) {\n _rzLampDiffuse = d * (1.0 / 3.141592653589793);\n _rzLampDiffuseSet = true;\n }\n return s;\n}\n\nstruct PrincipledIn {\n base: vec3f,\n metallic: f32,\n specular: f32,\n roughness: f32,\n spec_clamp: f32,\n sheen: f32,\n sheen_tint: f32,\n};\n\nfn eval_principled(\n p: PrincipledIn,\n N: vec3f, L: vec3f, V: vec3f,\n sun_rgb: vec3f, amb_rgb: vec3f, shadow: f32, wp: vec3f\n) -> vec3f {\n let NL = max(dot(N, L), 0.0);\n let NV = max(dot(N, V), 1e-4);\n\n // f0/f90 per gpu_shader_material_principled.glsl. specular_tint=0 is assumed\n // (all presets in this engine use the default white dielectric tint).\n let dielectric_f0 = vec3f(0.08 * p.specular);\n let f0 = mix(dielectric_f0, p.base, p.metallic);\n let f90 = mix(f0, vec3f(1.0), sqrt(p.specular));\n\n // Single LUT tap feeds both F_brdf_multi_scatter (split-sum DFG) and\n // ltc_brdf_scale_from_lut (LTC mag in .ba). See nodes.ts brdf_lut_sample.\n let lut = brdf_lut_sample(NV, p.roughness);\n let reflection_color = F_brdf_multi_scatter(f0, f90, lut.xy);\n\n // Direct glossy \u2014 bsdf_ggx already includes NL; no F applied here (tinted after\n // accum with reflection_color). ltc_brdf_scale rescales direct to match the\n // split-sum indirect path, matching EEVEE closure_eval_glossy_lib behavior.\n // The sun, and every lamp that reaches this point. One clamp over the pair:\n // a candle a hand's width from a polished floor is a firefly otherwise.\n let spec_direct_raw = (bsdf_ggx(N, L, V, NL, NV, p.roughness) * sun_rgb * shadow\n + rzLampsSpecular(wp, N, V, NV, p.roughness))\n * ltc_brdf_scale_from_lut(lut);\n let spec_direct = min(spec_direct_raw, vec3f(p.spec_clamp));\n // Indirect specular reads the world ALONG THE REFLECTION, at a roughness-\n // picked level \u2014 EEVEE's probe_evaluate_world_spec, where the diffuse half\n // below is probe_evaluate_world_diff along the normal. Taking the diffuse\n // answer for both gave every surface the same flat wash whatever its\n // roughness, so a chrome rail mirrored what a matte wall did and a stage's\n // metal only ever lost its diffuse. A uniform sky gives the same number\n // either way, so this moves direction, not energy.\n let spec_indirect = rzWorldSpecular(reflect(-V, N), p.roughness);\n let spec_radiance = (spec_direct + spec_indirect) * reflection_color;\n\n // Sheen add \u2014 when p.sheen=0 the whole term collapses, leaving diffuse_color=base.\n let base_tint = tint_from_color(p.base);\n let sheen_color = mix(vec3f(1.0), base_tint, p.sheen_tint);\n let diffuse_color = p.base + p.sheen * sheen_color * principled_sheen(NV);\n\n // diffuse_weight = (1-metallic). Indirect diffuse uses amb (L_w) with no \u03C0 factor\n // (probe_evaluate_world_diff returns SH-projected radiance, not cosine-convolved).\n let diffuse_weight = 1.0 - p.metallic;\n let diffuse_radiance = diffuse_color * (sun_rgb * NL * shadow / EEVEE_PI + amb_rgb) * diffuse_weight;\n\n return diffuse_radiance + spec_radiance;\n}\n\n";
1
+ export declare const NODES_WGSL = "\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\n// LINEAR ramp, three stops. Both halves are evaluated and selected between rather\n// than branched on, so there is no divergence; saturate outside each span already\n// gives Blender's clamp-to-end-stop behaviour.\nfn ramp_linear3(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f, p2: f32, c2: vec4f) -> vec4f {\n let lo = mix(c0, c1, saturate((f - p0) / max(p1 - p0, 1e-6)));\n let hi = mix(c1, c2, saturate((f - p1) / max(p2 - p1, 1e-6)));\n return select(lo, hi, f >= p1);\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\n// Transcribed from Blender 5.2's gpu_shader_common_math.glsl, including the\n// safe-guards \u2014 a Math node that divides by zero yields 0 there, not inf, and a\n// graph written against that behaviour relies on it.\nfn math_add(a: f32, b: f32) -> f32 { return a + b; }\nfn math_subtract(a: f32, b: f32) -> f32 { return a - b; }\nfn math_multiply(a: f32, b: f32) -> f32 { return a * b; }\nfn math_divide(a: f32, b: f32) -> f32 { return select(0.0, a / b, b != 0.0); }\nfn math_multiply_add(a: f32, b: f32, c: f32) -> f32 { return a * b + c; }\nfn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }\nfn math_logarithm(a: f32, b: f32) -> f32 { return select(0.0, log(a) / log(b), a > 0.0 && b > 0.0 && b != 1.0); }\nfn math_sqrt(a: f32) -> f32 { return sqrt(max(a, 0.0)); }\nfn math_inversesqrt(a: f32) -> f32 { return inverseSqrt(max(a, 1e-8)); }\nfn math_absolute(a: f32) -> f32 { return abs(a); }\nfn math_exponent(a: f32) -> f32 { return exp(a); }\nfn math_minimum(a: f32, b: f32) -> f32 { return min(a, b); }\nfn math_maximum(a: f32, b: f32) -> f32 { return max(a, b); }\nfn math_less_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a < b); }\nfn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }\nfn math_sign(a: f32) -> f32 { return sign(a); }\n// The tolerance floors at 1e-5 in Blender, so a zero third input still compares.\nfn math_compare(a: f32, b: f32, c: f32) -> f32 { return select(0.0, 1.0, abs(a - b) <= max(c, 1e-5)); }\nfn math_smooth_min(a: f32, b: f32, c: f32) -> f32 {\n if (c == 0.0) { return min(a, b); }\n let h = max(c - abs(a - b), 0.0) / c;\n return min(a, b) - h * h * h * c * (1.0 / 6.0);\n}\nfn math_smooth_max(a: f32, b: f32, c: f32) -> f32 { return -math_smooth_min(-a, -b, c); }\nfn math_round(a: f32) -> f32 { return floor(a + 0.5); }\nfn math_floor(a: f32) -> f32 { return floor(a); }\nfn math_ceil(a: f32) -> f32 { return ceil(a); }\nfn math_truncate(a: f32) -> f32 { return trunc(a); }\nfn math_fraction(a: f32) -> f32 { return a - floor(a); }\n// Blender's MODULO is fmod (truncated), not floorMod \u2014 the sign follows a.\nfn math_modulo(a: f32, b: f32) -> f32 { return select(0.0, a - b * trunc(a / b), b != 0.0); }\nfn math_floored_modulo(a: f32, b: f32) -> f32 { return select(0.0, a - floor(a / b) * b, b != 0.0); }\nfn math_snap(a: f32, b: f32) -> f32 { return floor(select(0.0, a / b, b != 0.0)) * b; }\nfn math_wrap(a: f32, b: f32, c: f32) -> f32 {\n let range = b - c;\n return select(c, a - range * floor((a - c) / range), range != 0.0);\n}\nfn math_pingpong(a: f32, b: f32) -> f32 {\n return select(0.0, abs(fract((a - b) / (b * 2.0)) * b * 2.0 - b), b != 0.0);\n}\nfn math_sine(a: f32) -> f32 { return sin(a); }\nfn math_cosine(a: f32) -> f32 { return cos(a); }\nfn math_tangent(a: f32) -> f32 { return tan(a); }\nfn math_arcsine(a: f32) -> f32 { return asin(clamp(a, -1.0, 1.0)); }\nfn math_arccosine(a: f32) -> f32 { return acos(clamp(a, -1.0, 1.0)); }\nfn math_arctangent(a: f32) -> f32 { return atan(a); }\nfn math_arctan2(a: f32, b: f32) -> f32 { return atan2(a, b); }\nfn math_radians(a: f32) -> f32 { return radians(a); }\nfn math_degrees(a: f32) -> f32 { return degrees(a); }\n\n// \u2500\u2500\u2500 RGB Curves, as five samples \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\n/**\n * Blender's RGB Curves holds arbitrary control points, which cannot cross into a\n * shader as sockets. So the curve arrives SAMPLED: five values at t = 0, .25,\n * .5, .75, 1, and this rebuilds it with the same monotone (Fritsch-Carlson)\n * interpolation the Filmic LUT uses \u2014 C1 continuous, and it cannot overshoot,\n * which matters because a curve that overshoots turns a highlight roll-off into\n * a bright ring.\n *\n * Five points captures the levels-and-contrast adjustments these curves are\n * actually used for. A curve with more structure than that will not survive, and\n * should be a ramp instead.\n */\nfn curve_slope(d0: f32, d1: f32) -> f32 {\n if (d0 * d1 <= 0.0) { return 0.0; }\n let m = (d0 + d1) * 0.5;\n return sign(d0) * min(abs(m), 2.0 * min(abs(d0), abs(d1)));\n}\nfn curve5(t0: f32, y0: f32, y1: f32, y2: f32, y3: f32, y4: f32) -> f32 {\n let t = clamp(t0, 0.0, 1.0) * 4.0;\n let i = min(floor(t), 3.0);\n let f = t - i;\n // NO local array, deliberately. This used to build array<f32, 5> and index it\n // with the runtime k \u2014 and a dynamic index on function-local memory is the one\n // construct this codebase has already caught Metal lowering to a\n // per-invocation copy plus a switch (the filmic LUT note in composite.ts).\n // rgb_curve calls this three times per node per pixel, so on a graph-heavy\n // close-up that lowering was paid in the hottest loop the frame has. Four\n // segments select cleanly: the values below are the SAME subtractions the\n // array indexing produced, chosen by k instead of loaded through it \u2014\n // bit-identical results, no local memory, nothing for the backend to spill.\n let k = i32(i);\n let s0 = y1 - y0;\n let s1 = y2 - y1;\n let s2 = y3 - y2;\n let s3 = y4 - y3;\n let pa = select(select(y0, y1, k == 1), select(y2, y3, k == 3), k >= 2);\n let pb = select(select(y1, y2, k == 1), select(y3, y4, k == 3), k >= 2);\n // Secants either side of each knot, clamped at the ends.\n let dHere = pb - pa;\n let dPrev = select(s0, select(s1, s2, k == 3), k >= 2);\n let dNext = select(select(s1, s2, k == 1), s3, k >= 2);\n let m0 = curve_slope(dPrev, dHere);\n let m1 = curve_slope(dHere, dNext);\n let f2 = f * f;\n let f3 = f2 * f;\n return (2.0 * f3 - 3.0 * f2 + 1.0) * pa + (f3 - 2.0 * f2 + f) * m0 +\n (-2.0 * f3 + 3.0 * f2) * pb + (f3 - f2) * m1;\n}\nfn rgb_curve(c: vec3f, y0: f32, y1: f32, y2: f32, y3: f32, y4: f32) -> vec3f {\n return vec3f(\n curve5(c.r, y0, y1, y2, y3, y4),\n curve5(c.g, y0, y1, y2, y3, y4),\n curve5(c.b, y0, y1, y2, y3, y4),\n );\n}\n\n// \u2500\u2500\u2500 Normal Map / Vector Transform \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\n/**\n * Normal Map, with the tangent frame derived rather than read.\n *\n * Blender reads a per-vertex tangent; PMX files do not carry one, so there is\n * nothing to read. The frame is rebuilt per-pixel from screen-space derivatives\n * of position and UV \u2014 the standard substitute, exact on flat-ish shading and\n * off only where UVs are severely sheared. Cheaper than the alternative (running\n * a tangent-generation pass over every imported mesh) and invisible on the\n * character work this is for.\n */\nfn node_normal_map(color: vec3f, strength: f32, N: vec3f, worldPos: vec3f, uv: vec2f) -> vec3f {\n let dp1 = dpdx(worldPos);\n let dp2 = dpdy(worldPos);\n let duv1 = dpdx(uv);\n let duv2 = dpdy(uv);\n let det = duv1.x * duv2.y - duv2.x * duv1.y;\n if (abs(det) < 1e-12) { return N; }\n let inv = 1.0 / det;\n let T = safe_normal((dp1 * duv2.y - dp2 * duv1.y) * inv);\n let B = safe_normal(cross(N, T));\n // Tangent-space [0,1] \u2192 [-1,1], then into world.\n let t = color * 2.0 - vec3f(1.0);\n let mapped = safe_normal(T * t.x + B * t.y + N * t.z);\n return safe_normal(mix(N, mapped, clamp(strength, 0.0, 1.0)));\n}\n\n/** Vector Transform, world \u2194 camera. Object space IS world here: a PMX model is\n * placed by its own transform and shaded in world space, so there is no\n * separate object frame to convert into. */\nfn vector_world_to_camera(v: vec3f) -> vec3f { return (camera.view * vec4f(v, 0.0)).xyz; }\nfn vector_camera_to_world(v: vec3f) -> vec3f { return (transpose(camera.view) * vec4f(v, 0.0)).xyz; }\nfn point_world_to_camera(p: vec3f) -> vec3f { return (camera.view * vec4f(p, 1.0)).xyz; }\n\n// \u2500\u2500\u2500 Style-group image maps (Image Texture on a non-PMX slot) \u2500\u2500\u2500\u2500\u2500\u2500\n// One function per slot because WGSL has no texture arrays-of-bindings here; the\n// slot is topology, exactly like a Math node's operation.\n\n/**\n * The world's radiance arriving from a direction \u2014 what a reflection sees.\n *\n * The engine's ambient is irradiance: the SH fit of the sky integrated over a\n * hemisphere, evaluated along the NORMAL. That is the right answer for diffuse\n * light and the wrong one for a mirror, which needs radiance along the\n * REFLECTION vector and needs it sharp. A stage ripped from a game asks for\n * this on nearly every surface \u2014 it is what a Unity material's DEFAULT_REFLECTION\n * keyword means \u2014 \n * and so does every metal the app has ever drawn.\n *\n * ROUGHNESS PICKS A MIP, which is the cheap prefilter: the chain is built by\n * successive halving at upload, so a level is a box average of the sky over a\n * widening solid angle. A cosine-lobe convolution would be more correct; the\n * visible difference is confined to the middle of the roughness range, and the\n * chain costs one downsample per level instead of a render pass per level.\n *\n * Without an HDRI there is nothing to reflect but the flat world colour, which\n * is what the ambient already is \u2014 so the two agree and a scene without a sky\n * texture sees no change at all.\n */\nfn rzWorldSpecular(dir: vec3f, roughness: f32) -> vec3f {\n return rzWorldSpecularLod(dir, roughness, 0.0);\n}\n\n/**\n * The same, with the blur curve chosen. unity = 0 is the sqrt ramp above; 1 is\n * Unity's reflection-probe lookup, mip = p\u00B7(1.7 \u2212 0.7p)\u00B78 \u2014 the curve a game's\n * glossy floor was tuned under, about a level sharper than sqrt through the\n * glossy range (roughness 0.1\u20130.3) and the same by 0.5.\n *\n * Its levels map one to one onto this chain's: a cube of N texels a face and an\n * equirect 4N wide resolve the same angle per texel, so a cube's last level \u2014\n * one texel a face \u2014 is this chain's log2(width/4), and the curve stops there\n * rather than averaging the whole sky, as the game's does.\n */\nfn rzWorldSpecularLod(dir: vec3f, roughness: f32, unity: f32) -> vec3f {\n // A SKY TEXTURE, or the best the scene has. The flag is 2 when an HDRI is\n // installed, 1 when the world is a gradient fitted to the same harmonics,\n // and 0 when it is one flat colour \u2014 and in the last two cases the ambient\n // already answers by direction, which is a blurred sky and exactly what a\n // world without a picture can honestly reflect.\n if (light.sh[0].w < 1.5) { return rzWorldAmbient(normalize(dir)); }\n let d = normalize(dir);\n // The composite's own convention, restated: LH world with +Z forward, which\n // is what makes a reflection line up with the dome the viewer can see.\n let u = 0.5 + atan2(d.x, d.z) * 0.15915494309;\n let v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) * 0.31830988618;\n let levels = f32(textureNumLevels(worldEnvTexture) - 1u);\n // sqrt, not linear: mip n covers roughly twice the angle of n-1, so a linear\n // ramp spends most of its travel in the blurry end and a mirror-to-satin\n // sweep happens entirely in the first tenth of the dial.\n let r = clamp(roughness, 0.0, 1.0);\n let unityLod = min(r * (1.7 - 0.7 * r) * 8.0, max(levels - 2.0, 0.0));\n let lod = clamp(mix(sqrt(r) * levels, unityLod, unity), 0.0, levels);\n return textureSampleLevel(worldEnvTexture, diffuseSampler, vec2f(u, v), lod).rgb * light.ambientColor.w;\n}\n\nfn group_tex0(uv: vec2f) -> vec4f { return textureSample(groupTexture0, diffuseSampler, uv); }\nfn group_tex1(uv: vec2f) -> vec4f { return textureSample(groupTexture1, diffuseSampler, uv); }\nfn group_tex2(uv: vec2f) -> vec4f { return textureSample(groupTexture2, diffuseSampler, uv); }\nfn group_tex3(uv: vec2f) -> vec4f { return textureSample(groupTexture3, diffuseSampler, uv); }\n\n// \u2500\u2500\u2500 VECTOR 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\n\nfn vector_add(a: vec3f, b: vec3f) -> vec3f { return a + b; }\nfn vector_subtract(a: vec3f, b: vec3f) -> vec3f { return a - b; }\nfn vector_multiply(a: vec3f, b: vec3f) -> vec3f { return a * b; }\nfn vector_divide(a: vec3f, b: vec3f) -> vec3f {\n return vec3f(math_divide(a.x, b.x), math_divide(a.y, b.y), math_divide(a.z, b.z));\n}\nfn vector_multiply_add(a: vec3f, b: vec3f, c: vec3f) -> vec3f { return a * b + c; }\nfn vector_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }\nfn vector_project(a: vec3f, b: vec3f) -> vec3f {\n let l = dot(b, b);\n return select(vec3f(0.0), b * (dot(a, b) / l), l != 0.0);\n}\nfn vector_reflect(a: vec3f, b: vec3f) -> vec3f { return reflect(a, safe_normal(b)); }\nfn vector_refract(a: vec3f, b: vec3f, ior: f32) -> vec3f { return refract(a, safe_normal(b), ior); }\nfn vector_faceforward(a: vec3f, b: vec3f, c: vec3f) -> vec3f {\n return select(a, -a, dot(b, c) < 0.0);\n}\nfn vector_dot(a: vec3f, b: vec3f) -> f32 { return dot(a, b); }\nfn vector_distance(a: vec3f, b: vec3f) -> f32 { return length(a - b); }\nfn vector_length(a: vec3f) -> f32 { return length(a); }\nfn vector_scale(a: vec3f, s: f32) -> vec3f { return a * s; }\n// Blender returns the zero vector rather than NaN for a degenerate input.\nfn vector_normalize(a: vec3f) -> vec3f {\n let l = length(a);\n return select(vec3f(0.0), a / l, l > 0.0);\n}\nfn vector_absolute(a: vec3f) -> vec3f { return abs(a); }\nfn vector_minimum(a: vec3f, b: vec3f) -> vec3f { return min(a, b); }\nfn vector_maximum(a: vec3f, b: vec3f) -> vec3f { return max(a, b); }\nfn vector_floor(a: vec3f) -> vec3f { return floor(a); }\nfn vector_ceil(a: vec3f) -> vec3f { return ceil(a); }\nfn vector_fraction(a: vec3f) -> vec3f { return a - floor(a); }\nfn vector_modulo(a: vec3f, b: vec3f) -> vec3f {\n return vec3f(math_modulo(a.x, b.x), math_modulo(a.y, b.y), math_modulo(a.z, b.z));\n}\nfn vector_snap(a: vec3f, b: vec3f) -> vec3f {\n return vec3f(math_snap(a.x, b.x), math_snap(a.y, b.y), math_snap(a.z, b.z));\n}\nfn vector_wrap(a: vec3f, b: vec3f, c: vec3f) -> vec3f {\n return vec3f(math_wrap(a.x, b.x, c.x), math_wrap(a.y, b.y, c.y), math_wrap(a.z, b.z, c.z));\n}\n\n/** Vector Rotate, axis-angle form \u2014 Rodrigues about an arbitrary axis. */\nfn vector_rotate_axis(v: vec3f, center: vec3f, axis: vec3f, angle: f32) -> vec3f {\n let n = safe_normal(axis);\n let p = v - center;\n let c = cos(angle);\n return center + p * c + cross(n, p) * sin(angle) + n * dot(n, p) * (1.0 - c);\n}\nfn vector_rotate_euler(v: vec3f, center: vec3f, rot: vec3f) -> vec3f {\n let p = v - center;\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 // XYZ order, matching Blender's default euler.\n let rx = vec3f(p.x, p.y * cx - p.z * sx, p.y * sx + p.z * cx);\n let ry = vec3f(rx.x * cy + rx.z * sy, rx.y, -rx.x * sy + rx.z * cy);\n return center + vec3f(ry.x * cz - ry.y * sz, ry.x * sz + ry.y * cz, ry.z);\n}\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// The rest of Blender's blend set, transcribed from\n// gpu_shader_material_mix_color.glsl. Note DODGE, BURN and DIVIDE are\n// per-channel with their own guards there, not vector expressions.\nfn mix_add(fac: f32, a: vec3f, b: vec3f) -> vec3f { return mix(a, a + b, fac); }\nfn mix_subtract(fac: f32, a: vec3f, b: vec3f) -> vec3f { return mix(a, a - b, fac); }\nfn mix_darken(fac: f32, a: vec3f, b: vec3f) -> vec3f { return mix(a, min(a, b), fac); }\nfn mix_difference(fac: f32, a: vec3f, b: vec3f) -> vec3f { return mix(a, abs(a - b), fac); }\nfn mix_exclusion(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return max(mix(a, a + b - 2.0 * a * b, fac), vec3f(0.0));\n}\nfn mix_screen(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let facm = 1.0 - fac;\n return vec3f(1.0) - (vec3f(facm) + fac * (vec3f(1.0) - b)) * (vec3f(1.0) - a);\n}\nfn mix_soft_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let facm = 1.0 - fac;\n let scr = vec3f(1.0) - (vec3f(1.0) - b) * (vec3f(1.0) - a);\n return facm * a + fac * ((vec3f(1.0) - a) * b * a + a * scr);\n}\nfn mix_dodge_1(fac: f32, a: f32, b: f32) -> f32 {\n if (a == 0.0) { return 0.0; }\n let t = 1.0 - fac * b;\n if (t <= 0.0) { return 1.0; }\n return min(a / t, 1.0);\n}\nfn mix_dodge(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return vec3f(mix_dodge_1(fac, a.r, b.r), mix_dodge_1(fac, a.g, b.g), mix_dodge_1(fac, a.b, b.b));\n}\nfn mix_burn_1(fac: f32, a: f32, b: f32) -> f32 {\n let facm = 1.0 - fac;\n let t = facm + fac * b;\n if (t <= 0.0) { return 0.0; }\n return clamp(1.0 - (1.0 - a) / t, 0.0, 1.0);\n}\nfn mix_burn(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return vec3f(mix_burn_1(fac, a.r, b.r), mix_burn_1(fac, a.g, b.g), mix_burn_1(fac, a.b, b.b));\n}\nfn mix_divide_1(fac: f32, a: f32, b: f32) -> f32 {\n return select(a, (1.0 - fac) * a + fac * a / b, b != 0.0);\n}\nfn mix_divide(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return vec3f(mix_divide_1(fac, a.r, b.r), mix_divide_1(fac, a.g, b.g), mix_divide_1(fac, a.b, b.b));\n}\n// The HSV-routed blends: take one channel from B, the rest from A.\nfn mix_hue(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let hsvB = rgb_to_hsv(b);\n if (hsvB.y == 0.0) { return a; }\n var hsv = rgb_to_hsv(a);\n hsv.x = hsvB.x;\n return mix(a, hsv_to_rgb(hsv), fac);\n}\nfn mix_saturation(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n var hsv = rgb_to_hsv(a);\n if (hsv.y == 0.0) { return a; }\n hsv.y = mix(hsv.y, rgb_to_hsv(b).y, fac);\n return hsv_to_rgb(hsv);\n}\nfn mix_color_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let hsvB = rgb_to_hsv(b);\n if (hsvB.y == 0.0) { return a; }\n var hsv = rgb_to_hsv(a);\n hsv.x = hsvB.x;\n hsv.y = hsvB.y;\n return mix(a, hsv_to_rgb(hsv), fac);\n}\nfn mix_value(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n var hsv = rgb_to_hsv(a);\n hsv.z = mix(hsv.z, rgb_to_hsv(b).z, fac);\n return hsv_to_rgb(hsv);\n}\n\n// \u2500\u2500\u2500 Colour utility nodes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\n/** Gamma node \u2014 Blender guards the zero case rather than emitting NaN. */\nfn node_gamma(c: vec3f, g: f32) -> vec3f {\n return vec3f(\n select(0.0, pow(c.r, g), c.r > 0.0),\n select(0.0, pow(c.g, g), c.g > 0.0),\n select(0.0, pow(c.b, g), c.b > 0.0),\n );\n}\n\n/** Map Range, linear, with the clamp Blender applies when the toggle is on. */\nfn map_range_linear(v: f32, fromMin: f32, fromMax: f32, toMin: f32, toMax: f32) -> f32 {\n let d = fromMax - fromMin;\n return select(0.0, toMin + ((v - fromMin) / d) * (toMax - toMin), d != 0.0);\n}\nfn map_range_clamped(v: f32, fromMin: f32, fromMax: f32, toMin: f32, toMax: f32) -> f32 {\n let r = map_range_linear(v, fromMin, fromMax, toMin, toMax);\n return clamp(r, min(toMin, toMax), max(toMin, toMax));\n}\n/** Map Range, smoothstep interpolation. */\nfn map_range_smooth(v: f32, fromMin: f32, fromMax: f32, toMin: f32, toMax: f32) -> f32 {\n let d = fromMax - fromMin;\n if (d == 0.0) { return 0.0; }\n let t = clamp((v - fromMin) / d, 0.0, 1.0);\n return toMin + (t * t * (3.0 - 2.0 * t)) * (toMax - toMin);\n}\n\n/** RGB\u2192HSL and back, for Separate/Combine Color's HSL mode. */\nfn rgb_to_hsl(c: vec3f) -> vec3f {\n let mx = max(c.r, max(c.g, c.b));\n let mn = min(c.r, min(c.g, c.b));\n let l = (mx + mn) * 0.5;\n if (mx == mn) { return vec3f(0.0, 0.0, l); }\n let d = mx - mn;\n let s = select(d / (2.0 - mx - mn), d / (mx + mn), l <= 0.5);\n var h: f32;\n if (mx == c.r) { h = (c.g - c.b) / d + select(0.0, 6.0, c.g < c.b); }\n else if (mx == c.g) { h = (c.b - c.r) / d + 2.0; }\n else { h = (c.r - c.g) / d + 4.0; }\n return vec3f(h / 6.0, s, l);\n}\nfn hsl_channel(p: f32, q: f32, t0: f32) -> f32 {\n var t = fract(t0);\n if (t < 1.0 / 6.0) { return p + (q - p) * 6.0 * t; }\n if (t < 0.5) { return q; }\n if (t < 2.0 / 3.0) { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }\n return p;\n}\nfn hsl_to_rgb(hsl: vec3f) -> vec3f {\n if (hsl.y == 0.0) { return vec3f(hsl.z); }\n let q = select(hsl.z + hsl.y - hsl.z * hsl.y, hsl.z * (1.0 + hsl.y), hsl.z < 0.5);\n let p = 2.0 * hsl.z - q;\n return vec3f(\n hsl_channel(p, q, hsl.x + 1.0 / 3.0),\n hsl_channel(p, q, hsl.x),\n hsl_channel(p, q, hsl.x - 1.0 / 3.0),\n );\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\n/**\n * Shader \u2192 RGB on a white diffuse closure, as a COLOUR.\n *\n * Blender's ShaderToRGB hands back what the closure actually radiates, and that\n * is coloured: a warm sun against a cool ambient gives warm light and cool\n * shadow, which is most of what an NPR ramp is reading. Collapsing it to\n * luminance first \u2014 which this used to do \u2014 throws that away and leaves every\n * ramp working on grey.\n *\n * Feeding this into a scalar socket still yields a float, because Blender's own\n * implicit Color \u2192 Value conversion (BT.601, see color_to_value) does it at the\n * link. So a graph that wants the scalar gets Blender's scalar, and one that\n * wants the colour now gets that too.\n *\n * The maths matches 5.2's bxdf_diffuse_eval, which is saturate(dot(N,L))\n * integrated against a cosine distribution: radiance = E\u00B7N\u00B7L/\u03C0 for the sun, plus\n * the world's own irradiance. EEVEE Next's extra transport \u2014 probe GI, virtual\n * shadow maps, ray-traced bounce \u2014 is machinery rather than a term, and is not\n * here.\n */\nfn shader_to_rgb_lit(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> vec3f {\n const PI_S: f32 = 3.141592653589793;\n let ndotl = max(dot(n, l), 0.0);\n return sun_rgb * (ndotl * shadow / PI_S) + ambient_rgb;\n}\n\nfn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> f32 {\n return luminance_rec709_linear(shader_to_rgb_lit(n, l, sun_rgb, ambient_rgb, shadow));\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\n// The same bump measured against the WORLD instead of the screen.\n//\n// bump_lh below takes dpdx(height) \u2014 the change across one PIXEL \u2014 and drops\n// the world size of that pixel on the floor when it normalizes the cross\n// products. Its slope is therefore per pixel, so the same surface ripples at a\n// distance, where a pixel spans metres, and goes glass-flat underfoot, where it\n// spans millimetres. For skin and cloth that reads as detail which holds its\n// size on screen, which is why it stays; for water it is the difference between\n// a pool and a mirror, because water is the thing you stand next to.\n//\n// Dividing each derivative by its own footprint gives the true world gradient:\n// view-independent, and strength becomes a real slope \u2014 0.25 means a quarter\n// rise over a run, whatever the camera does.\nfn bump_world(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let dhdx = dpdx(height) / max(length(dpdx_pos), 1e-8);\n let dhdy = dpdy(height) / max(length(dpdy_pos), 1e-8);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\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 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// 3D F1 voronoi. _f1 returns Distance; _color returns per-cell hash color\n// (matches Blender voronoi.cc: outColor = hash_int3_to_float3(cell + targetOffset)).\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// The per-cell jitter hash IS the Color output in Blender \u2014 reuse the same hash\n// tap for jitter + color instead of computing two.\nfn tex_voronoi_color(p: vec3f, scale: f32) -> vec3f {\n let coords = p * scale;\n let i = floor(coords);\n let f = fract(coords);\n var min_dist = 1e10;\n var min_hash = vec3f(0.5);\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 h = _hash33(i + neighbor) * 0.5 + 0.5;\n let diff = neighbor + h - f;\n let d = dot(diff, diff);\n if (d < min_dist) {\n min_dist = d;\n min_hash = h;\n }\n }\n }\n }\n return min_hash;\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// URP's direct-light specular, as a game on URP (or a fork of it) shades its\n// lamps and sun: D\u00B7V\u00B7F folded into a2 / (d\u00B2\u00B7max(0.1, LoH\u00B2)\u00B7(4a + 2)), a the\n// SQUARED roughness. Unity folds \u03C0 into its light units, and a light that came\n// from one reaches this engine with that \u03C0 restored (see the app's stage\n// loader), so it comes back out here. Returns BRDF \u00D7 NL, like bsdf_ggx.\nfn bsdf_urp(N: vec3f, L: vec3f, V: vec3f, NL: f32, roughness: f32) -> f32 {\n let a = max(roughness * roughness, 6.1035156e-5);\n let a2 = a * a;\n let H = normalize(L + V);\n let NH = max(dot(N, H), 0.0);\n let LH = max(dot(L, H), 0.0);\n let d = NH * NH * (a2 - 1.0) + 1.00001;\n return a2 / (d * d * max(0.1, LH * LH) * (a * 4.0 + 2.0)) * NL / EEVEE_PI;\n}\n\n/** The direct-light lobe a principled closure asked for: 0 the engine's own,\n * 1 URP's (principled's unity_direct). */\nfn _rzDirectLobe(N: vec3f, L: vec3f, V: vec3f, NL: f32, NV: f32, roughness: f32, unity: f32) -> f32 {\n if (unity > 0.5) { return bsdf_urp(N, L, V, NL, roughness); }\n return bsdf_ggx(N, L, V, NL, NV, roughness);\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// \u2500\u2500\u2500 Principled sheen (gpu_shader_material_principled.glsl:8-14) \u2500\u2500\u2500\u2500\n// Empirical NV-only curve that approximates grazing retroreflection on cloth/velvet.\n// Scales the sheen layer's diffuse contribution; no sheen call site has sheen=0\n// shortcut because the multiplier is tiny at normal view angles anyway.\nfn principled_sheen(NV: f32) -> f32 {\n let f = 1.0 - NV;\n return f * f * f * 0.077 + f * 0.01 + 0.00026;\n}\n\n// \u2500\u2500\u2500 Principled BSDF eval \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Shared EEVEE Principled path used by every material in the engine \u2014 metallic,\n// dielectric, and sheen variants all fold into these ~15 lines via the struct\n// fields. NPR materials still compute a separate toon/rim/warm stack on top and\n// mix(npr_stack, eval_principled(...), fac); see body.ts / face.ts / etc.\n//\n// Field conventions:\n// base \u2014 diffuse albedo. Mixed into f0 only when metallic > 0.\n// metallic \u2014 0 = dielectric (f0 from specular), 1 = pure metal (f0 = base).\n// specular \u2014 Principled Specular input (0.5 default \u2192 f0 = 0.04). sqrt for f90.\n// roughness \u2014 GGX roughness; drives BRDF LUT coord + bsdf_ggx.\n// spec_clamp \u2014 EEVEE Light Clamp equivalent. Caps firefly spec from noise-bumped\n// NDF aliasing (Blender hides this via TAA which we don't have).\n// Pass 1e30 (effectively disabled) for materials that don't bump.\n// sheen \u2014 0 disables. Scales the sheen diffuse add; cloth/stockings use ~0.7.\n// sheen_tint \u2014 0 = white sheen, 1 = fully tinted by base. Multiplied by sheen,\n// so value is don't-care when sheen=0.\n// The PMX sphere map, applied the way MMD applies it: the texture is a\n// VIEW-SPACE lighting mask, sampled by the camera-space normal rather than by\n// any UV the mesh carries, which is why it tracks the viewer and reads as a\n// highlight. The material's own mode picks the operator \u2014 .sph (1) multiplies\n// the shaded base, .spa (2) adds \u2014 so a graph asks for the effect and the model\n// decides which it meant. Mode 0, or a material with no sphere texture (the 1\u00D71\n// white fallback is bound then), is an exact no-op.\nfn pmx_sphere_map(base: vec3f, strength: f32, N: vec3f) -> vec3f {\n if (material.sphereMode < 0.5) {\n return base;\n }\n let camera_n = safe_normal((camera.view * vec4f(N, 0.0)).xyz);\n let sphere_uv = clamp(camera_n.xy * 0.5 + vec2f(0.5), vec2f(0.0), vec2f(1.0));\n let sphere_rgb = textureSample(sphereTexture, diffuseSampler, sphere_uv).rgb;\n let amount = max(strength, 0.0);\n if (material.sphereMode < 1.5) {\n return mix(base, base * sphere_rgb, min(amount, 1.0));\n }\n return base + sphere_rgb * amount;\n}\n\n/**\n * Principled v2 reflectance: IOR sets it, the level scales it.\n *\n * Blender 3.6 took Specular directly, where 0.5 meant f0 = 0.04. v2 computes\n * f0 from IOR \u2014 ((ior-1)/(ior+1))\u00B2 \u2014 and Specular IOR Level scales that, with\n * 0.5 meaning \"the IOR's own value\". Returned in the 3.6 convention the BSDF\n * below still speaks (f0 = 0.08 \u00B7 specular), so the two agree at their defaults\n * and v2 wins whenever a preset moves either socket.\n */\nfn principled_specular(ior: f32, level: f32) -> f32 {\n let r = (ior - 1.0) / max(ior + 1.0, 1e-6);\n return (r * r * 2.0 * max(level, 0.0)) / 0.08;\n}\n\n// \u2500\u2500\u2500 The scene's lamps, on a PBR closure \u2500\u2500\u2500\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//\n// A lamp reaches a material as rzLightsDiffuse, added outside the graph and\n// multiplied by the raw texture: Lambert and nothing else, because that layer\n// cannot know what the surface is. On a principled closure it can. A floor at\n// roughness 0.23 under forty candles carries forty glints, and a metal\n// REFLECTS a lamp instead of having it painted on \u2014 without this a ported\n// stage is a photograph of itself, lit but never shining.\n//\n// Same reach as _rzLightOne: the inverse square past RZ_LAMP_NEAR, the\n// (1 \u2212 (d/R)\u2074)\u00B2 window that makes the radius real, the squared cone. N\u00B7L is\n// inside bsdf_ggx. Declared here for bsdf_ggx and resolved against the lights\n// API the material prelude brings; the ground pass takes that API without\n// this file and is untouched.\n//\n// It walks the light grid a second time \u2014 the lamps THIS CELL can see, which\n// is what the grid is for \u2014 and with no lamps at all neither walk runs.\n\n// The walk also yields the DIFFUSE layer, against the prelude's normal\n// (_rzLampN, not the mapped N) and in the order rzLightsDiffuse sums it, so the\n// epilogue's rzLightsDiffuseOnce is that function's exact answer without a\n// second pass over the grid. See lights.ts.\nstruct _RzLampPair { d: vec3f, s: vec3f };\n\nfn _rzLampOne(i: u32, p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32, unity: f32) -> _RzLampPair {\n var out = _RzLampPair(vec3f(0.0), vec3f(0.0));\n let pr = _rzLightVec(i, 0u);\n let d = pr.xyz - p;\n let dist = length(d);\n if (dist >= pr.w) { return out; }\n let toLight = d / max(dist, 1e-4);\n let ndlD = max(dot(_rzLampN, toLight), 0.0);\n let ndl = dot(n, toLight);\n if (ndlD <= 0.0 && ndl <= 0.0) { return out; }\n let t = clamp(dist / max(pr.w, 1e-4), 0.0, 1.0);\n let t2 = t * t;\n let window = 1.0 - t2 * t2;\n let falloff = window * window / max(dist * dist, RZ_LAMP_NEAR * RZ_LAMP_NEAR);\n let cone = rzLightCone(i);\n let aim = clamp((dot(-toLight, rzLightAim(i)) - cone.x) / max(cone.y - cone.x, 1e-4), 0.0, 1.0);\n let c = rzLightColor(i);\n if (ndlD > 0.0) { out.d = c * (ndlD * falloff * aim * aim); }\n if (ndl > 0.0) { out.s = c * (_rzDirectLobe(n, toLight, v, ndl, ndv, roughness, unity) * falloff * aim * aim); }\n return out;\n}\n\nfn _rzLampWord(bits0: u32, base: u32, p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32, unity: f32) -> _RzLampPair {\n var acc = _RzLampPair(vec3f(0.0), vec3f(0.0));\n var bits = bits0;\n loop {\n if (bits == 0u) { break; }\n let i = base + firstTrailingBit(bits);\n bits = bits & (bits - 1u);\n let one = _rzLampOne(i, p, n, v, ndv, roughness, unity);\n acc.d = acc.d + one.d;\n acc.s = acc.s + one.s;\n }\n return acc;\n}\n\nfn rzLampsSpecular(p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32, unity: f32) -> vec3f {\n var d = vec3f(0.0);\n var s = vec3f(0.0);\n let count = rzLightCount();\n let docs = _rzLightDocCount();\n if (docs > 0u) {\n let m = _rzLightCellMask(p);\n let wx = _rzLampWord(m.x, 0u, p, n, v, ndv, roughness, unity);\n let wy = _rzLampWord(m.y, 32u, p, n, v, ndv, roughness, unity);\n let wz = _rzLampWord(m.z, 64u, p, n, v, ndv, roughness, unity);\n let ww = _rzLampWord(m.w, 96u, p, n, v, ndv, roughness, unity);\n d = d + wx.d + wy.d + wz.d + ww.d;\n s = s + wx.s + wy.s + wz.s + ww.s;\n }\n for (var i = docs; i < count; i = i + 1u) {\n let one = _rzLampOne(i, p, n, v, ndv, roughness, unity);\n d = d + one.d;\n s = s + one.s;\n }\n // The first closure's walk speaks for the fragment; a second principled in\n // the same graph shares p and the prelude normal, so its answer is the same.\n if (!_rzLampDiffuseSet) {\n _rzLampDiffuse = d * (1.0 / 3.141592653589793);\n _rzLampDiffuseSet = true;\n }\n return s;\n}\n\nstruct PrincipledIn {\n base: vec3f,\n metallic: f32,\n specular: f32,\n roughness: f32,\n spec_clamp: f32,\n sheen: f32,\n sheen_tint: f32,\n reflection_lod: f32,\n unity_direct: f32,\n};\n\nfn eval_principled(\n p: PrincipledIn,\n N: vec3f, L: vec3f, V: vec3f,\n sun_rgb: vec3f, amb_rgb: vec3f, shadow: f32, wp: vec3f\n) -> vec3f {\n let NL = max(dot(N, L), 0.0);\n let NV = max(dot(N, V), 1e-4);\n\n // f0/f90 per gpu_shader_material_principled.glsl. specular_tint=0 is assumed\n // (all presets in this engine use the default white dielectric tint).\n let dielectric_f0 = vec3f(0.08 * p.specular);\n let f0 = mix(dielectric_f0, p.base, p.metallic);\n let f90 = mix(f0, vec3f(1.0), sqrt(p.specular));\n\n // Single LUT tap feeds both F_brdf_multi_scatter (split-sum DFG) and\n // ltc_brdf_scale_from_lut (LTC mag in .ba). See nodes.ts brdf_lut_sample.\n let lut = brdf_lut_sample(NV, p.roughness);\n let reflection_color = F_brdf_multi_scatter(f0, f90, lut.xy);\n\n // Direct glossy \u2014 bsdf_ggx already includes NL; no F applied here (tinted after\n // accum with reflection_color). ltc_brdf_scale rescales direct to match the\n // split-sum indirect path, matching EEVEE closure_eval_glossy_lib behavior.\n // The sun, and every lamp that reaches this point. One clamp over the pair:\n // a candle a hand's width from a polished floor is a firefly otherwise.\n // URP's lobe carries its own normalisation, so the LTC rescale that matches\n // EEVEE's direct light to its split-sum indirect does not apply to it.\n let direct_scale = select(ltc_brdf_scale_from_lut(lut), 1.0, p.unity_direct > 0.5);\n let spec_direct_raw = (_rzDirectLobe(N, L, V, NL, NV, p.roughness, p.unity_direct) * sun_rgb * shadow\n + rzLampsSpecular(wp, N, V, NV, p.roughness, p.unity_direct))\n * direct_scale;\n let spec_direct = min(spec_direct_raw, vec3f(p.spec_clamp));\n // Indirect specular reads the world ALONG THE REFLECTION, at a roughness-\n // picked level \u2014 EEVEE's probe_evaluate_world_spec, where the diffuse half\n // below is probe_evaluate_world_diff along the normal. Taking the diffuse\n // answer for both gave every surface the same flat wash whatever its\n // roughness, so a chrome rail mirrored what a matte wall did and a stage's\n // metal only ever lost its diffuse. A uniform sky gives the same number\n // either way, so this moves direction, not energy.\n let spec_indirect = rzWorldSpecularLod(reflect(-V, N), p.roughness, p.reflection_lod);\n // URP tints a lamp's highlight by f0 alone \u2014 its D\u00B7V\u00B7F already folds the\n // Fresnel in \u2014 where the split-sum reflectance grows toward grazing; applied\n // to the direct term it made a far glossy floor several times brighter than\n // the game draws it. Reflections keep the split sum either way.\n let direct_tint = select(reflection_color, f0, p.unity_direct > 0.5);\n let spec_radiance = spec_direct * direct_tint + spec_indirect * reflection_color;\n\n // Sheen add \u2014 when p.sheen=0 the whole term collapses, leaving diffuse_color=base.\n let base_tint = tint_from_color(p.base);\n let sheen_color = mix(vec3f(1.0), base_tint, p.sheen_tint);\n let diffuse_color = p.base + p.sheen * sheen_color * principled_sheen(NV);\n\n // diffuse_weight = (1-metallic). Indirect diffuse uses amb (L_w) with no \u03C0 factor\n // (probe_evaluate_world_diff returns SH-projected radiance, not cosine-convolved).\n let diffuse_weight = 1.0 - p.metallic;\n var diffuse_radiance = diffuse_color * (sun_rgb * NL * shadow / EEVEE_PI + amb_rgb) * diffuse_weight;\n // The lamps' diffuse, in Unity mode, as URP shades it: THIS surface's albedo\n // \u2014 its tint and its maps, not the raw texture the epilogue would use \u2014\n // times 0.96\u00B7(1 \u2212 metal). X340's floor wears a 0.588 tint the epilogue never\n // saw, and every lamp on it landed 1.7\u00D7 too bright.\n if (p.unity_direct > 0.5 && !_rzLampDiffuseTaken) {\n diffuse_radiance += p.base * (0.96 * (1.0 - p.metallic)) * rzLightsDiffuseOnce(wp, N);\n _rzLampDiffuseTaken = true;\n }\n\n // The cast's shadow on a stage (Unity mode): the game lays its character\n // shadow over the whole lit surface as a multiply toward the shadow colour.\n var lit = diffuse_radiance + spec_radiance;\n if (p.unity_direct > 0.5) {\n lit = lit * mix(vec3f(1.0), light.castShadow.rgb, _rzCastShadow(wp) * light.castShadow.w);\n }\n return lit;\n}\n\n";
2
2
  //# sourceMappingURL=nodes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"nodes.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/nodes.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,UAAU,y1tDAsmCtB,CAAC"}
1
+ {"version":3,"file":"nodes.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/nodes.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,UAAU,w70DAqqCtB,CAAC"}