reze-engine 0.60.4 → 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.
- package/dist/_dbg-loader.mjs +1100 -0
- package/dist/engine.d.ts +121 -5
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +284 -19
- package/dist/graph/compile.d.ts.map +1 -1
- package/dist/graph/compile.js +4 -1
- package/dist/graph/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +14 -2
- package/dist/graph/slots.d.ts +1 -1
- package/dist/graph/slots.d.ts.map +1 -1
- package/dist/graph/slots.js +19 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/physics-debug.d.ts +30 -0
- package/dist/physics-debug.d.ts.map +1 -0
- package/dist/physics-debug.js +526 -0
- package/dist/shaders/lights.d.ts.map +1 -1
- package/dist/shaders/lights.js +19 -0
- package/dist/shaders/materials/body.d.ts +2 -0
- package/dist/shaders/materials/body.d.ts.map +1 -0
- package/dist/shaders/materials/body.js +95 -0
- package/dist/shaders/materials/cloth_rough.d.ts +2 -0
- package/dist/shaders/materials/cloth_rough.d.ts.map +1 -0
- package/dist/shaders/materials/cloth_rough.js +69 -0
- package/dist/shaders/materials/cloth_smooth.d.ts +2 -0
- package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -0
- package/dist/shaders/materials/cloth_smooth.js +61 -0
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +47 -0
- package/dist/shaders/materials/default.d.ts +2 -0
- package/dist/shaders/materials/default.d.ts.map +1 -0
- package/dist/shaders/materials/default.js +43 -0
- package/dist/shaders/materials/eye.d.ts +2 -0
- package/dist/shaders/materials/eye.d.ts.map +1 -0
- package/dist/shaders/materials/eye.js +60 -0
- package/dist/shaders/materials/face.d.ts +2 -0
- package/dist/shaders/materials/face.d.ts.map +1 -0
- package/dist/shaders/materials/face.js +95 -0
- package/dist/shaders/materials/hair.d.ts +2 -0
- package/dist/shaders/materials/hair.d.ts.map +1 -0
- package/dist/shaders/materials/hair.js +90 -0
- package/dist/shaders/materials/metal.d.ts +2 -0
- package/dist/shaders/materials/metal.d.ts.map +1 -0
- package/dist/shaders/materials/metal.js +77 -0
- package/dist/shaders/materials/mmd_classic.d.ts +2 -0
- package/dist/shaders/materials/mmd_classic.d.ts.map +1 -0
- package/dist/shaders/materials/mmd_classic.js +66 -0
- package/dist/shaders/materials/nodes.d.ts +1 -1
- package/dist/shaders/materials/nodes.d.ts.map +1 -1
- package/dist/shaders/materials/nodes.js +110 -23
- package/dist/shaders/materials/stockings.d.ts +2 -0
- package/dist/shaders/materials/stockings.d.ts.map +1 -0
- package/dist/shaders/materials/stockings.js +122 -0
- package/dist/shaders/passes/composite.d.ts +1 -1
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +34 -2
- package/dist/shaders/passes/physics-debug.d.ts +2 -0
- package/dist/shaders/passes/physics-debug.d.ts.map +1 -0
- package/dist/shaders/passes/physics-debug.js +69 -0
- package/dist/shaders/scene-tap.js +1 -1
- package/package.json +1 -1
- package/src/engine.ts +312 -19
- package/src/graph/compile.ts +4 -1
- package/src/graph/registry.ts +14 -2
- package/src/graph/slots.ts +20 -5
- package/src/index.ts +4 -0
- package/src/shaders/lights.ts +19 -0
- package/src/shaders/materials/common.ts +47 -0
- package/src/shaders/materials/nodes.ts +110 -23
- package/src/shaders/passes/composite.ts +34 -2
- package/src/shaders/scene-tap.ts +1 -1
- package/dist/graph/presets/pool_floor.d.ts +0 -3
- package/dist/graph/presets/pool_floor.d.ts.map +0 -1
- package/dist/graph/presets/pool_floor.js +0 -42
- package/dist/graph/presets/water.d.ts +0 -3
- package/dist/graph/presets/water.d.ts.map +0 -1
- package/dist/graph/presets/water.js +0 -31
- package/dist/physics/autofit.d.ts +0 -147
- package/dist/physics/autofit.d.ts.map +0 -1
- package/dist/physics/autofit.js +0 -501
- package/dist/shaders/passes/field-blit.d.ts +0 -26
- package/dist/shaders/passes/field-blit.d.ts.map +0 -1
- package/dist/shaders/passes/field-blit.js +0 -65
- package/dist/shaders/passes/ground-noise.d.ts +0 -7
- package/dist/shaders/passes/ground-noise.d.ts.map +0 -1
- package/dist/shaders/passes/ground-noise.js +0 -88
- package/dist/shaders/passes/sim.d.ts +0 -34
- package/dist/shaders/passes/sim.d.ts.map +0 -1
- package/dist/shaders/passes/sim.js +0 -169
- package/dist/shaders/score-api.d.ts +0 -10
- package/dist/shaders/score-api.d.ts.map +0 -1
- package/dist/shaders/score-api.js +0 -114
- package/dist/shaders/shadow-api.d.ts +0 -29
- package/dist/shaders/shadow-api.d.ts.map +0 -1
- package/dist/shaders/shadow-api.js +0 -112
|
@@ -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\nfn _rzLampSpecOne(i: u32, p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32) -> vec3f {\n let pr = _rzLightVec(i, 0u);\n let d = pr.xyz - p;\n let dist = length(d);\n if (dist >= pr.w) { return vec3f(0.0); }\n let toLight = d / max(dist, 1e-4);\n let ndl = dot(n, toLight);\n if (ndl <= 0.0) { return vec3f(0.0); }\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 return rzLightColor(i) * (bsdf_ggx(n, toLight, v, ndl, ndv, roughness) * falloff * aim * aim);\n}\n\nfn _rzLampSpecWord(bits0: u32, base: u32, p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32) -> vec3f {\n var acc = 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 acc = acc + _rzLampSpecOne(i, p, n, v, ndv, roughness);\n }\n return acc;\n}\n\nfn rzLampsSpecular(p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32) -> vec3f {\n var acc = vec3f(0.0);\n let count = rzLightCount();\n let docs = _rzLightDocCount();\n if (docs > 0u) {\n let m = _rzLightCellMask(p);\n acc = acc + _rzLampSpecWord(m.x, 0u, p, n, v, ndv, roughness) +\n _rzLampSpecWord(m.y, 32u, p, n, v, ndv, roughness) +\n _rzLampSpecWord(m.z, 64u, p, n, v, ndv, roughness) +\n _rzLampSpecWord(m.w, 96u, p, n, v, ndv, roughness);\n }\n for (var i = docs; i < count; i = i + 1u) {\n acc = acc + _rzLampSpecOne(i, p, n, v, ndv, roughness);\n }\n return acc;\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,
|
|
1
|
+
{"version":3,"file":"nodes.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/nodes.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,UAAU,w70DAqqCtB,CAAC"}
|
|
@@ -311,6 +311,21 @@ fn point_world_to_camera(p: vec3f) -> vec3f { return (camera.view * vec4f(p, 1.0
|
|
|
311
311
|
* texture sees no change at all.
|
|
312
312
|
*/
|
|
313
313
|
fn rzWorldSpecular(dir: vec3f, roughness: f32) -> vec3f {
|
|
314
|
+
return rzWorldSpecularLod(dir, roughness, 0.0);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The same, with the blur curve chosen. unity = 0 is the sqrt ramp above; 1 is
|
|
319
|
+
* Unity's reflection-probe lookup, mip = p·(1.7 − 0.7p)·8 — the curve a game's
|
|
320
|
+
* glossy floor was tuned under, about a level sharper than sqrt through the
|
|
321
|
+
* glossy range (roughness 0.1–0.3) and the same by 0.5.
|
|
322
|
+
*
|
|
323
|
+
* Its levels map one to one onto this chain's: a cube of N texels a face and an
|
|
324
|
+
* equirect 4N wide resolve the same angle per texel, so a cube's last level —
|
|
325
|
+
* one texel a face — is this chain's log2(width/4), and the curve stops there
|
|
326
|
+
* rather than averaging the whole sky, as the game's does.
|
|
327
|
+
*/
|
|
328
|
+
fn rzWorldSpecularLod(dir: vec3f, roughness: f32, unity: f32) -> vec3f {
|
|
314
329
|
// A SKY TEXTURE, or the best the scene has. The flag is 2 when an HDRI is
|
|
315
330
|
// installed, 1 when the world is a gradient fitted to the same harmonics,
|
|
316
331
|
// and 0 when it is one flat colour — and in the last two cases the ambient
|
|
@@ -326,7 +341,9 @@ fn rzWorldSpecular(dir: vec3f, roughness: f32) -> vec3f {
|
|
|
326
341
|
// sqrt, not linear: mip n covers roughly twice the angle of n-1, so a linear
|
|
327
342
|
// ramp spends most of its travel in the blurry end and a mirror-to-satin
|
|
328
343
|
// sweep happens entirely in the first tenth of the dial.
|
|
329
|
-
let
|
|
344
|
+
let r = clamp(roughness, 0.0, 1.0);
|
|
345
|
+
let unityLod = min(r * (1.7 - 0.7 * r) * 8.0, max(levels - 2.0, 0.0));
|
|
346
|
+
let lod = clamp(mix(sqrt(r) * levels, unityLod, unity), 0.0, levels);
|
|
330
347
|
return textureSampleLevel(worldEnvTexture, diffuseSampler, vec2f(u, v), lod).rgb * light.ambientColor.w;
|
|
331
348
|
}
|
|
332
349
|
|
|
@@ -865,6 +882,28 @@ fn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32
|
|
|
865
882
|
return NL * a2 / (D_opti * G);
|
|
866
883
|
}
|
|
867
884
|
|
|
885
|
+
// URP's direct-light specular, as a game on URP (or a fork of it) shades its
|
|
886
|
+
// lamps and sun: D·V·F folded into a2 / (d²·max(0.1, LoH²)·(4a + 2)), a the
|
|
887
|
+
// SQUARED roughness. Unity folds π into its light units, and a light that came
|
|
888
|
+
// from one reaches this engine with that π restored (see the app's stage
|
|
889
|
+
// loader), so it comes back out here. Returns BRDF × NL, like bsdf_ggx.
|
|
890
|
+
fn bsdf_urp(N: vec3f, L: vec3f, V: vec3f, NL: f32, roughness: f32) -> f32 {
|
|
891
|
+
let a = max(roughness * roughness, 6.1035156e-5);
|
|
892
|
+
let a2 = a * a;
|
|
893
|
+
let H = normalize(L + V);
|
|
894
|
+
let NH = max(dot(N, H), 0.0);
|
|
895
|
+
let LH = max(dot(L, H), 0.0);
|
|
896
|
+
let d = NH * NH * (a2 - 1.0) + 1.00001;
|
|
897
|
+
return a2 / (d * d * max(0.1, LH * LH) * (a * 4.0 + 2.0)) * NL / EEVEE_PI;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** The direct-light lobe a principled closure asked for: 0 the engine's own,
|
|
901
|
+
* 1 URP's (principled's unity_direct). */
|
|
902
|
+
fn _rzDirectLobe(N: vec3f, L: vec3f, V: vec3f, NL: f32, NV: f32, roughness: f32, unity: f32) -> f32 {
|
|
903
|
+
if (unity > 0.5) { return bsdf_urp(N, L, V, NL, roughness); }
|
|
904
|
+
return bsdf_ggx(N, L, V, NL, NV, roughness);
|
|
905
|
+
}
|
|
906
|
+
|
|
868
907
|
// Split-sum DFG LUT — Karis 2013 curve fit stand-in for the 64×64 baked LUT.
|
|
869
908
|
// Returns (lut.x, lut.y) in Blender convention: tint = f0·lut.x + f90·lut.y.
|
|
870
909
|
fn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {
|
|
@@ -996,50 +1035,74 @@ fn principled_specular(ior: f32, level: f32) -> f32 {
|
|
|
996
1035
|
// It walks the light grid a second time — the lamps THIS CELL can see, which
|
|
997
1036
|
// is what the grid is for — and with no lamps at all neither walk runs.
|
|
998
1037
|
|
|
999
|
-
|
|
1038
|
+
// The walk also yields the DIFFUSE layer, against the prelude's normal
|
|
1039
|
+
// (_rzLampN, not the mapped N) and in the order rzLightsDiffuse sums it, so the
|
|
1040
|
+
// epilogue's rzLightsDiffuseOnce is that function's exact answer without a
|
|
1041
|
+
// second pass over the grid. See lights.ts.
|
|
1042
|
+
struct _RzLampPair { d: vec3f, s: vec3f };
|
|
1043
|
+
|
|
1044
|
+
fn _rzLampOne(i: u32, p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32, unity: f32) -> _RzLampPair {
|
|
1045
|
+
var out = _RzLampPair(vec3f(0.0), vec3f(0.0));
|
|
1000
1046
|
let pr = _rzLightVec(i, 0u);
|
|
1001
1047
|
let d = pr.xyz - p;
|
|
1002
1048
|
let dist = length(d);
|
|
1003
|
-
if (dist >= pr.w) { return
|
|
1049
|
+
if (dist >= pr.w) { return out; }
|
|
1004
1050
|
let toLight = d / max(dist, 1e-4);
|
|
1051
|
+
let ndlD = max(dot(_rzLampN, toLight), 0.0);
|
|
1005
1052
|
let ndl = dot(n, toLight);
|
|
1006
|
-
if (ndl <= 0.0) { return
|
|
1053
|
+
if (ndlD <= 0.0 && ndl <= 0.0) { return out; }
|
|
1007
1054
|
let t = clamp(dist / max(pr.w, 1e-4), 0.0, 1.0);
|
|
1008
1055
|
let t2 = t * t;
|
|
1009
1056
|
let window = 1.0 - t2 * t2;
|
|
1010
1057
|
let falloff = window * window / max(dist * dist, RZ_LAMP_NEAR * RZ_LAMP_NEAR);
|
|
1011
1058
|
let cone = rzLightCone(i);
|
|
1012
1059
|
let aim = clamp((dot(-toLight, rzLightAim(i)) - cone.x) / max(cone.y - cone.x, 1e-4), 0.0, 1.0);
|
|
1013
|
-
|
|
1060
|
+
let c = rzLightColor(i);
|
|
1061
|
+
if (ndlD > 0.0) { out.d = c * (ndlD * falloff * aim * aim); }
|
|
1062
|
+
if (ndl > 0.0) { out.s = c * (_rzDirectLobe(n, toLight, v, ndl, ndv, roughness, unity) * falloff * aim * aim); }
|
|
1063
|
+
return out;
|
|
1014
1064
|
}
|
|
1015
1065
|
|
|
1016
|
-
fn
|
|
1017
|
-
var acc = vec3f(0.0);
|
|
1066
|
+
fn _rzLampWord(bits0: u32, base: u32, p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32, unity: f32) -> _RzLampPair {
|
|
1067
|
+
var acc = _RzLampPair(vec3f(0.0), vec3f(0.0));
|
|
1018
1068
|
var bits = bits0;
|
|
1019
1069
|
loop {
|
|
1020
1070
|
if (bits == 0u) { break; }
|
|
1021
1071
|
let i = base + firstTrailingBit(bits);
|
|
1022
1072
|
bits = bits & (bits - 1u);
|
|
1023
|
-
|
|
1073
|
+
let one = _rzLampOne(i, p, n, v, ndv, roughness, unity);
|
|
1074
|
+
acc.d = acc.d + one.d;
|
|
1075
|
+
acc.s = acc.s + one.s;
|
|
1024
1076
|
}
|
|
1025
1077
|
return acc;
|
|
1026
1078
|
}
|
|
1027
1079
|
|
|
1028
|
-
fn rzLampsSpecular(p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32) -> vec3f {
|
|
1029
|
-
var
|
|
1080
|
+
fn rzLampsSpecular(p: vec3f, n: vec3f, v: vec3f, ndv: f32, roughness: f32, unity: f32) -> vec3f {
|
|
1081
|
+
var d = vec3f(0.0);
|
|
1082
|
+
var s = vec3f(0.0);
|
|
1030
1083
|
let count = rzLightCount();
|
|
1031
1084
|
let docs = _rzLightDocCount();
|
|
1032
1085
|
if (docs > 0u) {
|
|
1033
1086
|
let m = _rzLightCellMask(p);
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1087
|
+
let wx = _rzLampWord(m.x, 0u, p, n, v, ndv, roughness, unity);
|
|
1088
|
+
let wy = _rzLampWord(m.y, 32u, p, n, v, ndv, roughness, unity);
|
|
1089
|
+
let wz = _rzLampWord(m.z, 64u, p, n, v, ndv, roughness, unity);
|
|
1090
|
+
let ww = _rzLampWord(m.w, 96u, p, n, v, ndv, roughness, unity);
|
|
1091
|
+
d = d + wx.d + wy.d + wz.d + ww.d;
|
|
1092
|
+
s = s + wx.s + wy.s + wz.s + ww.s;
|
|
1038
1093
|
}
|
|
1039
1094
|
for (var i = docs; i < count; i = i + 1u) {
|
|
1040
|
-
|
|
1095
|
+
let one = _rzLampOne(i, p, n, v, ndv, roughness, unity);
|
|
1096
|
+
d = d + one.d;
|
|
1097
|
+
s = s + one.s;
|
|
1041
1098
|
}
|
|
1042
|
-
|
|
1099
|
+
// The first closure's walk speaks for the fragment; a second principled in
|
|
1100
|
+
// the same graph shares p and the prelude normal, so its answer is the same.
|
|
1101
|
+
if (!_rzLampDiffuseSet) {
|
|
1102
|
+
_rzLampDiffuse = d * (1.0 / 3.141592653589793);
|
|
1103
|
+
_rzLampDiffuseSet = true;
|
|
1104
|
+
}
|
|
1105
|
+
return s;
|
|
1043
1106
|
}
|
|
1044
1107
|
|
|
1045
1108
|
struct PrincipledIn {
|
|
@@ -1050,6 +1113,8 @@ struct PrincipledIn {
|
|
|
1050
1113
|
spec_clamp: f32,
|
|
1051
1114
|
sheen: f32,
|
|
1052
1115
|
sheen_tint: f32,
|
|
1116
|
+
reflection_lod: f32,
|
|
1117
|
+
unity_direct: f32,
|
|
1053
1118
|
};
|
|
1054
1119
|
|
|
1055
1120
|
fn eval_principled(
|
|
@@ -1076,9 +1141,12 @@ fn eval_principled(
|
|
|
1076
1141
|
// split-sum indirect path, matching EEVEE closure_eval_glossy_lib behavior.
|
|
1077
1142
|
// The sun, and every lamp that reaches this point. One clamp over the pair:
|
|
1078
1143
|
// a candle a hand's width from a polished floor is a firefly otherwise.
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1144
|
+
// URP's lobe carries its own normalisation, so the LTC rescale that matches
|
|
1145
|
+
// EEVEE's direct light to its split-sum indirect does not apply to it.
|
|
1146
|
+
let direct_scale = select(ltc_brdf_scale_from_lut(lut), 1.0, p.unity_direct > 0.5);
|
|
1147
|
+
let spec_direct_raw = (_rzDirectLobe(N, L, V, NL, NV, p.roughness, p.unity_direct) * sun_rgb * shadow
|
|
1148
|
+
+ rzLampsSpecular(wp, N, V, NV, p.roughness, p.unity_direct))
|
|
1149
|
+
* direct_scale;
|
|
1082
1150
|
let spec_direct = min(spec_direct_raw, vec3f(p.spec_clamp));
|
|
1083
1151
|
// Indirect specular reads the world ALONG THE REFLECTION, at a roughness-
|
|
1084
1152
|
// picked level — EEVEE's probe_evaluate_world_spec, where the diffuse half
|
|
@@ -1087,8 +1155,13 @@ fn eval_principled(
|
|
|
1087
1155
|
// roughness, so a chrome rail mirrored what a matte wall did and a stage's
|
|
1088
1156
|
// metal only ever lost its diffuse. A uniform sky gives the same number
|
|
1089
1157
|
// either way, so this moves direction, not energy.
|
|
1090
|
-
let spec_indirect =
|
|
1091
|
-
|
|
1158
|
+
let spec_indirect = rzWorldSpecularLod(reflect(-V, N), p.roughness, p.reflection_lod);
|
|
1159
|
+
// URP tints a lamp's highlight by f0 alone — its D·V·F already folds the
|
|
1160
|
+
// Fresnel in — where the split-sum reflectance grows toward grazing; applied
|
|
1161
|
+
// to the direct term it made a far glossy floor several times brighter than
|
|
1162
|
+
// the game draws it. Reflections keep the split sum either way.
|
|
1163
|
+
let direct_tint = select(reflection_color, f0, p.unity_direct > 0.5);
|
|
1164
|
+
let spec_radiance = spec_direct * direct_tint + spec_indirect * reflection_color;
|
|
1092
1165
|
|
|
1093
1166
|
// Sheen add — when p.sheen=0 the whole term collapses, leaving diffuse_color=base.
|
|
1094
1167
|
let base_tint = tint_from_color(p.base);
|
|
@@ -1098,9 +1171,23 @@ fn eval_principled(
|
|
|
1098
1171
|
// diffuse_weight = (1-metallic). Indirect diffuse uses amb (L_w) with no π factor
|
|
1099
1172
|
// (probe_evaluate_world_diff returns SH-projected radiance, not cosine-convolved).
|
|
1100
1173
|
let diffuse_weight = 1.0 - p.metallic;
|
|
1101
|
-
|
|
1174
|
+
var diffuse_radiance = diffuse_color * (sun_rgb * NL * shadow / EEVEE_PI + amb_rgb) * diffuse_weight;
|
|
1175
|
+
// The lamps' diffuse, in Unity mode, as URP shades it: THIS surface's albedo
|
|
1176
|
+
// — its tint and its maps, not the raw texture the epilogue would use —
|
|
1177
|
+
// times 0.96·(1 − metal). X340's floor wears a 0.588 tint the epilogue never
|
|
1178
|
+
// saw, and every lamp on it landed 1.7× too bright.
|
|
1179
|
+
if (p.unity_direct > 0.5 && !_rzLampDiffuseTaken) {
|
|
1180
|
+
diffuse_radiance += p.base * (0.96 * (1.0 - p.metallic)) * rzLightsDiffuseOnce(wp, N);
|
|
1181
|
+
_rzLampDiffuseTaken = true;
|
|
1182
|
+
}
|
|
1102
1183
|
|
|
1103
|
-
|
|
1184
|
+
// The cast's shadow on a stage (Unity mode): the game lays its character
|
|
1185
|
+
// shadow over the whole lit surface as a multiply toward the shadow colour.
|
|
1186
|
+
var lit = diffuse_radiance + spec_radiance;
|
|
1187
|
+
if (p.unity_direct > 0.5) {
|
|
1188
|
+
lit = lit * mix(vec3f(1.0), light.castShadow.rgb, _rzCastShadow(wp) * light.castShadow.w);
|
|
1189
|
+
}
|
|
1190
|
+
return lit;
|
|
1104
1191
|
}
|
|
1105
1192
|
|
|
1106
1193
|
`;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const STOCKINGS_SHADER_WGSL = "\n\n\n\n// Baked 64\u00D764 rgba8unorm combined BRDF LUT \u2014 created once at engine init by dfg_lut.ts.\n// .rg = split-sum DFG (Karis: tint = f0\u00B7x + f90\u00B7y) \u2192 F_brdf_*_scatter\n// .ba = Heitz 2016 LTC magnitude (ltc_mag_ggx) \u2192 ltc_brdf_scale_from_lut\n// Paired with group(0) binding(2) diffuseSampler (linear filter). Sample once per\n// fragment via brdf_lut_sample() \u2014 callers feed .rg and the whole vec4 into the\n// helpers below, halving LUT taps on the default Principled path.\n@group(0) @binding(9) var brdfLut: texture_2d<f32>;\n\n// \u2500\u2500\u2500 RGB \u2194 HSV \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn rgb_to_hsv(rgb: vec3f) -> vec3f {\n let c_max = max(rgb.r, max(rgb.g, rgb.b));\n let c_min = min(rgb.r, min(rgb.g, rgb.b));\n let delta = c_max - c_min;\n\n var h = 0.0;\n if (delta > 1e-6) {\n if (c_max == rgb.r) {\n h = (rgb.g - rgb.b) / delta;\n if (h < 0.0) { h += 6.0; }\n } else if (c_max == rgb.g) {\n h = 2.0 + (rgb.b - rgb.r) / delta;\n } else {\n h = 4.0 + (rgb.r - rgb.g) / delta;\n }\n h /= 6.0;\n }\n let s = select(0.0, delta / c_max, c_max > 1e-6);\n return vec3f(h, s, c_max);\n}\n\nfn hsv_to_rgb(hsv: vec3f) -> vec3f {\n let h = hsv.x;\n let s = hsv.y;\n let v = hsv.z;\n if (s < 1e-6) { return vec3f(v); }\n\n let hh = fract(h) * 6.0;\n let sector = u32(hh);\n let f = hh - f32(sector);\n let p = v * (1.0 - s);\n let q = v * (1.0 - s * f);\n let t = v * (1.0 - s * (1.0 - f));\n\n switch (sector) {\n case 0u: { return vec3f(v, t, p); }\n case 1u: { return vec3f(q, v, p); }\n case 2u: { return vec3f(p, v, t); }\n case 3u: { return vec3f(p, q, v); }\n case 4u: { return vec3f(t, p, v); }\n default: { return vec3f(v, p, q); }\n }\n}\n\n// \u2500\u2500\u2500 HUE_SAT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn hue_sat(hue: f32, saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n var hsv = rgb_to_hsv(color);\n hsv.x = fract(hsv.x + hue - 0.5);\n hsv.y = clamp(hsv.y * saturation, 0.0, 1.0);\n hsv.z *= value;\n return mix(color, hsv_to_rgb(hsv), fac);\n}\n\n// hue_sat specialization for hue=0.5 (identity hue shift \u2014 fract(h + 0.5 - 0.5) = h).\n// Branchless equivalent that skips the rgb_to_hsv \u2192 hsv_to_rgb roundtrip: WebKit's\n// Metal backend serializes the 3-way if chain in rgb_to_hsv and the 6-way switch in\n// hsv_to_rgb, where this form compiles to linear SIMD ops + a single select.\nfn hue_sat_id(saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n let m = max(max(color.r, color.g), color.b);\n let n = min(min(color.r, color.g), color.b);\n // Unclamped (sat*old_s \u2264 1): reproj = mix(vec3f(m), color, saturation).\n // Clamped (saturated to 1): reproj = (color - n) * m / (m - n).\n let range = max(m - n, 1e-6);\n let unclamped = mix(vec3f(m), color, saturation);\n let clamped = (color - vec3f(n)) * m / range;\n let needs_clamp = (m - n) * saturation >= m;\n let reproj = select(unclamped, clamped, needs_clamp);\n return mix(color, reproj * value, fac);\n}\n\n// \u2500\u2500\u2500 BRIGHTCONTRAST node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn bright_contrast(color: vec3f, bright: f32, contrast: f32) -> vec3f {\n let a = 1.0 + contrast;\n let b = bright - contrast * 0.5;\n return max(vec3f(0.0), color * a + vec3f(b));\n}\n\n// \u2500\u2500\u2500 INVERT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn invert(fac: f32, color: vec3f) -> vec3f {\n return mix(color, vec3f(1.0) - color, fac);\n}\n\nfn invert_f(fac: f32, val: f32) -> f32 {\n return mix(val, 1.0 - val, fac);\n}\n\n// \u2500\u2500\u2500 Color ramp (VALTORGB) \u2014 2-stop variants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// All 7 presets use exclusively 2-stop ramps.\n\nfn ramp_constant(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n return select(c0, c1, f >= p1);\n}\n\n// CONSTANT ramp with screen-space edge AA \u2014 kills sparkle where fwidth(f) straddles a hard step (NPR terminator)\nfn ramp_constant_edge_aa(f: f32, edge: f32, c0: vec4f, c1: vec4f) -> vec4f {\n let w = max(fwidth(f) * 1.75, 6e-6);\n let t = smoothstep(edge - w, edge + w, f);\n return mix(c0, c1, t);\n}\n\nfn ramp_linear(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n return mix(c0, c1, t);\n}\n\nfn ramp_cardinal(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n // cardinal spline with 2 stops degrades to smoothstep\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n let ss = t * t * (3.0 - 2.0 * t);\n return mix(c0, c1, ss);\n}\n\n// \u2500\u2500\u2500 MATH node operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn math_add(a: f32, b: f32) -> f32 { return a + b; }\nfn math_multiply(a: f32, b: f32) -> f32 { return a * b; }\nfn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }\nfn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }\n\n// Blender's implicit Color \u2192 Float socket conversion uses BT.601 grayscale\n// (rgb_to_grayscale in blenkernel/intern/node.cc). When a material graph plugs a\n// Color output into a Math node's Value input, this is the scalar it actually sees.\nfn color_to_value(c: vec3f) -> f32 {\n return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;\n}\n\n// \u2500\u2500\u2500 MIX node (blend_type variants) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn mix_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, b, fac);\n}\n\nfn mix_overlay(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let lo = 2.0 * a * b;\n let hi = vec3f(1.0) - 2.0 * (vec3f(1.0) - a) * (vec3f(1.0) - b);\n let overlay = select(hi, lo, a < vec3f(0.5));\n return mix(a, overlay, fac);\n}\n\nfn mix_multiply(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a * b, fac);\n}\n\nfn mix_lighten(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, max(a, b), fac);\n}\n\n// Blender Mix (Color) blend LINEAR_LIGHT: result = mix(A, A + 2*B - 1, Fac)\nfn mix_linear_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a + 2.0 * b - vec3f(1.0), fac);\n}\n\n// Luminance for Shader\u2192RGB scalar gates (linear RGB, Rec.709 weights)\nfn luminance_rec709_linear(c: vec3f) -> f32 {\n return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));\n}\n\n// \u2500\u2500\u2500 FRESNEL node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Schlick approximation matching Blender's Fresnel node\n\nfn fresnel(ior: f32, n: vec3f, v: vec3f) -> f32 {\n let r = (ior - 1.0) / (ior + 1.0);\n let f0 = r * r;\n let cos_theta = clamp(dot(n, v), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\n// \u2500\u2500\u2500 LAYER_WEIGHT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn layer_weight_fresnel(blend: f32, n: vec3f, v: vec3f) -> f32 {\n let eta = max(1.0 - blend, 1e-4);\n let r = (1.0 - eta) / (1.0 + eta);\n let f0 = r * r;\n let cos_theta = clamp(abs(dot(n, v)), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\nfn layer_weight_facing(blend: f32, n: vec3f, v: vec3f) -> f32 {\n var facing = abs(dot(n, v));\n let b = clamp(blend, 0.0, 0.99999);\n if (b != 0.5) {\n let exponent = select(2.0 * b, 0.5 / (1.0 - b), b >= 0.5);\n facing = pow(facing, exponent);\n }\n return 1.0 - facing;\n}\n\n// \u2500\u2500\u2500 SHADER_TO_RGB (white DiffuseBSDF) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Eevee captures lit diffuse: (albedo/\u03C0)*sun*N\u00B7L*shadow + ambient (linear). Albedo=1.\n// Matches default.ts direct term scale so VALTORGB thresholds from Blender JSON stay valid.\n\nfn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> f32 {\n const PI_S: f32 = 3.141592653589793;\n let ndotl = max(dot(n, l), 0.0);\n let rgb = sun_rgb * (ndotl * shadow / PI_S) + ambient_rgb;\n return luminance_rec709_linear(rgb);\n}\n\n// \u2500\u2500\u2500 BUMP node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Screen-space bump from a scalar height field. Needs dFdx/dFdy which\n// WGSL provides as dpdx/dpdy.\n\nfn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) + dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference\nfn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// \u2500\u2500\u2500 NOISE texture (Perlin-style) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Simplified gradient noise matching Blender's default noise output.\n\n// PCG-style integer hash. Replaces the classic 'fract(sin(q) * LARGE)' trick because\n// WebKit's Metal backend compiles 'sin' to a full transcendental op (slow), while\n// Safari's Apple-GPU scalar ALU handles int muls/xors near free. Inputs arrive as\n// integer-valued floats (floor(p) + unit offsets) from _noise3, so vec3i cast is exact.\nfn _hash33(p: vec3f) -> vec3f {\n var h = vec3u(vec3i(p) + vec3i(32768));\n h = h * vec3u(1664525u, 1013904223u, 2654435761u);\n h = (h.yzx ^ h) * vec3u(2246822519u, 3266489917u, 668265263u);\n h = h ^ (h >> vec3u(16u));\n // Mask to 24 bits \u2014 above that f32 loses precision on the u32\u2192f32 convert.\n let hm = h & vec3u(16777215u);\n return vec3f(hm) * (2.0 / 16777216.0) - 1.0;\n}\n\nfn _noise3(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return mix(\n mix(\n mix(dot(_hash33(i + vec3f(0,0,0)), f - vec3f(0,0,0)),\n dot(_hash33(i + vec3f(1,0,0)), f - vec3f(1,0,0)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,0)), f - vec3f(0,1,0)),\n dot(_hash33(i + vec3f(1,1,0)), f - vec3f(1,1,0)), u.x), u.y),\n mix(\n mix(dot(_hash33(i + vec3f(0,0,1)), f - vec3f(0,0,1)),\n dot(_hash33(i + vec3f(1,0,1)), f - vec3f(1,0,1)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,1)), f - vec3f(0,1,1)),\n dot(_hash33(i + vec3f(1,1,1)), f - vec3f(1,1,1)), u.x), u.y),\n u.z);\n}\n\nfn tex_noise(p: vec3f, scale: f32, detail: f32, roughness: f32, distortion: f32) -> f32 {\n var q = p;\n if (abs(distortion) > 1e-6) {\n let w = _noise3(p * scale * 1.37 + vec3f(2.31, 5.17, 8.09));\n q = p + (w * 2.0 - 1.0) * distortion;\n }\n let coords = q * scale;\n var value = 0.0;\n var amplitude = 1.0;\n var frequency = 1.0;\n var total_amp = 0.0;\n let octaves = i32(clamp(detail, 0.0, 15.0)) + 1;\n for (var i = 0; i < octaves; i++) {\n value += amplitude * _noise3(coords * frequency);\n total_amp += amplitude;\n amplitude *= roughness;\n frequency *= 2.0;\n }\n return value / max(total_amp, 1e-6) * 0.5 + 0.5;\n}\n\n// tex_noise specialization: detail=2.0 (3 octaves), roughness=0.5, distortion=0.\n// WebKit can't unroll tex_noise's for-loop because 'octaves' is a runtime value;\n// this variant is fully unrolled with constants folded (total_amp = 1.75).\nfn tex_noise_d2(p: vec3f, scale: f32) -> f32 {\n let c = p * scale;\n let v = _noise3(c) + 0.5 * _noise3(c * 2.0) + 0.25 * _noise3(c * 4.0);\n return v * (1.0 / 1.75) * 0.5 + 0.5;\n}\n\n// \u2500\u2500\u2500 TEX_GRADIENT (linear) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Stockings preset. Maps the input vector's X to a 0\u20131 gradient.\n\nfn tex_gradient_linear(uv: vec3f) -> f32 {\n return clamp(uv.x, 0.0, 1.0);\n}\n\n// \u2500\u2500\u2500 TEX_VORONOI \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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.\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\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 let spec_direct_raw = bsdf_ggx(N, L, V, NL, NV, p.roughness)\n * sun_rgb * shadow * ltc_brdf_scale_from_lut(lut);\n let spec_direct = min(spec_direct_raw, vec3f(p.spec_clamp));\n let spec_indirect = amb_rgb;\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\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// Principled params from dump (Alpha=0.95 intentionally dropped \u2014 see hash note below).\nconst STOCK_METALLIC: f32 = 0.1;\nconst STOCK_SPECULAR: f32 = 1.0;\nconst STOCK_ROUGHNESS: f32 = 0.5;\nconst STOCK_SHEEN: f32 = 0.7017999887466431;\nconst STOCK_SHEEN_TINT: f32 = 0.5;\n// NPR mask ramps\nconst STOCK_RAMP002_P1: f32 = 0.9565; // EASE [0\u2192black, 0.9565\u2192white]\nconst STOCK_RAMPFACE_P1: f32 = 0.5435; // EASE [0\u2192black, 0.5435\u2192white]\nconst STOCK_LW_BLEND: f32 = 0.4; // Layer Weight Blend\n\n// Wyman & McGuire \"Hashed Alpha Testing\" (2017) \u2014 object-space hash with derivative-aware\n// pixel-scale selection, matches Blender EEVEE prepass_frag.glsl::hashed_alpha_threshold.\n// Key property: dither pattern is stable in object space (doesn't swim) and stays at\n// one-pixel frequency regardless of view distance, which makes it tolerable without TAA.\n// The hash grid is fed the bind-pose restPos (not worldPos), so the dots stay pinned to\n// the fabric even under root (\u30BB\u30F3\u30BF\u30FC) motion \u2014 worldPos would crawl across the surface.\n// Since MMD skinning is rigid, length(dpdx(restPos)) == length(dpdx(worldPos)), so the\n// pixel-scale selection is unaffected by the space change.\n// Grid samples reuse the PCG _hash33 from nodes.ts (integer-lattice inputs, Metal-friendly)\n// instead of a fract(sin()) hash \u2014 sin compiles to a slow transcendental on Apple's Metal\n// backend and this runs per-fragment on every stockings pixel.\nfn _hash3d_wm(a: vec3f) -> f32 {\n return _hash33(a).x * 0.5 + 0.5;\n}\nfn hashed_alpha_threshold(co: vec3f) -> f32 {\n let alphaHashScale: f32 = 1.0;\n let max_deriv = max(length(dpdx(co)), length(dpdy(co)));\n let pix_scale = 1.0 / max(alphaHashScale * max_deriv, 1e-6);\n let pix_scale_log = log2(pix_scale);\n let px_lo = exp2(floor(pix_scale_log));\n let px_hi = exp2(ceil(pix_scale_log));\n let a_lo = _hash3d_wm(floor(px_lo * co));\n let a_hi = _hash3d_wm(floor(px_hi * co));\n let fac = fract(pix_scale_log);\n let x = mix(a_lo, a_hi, fac);\n // CDF remap so discard-probability = (1 - alpha) uniformly across scale transitions\n let a = min(fac, 1.0 - fac);\n let one_a = 1.0 - a;\n let denom = 1.0 / max(2.0 * a * one_a, 1e-6);\n let one_x = 1.0 - x;\n let case_lo = (x * x) * denom;\n let case_mid = (x - 0.5 * a) / max(one_a, 1e-6);\n let case_hi = 1.0 - (one_x * one_x) * denom;\n var threshold = select(case_hi, select(case_lo, case_mid, x >= a), x < one_a);\n return clamp(threshold, 1e-6, 1.0);\n}\n\n// Smoothstep-based EASE ramp (Blender VALTORGB EASE) \u2014 2 stops, saturate+smoothstep between\nfn ramp_ease_s(f: f32, p0: f32, p1: f32) -> f32 {\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n return t * t * (3.0 - 2.0 * t);\n}\n\n@fragment fn fs(input: VertexOutput) -> FSOut {\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 let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);\n let tex_rgb = tex_s.rgb;\n // Alpha HASHED (Blender EEVEE \"Hashed\" blend mode) per preset author's note \u2014 self-overlap\n // on the stockings produces sort cracks under alpha blend. Wyman-style object-space hash +\n // depth-write is sort-independent. NOTE: Principled.Alpha=0.95 from the dump is DROPPED;\n // it relies on TAA to smooth the 5%-everywhere dither, and without TAA it shows as a\n // pervasive dot pattern. Hash now gates only on texture/material alpha.\n let combined_alpha = material.alpha * tex_s.a;\n if (combined_alpha < hashed_alpha_threshold(input.restPos)) { discard; }\n\n // \u2550\u2550\u2550 NPR MASK \u2550\u2550\u2550 TEX_COORD.Generated \u2192 Mapping(Rot=0,\u03C0/2,\u03C0/2, Loc=(1,1,1)) \u2192 Gradient.\n // The Blender mapping reduces to gradient.x = 1 - input.y (rot swaps axes, loc offsets).\n // We approximate Generated with UV since Y-up PMX has no object bbox in pipeline state.\n let gen_coord = vec3f(input.uv, 0.0);\n let mapped = mapping_point(gen_coord, vec3f(1.0), vec3f(0.0, 1.5708, 1.5708), vec3f(1.0));\n let gradient = tex_gradient_linear(mapped);\n\n // Ramp.001 LINEAR [0\u2192black, 0.5\u2192white, 1.0\u2192black] \u2014 triangular peak at 0.5\n let ramp001 = 1.0 - abs(2.0 * gradient - 1.0);\n let ramp002 = ramp_ease_s(ramp001, 0.0, STOCK_RAMP002_P1);\n\n let facing = layer_weight_facing(STOCK_LW_BLEND, n, v);\n let ramp_face = ramp_ease_s(facing, 0.0, STOCK_RAMPFACE_P1);\n\n // Mix.001: MIX blend Fac=0.5, A=white, B=ramp_face\n let mix001 = mix(1.0, ramp_face, 0.5);\n // Mix: LIGHTEN blend Fac=0.5, A=mix001, B=ramp002\n let lighten = max(mix001, ramp002);\n let mask = mix(mix001, lighten, 0.5);\n\n // \u2550\u2550\u2550 EMISSION SHADER \u2550\u2550\u2550 Hue=0.5 (identity), Sat=1.0, Val=5.0 (5\u00D7 brightness), Fac=1.\n let emission = hue_sat_id(1.0, 5.0, 1.0, tex_rgb);\n\n // \u2550\u2550\u2550 PRINCIPLED BSDF with sheen \u2550\u2550\u2550 metallic=0.1, sheen=0.7, sheen_tint=0.5.\n let principled = eval_principled(\n PrincipledIn(tex_rgb, STOCK_METALLIC, STOCK_SPECULAR, STOCK_ROUGHNESS, 1e30, STOCK_SHEEN, STOCK_SHEEN_TINT),\n n, l, v, sun, amb, shadow\n );\n\n // MIX SHADER: Shader=Emission, Shader_001=Principled, Fac=mask\n let final_color = mix(emission, principled, mask);\n\n var out: FSOut;\n out.color = vec4f(final_color, 1.0);\n out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);\n return out;\n}\n\n";
|
|
2
|
+
//# sourceMappingURL=stockings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stockings.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/stockings.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,qBAAqB,g/qCAmHjC,CAAA"}
|