reze-engine 0.50.2 → 0.50.4
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/engine.d.ts +294 -7
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +900 -109
- package/dist/shaders/cast-api.d.ts +1 -1
- package/dist/shaders/cast-api.d.ts.map +1 -1
- package/dist/shaders/cast-layout.d.ts +44 -1
- package/dist/shaders/cast-layout.d.ts.map +1 -1
- package/dist/shaders/cast-layout.js +44 -1
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +7 -1
- 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 +17 -9
- package/dist/shaders/passes/composite.d.ts +1 -1
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/depth-prepass.d.ts +1 -1
- package/dist/shaders/passes/depth-prepass.d.ts.map +1 -1
- package/dist/shaders/passes/depth-prepass.js +52 -12
- package/dist/shaders/passes/ground.d.ts +16 -0
- package/dist/shaders/passes/ground.d.ts.map +1 -1
- package/dist/shaders/passes/ground.js +143 -49
- package/dist/shaders/passes/outline.d.ts +1 -1
- package/dist/shaders/passes/outline.d.ts.map +1 -1
- package/dist/shaders/passes/outline.js +12 -3
- package/dist/shaders/passes/particles.d.ts.map +1 -1
- package/dist/shaders/passes/particles.js +6 -2
- package/dist/shaders/passes/scene-contract.d.ts +38 -6
- package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
- package/dist/shaders/passes/scene-contract.js +53 -16
- package/dist/shaders/passes/trails.d.ts.map +1 -1
- package/dist/shaders/passes/trails.js +36 -8
- package/package.json +2 -2
- package/src/engine.ts +952 -100
- package/src/shaders/cast-layout.ts +44 -1
- package/src/shaders/materials/common.ts +7 -1
- package/src/shaders/materials/nodes.ts +17 -9
- package/src/shaders/passes/depth-prepass.ts +53 -12
- package/src/shaders/passes/ground.ts +145 -49
- package/src/shaders/passes/outline.ts +14 -3
- package/src/shaders/passes/particles.ts +6 -2
- package/src/shaders/passes/scene-contract.ts +55 -16
- package/src/shaders/passes/trails.ts +36 -8
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const CAST_API = "\nconst RZ_SUBJECTS: i32 = 4;\nconst RZ_SAMPLES: i32 = 128;\n/** The anchor ADDRESS SPACE \u2014 how many an effect may declare, not how many it\n * did. RZ_TRAIL_SLOTS is the per-effect number and is not this one; the two\n * being one number was the old trail bug. */\nconst RZ_MAX_ANCHORS: i32 =
|
|
1
|
+
export declare const CAST_API = "\nconst RZ_SUBJECTS: i32 = 4;\nconst RZ_SAMPLES: i32 = 128;\n/** The anchor ADDRESS SPACE \u2014 how many an effect may declare, not how many it\n * did. RZ_TRAIL_SLOTS is the per-effect number and is not this one; the two\n * being one number was the old trail bug. */\nconst RZ_MAX_ANCHORS: i32 = 16;\nconst RZ_TRAIL_SAMPLES: i32 = 128;\n\nstruct RzSubject {\n /** On the FLOOR, under the body \u2014 where a ring or a magic circle belongs. */\n root: vec3f,\n /** At the hips, the middle of the body \u2014 where an aura belongs. */\n center: vec3f,\n /** Bounding sphere: xyz centre, w radius. Deliberately generous \u2014 cull with it. */\n bounds: vec4f,\n /** False past the end of the cast, and every field is then zero. */\n valid: bool,\n}\n\nstruct RzAnchor {\n pos: vec3f,\n /** World units per second, from the previous frame. Direction for a trail,\n * magnitude for anything that should react to how hard someone is moving. */\n vel: vec3f,\n /** The bone's forward axis \u2014 which way a foot points, where a head looks. */\n fwd: vec3f,\n /** False when this rig has no such bone. Check it: the alternative is drawing\n * a hand effect at the world origin on every model that spells it differently. */\n valid: bool,\n}\n\n/** Which model this is, stable across a scene \u2014 for per-subject variation. */\nfn rzSubjectId(i: i32) -> u32 {\n if (i < 0 || i >= rzSubjectCount()) { return 0u; }\n return u32(_rzCast[i * 3 + 1].w);\n}\n\nfn rzSubject(i: i32) -> RzSubject {\n var s: RzSubject;\n s.valid = i >= 0 && i < rzSubjectCount();\n if (!s.valid) { return s; }\n let b = i * 3;\n s.root = _rzCast[b].xyz;\n s.center = _rzCast[b + 1].xyz;\n s.bounds = _rzCast[b + 2];\n return s;\n}\n\n/**\n * Where a named bone is, this frame.\n *\n * The slot is the author's own: the Nth @anchor in their file, in the order\n * they wrote them. _rzSlot turns that into the scene's address, which is what\n * keeps two effects that both anchor to a wrist from reading each other's.\n */\nfn rzAnchor(subject: i32, slot: i32) -> RzAnchor {\n var a: RzAnchor;\n a.valid = false;\n let g = _rzSlot(slot);\n if (subject < 0 || subject >= rzSubjectCount() || g < 0 || g >= RZ_MAX_ANCHORS) { return a; }\n let b = 12 + (g * 4 + subject) * 3;\n a.valid = _rzCast[b].w > 0.5;\n a.pos = _rzCast[b].xyz;\n a.vel = _rzCast[b + 1].xyz;\n a.fwd = _rzCast[b + 2].xyz;\n return a;\n}\n\n/**\n * How many samples of a path are recorded \u2014 0 for an anchor that asked for no\n * trail, and for one that has not moved yet.\n *\n * Bounded by the anchor cap, NOT by how many anchors asked for a trail. Those\n * are different index spaces: storage is addressed by anchor slot, so an\n * untrailed @anchor followed by a trailed one put the trail at index 1 with a\n * bound of 1 and rzTrail returned zero \u2014 a ribbon that silently did not draw.\n */\nfn rzTrailCount(subject: i32, slot: i32) -> i32 {\n let g = _rzSlot(slot);\n if (subject < 0 || subject >= rzSubjectCount() || g < 0 || g >= RZ_MAX_ANCHORS) { return 0; }\n return i32(_rzCast[12 + (g * 4 + subject) * 3 + 2].w);\n}\n\n/** Sample i of a path: xyz where it was, w how many seconds ago. i = 0 is now. */\nfn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {\n let n = rzTrailCount(subject, slot);\n if (i < 0 || i >= n) { return vec4f(0.0); }\n let base = 204 + (_rzSlot(slot) * 4 + subject) * RZ_TRAIL_SAMPLES;\n return _rzCast[base + i];\n}\n";
|
|
2
2
|
//# sourceMappingURL=cast-api.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cast-api.d.ts","sourceRoot":"","sources":["../../src/shaders/cast-api.ts"],"names":[],"mappings":"AA+BA,eAAO,MAAM,QAAQ,
|
|
1
|
+
{"version":3,"file":"cast-api.d.ts","sourceRoot":"","sources":["../../src/shaders/cast-api.ts"],"names":[],"mappings":"AA+BA,eAAO,MAAM,QAAQ,m4GA2FpB,CAAA"}
|
|
@@ -14,7 +14,50 @@
|
|
|
14
14
|
* the whole engine failing to start on an import order nobody chose.
|
|
15
15
|
*/
|
|
16
16
|
export declare const EFFECT_SUBJECTS = 4;
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Bone anchors, for the WHOLE SCENE rather than per effect — this is an address
|
|
19
|
+
* space that every installed effect draws slots from, and an effect asking for
|
|
20
|
+
* one the table has already given away is told so and has it dropped.
|
|
21
|
+
*
|
|
22
|
+
* 8 was reachable, and quietly: two effects each wanting two hands, two feet and
|
|
23
|
+
* a head is ten, so the second one silently lost its ribbons to a diagnostic
|
|
24
|
+
* nobody was reading. 16 is double the headroom for 131KB of storage buffer,
|
|
25
|
+
* where 8 cost 66KB.
|
|
26
|
+
*
|
|
27
|
+
* The cost really is only that. The per-frame upload is bounded by the last
|
|
28
|
+
* TRAILED slot, not by this cap (see the writeBuffer in updateCastBuffer), so a
|
|
29
|
+
* scene using three anchors uploads three anchors' worth whatever this says.
|
|
30
|
+
* The CPU-side path rings are keyed by (model, slot) and allocated on use. And
|
|
31
|
+
* effects never index this directly — they loop to rzTrailCount / rzSubjectCount
|
|
32
|
+
* and read through rzAnchor/rzTrail, both of which bounds-check against it.
|
|
33
|
+
*
|
|
34
|
+
* It stays a compile-time constant because the accessors in cast-api.ts are
|
|
35
|
+
* interpolated into every effect module as WGSL literals; making it dynamic
|
|
36
|
+
* means resizing the buffer and recompiling every installed effect whenever the
|
|
37
|
+
* scene's anchor count grows, which is a different feature from raising a number
|
|
38
|
+
* that was never load-bearing.
|
|
39
|
+
*/
|
|
40
|
+
export declare const EFFECT_ANCHORS = 16;
|
|
41
|
+
/**
|
|
42
|
+
* Path samples kept per trailed anchor — 128 at the 60Hz sampling rate is a
|
|
43
|
+
* ~2.1 second ribbon.
|
|
44
|
+
*
|
|
45
|
+
* Briefly 256, and reverted with the reason, because the reason is the useful
|
|
46
|
+
* part: a ribbon's cost is not its geometry, it is its FRAGMENTS. It is a wide
|
|
47
|
+
* translucent strip blended additively into the HDR target at the pass's sample
|
|
48
|
+
* count, and it overlaps itself — so its cost tracks the screen AREA it covers,
|
|
49
|
+
* and a twice-as-long ribbon covers roughly twice as much.
|
|
50
|
+
*
|
|
51
|
+
* That lands very differently on the two backends. Overdraw and blend bandwidth
|
|
52
|
+
* are what a tile-based GPU pays for most and what an immediate-mode desktop GPU
|
|
53
|
+
* absorbs, which is why ribbons were reported as costing far more on Safari than
|
|
54
|
+
* on Chrome for the same scene. Doubling this doubles the one thing already
|
|
55
|
+
* known to be the bottleneck there.
|
|
56
|
+
*
|
|
57
|
+
* Raising it is still SAFE — effects loop to rzTrailCount and nothing breaks —
|
|
58
|
+
* it is simply not cheap, and the cost shows up on the slower of the two
|
|
59
|
+
* browsers rather than the one it would be measured on.
|
|
60
|
+
*/
|
|
18
61
|
export declare const EFFECT_TRAIL_SAMPLES = 128;
|
|
19
62
|
/** vec4 slot where the trails begin — after the subjects and the anchors. */
|
|
20
63
|
export declare const EFFECT_TRAIL_BASE: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cast-layout.d.ts","sourceRoot":"","sources":["../../src/shaders/cast-layout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,eAAe,IAAI,CAAA;AAChC,eAAO,MAAM,cAAc,
|
|
1
|
+
{"version":3,"file":"cast-layout.d.ts","sourceRoot":"","sources":["../../src/shaders/cast-layout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,eAAe,IAAI,CAAA;AAChC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,cAAc,KAAK,CAAA;AAChC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,oBAAoB,MAAM,CAAA;AACvC,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB,QAA6D,CAAA"}
|
|
@@ -14,7 +14,50 @@
|
|
|
14
14
|
* the whole engine failing to start on an import order nobody chose.
|
|
15
15
|
*/
|
|
16
16
|
export const EFFECT_SUBJECTS = 4;
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Bone anchors, for the WHOLE SCENE rather than per effect — this is an address
|
|
19
|
+
* space that every installed effect draws slots from, and an effect asking for
|
|
20
|
+
* one the table has already given away is told so and has it dropped.
|
|
21
|
+
*
|
|
22
|
+
* 8 was reachable, and quietly: two effects each wanting two hands, two feet and
|
|
23
|
+
* a head is ten, so the second one silently lost its ribbons to a diagnostic
|
|
24
|
+
* nobody was reading. 16 is double the headroom for 131KB of storage buffer,
|
|
25
|
+
* where 8 cost 66KB.
|
|
26
|
+
*
|
|
27
|
+
* The cost really is only that. The per-frame upload is bounded by the last
|
|
28
|
+
* TRAILED slot, not by this cap (see the writeBuffer in updateCastBuffer), so a
|
|
29
|
+
* scene using three anchors uploads three anchors' worth whatever this says.
|
|
30
|
+
* The CPU-side path rings are keyed by (model, slot) and allocated on use. And
|
|
31
|
+
* effects never index this directly — they loop to rzTrailCount / rzSubjectCount
|
|
32
|
+
* and read through rzAnchor/rzTrail, both of which bounds-check against it.
|
|
33
|
+
*
|
|
34
|
+
* It stays a compile-time constant because the accessors in cast-api.ts are
|
|
35
|
+
* interpolated into every effect module as WGSL literals; making it dynamic
|
|
36
|
+
* means resizing the buffer and recompiling every installed effect whenever the
|
|
37
|
+
* scene's anchor count grows, which is a different feature from raising a number
|
|
38
|
+
* that was never load-bearing.
|
|
39
|
+
*/
|
|
40
|
+
export const EFFECT_ANCHORS = 16;
|
|
41
|
+
/**
|
|
42
|
+
* Path samples kept per trailed anchor — 128 at the 60Hz sampling rate is a
|
|
43
|
+
* ~2.1 second ribbon.
|
|
44
|
+
*
|
|
45
|
+
* Briefly 256, and reverted with the reason, because the reason is the useful
|
|
46
|
+
* part: a ribbon's cost is not its geometry, it is its FRAGMENTS. It is a wide
|
|
47
|
+
* translucent strip blended additively into the HDR target at the pass's sample
|
|
48
|
+
* count, and it overlaps itself — so its cost tracks the screen AREA it covers,
|
|
49
|
+
* and a twice-as-long ribbon covers roughly twice as much.
|
|
50
|
+
*
|
|
51
|
+
* That lands very differently on the two backends. Overdraw and blend bandwidth
|
|
52
|
+
* are what a tile-based GPU pays for most and what an immediate-mode desktop GPU
|
|
53
|
+
* absorbs, which is why ribbons were reported as costing far more on Safari than
|
|
54
|
+
* on Chrome for the same scene. Doubling this doubles the one thing already
|
|
55
|
+
* known to be the bottleneck there.
|
|
56
|
+
*
|
|
57
|
+
* Raising it is still SAFE — effects loop to rzTrailCount and nothing breaks —
|
|
58
|
+
* it is simply not cheap, and the cost shows up on the slower of the two
|
|
59
|
+
* browsers rather than the one it would be measured on.
|
|
60
|
+
*/
|
|
18
61
|
export const EFFECT_TRAIL_SAMPLES = 128;
|
|
19
62
|
/** vec4 slot where the trails begin — after the subjects and the anchors. */
|
|
20
63
|
export const EFFECT_TRAIL_BASE = EFFECT_SUBJECTS * 3 + EFFECT_ANCHORS * EFFECT_SUBJECTS * 3;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/common.ts"],"names":[],"mappings":"AA2BA,eAAO,MAAM,oBAAoB,
|
|
1
|
+
{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/common.ts"],"names":[],"mappings":"AA2BA,eAAO,MAAM,oBAAoB,QA8GhC,CAAC;AAgIF,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAQD,eAAO,MAAM,4BAA4B,QAC0D,CAAA"}
|
|
@@ -70,7 +70,13 @@ struct MaterialUniforms {
|
|
|
70
70
|
};
|
|
71
71
|
|
|
72
72
|
struct VertexOutput {
|
|
73
|
-
@
|
|
73
|
+
// @invariant: the opaque depth prepass rasterises this same skinned position
|
|
74
|
+
// through a DIFFERENT shader module, and the colour pass then depth-tests
|
|
75
|
+
// less-equal against what it wrote. Without invariance a backend is free to
|
|
76
|
+
// optimise the two position computations differently, and a one-ulp
|
|
77
|
+
// disagreement is a pixel of missing character. Invariance pins both to the
|
|
78
|
+
// same result; it costs only that freedom.
|
|
79
|
+
@builtin(position) @invariant position: vec4f,
|
|
74
80
|
@location(0) normal: vec3f,
|
|
75
81
|
@location(1) uv: vec2f,
|
|
76
82
|
@location(2) worldPos: vec3f,
|
|
@@ -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 // A var, not a let: WGSL only allows a dynamic index on a REFERENCE, and a\n // let-bound array is a value. Indexing it with a runtime k is a compile error \u2014\n // and because this file is concatenated into every material shader, that error\n // took every graph in the library down with it, not just curves.\n var ys = array<f32, 5>(y0, y1, y2, y3, y4);\n let k = i32(i);\n let pa = ys[k];\n let pb = ys[k + 1];\n // Secants either side of each knot, clamped at the ends.\n let dPrev = select(ys[max(k, 1)] - ys[max(k, 1) - 1], pb - pa, k == 0);\n let dHere = pb - pa;\n let dNext = select(ys[min(k + 2, 4)] - ys[min(k + 1, 3)], pb - pa, k == 3);\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\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\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\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";
|
|
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\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\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\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";
|
|
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,yi9CAq8BtB,CAAC"}
|
|
@@ -215,18 +215,26 @@ fn curve5(t0: f32, y0: f32, y1: f32, y2: f32, y3: f32, y4: f32) -> f32 {
|
|
|
215
215
|
let t = clamp(t0, 0.0, 1.0) * 4.0;
|
|
216
216
|
let i = min(floor(t), 3.0);
|
|
217
217
|
let f = t - i;
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
218
|
+
// NO local array, deliberately. This used to build array<f32, 5> and index it
|
|
219
|
+
// with the runtime k — and a dynamic index on function-local memory is the one
|
|
220
|
+
// construct this codebase has already caught Metal lowering to a
|
|
221
|
+
// per-invocation copy plus a switch (the filmic LUT note in composite.ts).
|
|
222
|
+
// rgb_curve calls this three times per node per pixel, so on a graph-heavy
|
|
223
|
+
// close-up that lowering was paid in the hottest loop the frame has. Four
|
|
224
|
+
// segments select cleanly: the values below are the SAME subtractions the
|
|
225
|
+
// array indexing produced, chosen by k instead of loaded through it —
|
|
226
|
+
// bit-identical results, no local memory, nothing for the backend to spill.
|
|
223
227
|
let k = i32(i);
|
|
224
|
-
let
|
|
225
|
-
let
|
|
228
|
+
let s0 = y1 - y0;
|
|
229
|
+
let s1 = y2 - y1;
|
|
230
|
+
let s2 = y3 - y2;
|
|
231
|
+
let s3 = y4 - y3;
|
|
232
|
+
let pa = select(select(y0, y1, k == 1), select(y2, y3, k == 3), k >= 2);
|
|
233
|
+
let pb = select(select(y1, y2, k == 1), select(y3, y4, k == 3), k >= 2);
|
|
226
234
|
// Secants either side of each knot, clamped at the ends.
|
|
227
|
-
let dPrev = select(ys[max(k, 1)] - ys[max(k, 1) - 1], pb - pa, k == 0);
|
|
228
235
|
let dHere = pb - pa;
|
|
229
|
-
let
|
|
236
|
+
let dPrev = select(s0, select(s1, s2, k == 3), k >= 2);
|
|
237
|
+
let dNext = select(select(s1, s2, k == 1), s3, k >= 2);
|
|
230
238
|
let m0 = curve_slope(dPrev, dHere);
|
|
231
239
|
let m1 = curve_slope(dHere, dNext);
|
|
232
240
|
let f2 = f * f;
|
|
@@ -92,7 +92,7 @@ type CompositeEffectSource = {
|
|
|
92
92
|
* Depends on `viewU` and `_rzCast` being declared by the including module, and
|
|
93
93
|
* on nothing else: no textures, no samplers.
|
|
94
94
|
*/
|
|
95
|
-
export declare const EFFECT_SCENE_API = "\n// \u2500\u2500 The effect API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Named rz*, for the engine. The prefix earns its place twice: user code is\n// concatenated into THIS module, so an unprefixed rzAnchor() would collide with\n// exactly the helper an author would write, and the old bg* prefix stopped being\n// true in 0.41.0 when effects gained a mount over the finished frame.\n//\n// The bg* names below are permanent aliases, not a deprecation with an end date.\n// A published link is immutable, so a scene pinned to a bg* effect has to keep\n// compiling forever. They are one-line and inlined; no new function gets one.\n\n/** Canvas size in pixels \u2014 for aspect correction. */\nfn rzResolution() -> vec2f { return viewU[6].zw; }\n\n/** The camera's world position. */\nfn rzCameraPos() -> vec3f { return viewU[10].xyz; }\n\n/** How many characters are in the scene, up to four. */\nfn rzSubjectCount() -> i32 { return i32(viewU[10].w); }\n\n/**\n * A world point as the camera sees it: xy the uv it lands on, z its distance\n * along the VIEW AXIS in metres.\n *\n * The exact inverse of the ray this pass builds per pixel, so it is the cheap way\n * to work with anything anchored in the world. Marching a curve or a trail in 3D\n * costs a distance evaluation per sample per pixel; projecting its points once\n * and measuring in 2D costs a subtraction, which is the difference between a\n * ribbon that runs at 4K and one that does not.\n *\n * z is directly comparable to the depth handed to foreground(), so occlusion is\n * a single test: draw where your z is nearer than the scene's. It is returned\n * SIGNED and unclamped \u2014 behind the camera is negative, and worth rejecting\n * before you use the uv, which is meaningless there.\n */\nfn rzProject(p: vec3f) -> vec3f {\n let d = p - viewU[10].xyz;\n let z = dot(d, viewU[5].xyz);\n // Guard only the divide. z itself is returned as it is, so the caller can see\n // the sign; clamping it here would put points behind the lens on the horizon.\n let inv = 1.0 / select(z, 1e-4, z < 1e-4);\n let ndc = vec2f(dot(d, viewU[3].xyz) * inv / viewU[3].w, dot(d, viewU[4].xyz) * inv / viewU[4].w);\n return vec3f(ndc * 0.5 + 0.5, z);\n}\n\nfn rzCamPos() -> vec3f { return rzCameraPos(); }\nfn rzCameraRight() -> vec3f { return viewU[3].xyz; }\nfn rzCameraUp() -> vec3f { return viewU[4].xyz; }\nfn rzCameraForward() -> vec3f { return viewU[5].xyz; }\n\n// The cast \u2014 subjects, anchors, trails \u2014 is CAST_API, shared verbatim with the\n// particle and trail modules. It used to be written out here, a second time, and\n// the two copies had drifted: this one had rzAnchor and that one did not.\n\nconst RZ_SUBJECTS: i32 = 4;\nconst RZ_SAMPLES: i32 = 128;\n/** The anchor ADDRESS SPACE \u2014 how many an effect may declare, not how many it\n * did. RZ_TRAIL_SLOTS is the per-effect number and is not this one; the two\n * being one number was the old trail bug. */\nconst RZ_MAX_ANCHORS: i32 = 8;\nconst RZ_TRAIL_SAMPLES: i32 = 128;\n\nstruct RzSubject {\n /** On the FLOOR, under the body \u2014 where a ring or a magic circle belongs. */\n root: vec3f,\n /** At the hips, the middle of the body \u2014 where an aura belongs. */\n center: vec3f,\n /** Bounding sphere: xyz centre, w radius. Deliberately generous \u2014 cull with it. */\n bounds: vec4f,\n /** False past the end of the cast, and every field is then zero. */\n valid: bool,\n}\n\nstruct RzAnchor {\n pos: vec3f,\n /** World units per second, from the previous frame. Direction for a trail,\n * magnitude for anything that should react to how hard someone is moving. */\n vel: vec3f,\n /** The bone's forward axis \u2014 which way a foot points, where a head looks. */\n fwd: vec3f,\n /** False when this rig has no such bone. Check it: the alternative is drawing\n * a hand effect at the world origin on every model that spells it differently. */\n valid: bool,\n}\n\n/** Which model this is, stable across a scene \u2014 for per-subject variation. */\nfn rzSubjectId(i: i32) -> u32 {\n if (i < 0 || i >= rzSubjectCount()) { return 0u; }\n return u32(_rzCast[i * 3 + 1].w);\n}\n\nfn rzSubject(i: i32) -> RzSubject {\n var s: RzSubject;\n s.valid = i >= 0 && i < rzSubjectCount();\n if (!s.valid) { return s; }\n let b = i * 3;\n s.root = _rzCast[b].xyz;\n s.center = _rzCast[b + 1].xyz;\n s.bounds = _rzCast[b + 2];\n return s;\n}\n\n/**\n * Where a named bone is, this frame.\n *\n * The slot is the author's own: the Nth @anchor in their file, in the order\n * they wrote them. _rzSlot turns that into the scene's address, which is what\n * keeps two effects that both anchor to a wrist from reading each other's.\n */\nfn rzAnchor(subject: i32, slot: i32) -> RzAnchor {\n var a: RzAnchor;\n a.valid = false;\n let g = _rzSlot(slot);\n if (subject < 0 || subject >= rzSubjectCount() || g < 0 || g >= RZ_MAX_ANCHORS) { return a; }\n let b = 12 + (g * 4 + subject) * 3;\n a.valid = _rzCast[b].w > 0.5;\n a.pos = _rzCast[b].xyz;\n a.vel = _rzCast[b + 1].xyz;\n a.fwd = _rzCast[b + 2].xyz;\n return a;\n}\n\n/**\n * How many samples of a path are recorded \u2014 0 for an anchor that asked for no\n * trail, and for one that has not moved yet.\n *\n * Bounded by the anchor cap, NOT by how many anchors asked for a trail. Those\n * are different index spaces: storage is addressed by anchor slot, so an\n * untrailed @anchor followed by a trailed one put the trail at index 1 with a\n * bound of 1 and rzTrail returned zero \u2014 a ribbon that silently did not draw.\n */\nfn rzTrailCount(subject: i32, slot: i32) -> i32 {\n let g = _rzSlot(slot);\n if (subject < 0 || subject >= rzSubjectCount() || g < 0 || g >= RZ_MAX_ANCHORS) { return 0; }\n return i32(_rzCast[12 + (g * 4 + subject) * 3 + 2].w);\n}\n\n/** Sample i of a path: xyz where it was, w how many seconds ago. i = 0 is now. */\nfn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {\n let n = rzTrailCount(subject, slot);\n if (i < 0 || i >= n) { return vec4f(0.0); }\n let base = 108 + (_rzSlot(slot) * 4 + subject) * RZ_TRAIL_SAMPLES;\n return _rzCast[base + i];\n}\n\n\n// The hashes, the noise and rzFalloff \u2014 EFFECT_MATH_API, the same text the\n// particle and trail modules get. The field module used to be missing most of\n// it, so a helper an author wrote for one mount failed to compile in another\n// for no reason visible in the file.\n\nfn rzHash11(x: f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\nfn rzHash21(p: vec2f) -> f32 {\n var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n/** Three independent randoms from one seed \u2014 the usual need when spawning. */\nfn rzHash13(x: f32) -> vec3f {\n return vec3f(rzHash11(x), rzHash11(x + 17.13), rzHash11(x + 41.71));\n}\n/**\n * Compact-support falloff: 1 at the centre, exactly 0 at r, smooth between.\n *\n * It reaches exactly zero rather than merely getting small, because a glow that\n * never quite ends has to be culled somewhere, and culling it wherever it looks\n * close enough is what put a visible hard edge on the first halo effect.\n */\nfn rzFalloff(d: f32, r: f32) -> f32 {\n let x = clamp(d / max(r, 1e-6), 0.0, 1.0);\n let f = 1.0 - x;\n return f * f * f;\n}\nfn rzHash31(p: vec3f) -> f32 {\n var p3 = fract(p * 0.1031);\n p3 = p3 + dot(p3, p3.zyx + 31.32);\n return fract((p3.x + p3.y) * p3.z);\n}\nfn rzValueNoise(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = rzHash31(i);\n let n100 = rzHash31(i + vec3f(1.0, 0.0, 0.0));\n let n010 = rzHash31(i + vec3f(0.0, 1.0, 0.0));\n let n110 = rzHash31(i + vec3f(1.0, 1.0, 0.0));\n let n001 = rzHash31(i + vec3f(0.0, 0.0, 1.0));\n let n101 = rzHash31(i + vec3f(1.0, 0.0, 1.0));\n let n011 = rzHash31(i + vec3f(0.0, 1.0, 1.0));\n let n111 = rzHash31(i + vec3f(1.0, 1.0, 1.0));\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n/** Divergence-free flow \u2014 the field a wisp of smoke follows without a solver. */\nfn rzCurlNoise(p: vec3f) -> vec3f {\n let e = 0.1;\n let dx = vec3f(e, 0.0, 0.0);\n let dy = vec3f(0.0, e, 0.0);\n let dz = vec3f(0.0, 0.0, e);\n let x0 = rzValueNoise(p - dx); let x1 = rzValueNoise(p + dx);\n let y0 = rzValueNoise(p - dy); let y1 = rzValueNoise(p + dy);\n let z0 = rzValueNoise(p - dz); let z1 = rzValueNoise(p + dz);\n return normalize(vec3f((y1 - y0) - (z1 - z0), (z1 - z0) - (x1 - x0), (x1 - x0) - (y1 - y0)) + vec3f(1e-6));\n}\n\nstruct Particle {\n pos: vec3f,\n age: f32,\n vel: vec3f,\n life: f32,\n size: f32,\n rot: f32,\n seed: f32,\n // Aspect along the direction of travel. 1 or less is a square billboard; a\n // raindrop is 10 or 20. Zero-initialised, so an effect that never sets it gets\n // the square it expects.\n stretch: f32,\n}\n\n\n\nfn bgResolution() -> vec2f { return rzResolution(); }\nfn bgCameraPos() -> vec3f { return rzCameraPos(); }\nfn bgSubjectCount() -> i32 { return rzSubjectCount(); }\n\n/**\n * Where a character IS, in world space \u2014 at the hips, not on the floor.\n *\n * An effect that wants to RESPOND to the cast \u2014 a glow that follows someone,\n * dust kicked up where they are \u2014 needs to know where they are, and the ray and\n * the depth cannot tell it: they describe the pixel, not the scene.\n *\n * The value is model.position + \u30BB\u30F3\u30BF\u30FC + \u5168\u3066\u306E\u89AA. \u30BB\u30F3\u30BF\u30FC sits at hip\n * height on every standard MMD rig, so this is a point in the middle of the\n * body. It is NOT the contact point: a ripple drawn here appears at the waist.\n * Ground effects want the .xz of this and their own floor height, which is what\n * the effects that shipped against it already do.\n *\n * The comment here used to claim it was \"between the feet on the floor\", which\n * is where that habit came from. Left as it is regardless of the name: a\n * published link is immutable, so every shared scene pinning an effect that\n * reads this depends on it meaning exactly what it has always meant.\n *\n * Clamped rather than bounds-checked: an effect looping past the count reads the\n * last subject instead of sampling whatever follows the array, which is a wrong\n * ripple rather than an undefined one.\n */\nfn rzSubjectHip(i: i32) -> vec3f { return viewU[11 + clamp(i, 0, 3)].xyz; }\n\nfn bgSubjectPos(i: i32) -> vec3f { return rzSubjectHip(i); }\n\n/** Where in the WORLD the scene drew this pixel \u2014 the depth handed to\n * foreground() turned into a place. Without it an effect can only think in\n * distances from the lens, which is no use to anything that belongs somewhere:\n * fog lying on the ground has to know where the ground is.\n *\n * depth measures along the VIEW AXIS, not along the ray, so it is divided by\n * the ray's projection onto camera-forward before being walked out. At the far\n * plane (nothing drawn) this lands a very long way off, which is what a sky\n * should do to anything reading it. */\nfn rzWorldPos(ray: vec3f, depth: f32) -> vec3f {\n let axis = max(dot(normalize(ray), viewU[5].xyz), 1e-4);\n return rzCameraPos() + normalize(ray) * (depth / axis);\n}\n\nfn bgWorldPos(ray: vec3f, depth: f32) -> vec3f { return rzWorldPos(ray, depth); }\n\n/** What an effect returns for one of its lights. */\nstruct RzLight {\n pos: vec3f,\n color: vec3f,\n intensity: f32,\n radius: f32,\n}\n\n\n/** Color grading, applied to the tonemapped SCENE (not the background \u2014 see the\n * call site). The core is ASC CDL, the film-industry interchange standard:\n *\n * out = (in \u00B7 slope + offset) ^ power then saturation (SOP \u2192 SAT)\n *\n * Using the real standard rather than invented controls means a look authored\n * here maps onto any grading tool. slope/offset/power are derived on the CPU\n * from the UI's shadow/midtone/highlight colors (see setColorGrading), so the\n * per-pixel cost is one mul-add, one pow, one lerp. */\nfn grade(c: vec3f) -> vec3f {\n var x = pow(max(c * viewU[9].xyz + viewU[7].xyz, vec3f(0.0)), viewU[8].xyz);\n // Contrast pivots on 0.5 \u2014 display-referred midpoint, since we grade post-Filmic.\n x = (x - vec3f(0.5)) * viewU[7].w + vec3f(0.5);\n // Rec.709 luma, matching the ASC SAT node.\n let luma = dot(x, vec3f(0.2126, 0.7152, 0.0722));\n return max(mix(vec3f(luma), x, viewU[8].w), vec3f(0.0));\n}\n";
|
|
95
|
+
export declare const EFFECT_SCENE_API = "\n// \u2500\u2500 The effect API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Named rz*, for the engine. The prefix earns its place twice: user code is\n// concatenated into THIS module, so an unprefixed rzAnchor() would collide with\n// exactly the helper an author would write, and the old bg* prefix stopped being\n// true in 0.41.0 when effects gained a mount over the finished frame.\n//\n// The bg* names below are permanent aliases, not a deprecation with an end date.\n// A published link is immutable, so a scene pinned to a bg* effect has to keep\n// compiling forever. They are one-line and inlined; no new function gets one.\n\n/** Canvas size in pixels \u2014 for aspect correction. */\nfn rzResolution() -> vec2f { return viewU[6].zw; }\n\n/** The camera's world position. */\nfn rzCameraPos() -> vec3f { return viewU[10].xyz; }\n\n/** How many characters are in the scene, up to four. */\nfn rzSubjectCount() -> i32 { return i32(viewU[10].w); }\n\n/**\n * A world point as the camera sees it: xy the uv it lands on, z its distance\n * along the VIEW AXIS in metres.\n *\n * The exact inverse of the ray this pass builds per pixel, so it is the cheap way\n * to work with anything anchored in the world. Marching a curve or a trail in 3D\n * costs a distance evaluation per sample per pixel; projecting its points once\n * and measuring in 2D costs a subtraction, which is the difference between a\n * ribbon that runs at 4K and one that does not.\n *\n * z is directly comparable to the depth handed to foreground(), so occlusion is\n * a single test: draw where your z is nearer than the scene's. It is returned\n * SIGNED and unclamped \u2014 behind the camera is negative, and worth rejecting\n * before you use the uv, which is meaningless there.\n */\nfn rzProject(p: vec3f) -> vec3f {\n let d = p - viewU[10].xyz;\n let z = dot(d, viewU[5].xyz);\n // Guard only the divide. z itself is returned as it is, so the caller can see\n // the sign; clamping it here would put points behind the lens on the horizon.\n let inv = 1.0 / select(z, 1e-4, z < 1e-4);\n let ndc = vec2f(dot(d, viewU[3].xyz) * inv / viewU[3].w, dot(d, viewU[4].xyz) * inv / viewU[4].w);\n return vec3f(ndc * 0.5 + 0.5, z);\n}\n\nfn rzCamPos() -> vec3f { return rzCameraPos(); }\nfn rzCameraRight() -> vec3f { return viewU[3].xyz; }\nfn rzCameraUp() -> vec3f { return viewU[4].xyz; }\nfn rzCameraForward() -> vec3f { return viewU[5].xyz; }\n\n// The cast \u2014 subjects, anchors, trails \u2014 is CAST_API, shared verbatim with the\n// particle and trail modules. It used to be written out here, a second time, and\n// the two copies had drifted: this one had rzAnchor and that one did not.\n\nconst RZ_SUBJECTS: i32 = 4;\nconst RZ_SAMPLES: i32 = 128;\n/** The anchor ADDRESS SPACE \u2014 how many an effect may declare, not how many it\n * did. RZ_TRAIL_SLOTS is the per-effect number and is not this one; the two\n * being one number was the old trail bug. */\nconst RZ_MAX_ANCHORS: i32 = 16;\nconst RZ_TRAIL_SAMPLES: i32 = 128;\n\nstruct RzSubject {\n /** On the FLOOR, under the body \u2014 where a ring or a magic circle belongs. */\n root: vec3f,\n /** At the hips, the middle of the body \u2014 where an aura belongs. */\n center: vec3f,\n /** Bounding sphere: xyz centre, w radius. Deliberately generous \u2014 cull with it. */\n bounds: vec4f,\n /** False past the end of the cast, and every field is then zero. */\n valid: bool,\n}\n\nstruct RzAnchor {\n pos: vec3f,\n /** World units per second, from the previous frame. Direction for a trail,\n * magnitude for anything that should react to how hard someone is moving. */\n vel: vec3f,\n /** The bone's forward axis \u2014 which way a foot points, where a head looks. */\n fwd: vec3f,\n /** False when this rig has no such bone. Check it: the alternative is drawing\n * a hand effect at the world origin on every model that spells it differently. */\n valid: bool,\n}\n\n/** Which model this is, stable across a scene \u2014 for per-subject variation. */\nfn rzSubjectId(i: i32) -> u32 {\n if (i < 0 || i >= rzSubjectCount()) { return 0u; }\n return u32(_rzCast[i * 3 + 1].w);\n}\n\nfn rzSubject(i: i32) -> RzSubject {\n var s: RzSubject;\n s.valid = i >= 0 && i < rzSubjectCount();\n if (!s.valid) { return s; }\n let b = i * 3;\n s.root = _rzCast[b].xyz;\n s.center = _rzCast[b + 1].xyz;\n s.bounds = _rzCast[b + 2];\n return s;\n}\n\n/**\n * Where a named bone is, this frame.\n *\n * The slot is the author's own: the Nth @anchor in their file, in the order\n * they wrote them. _rzSlot turns that into the scene's address, which is what\n * keeps two effects that both anchor to a wrist from reading each other's.\n */\nfn rzAnchor(subject: i32, slot: i32) -> RzAnchor {\n var a: RzAnchor;\n a.valid = false;\n let g = _rzSlot(slot);\n if (subject < 0 || subject >= rzSubjectCount() || g < 0 || g >= RZ_MAX_ANCHORS) { return a; }\n let b = 12 + (g * 4 + subject) * 3;\n a.valid = _rzCast[b].w > 0.5;\n a.pos = _rzCast[b].xyz;\n a.vel = _rzCast[b + 1].xyz;\n a.fwd = _rzCast[b + 2].xyz;\n return a;\n}\n\n/**\n * How many samples of a path are recorded \u2014 0 for an anchor that asked for no\n * trail, and for one that has not moved yet.\n *\n * Bounded by the anchor cap, NOT by how many anchors asked for a trail. Those\n * are different index spaces: storage is addressed by anchor slot, so an\n * untrailed @anchor followed by a trailed one put the trail at index 1 with a\n * bound of 1 and rzTrail returned zero \u2014 a ribbon that silently did not draw.\n */\nfn rzTrailCount(subject: i32, slot: i32) -> i32 {\n let g = _rzSlot(slot);\n if (subject < 0 || subject >= rzSubjectCount() || g < 0 || g >= RZ_MAX_ANCHORS) { return 0; }\n return i32(_rzCast[12 + (g * 4 + subject) * 3 + 2].w);\n}\n\n/** Sample i of a path: xyz where it was, w how many seconds ago. i = 0 is now. */\nfn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {\n let n = rzTrailCount(subject, slot);\n if (i < 0 || i >= n) { return vec4f(0.0); }\n let base = 204 + (_rzSlot(slot) * 4 + subject) * RZ_TRAIL_SAMPLES;\n return _rzCast[base + i];\n}\n\n\n// The hashes, the noise and rzFalloff \u2014 EFFECT_MATH_API, the same text the\n// particle and trail modules get. The field module used to be missing most of\n// it, so a helper an author wrote for one mount failed to compile in another\n// for no reason visible in the file.\n\nfn rzHash11(x: f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\nfn rzHash21(p: vec2f) -> f32 {\n var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n/** Three independent randoms from one seed \u2014 the usual need when spawning. */\nfn rzHash13(x: f32) -> vec3f {\n return vec3f(rzHash11(x), rzHash11(x + 17.13), rzHash11(x + 41.71));\n}\n/**\n * Compact-support falloff: 1 at the centre, exactly 0 at r, smooth between.\n *\n * It reaches exactly zero rather than merely getting small, because a glow that\n * never quite ends has to be culled somewhere, and culling it wherever it looks\n * close enough is what put a visible hard edge on the first halo effect.\n */\nfn rzFalloff(d: f32, r: f32) -> f32 {\n let x = clamp(d / max(r, 1e-6), 0.0, 1.0);\n let f = 1.0 - x;\n return f * f * f;\n}\nfn rzHash31(p: vec3f) -> f32 {\n var p3 = fract(p * 0.1031);\n p3 = p3 + dot(p3, p3.zyx + 31.32);\n return fract((p3.x + p3.y) * p3.z);\n}\nfn rzValueNoise(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = rzHash31(i);\n let n100 = rzHash31(i + vec3f(1.0, 0.0, 0.0));\n let n010 = rzHash31(i + vec3f(0.0, 1.0, 0.0));\n let n110 = rzHash31(i + vec3f(1.0, 1.0, 0.0));\n let n001 = rzHash31(i + vec3f(0.0, 0.0, 1.0));\n let n101 = rzHash31(i + vec3f(1.0, 0.0, 1.0));\n let n011 = rzHash31(i + vec3f(0.0, 1.0, 1.0));\n let n111 = rzHash31(i + vec3f(1.0, 1.0, 1.0));\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n/** Divergence-free flow \u2014 the field a wisp of smoke follows without a solver. */\nfn rzCurlNoise(p: vec3f) -> vec3f {\n let e = 0.1;\n let dx = vec3f(e, 0.0, 0.0);\n let dy = vec3f(0.0, e, 0.0);\n let dz = vec3f(0.0, 0.0, e);\n let x0 = rzValueNoise(p - dx); let x1 = rzValueNoise(p + dx);\n let y0 = rzValueNoise(p - dy); let y1 = rzValueNoise(p + dy);\n let z0 = rzValueNoise(p - dz); let z1 = rzValueNoise(p + dz);\n return normalize(vec3f((y1 - y0) - (z1 - z0), (z1 - z0) - (x1 - x0), (x1 - x0) - (y1 - y0)) + vec3f(1e-6));\n}\n\nstruct Particle {\n pos: vec3f,\n age: f32,\n vel: vec3f,\n life: f32,\n size: f32,\n rot: f32,\n seed: f32,\n // Aspect along the direction of travel. 1 or less is a square billboard; a\n // raindrop is 10 or 20. Zero-initialised, so an effect that never sets it gets\n // the square it expects.\n stretch: f32,\n}\n\n\n\nfn bgResolution() -> vec2f { return rzResolution(); }\nfn bgCameraPos() -> vec3f { return rzCameraPos(); }\nfn bgSubjectCount() -> i32 { return rzSubjectCount(); }\n\n/**\n * Where a character IS, in world space \u2014 at the hips, not on the floor.\n *\n * An effect that wants to RESPOND to the cast \u2014 a glow that follows someone,\n * dust kicked up where they are \u2014 needs to know where they are, and the ray and\n * the depth cannot tell it: they describe the pixel, not the scene.\n *\n * The value is model.position + \u30BB\u30F3\u30BF\u30FC + \u5168\u3066\u306E\u89AA. \u30BB\u30F3\u30BF\u30FC sits at hip\n * height on every standard MMD rig, so this is a point in the middle of the\n * body. It is NOT the contact point: a ripple drawn here appears at the waist.\n * Ground effects want the .xz of this and their own floor height, which is what\n * the effects that shipped against it already do.\n *\n * The comment here used to claim it was \"between the feet on the floor\", which\n * is where that habit came from. Left as it is regardless of the name: a\n * published link is immutable, so every shared scene pinning an effect that\n * reads this depends on it meaning exactly what it has always meant.\n *\n * Clamped rather than bounds-checked: an effect looping past the count reads the\n * last subject instead of sampling whatever follows the array, which is a wrong\n * ripple rather than an undefined one.\n */\nfn rzSubjectHip(i: i32) -> vec3f { return viewU[11 + clamp(i, 0, 3)].xyz; }\n\nfn bgSubjectPos(i: i32) -> vec3f { return rzSubjectHip(i); }\n\n/** Where in the WORLD the scene drew this pixel \u2014 the depth handed to\n * foreground() turned into a place. Without it an effect can only think in\n * distances from the lens, which is no use to anything that belongs somewhere:\n * fog lying on the ground has to know where the ground is.\n *\n * depth measures along the VIEW AXIS, not along the ray, so it is divided by\n * the ray's projection onto camera-forward before being walked out. At the far\n * plane (nothing drawn) this lands a very long way off, which is what a sky\n * should do to anything reading it. */\nfn rzWorldPos(ray: vec3f, depth: f32) -> vec3f {\n let axis = max(dot(normalize(ray), viewU[5].xyz), 1e-4);\n return rzCameraPos() + normalize(ray) * (depth / axis);\n}\n\nfn bgWorldPos(ray: vec3f, depth: f32) -> vec3f { return rzWorldPos(ray, depth); }\n\n/** What an effect returns for one of its lights. */\nstruct RzLight {\n pos: vec3f,\n color: vec3f,\n intensity: f32,\n radius: f32,\n}\n\n\n/** Color grading, applied to the tonemapped SCENE (not the background \u2014 see the\n * call site). The core is ASC CDL, the film-industry interchange standard:\n *\n * out = (in \u00B7 slope + offset) ^ power then saturation (SOP \u2192 SAT)\n *\n * Using the real standard rather than invented controls means a look authored\n * here maps onto any grading tool. slope/offset/power are derived on the CPU\n * from the UI's shadow/midtone/highlight colors (see setColorGrading), so the\n * per-pixel cost is one mul-add, one pow, one lerp. */\nfn grade(c: vec3f) -> vec3f {\n var x = pow(max(c * viewU[9].xyz + viewU[7].xyz, vec3f(0.0)), viewU[8].xyz);\n // Contrast pivots on 0.5 \u2014 display-referred midpoint, since we grade post-Filmic.\n x = (x - vec3f(0.5)) * viewU[7].w + vec3f(0.5);\n // Rec.709 luma, matching the ASC SAT node.\n let luma = dot(x, vec3f(0.2126, 0.7152, 0.0722));\n return max(mix(vec3f(luma), x, viewU[8].w), vec3f(0.0));\n}\n";
|
|
96
96
|
export declare function buildCompositeShader(effect?: CompositeEffectSource | null): string;
|
|
97
97
|
export declare function buildFieldShader(effect: CompositeEffectSource): string;
|
|
98
98
|
/** Kept for compatibility with existing imports (the base, no-effect shader). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AAwBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BAgC+B;AAC/B;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,EAAE,CAIhG;AAID,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAEzG,KAAK,qBAAqB,GAAG;IAC3B,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;IACtB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAA;IAChB;mFAC+E;IAC/E,GAAG,CAAC,EAAE,OAAO,CAAA;IACb;;;yEAGqE;IACrE,UAAU,EAAE,MAAM,CAAA;IAClB;;8EAE0E;IAC1E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB,CAAA;AA6LD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,
|
|
1
|
+
{"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AAwBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BAgC+B;AAC/B;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,EAAE,CAIhG;AAID,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAEzG,KAAK,qBAAqB,GAAG;IAC3B,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;IACtB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAA;IAChB;mFAC+E;IAC/E,GAAG,CAAC,EAAE,OAAO,CAAA;IACb;;;yEAGqE;IACrE,UAAU,EAAE,MAAM,CAAA;IAClB;;8EAE0E;IAC1E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB,CAAA;AA6LD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,mqZA+H5B,CAAA;AA4LD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAQlF;AAuDD,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CAqEtE;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare
|
|
1
|
+
export declare function transparentDepthPrepassWgsl(): string;
|
|
2
2
|
//# sourceMappingURL=depth-prepass.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"depth-prepass.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/depth-prepass.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"depth-prepass.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/depth-prepass.ts"],"names":[],"mappings":"AA+BA,wBAAgB,2BAA2B,IAAI,MAAM,CAkEpD"}
|