reze-engine 0.61.5 → 0.61.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/_dbg-loader.mjs +1100 -0
  2. package/dist/engine.d.ts +20 -0
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +87 -13
  5. package/dist/graph/presets/cloth_rough.d.ts.map +1 -1
  6. package/dist/graph/presets/cloth_rough.js +5 -3
  7. package/dist/graph/schema.d.ts +1 -1
  8. package/dist/graph/schema.d.ts.map +1 -1
  9. package/dist/graph/schema.js +3 -1
  10. package/dist/physics-debug.d.ts +30 -0
  11. package/dist/physics-debug.d.ts.map +1 -0
  12. package/dist/physics-debug.js +526 -0
  13. package/dist/shaders/directives.d.ts +9 -0
  14. package/dist/shaders/directives.d.ts.map +1 -1
  15. package/dist/shaders/directives.js +5 -0
  16. package/dist/shaders/materials/body.d.ts +2 -0
  17. package/dist/shaders/materials/body.d.ts.map +1 -0
  18. package/dist/shaders/materials/body.js +95 -0
  19. package/dist/shaders/materials/cloth_rough.d.ts +2 -0
  20. package/dist/shaders/materials/cloth_rough.d.ts.map +1 -0
  21. package/dist/shaders/materials/cloth_rough.js +69 -0
  22. package/dist/shaders/materials/cloth_smooth.d.ts +2 -0
  23. package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -0
  24. package/dist/shaders/materials/cloth_smooth.js +61 -0
  25. package/dist/shaders/materials/default.d.ts +2 -0
  26. package/dist/shaders/materials/default.d.ts.map +1 -0
  27. package/dist/shaders/materials/default.js +43 -0
  28. package/dist/shaders/materials/eye.d.ts +2 -0
  29. package/dist/shaders/materials/eye.d.ts.map +1 -0
  30. package/dist/shaders/materials/eye.js +60 -0
  31. package/dist/shaders/materials/face.d.ts +2 -0
  32. package/dist/shaders/materials/face.d.ts.map +1 -0
  33. package/dist/shaders/materials/face.js +95 -0
  34. package/dist/shaders/materials/hair.d.ts +2 -0
  35. package/dist/shaders/materials/hair.d.ts.map +1 -0
  36. package/dist/shaders/materials/hair.js +90 -0
  37. package/dist/shaders/materials/metal.d.ts +2 -0
  38. package/dist/shaders/materials/metal.d.ts.map +1 -0
  39. package/dist/shaders/materials/metal.js +77 -0
  40. package/dist/shaders/materials/mmd_classic.d.ts +2 -0
  41. package/dist/shaders/materials/mmd_classic.d.ts.map +1 -0
  42. package/dist/shaders/materials/mmd_classic.js +66 -0
  43. package/dist/shaders/materials/stockings.d.ts +2 -0
  44. package/dist/shaders/materials/stockings.d.ts.map +1 -0
  45. package/dist/shaders/materials/stockings.js +122 -0
  46. package/dist/shaders/passes/physics-debug.d.ts +2 -0
  47. package/dist/shaders/passes/physics-debug.d.ts.map +1 -0
  48. package/dist/shaders/passes/physics-debug.js +69 -0
  49. package/package.json +1 -1
  50. package/src/engine.ts +76 -4
  51. package/src/graph/presets/cloth_rough.ts +5 -3
  52. package/src/graph/schema.ts +3 -1
  53. package/src/shaders/directives.ts +14 -0
  54. package/dist/graph/presets/pool_floor.d.ts +0 -3
  55. package/dist/graph/presets/pool_floor.d.ts.map +0 -1
  56. package/dist/graph/presets/pool_floor.js +0 -42
  57. package/dist/graph/presets/water.d.ts +0 -3
  58. package/dist/graph/presets/water.d.ts.map +0 -1
  59. package/dist/graph/presets/water.js +0 -31
  60. package/dist/physics/autofit.d.ts +0 -147
  61. package/dist/physics/autofit.d.ts.map +0 -1
  62. package/dist/physics/autofit.js +0 -501
  63. package/dist/shaders/passes/field-blit.d.ts +0 -26
  64. package/dist/shaders/passes/field-blit.d.ts.map +0 -1
  65. package/dist/shaders/passes/field-blit.js +0 -65
  66. package/dist/shaders/passes/ground-noise.d.ts +0 -7
  67. package/dist/shaders/passes/ground-noise.d.ts.map +0 -1
  68. package/dist/shaders/passes/ground-noise.js +0 -88
  69. package/dist/shaders/passes/sim.d.ts +0 -34
  70. package/dist/shaders/passes/sim.d.ts.map +0 -1
  71. package/dist/shaders/passes/sim.js +0 -169
  72. package/dist/shaders/score-api.d.ts +0 -10
  73. package/dist/shaders/score-api.d.ts.map +0 -1
  74. package/dist/shaders/score-api.js +0 -114
  75. package/dist/shaders/shadow-api.d.ts +0 -29
  76. package/dist/shaders/shadow-api.d.ts.map +0 -1
  77. package/dist/shaders/shadow-api.js +0 -112
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metal.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/metal.ts"],"names":[],"mappings":"AAaA,eAAO,MAAM,iBAAiB,8ilCAiE7B,CAAA"}
@@ -0,0 +1,77 @@
1
+ // M_Metal — Metallic Principled (Metallic=1.0, Specular=1.0, Roughness=0.3) with a
2
+ // reflection-coord Voronoi pattern driving base color for metallic sparkle, plus an
3
+ // NPR toon/overlay emission stack mixed at MixShader Fac=0.6967.
4
+ //
5
+ // Graph's base color chain: 纹理坐标.Reflection → 矢量运算.007(CROSS, Vec2=(0,1,0)) →
6
+ // 沃罗诺伊纹理(F1, Color out) → 颜色渐变(linear) → 混合.005. The dumper did not capture
7
+ // the VectorMath op — CROSS is assumed based on the hardcoded (0,1,0) Vector_001
8
+ // constant (MULTIPLY would zero X/Z producing 1D bands; CROSS produces horizontal ring
9
+ // patterns consistent with metallic anisotropy).
10
+ import { NODES_WGSL } from "./nodes";
11
+ import { COMMON_MATERIAL_PRELUDE_WGSL } from "./common";
12
+ export const METAL_SHADER_WGSL = /* wgsl */ `
13
+
14
+ ${NODES_WGSL}
15
+ ${COMMON_MATERIAL_PRELUDE_WGSL}
16
+
17
+ const METAL_SPECULAR: f32 = 1.0;
18
+ const METAL_METALLIC: f32 = 1.0;
19
+ const METAL_ROUGHNESS: f32 = 0.3;
20
+ const METAL_TOON_EDGE: f32 = 0.2966;
21
+ const METAL_MIX04_MUL: f32 = 0.5;
22
+ const METAL_EMIT_STR: f32 = 8.100000381469727;
23
+ const METAL_MIX_SHADER_FAC: f32 = 0.6967;
24
+ const METAL_VORONOI_SCALE: f32 = 4.3;
25
+
26
+ @fragment fn fs(input: VertexOutput) -> FSOut {
27
+ let n = safe_normal(input.normal);
28
+ let v = normalize(camera.viewPos - input.worldPos);
29
+ let l = -light.lights[0].direction.xyz;
30
+ let sun = light.lights[0].color.xyz * light.lights[0].color.w;
31
+ let amb = light.ambientColor.xyz;
32
+ let shadow = sampleShadow(input.worldPos, n);
33
+
34
+ let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);
35
+ let tex_rgb = tex_s.rgb;
36
+ let out_alpha = material.alpha * tex_s.a;
37
+ if (out_alpha < 0.001) { discard; }
38
+
39
+ // ═══ NPR STACK ═══
40
+ let tex_tint = hue_sat_id(1.0, 0.800000011920929, 1.0, tex_rgb);
41
+ let lum_shade = shader_to_rgb_diffuse(n, l, sun, amb, shadow);
42
+ let ramp008 = ramp_constant_edge_aa(lum_shade, METAL_TOON_EDGE, vec4f(0,0,0,1), vec4f(1,1,1,1));
43
+ let mix04_fac = math_multiply(ramp008.r, METAL_MIX04_MUL);
44
+
45
+ let dark_tex = hue_sat_id(1.0, 0.19999998807907104, 1.0, tex_tint);
46
+ let mix04 = mix_blend(mix04_fac, dark_tex, tex_tint);
47
+
48
+ let hue004 = hue_sat_id(1.0, 2.0, 1.0, mix04);
49
+ let npr_rgb = mix_overlay(1.0, mix04, hue004);
50
+ let npr_emission = npr_rgb * METAL_EMIT_STR;
51
+
52
+ // ═══ PRINCIPLED BSDF (metallic=1, voronoi-driven base) ═══
53
+ // Reflection-coord Voronoi produces the metallic sparkle variation.
54
+ // VALTORGB takes Color → Fac via Blender's BT.601 implicit color_to_value.
55
+ let refl_dir = reflect(-v, n);
56
+ let voro_input = cross(refl_dir, vec3f(0.0, 1.0, 0.0));
57
+ let voro_rgb = tex_voronoi_color(voro_input, METAL_VORONOI_SCALE);
58
+ let voro_scalar = color_to_value(voro_rgb);
59
+ let voro_ramp = ramp_linear(voro_scalar, 0.0, vec4f(0,0,0,1), 1.0, vec4f(1,1,1,1)).r;
60
+ let hue006 = hue_sat_id(1.5, 1.2999999523162842, 1.0, tex_tint);
61
+ let albedo = mix_blend(voro_ramp, vec3f(voro_ramp), hue006);
62
+
63
+ // metallic=1 collapses f0 = mix(dielectric, albedo, 1) = albedo; diffuse_weight = 0.
64
+ let principled = eval_principled(
65
+ PrincipledIn(albedo, METAL_METALLIC, METAL_SPECULAR, METAL_ROUGHNESS, 1e30, 0.0, 0.0),
66
+ n, l, v, sun, amb, shadow
67
+ );
68
+
69
+ let final_color = mix(npr_emission, principled, METAL_MIX_SHADER_FAC);
70
+
71
+ var out: FSOut;
72
+ out.color = vec4f(final_color, out_alpha);
73
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
74
+ return out;
75
+ }
76
+
77
+ `;
@@ -0,0 +1,2 @@
1
+ export declare const MMD_CLASSIC_SHADER_WGSL = "\n\n\n\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct Light {\n direction: vec4f,\n color: vec4f,\n};\n\nstruct LightUniforms {\n ambientColor: vec4f,\n lights: array<Light, 4>,\n};\n\n// Per-material uniforms. Every material binds this layout even if it ignores fields;\n// the engine keeps one bind group layout across all material pipelines. The PMX\n// classic-material fields (ambient/specular/toon/sphere) are consumed by the\n// mmd_classic preset; NPR presets ignore them.\nstruct MaterialUniforms {\n diffuseColor: vec3f, // PMX diffuse rgb (mmd_classic); reserved for others\n alpha: f32, // 0 \u2192 discard; <1 \u2192 transparent draw call\n ambient: vec3f, // PMX ambient rgb\n shininess: f32, // PMX specular power\n specular: vec3f, // PMX specular rgb\n sphereMode: f32, // 0 none \u00B7 1 multiply (sph) \u00B7 2 add (spa)\n // Skeleton index of the \u982D (head) bone, or -1. Lets the eye shader gate\n // the post-alpha-eye stencil by camera-vs-face hemisphere.\n headBoneIndex: f32,\n _pad0: f32,\n _pad1: f32,\n _pad2: f32,\n};\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) normal: vec3f,\n @location(1) uv: vec2f,\n @location(2) worldPos: vec3f,\n // Bind-pose object-space position (the raw pre-skin vertex attribute). Procedural\n // textures (noise bump, sparkle, Generated-coord gradients) key off this instead of\n // worldPos so the pattern rides with the surface \u2014 otherwise the mesh swims through a\n // world-static noise field under any skinning deformation or root (\u30BB\u30F3\u30BF\u30FC) motion.\n // At rest skinMats are identity so restPos == worldPos, which is why existing noise-\n // scale constants stay valid without retuning.\n @location(3) restPos: vec3f,\n};\n\nstruct LightVP { viewProj: mat4x4f, };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var diffuseSampler: sampler;\n@group(0) @binding(3) var shadowMap: texture_depth_2d;\n@group(0) @binding(4) var shadowSampler: sampler_comparison;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n// binding(9) brdfLut is declared inside NODES_WGSL (nodes.ts).\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;\n@group(2) @binding(1) var<uniform> material: MaterialUniforms;\n// mmd_classic inputs; other presets leave them unread (fallback 1\u00D71 whites).\n@group(2) @binding(2) var toonTexture: texture_2d<f32>;\n@group(2) @binding(3) var sphereTexture: texture_2d<f32>;\n\n// Four-bone blended normals can cancel to ~zero on physics-driven parts\n// (opposing bone rotations at 50/50 weights) \u2014 normalize(0) is 0/0 = NaN,\n// which poisons the whole shading stack and flashes through bloom. Fall\n// back to up for degenerate normals instead.\nfn safe_normal(nIn: vec3f) -> vec3f {\n let l2 = dot(nIn, nIn);\n if (l2 < 1e-12) { return vec3f(0.0, 1.0, 0.0); }\n return nIn * inverseSqrt(l2);\n}\n\n\n\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\n if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }\n let biasedPos = worldPos + n * 0.08;\n let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let cmpZ = ndc.z - 0.001;\n let ts = 1.0 / 2048.0;\n let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);\n let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);\n let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);\n let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);\n let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);\n let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);\n let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);\n let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);\n let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);\n return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);\n}\n\n\n\n@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\n @location(2) uv: vec2f,\n @location(3) joints0: vec4<u32>,\n @location(4) weights0: vec4<f32>\n) -> VertexOutput {\n var output: VertexOutput;\n let pos4 = vec4f(position, 1.0);\n let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n var skinnedPos = vec4f(0.0);\n var skinnedNrm = vec3f(0.0);\n for (var i = 0u; i < 4u; i++) {\n let m = skinMats[joints0[i]];\n let w = nw[i];\n skinnedPos += (m * pos4) * w;\n skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;\n }\n output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);\n output.normal = skinnedNrm;\n output.uv = uv;\n output.worldPos = skinnedPos.xyz;\n output.restPos = position;\n return output;\n}\n\n\n\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) mask: vec4f,\n};\n\n\n\n@fragment fn fs(input: VertexOutput) -> FSOut {\n let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);\n // MMD alpha semantics: material alpha \u00D7 texture alpha.\n let alpha = material.alpha * tex_s.a;\n if (alpha < 0.001) { discard; }\n\n let n = safe_normal(input.normal);\n let v = normalize(camera.viewPos - input.worldPos);\n let l = -light.lights[0].direction.xyz;\n let sun = light.lights[0].color.xyz * light.lights[0].color.w;\n let amb = light.ambientColor.xyz;\n let shadow = sampleShadow(input.worldPos, n);\n\n // Sphere-map UV from the view-space normal \u2014 sampled up front to keep the\n // texture reads in uniform control flow.\n let vn = normalize((camera.view * vec4f(n, 0.0)).xyz);\n let sph_uv = vec2f(vn.x * 0.5 + 0.5, 0.5 - vn.y * 0.5);\n let sphere_rgb = textureSample(sphereTexture, diffuseSampler, sph_uv).rgb;\n\n // Toon ramp: v runs light (0) \u2192 shadow (1); the ramp texture carries the\n // terminator shape and shadow tint. Self-shadow pushes toward the shadow\n // end by attenuating N\u00B7L. Sampled up front for the same uniformity reason.\n let ndl = dot(n, l) * shadow;\n let toon_v = clamp(0.5 - 0.5 * ndl, 0.0, 1.0);\n let toon_rgb = textureSample(toonTexture, diffuseSampler, vec2f(0.5, toon_v)).rgb;\n\n // MMD base: saturate(ambient + diffuse \u00D7 light color), modulated by texture.\n // The engine's ambient light scales the material-ambient floor so scene\n // lighting still has authority over overall exposure.\n let base = clamp(material.ambient * max(amb, vec3f(0.35)) + material.diffuseColor * sun, vec3f(0.0), vec3f(1.0));\n var color = base * tex_s.rgb * toon_rgb;\n\n // Sphere map: 1 = sph (multiply), 2 = spa (add). Mode 3 (sub-texture) and\n // 0 fall through unchanged.\n if (material.sphereMode == 1.0) {\n color *= sphere_rgb;\n } else if (material.sphereMode == 2.0) {\n color += sphere_rgb;\n }\n\n // Blinn-Phong specular with PMX shininess. shininess 0 disables (many\n // rigs author 0 to mean \"no highlight\").\n if (material.shininess > 0.0) {\n let h = normalize(l + v);\n let spec = pow(max(dot(n, h), 0.0), max(material.shininess, 1.0));\n color += material.specular * sun * (spec * shadow);\n }\n\n var out: FSOut;\n out.color = vec4f(color, alpha);\n out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);\n return out;\n}\n\n";
2
+ //# sourceMappingURL=mmd_classic.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mmd_classic.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/mmd_classic.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,uBAAuB,gvPA0DnC,CAAA"}
@@ -0,0 +1,66 @@
1
+ // M_MMDClassic — authentic MMD/MikuMikuDance fixed-pipeline material. Uses the
2
+ // PMX material data every model author actually tuned: ambient + diffuse×light
3
+ // base, toon-ramp shading (ramp carries the terminator, NOT an N·L multiply),
4
+ // sphere map (sph multiply / spa add), and Blinn-Phong specular with the PMX
5
+ // shininess. This is the universal fallback for materials no preset map or
6
+ // name heuristic covers — it renders what the author saw in MMD/PMXEditor.
7
+ import { COMMON_MATERIAL_PRELUDE_WGSL } from "./common";
8
+ export const MMD_CLASSIC_SHADER_WGSL = /* wgsl */ `
9
+
10
+ ${COMMON_MATERIAL_PRELUDE_WGSL}
11
+
12
+ @fragment fn fs(input: VertexOutput) -> FSOut {
13
+ let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);
14
+ // MMD alpha semantics: material alpha × texture alpha.
15
+ let alpha = material.alpha * tex_s.a;
16
+ if (alpha < 0.001) { discard; }
17
+
18
+ let n = safe_normal(input.normal);
19
+ let v = normalize(camera.viewPos - input.worldPos);
20
+ let l = -light.lights[0].direction.xyz;
21
+ let sun = light.lights[0].color.xyz * light.lights[0].color.w;
22
+ let amb = light.ambientColor.xyz;
23
+ let shadow = sampleShadow(input.worldPos, n);
24
+
25
+ // Sphere-map UV from the view-space normal — sampled up front to keep the
26
+ // texture reads in uniform control flow.
27
+ let vn = normalize((camera.view * vec4f(n, 0.0)).xyz);
28
+ let sph_uv = vec2f(vn.x * 0.5 + 0.5, 0.5 - vn.y * 0.5);
29
+ let sphere_rgb = textureSample(sphereTexture, diffuseSampler, sph_uv).rgb;
30
+
31
+ // Toon ramp: v runs light (0) → shadow (1); the ramp texture carries the
32
+ // terminator shape and shadow tint. Self-shadow pushes toward the shadow
33
+ // end by attenuating N·L. Sampled up front for the same uniformity reason.
34
+ let ndl = dot(n, l) * shadow;
35
+ let toon_v = clamp(0.5 - 0.5 * ndl, 0.0, 1.0);
36
+ let toon_rgb = textureSample(toonTexture, diffuseSampler, vec2f(0.5, toon_v)).rgb;
37
+
38
+ // MMD base: saturate(ambient + diffuse × light color), modulated by texture.
39
+ // The engine's ambient light scales the material-ambient floor so scene
40
+ // lighting still has authority over overall exposure.
41
+ let base = clamp(material.ambient * max(amb, vec3f(0.35)) + material.diffuseColor * sun, vec3f(0.0), vec3f(1.0));
42
+ var color = base * tex_s.rgb * toon_rgb;
43
+
44
+ // Sphere map: 1 = sph (multiply), 2 = spa (add). Mode 3 (sub-texture) and
45
+ // 0 fall through unchanged.
46
+ if (material.sphereMode == 1.0) {
47
+ color *= sphere_rgb;
48
+ } else if (material.sphereMode == 2.0) {
49
+ color += sphere_rgb;
50
+ }
51
+
52
+ // Blinn-Phong specular with PMX shininess. shininess 0 disables (many
53
+ // rigs author 0 to mean "no highlight").
54
+ if (material.shininess > 0.0) {
55
+ let h = normalize(l + v);
56
+ let spec = pow(max(dot(n, h), 0.0), max(material.shininess, 1.0));
57
+ color += material.specular * sun * (spec * shadow);
58
+ }
59
+
60
+ var out: FSOut;
61
+ out.color = vec4f(color, alpha);
62
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
63
+ return out;
64
+ }
65
+
66
+ `;
@@ -0,0 +1,2 @@
1
+ export declare const STOCKINGS_SHADER_WGSL = "\n\n\n\n// Baked 64\u00D764 rgba8unorm combined BRDF LUT \u2014 created once at engine init by dfg_lut.ts.\n// .rg = split-sum DFG (Karis: tint = f0\u00B7x + f90\u00B7y) \u2192 F_brdf_*_scatter\n// .ba = Heitz 2016 LTC magnitude (ltc_mag_ggx) \u2192 ltc_brdf_scale_from_lut\n// Paired with group(0) binding(2) diffuseSampler (linear filter). Sample once per\n// fragment via brdf_lut_sample() \u2014 callers feed .rg and the whole vec4 into the\n// helpers below, halving LUT taps on the default Principled path.\n@group(0) @binding(9) var brdfLut: texture_2d<f32>;\n\n// \u2500\u2500\u2500 RGB \u2194 HSV \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn rgb_to_hsv(rgb: vec3f) -> vec3f {\n let c_max = max(rgb.r, max(rgb.g, rgb.b));\n let c_min = min(rgb.r, min(rgb.g, rgb.b));\n let delta = c_max - c_min;\n\n var h = 0.0;\n if (delta > 1e-6) {\n if (c_max == rgb.r) {\n h = (rgb.g - rgb.b) / delta;\n if (h < 0.0) { h += 6.0; }\n } else if (c_max == rgb.g) {\n h = 2.0 + (rgb.b - rgb.r) / delta;\n } else {\n h = 4.0 + (rgb.r - rgb.g) / delta;\n }\n h /= 6.0;\n }\n let s = select(0.0, delta / c_max, c_max > 1e-6);\n return vec3f(h, s, c_max);\n}\n\nfn hsv_to_rgb(hsv: vec3f) -> vec3f {\n let h = hsv.x;\n let s = hsv.y;\n let v = hsv.z;\n if (s < 1e-6) { return vec3f(v); }\n\n let hh = fract(h) * 6.0;\n let sector = u32(hh);\n let f = hh - f32(sector);\n let p = v * (1.0 - s);\n let q = v * (1.0 - s * f);\n let t = v * (1.0 - s * (1.0 - f));\n\n switch (sector) {\n case 0u: { return vec3f(v, t, p); }\n case 1u: { return vec3f(q, v, p); }\n case 2u: { return vec3f(p, v, t); }\n case 3u: { return vec3f(p, q, v); }\n case 4u: { return vec3f(t, p, v); }\n default: { return vec3f(v, p, q); }\n }\n}\n\n// \u2500\u2500\u2500 HUE_SAT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn hue_sat(hue: f32, saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n var hsv = rgb_to_hsv(color);\n hsv.x = fract(hsv.x + hue - 0.5);\n hsv.y = clamp(hsv.y * saturation, 0.0, 1.0);\n hsv.z *= value;\n return mix(color, hsv_to_rgb(hsv), fac);\n}\n\n// hue_sat specialization for hue=0.5 (identity hue shift \u2014 fract(h + 0.5 - 0.5) = h).\n// Branchless equivalent that skips the rgb_to_hsv \u2192 hsv_to_rgb roundtrip: WebKit's\n// Metal backend serializes the 3-way if chain in rgb_to_hsv and the 6-way switch in\n// hsv_to_rgb, where this form compiles to linear SIMD ops + a single select.\nfn hue_sat_id(saturation: f32, value: f32, fac: f32, color: vec3f) -> vec3f {\n let m = max(max(color.r, color.g), color.b);\n let n = min(min(color.r, color.g), color.b);\n // Unclamped (sat*old_s \u2264 1): reproj = mix(vec3f(m), color, saturation).\n // Clamped (saturated to 1): reproj = (color - n) * m / (m - n).\n let range = max(m - n, 1e-6);\n let unclamped = mix(vec3f(m), color, saturation);\n let clamped = (color - vec3f(n)) * m / range;\n let needs_clamp = (m - n) * saturation >= m;\n let reproj = select(unclamped, clamped, needs_clamp);\n return mix(color, reproj * value, fac);\n}\n\n// \u2500\u2500\u2500 BRIGHTCONTRAST node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn bright_contrast(color: vec3f, bright: f32, contrast: f32) -> vec3f {\n let a = 1.0 + contrast;\n let b = bright - contrast * 0.5;\n return max(vec3f(0.0), color * a + vec3f(b));\n}\n\n// \u2500\u2500\u2500 INVERT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn invert(fac: f32, color: vec3f) -> vec3f {\n return mix(color, vec3f(1.0) - color, fac);\n}\n\nfn invert_f(fac: f32, val: f32) -> f32 {\n return mix(val, 1.0 - val, fac);\n}\n\n// \u2500\u2500\u2500 Color ramp (VALTORGB) \u2014 2-stop variants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// All 7 presets use exclusively 2-stop ramps.\n\nfn ramp_constant(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n return select(c0, c1, f >= p1);\n}\n\n// CONSTANT ramp with screen-space edge AA \u2014 kills sparkle where fwidth(f) straddles a hard step (NPR terminator)\nfn ramp_constant_edge_aa(f: f32, edge: f32, c0: vec4f, c1: vec4f) -> vec4f {\n let w = max(fwidth(f) * 1.75, 6e-6);\n let t = smoothstep(edge - w, edge + w, f);\n return mix(c0, c1, t);\n}\n\nfn ramp_linear(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n return mix(c0, c1, t);\n}\n\nfn ramp_cardinal(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {\n // cardinal spline with 2 stops degrades to smoothstep\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n let ss = t * t * (3.0 - 2.0 * t);\n return mix(c0, c1, ss);\n}\n\n// \u2500\u2500\u2500 MATH node operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn math_add(a: f32, b: f32) -> f32 { return a + b; }\nfn math_multiply(a: f32, b: f32) -> f32 { return a * b; }\nfn math_power(a: f32, b: f32) -> f32 { return pow(max(a, 0.0), b); }\nfn math_greater_than(a: f32, b: f32) -> f32 { return select(0.0, 1.0, a > b); }\n\n// Blender's implicit Color \u2192 Float socket conversion uses BT.601 grayscale\n// (rgb_to_grayscale in blenkernel/intern/node.cc). When a material graph plugs a\n// Color output into a Math node's Value input, this is the scalar it actually sees.\nfn color_to_value(c: vec3f) -> f32 {\n return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;\n}\n\n// \u2500\u2500\u2500 MIX node (blend_type variants) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn mix_blend(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, b, fac);\n}\n\nfn mix_overlay(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n let lo = 2.0 * a * b;\n let hi = vec3f(1.0) - 2.0 * (vec3f(1.0) - a) * (vec3f(1.0) - b);\n let overlay = select(hi, lo, a < vec3f(0.5));\n return mix(a, overlay, fac);\n}\n\nfn mix_multiply(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a * b, fac);\n}\n\nfn mix_lighten(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, max(a, b), fac);\n}\n\n// Blender Mix (Color) blend LINEAR_LIGHT: result = mix(A, A + 2*B - 1, Fac)\nfn mix_linear_light(fac: f32, a: vec3f, b: vec3f) -> vec3f {\n return mix(a, a + 2.0 * b - vec3f(1.0), fac);\n}\n\n// Luminance for Shader\u2192RGB scalar gates (linear RGB, Rec.709 weights)\nfn luminance_rec709_linear(c: vec3f) -> f32 {\n return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));\n}\n\n// \u2500\u2500\u2500 FRESNEL node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Schlick approximation matching Blender's Fresnel node\n\nfn fresnel(ior: f32, n: vec3f, v: vec3f) -> f32 {\n let r = (ior - 1.0) / (ior + 1.0);\n let f0 = r * r;\n let cos_theta = clamp(dot(n, v), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\n// \u2500\u2500\u2500 LAYER_WEIGHT node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn layer_weight_fresnel(blend: f32, n: vec3f, v: vec3f) -> f32 {\n let eta = max(1.0 - blend, 1e-4);\n let r = (1.0 - eta) / (1.0 + eta);\n let f0 = r * r;\n let cos_theta = clamp(abs(dot(n, v)), 0.0, 1.0);\n let m = 1.0 - cos_theta;\n let m2 = m * m;\n let m5 = m2 * m2 * m;\n return f0 + (1.0 - f0) * m5;\n}\n\nfn layer_weight_facing(blend: f32, n: vec3f, v: vec3f) -> f32 {\n var facing = abs(dot(n, v));\n let b = clamp(blend, 0.0, 0.99999);\n if (b != 0.5) {\n let exponent = select(2.0 * b, 0.5 / (1.0 - b), b >= 0.5);\n facing = pow(facing, exponent);\n }\n return 1.0 - facing;\n}\n\n// \u2500\u2500\u2500 SHADER_TO_RGB (white DiffuseBSDF) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Eevee captures lit diffuse: (albedo/\u03C0)*sun*N\u00B7L*shadow + ambient (linear). Albedo=1.\n// Matches default.ts direct term scale so VALTORGB thresholds from Blender JSON stay valid.\n\nfn shader_to_rgb_diffuse(n: vec3f, l: vec3f, sun_rgb: vec3f, ambient_rgb: vec3f, shadow: f32) -> f32 {\n const PI_S: f32 = 3.141592653589793;\n let ndotl = max(dot(n, l), 0.0);\n let rgb = sun_rgb * (ndotl * shadow / PI_S) + ambient_rgb;\n return luminance_rec709_linear(rgb);\n}\n\n// \u2500\u2500\u2500 BUMP node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Screen-space bump from a scalar height field. Needs dFdx/dFdy which\n// WGSL provides as dpdx/dpdy.\n\nfn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) + dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference\nfn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// \u2500\u2500\u2500 NOISE texture (Perlin-style) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Simplified gradient noise matching Blender's default noise output.\n\n// PCG-style integer hash. Replaces the classic 'fract(sin(q) * LARGE)' trick because\n// WebKit's Metal backend compiles 'sin' to a full transcendental op (slow), while\n// Safari's Apple-GPU scalar ALU handles int muls/xors near free. Inputs arrive as\n// integer-valued floats (floor(p) + unit offsets) from _noise3, so vec3i cast is exact.\nfn _hash33(p: vec3f) -> vec3f {\n var h = vec3u(vec3i(p) + vec3i(32768));\n h = h * vec3u(1664525u, 1013904223u, 2654435761u);\n h = (h.yzx ^ h) * vec3u(2246822519u, 3266489917u, 668265263u);\n h = h ^ (h >> vec3u(16u));\n // Mask to 24 bits \u2014 above that f32 loses precision on the u32\u2192f32 convert.\n let hm = h & vec3u(16777215u);\n return vec3f(hm) * (2.0 / 16777216.0) - 1.0;\n}\n\nfn _noise3(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return mix(\n mix(\n mix(dot(_hash33(i + vec3f(0,0,0)), f - vec3f(0,0,0)),\n dot(_hash33(i + vec3f(1,0,0)), f - vec3f(1,0,0)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,0)), f - vec3f(0,1,0)),\n dot(_hash33(i + vec3f(1,1,0)), f - vec3f(1,1,0)), u.x), u.y),\n mix(\n mix(dot(_hash33(i + vec3f(0,0,1)), f - vec3f(0,0,1)),\n dot(_hash33(i + vec3f(1,0,1)), f - vec3f(1,0,1)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,1)), f - vec3f(0,1,1)),\n dot(_hash33(i + vec3f(1,1,1)), f - vec3f(1,1,1)), u.x), u.y),\n u.z);\n}\n\nfn tex_noise(p: vec3f, scale: f32, detail: f32, roughness: f32, distortion: f32) -> f32 {\n var q = p;\n if (abs(distortion) > 1e-6) {\n let w = _noise3(p * scale * 1.37 + vec3f(2.31, 5.17, 8.09));\n q = p + (w * 2.0 - 1.0) * distortion;\n }\n let coords = q * scale;\n var value = 0.0;\n var amplitude = 1.0;\n var frequency = 1.0;\n var total_amp = 0.0;\n let octaves = i32(clamp(detail, 0.0, 15.0)) + 1;\n for (var i = 0; i < octaves; i++) {\n value += amplitude * _noise3(coords * frequency);\n total_amp += amplitude;\n amplitude *= roughness;\n frequency *= 2.0;\n }\n return value / max(total_amp, 1e-6) * 0.5 + 0.5;\n}\n\n// tex_noise specialization: detail=2.0 (3 octaves), roughness=0.5, distortion=0.\n// WebKit can't unroll tex_noise's for-loop because 'octaves' is a runtime value;\n// this variant is fully unrolled with constants folded (total_amp = 1.75).\nfn tex_noise_d2(p: vec3f, scale: f32) -> f32 {\n let c = p * scale;\n let v = _noise3(c) + 0.5 * _noise3(c * 2.0) + 0.25 * _noise3(c * 4.0);\n return v * (1.0 / 1.75) * 0.5 + 0.5;\n}\n\n// \u2500\u2500\u2500 TEX_GRADIENT (linear) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Stockings preset. Maps the input vector's X to a 0\u20131 gradient.\n\nfn tex_gradient_linear(uv: vec3f) -> f32 {\n return clamp(uv.x, 0.0, 1.0);\n}\n\n// \u2500\u2500\u2500 TEX_VORONOI \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// 3D F1 voronoi. _f1 returns Distance; _color returns per-cell hash color\n// (matches Blender voronoi.cc: outColor = hash_int3_to_float3(cell + targetOffset)).\n\nfn tex_voronoi_f1(p: vec3f, scale: f32) -> f32 {\n let coords = p * scale;\n let i = floor(coords);\n let f = fract(coords);\n var min_dist = 1e10;\n for (var z = -1; z <= 1; z++) {\n for (var y = -1; y <= 1; y++) {\n for (var x = -1; x <= 1; x++) {\n let neighbor = vec3f(f32(x), f32(y), f32(z));\n let point = _hash33(i + neighbor) * 0.5 + 0.5;\n let diff = neighbor + point - f;\n min_dist = min(min_dist, dot(diff, diff));\n }\n }\n }\n return sqrt(min_dist);\n}\n\n// The per-cell jitter hash IS the Color output in Blender \u2014 reuse the same hash\n// tap for jitter + color instead of computing two.\nfn tex_voronoi_color(p: vec3f, scale: f32) -> vec3f {\n let coords = p * scale;\n let i = floor(coords);\n let f = fract(coords);\n var min_dist = 1e10;\n var min_hash = vec3f(0.5);\n for (var z = -1; z <= 1; z++) {\n for (var y = -1; y <= 1; y++) {\n for (var x = -1; x <= 1; x++) {\n let neighbor = vec3f(f32(x), f32(y), f32(z));\n let h = _hash33(i + neighbor) * 0.5 + 0.5;\n let diff = neighbor + h - f;\n let d = dot(diff, diff);\n if (d < min_dist) {\n min_dist = d;\n min_hash = h;\n }\n }\n }\n }\n return min_hash;\n}\n\n// \u2500\u2500\u2500 SEPXYZ node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn separate_xyz(v: vec3f) -> vec3f { return v; }\n\n// \u2500\u2500\u2500 VECT_MATH (cross product) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn vect_math_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }\n\n// \u2500\u2500\u2500 MAPPING node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Point-type mapping: scale, rotate (euler XYZ), translate.\n\nfn mapping_point(v: vec3f, loc: vec3f, rot: vec3f, scl: vec3f) -> vec3f {\n var p = v * scl;\n // simplified: skip rotation when all angles are zero (common case)\n if (abs(rot.x) + abs(rot.y) + abs(rot.z) > 1e-6) {\n let cx = cos(rot.x); let sx = sin(rot.x);\n let cy = cos(rot.y); let sy = sin(rot.y);\n let cz = cos(rot.z); let sz = sin(rot.z);\n let rx = vec3f(p.x, cx*p.y - sx*p.z, sx*p.y + cx*p.z);\n let ry = vec3f(cy*rx.x + sy*rx.z, rx.y, -sy*rx.x + cy*rx.z);\n p = vec3f(cz*ry.x - sz*ry.y, sz*ry.x + cz*ry.y, ry.z);\n }\n return p + loc;\n}\n\n// \u2500\u2500\u2500 NORMAL_MAP node (tangent-space) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Applies a tangent-space normal map. Requires TBN from vertex stage.\n\nfn normal_map(strength: f32, map_color: vec3f, normal: vec3f, tangent: vec3f, bitangent: vec3f) -> vec3f {\n let ts = map_color * 2.0 - 1.0;\n let perturbed = normalize(tangent * ts.x + bitangent * ts.y + normal * ts.z);\n return normalize(mix(normal, perturbed, strength));\n}\n\n// \u2500\u2500\u2500 EEVEE Principled BSDF primitives \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Ports from Blender 3.6 source/blender/draw/engines/eevee/shaders/\n// bsdf_common_lib.glsl + gpu_shader_material_principled.glsl.\n// Usage pattern (see material shaders): direct spec = bsdf_ggx \u00D7 sun \u00D7 shadow\n// (NL baked in, no F yet); ambient spec = probe_radiance; tint both with\n// reflection_color = F_brdf_multi_scatter(f0, f90, split_sum) AFTER summing.\n\nconst EEVEE_PI: f32 = 3.141592653589793;\n\n// Fused analytic GGX specular (direct lights). Returns BRDF \u00D7 NL.\n// 4\u00B7NL\u00B7NV is cancelled via G1_Smith reciprocal form \u2014 see bsdf_common_lib.glsl:115.\n// Caller passes NL, NV (already computed for diffuse + brdf_lut_sample) so WebKit\n// can reuse them instead of recomputing dot products across the function boundary.\nfn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32) -> f32 {\n let a = max(roughness, 1e-4);\n let a2 = a * a;\n let H = normalize(L + V);\n let NH = max(dot(N, H), 1e-8);\n let NL = max(NL_in, 1e-8);\n let NV = max(NV_in, 1e-8);\n // G1_Smith_GGX_opti reciprocal form \u2014 denominator piece only.\n let G1L = NL + sqrt(NL * (NL - NL * a2) + a2);\n let G1V = NV + sqrt(NV * (NV - NV * a2) + a2);\n let G = G1L * G1V;\n // D_ggx_opti = pi * denom\u00B2 \u2014 reciprocal D \u00D7 a\u00B2.\n let tmp = (NH * a2 - NH) * NH + 1.0;\n let D_opti = EEVEE_PI * tmp * tmp;\n return NL * a2 / (D_opti * G);\n}\n\n// Split-sum DFG LUT \u2014 Karis 2013 curve fit stand-in for the 64\u00D764 baked LUT.\n// Returns (lut.x, lut.y) in Blender convention: tint = f0\u00B7lut.x + f90\u00B7lut.y.\nfn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {\n let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);\n let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);\n let r = roughness * c0 + c1;\n let a004 = min(r.x * r.x, exp2(-9.28 * NV)) * r.x + r.y;\n return vec2f(-1.04, 1.04) * a004 + r.zw;\n}\n\n// Baked combined BRDF LUT \u2014 exact port of Blender bsdf_lut_frag.glsl packed with\n// ltc_mag_ggx from eevee_lut.c. Single sample returns DFG (.rg) and LTC mag (.ba).\n// Addressed as Blender's common_utiltex_lib.glsl:lut_coords:\n// coords = (roughness, sqrt(1 - NV)), then half-texel bias for filtering.\n// Requires group(0) binding(9) brdfLut + binding(2) diffuseSampler in the host shader.\nfn brdf_lut_sample(NV: f32, roughness: f32) -> vec4f {\n let LUT_SIZE: f32 = 64.0;\n var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));\n uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;\n return textureSampleLevel(brdfLut, diffuseSampler, uv, 0.0);\n}\n\nfn F_brdf_single_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n return lut.y * f90 + lut.x * f0;\n}\n\n// Fdez-Ag\u00FCera 2019 multi-scatter compensation (EEVEE do_multiscatter=1).\nfn F_brdf_multi_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n let FssEss = lut.y * f90 + lut.x * f0;\n let Ess = lut.x + lut.y;\n let Ems = 1.0 - Ess;\n let Favg = f0 + (1.0 - f0) / 21.0;\n let Fms = FssEss * Favg / (1.0 - (1.0 - Ess) * Favg);\n return FssEss + Fms * Ems;\n}\n\n// EEVEE direct-specular energy compensation factor \u2014 closure_eval_glossy_lib.glsl:79-81:\n// ltc_brdf_scale = (ltc.x + ltc.y) / (split_sum.x + split_sum.y)\n// Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum;\n// direct radiance is rescaled so total-energy matches the split-sum LUT.\n// Takes a pre-sampled vec4f from brdf_lut_sample() to share the fetch with\n// F_brdf_multi_scatter on the same fragment.\nfn ltc_brdf_scale_from_lut(lut: vec4f) -> f32 {\n return (lut.z + lut.w) / max(lut.x + lut.y, 1e-6);\n}\n\n// Luminance-normalized hue extraction \u2014 Blender tint_from_color (isolates hue+sat).\nfn tint_from_color(color: vec3f) -> vec3f {\n let lum = dot(color, vec3f(0.3, 0.6, 0.1));\n return select(vec3f(1.0), color / lum, lum > 0.0);\n}\n\n// \u2500\u2500\u2500 Principled sheen (gpu_shader_material_principled.glsl:8-14) \u2500\u2500\u2500\u2500\n// Empirical NV-only curve that approximates grazing retroreflection on cloth/velvet.\n// Scales the sheen layer's diffuse contribution; no sheen call site has sheen=0\n// shortcut because the multiplier is tiny at normal view angles anyway.\nfn principled_sheen(NV: f32) -> f32 {\n let f = 1.0 - NV;\n return f * f * f * 0.077 + f * 0.01 + 0.00026;\n}\n\n// \u2500\u2500\u2500 Principled BSDF eval \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Shared EEVEE Principled path used by every material in the engine \u2014 metallic,\n// dielectric, and sheen variants all fold into these ~15 lines via the struct\n// fields. NPR materials still compute a separate toon/rim/warm stack on top and\n// mix(npr_stack, eval_principled(...), fac); see body.ts / face.ts / etc.\n//\n// Field conventions:\n// base \u2014 diffuse albedo. Mixed into f0 only when metallic > 0.\n// metallic \u2014 0 = dielectric (f0 from specular), 1 = pure metal (f0 = base).\n// specular \u2014 Principled Specular input (0.5 default \u2192 f0 = 0.04). sqrt for f90.\n// roughness \u2014 GGX roughness; drives BRDF LUT coord + bsdf_ggx.\n// spec_clamp \u2014 EEVEE Light Clamp equivalent. Caps firefly spec from noise-bumped\n// NDF aliasing (Blender hides this via TAA which we don't have).\n// Pass 1e30 (effectively disabled) for materials that don't bump.\n// sheen \u2014 0 disables. Scales the sheen diffuse add; cloth/stockings use ~0.7.\n// sheen_tint \u2014 0 = white sheen, 1 = fully tinted by base. Multiplied by sheen,\n// so value is don't-care when sheen=0.\nstruct PrincipledIn {\n base: vec3f,\n metallic: f32,\n specular: f32,\n roughness: f32,\n spec_clamp: f32,\n sheen: f32,\n sheen_tint: f32,\n};\n\nfn eval_principled(\n p: PrincipledIn,\n N: vec3f, L: vec3f, V: vec3f,\n sun_rgb: vec3f, amb_rgb: vec3f, shadow: f32\n) -> vec3f {\n let NL = max(dot(N, L), 0.0);\n let NV = max(dot(N, V), 1e-4);\n\n // f0/f90 per gpu_shader_material_principled.glsl. specular_tint=0 is assumed\n // (all presets in this engine use the default white dielectric tint).\n let dielectric_f0 = vec3f(0.08 * p.specular);\n let f0 = mix(dielectric_f0, p.base, p.metallic);\n let f90 = mix(f0, vec3f(1.0), sqrt(p.specular));\n\n // Single LUT tap feeds both F_brdf_multi_scatter (split-sum DFG) and\n // ltc_brdf_scale_from_lut (LTC mag in .ba). See nodes.ts brdf_lut_sample.\n let lut = brdf_lut_sample(NV, p.roughness);\n let reflection_color = F_brdf_multi_scatter(f0, f90, lut.xy);\n\n // Direct glossy \u2014 bsdf_ggx already includes NL; no F applied here (tinted after\n // accum with reflection_color). ltc_brdf_scale rescales direct to match the\n // split-sum indirect path, matching EEVEE closure_eval_glossy_lib behavior.\n let spec_direct_raw = bsdf_ggx(N, L, V, NL, NV, p.roughness)\n * sun_rgb * shadow * ltc_brdf_scale_from_lut(lut);\n let spec_direct = min(spec_direct_raw, vec3f(p.spec_clamp));\n let spec_indirect = amb_rgb;\n let spec_radiance = (spec_direct + spec_indirect) * reflection_color;\n\n // Sheen add \u2014 when p.sheen=0 the whole term collapses, leaving diffuse_color=base.\n let base_tint = tint_from_color(p.base);\n let sheen_color = mix(vec3f(1.0), base_tint, p.sheen_tint);\n let diffuse_color = p.base + p.sheen * sheen_color * principled_sheen(NV);\n\n // diffuse_weight = (1-metallic). Indirect diffuse uses amb (L_w) with no \u03C0 factor\n // (probe_evaluate_world_diff returns SH-projected radiance, not cosine-convolved).\n let diffuse_weight = 1.0 - p.metallic;\n let diffuse_radiance = diffuse_color * (sun_rgb * NL * shadow / EEVEE_PI + amb_rgb) * diffuse_weight;\n\n return diffuse_radiance + spec_radiance;\n}\n\n\n\n\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct Light {\n direction: vec4f,\n color: vec4f,\n};\n\nstruct LightUniforms {\n ambientColor: vec4f,\n lights: array<Light, 4>,\n};\n\n// Per-material uniforms. Every material binds this layout even if it ignores fields;\n// the engine keeps one bind group layout across all material pipelines. The PMX\n// classic-material fields (ambient/specular/toon/sphere) are consumed by the\n// mmd_classic preset; NPR presets ignore them.\nstruct MaterialUniforms {\n diffuseColor: vec3f, // PMX diffuse rgb (mmd_classic); reserved for others\n alpha: f32, // 0 \u2192 discard; <1 \u2192 transparent draw call\n ambient: vec3f, // PMX ambient rgb\n shininess: f32, // PMX specular power\n specular: vec3f, // PMX specular rgb\n sphereMode: f32, // 0 none \u00B7 1 multiply (sph) \u00B7 2 add (spa)\n // Skeleton index of the \u982D (head) bone, or -1. Lets the eye shader gate\n // the post-alpha-eye stencil by camera-vs-face hemisphere.\n headBoneIndex: f32,\n _pad0: f32,\n _pad1: f32,\n _pad2: f32,\n};\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) normal: vec3f,\n @location(1) uv: vec2f,\n @location(2) worldPos: vec3f,\n // Bind-pose object-space position (the raw pre-skin vertex attribute). Procedural\n // textures (noise bump, sparkle, Generated-coord gradients) key off this instead of\n // worldPos so the pattern rides with the surface \u2014 otherwise the mesh swims through a\n // world-static noise field under any skinning deformation or root (\u30BB\u30F3\u30BF\u30FC) motion.\n // At rest skinMats are identity so restPos == worldPos, which is why existing noise-\n // scale constants stay valid without retuning.\n @location(3) restPos: vec3f,\n};\n\nstruct LightVP { viewProj: mat4x4f, };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var diffuseSampler: sampler;\n@group(0) @binding(3) var shadowMap: texture_depth_2d;\n@group(0) @binding(4) var shadowSampler: sampler_comparison;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n// binding(9) brdfLut is declared inside NODES_WGSL (nodes.ts).\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;\n@group(2) @binding(1) var<uniform> material: MaterialUniforms;\n// mmd_classic inputs; other presets leave them unread (fallback 1\u00D71 whites).\n@group(2) @binding(2) var toonTexture: texture_2d<f32>;\n@group(2) @binding(3) var sphereTexture: texture_2d<f32>;\n\n// Four-bone blended normals can cancel to ~zero on physics-driven parts\n// (opposing bone rotations at 50/50 weights) \u2014 normalize(0) is 0/0 = NaN,\n// which poisons the whole shading stack and flashes through bloom. Fall\n// back to up for degenerate normals instead.\nfn safe_normal(nIn: vec3f) -> vec3f {\n let l2 = dot(nIn, nIn);\n if (l2 < 1e-12) { return vec3f(0.0, 1.0, 0.0); }\n return nIn * inverseSqrt(l2);\n}\n\n\n\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\n if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }\n let biasedPos = worldPos + n * 0.08;\n let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let cmpZ = ndc.z - 0.001;\n let ts = 1.0 / 2048.0;\n let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);\n let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);\n let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);\n let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);\n let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);\n let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);\n let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);\n let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);\n let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);\n return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);\n}\n\n\n\n@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\n @location(2) uv: vec2f,\n @location(3) joints0: vec4<u32>,\n @location(4) weights0: vec4<f32>\n) -> VertexOutput {\n var output: VertexOutput;\n let pos4 = vec4f(position, 1.0);\n let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n var skinnedPos = vec4f(0.0);\n var skinnedNrm = vec3f(0.0);\n for (var i = 0u; i < 4u; i++) {\n let m = skinMats[joints0[i]];\n let w = nw[i];\n skinnedPos += (m * pos4) * w;\n skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;\n }\n output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);\n output.normal = skinnedNrm;\n output.uv = uv;\n output.worldPos = skinnedPos.xyz;\n output.restPos = position;\n return output;\n}\n\n\n\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) mask: vec4f,\n};\n\n\n\n// Principled params from dump (Alpha=0.95 intentionally dropped \u2014 see hash note below).\nconst STOCK_METALLIC: f32 = 0.1;\nconst STOCK_SPECULAR: f32 = 1.0;\nconst STOCK_ROUGHNESS: f32 = 0.5;\nconst STOCK_SHEEN: f32 = 0.7017999887466431;\nconst STOCK_SHEEN_TINT: f32 = 0.5;\n// NPR mask ramps\nconst STOCK_RAMP002_P1: f32 = 0.9565; // EASE [0\u2192black, 0.9565\u2192white]\nconst STOCK_RAMPFACE_P1: f32 = 0.5435; // EASE [0\u2192black, 0.5435\u2192white]\nconst STOCK_LW_BLEND: f32 = 0.4; // Layer Weight Blend\n\n// Wyman & McGuire \"Hashed Alpha Testing\" (2017) \u2014 object-space hash with derivative-aware\n// pixel-scale selection, matches Blender EEVEE prepass_frag.glsl::hashed_alpha_threshold.\n// Key property: dither pattern is stable in object space (doesn't swim) and stays at\n// one-pixel frequency regardless of view distance, which makes it tolerable without TAA.\n// The hash grid is fed the bind-pose restPos (not worldPos), so the dots stay pinned to\n// the fabric even under root (\u30BB\u30F3\u30BF\u30FC) motion \u2014 worldPos would crawl across the surface.\n// Since MMD skinning is rigid, length(dpdx(restPos)) == length(dpdx(worldPos)), so the\n// pixel-scale selection is unaffected by the space change.\n// Grid samples reuse the PCG _hash33 from nodes.ts (integer-lattice inputs, Metal-friendly)\n// instead of a fract(sin()) hash \u2014 sin compiles to a slow transcendental on Apple's Metal\n// backend and this runs per-fragment on every stockings pixel.\nfn _hash3d_wm(a: vec3f) -> f32 {\n return _hash33(a).x * 0.5 + 0.5;\n}\nfn hashed_alpha_threshold(co: vec3f) -> f32 {\n let alphaHashScale: f32 = 1.0;\n let max_deriv = max(length(dpdx(co)), length(dpdy(co)));\n let pix_scale = 1.0 / max(alphaHashScale * max_deriv, 1e-6);\n let pix_scale_log = log2(pix_scale);\n let px_lo = exp2(floor(pix_scale_log));\n let px_hi = exp2(ceil(pix_scale_log));\n let a_lo = _hash3d_wm(floor(px_lo * co));\n let a_hi = _hash3d_wm(floor(px_hi * co));\n let fac = fract(pix_scale_log);\n let x = mix(a_lo, a_hi, fac);\n // CDF remap so discard-probability = (1 - alpha) uniformly across scale transitions\n let a = min(fac, 1.0 - fac);\n let one_a = 1.0 - a;\n let denom = 1.0 / max(2.0 * a * one_a, 1e-6);\n let one_x = 1.0 - x;\n let case_lo = (x * x) * denom;\n let case_mid = (x - 0.5 * a) / max(one_a, 1e-6);\n let case_hi = 1.0 - (one_x * one_x) * denom;\n var threshold = select(case_hi, select(case_lo, case_mid, x >= a), x < one_a);\n return clamp(threshold, 1e-6, 1.0);\n}\n\n// Smoothstep-based EASE ramp (Blender VALTORGB EASE) \u2014 2 stops, saturate+smoothstep between\nfn ramp_ease_s(f: f32, p0: f32, p1: f32) -> f32 {\n let t = saturate((f - p0) / max(p1 - p0, 1e-6));\n return t * t * (3.0 - 2.0 * t);\n}\n\n@fragment fn fs(input: VertexOutput) -> FSOut {\n let n = safe_normal(input.normal);\n let v = normalize(camera.viewPos - input.worldPos);\n let l = -light.lights[0].direction.xyz;\n let sun = light.lights[0].color.xyz * light.lights[0].color.w;\n let amb = light.ambientColor.xyz;\n let shadow = sampleShadow(input.worldPos, n);\n\n let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);\n let tex_rgb = tex_s.rgb;\n // Alpha HASHED (Blender EEVEE \"Hashed\" blend mode) per preset author's note \u2014 self-overlap\n // on the stockings produces sort cracks under alpha blend. Wyman-style object-space hash +\n // depth-write is sort-independent. NOTE: Principled.Alpha=0.95 from the dump is DROPPED;\n // it relies on TAA to smooth the 5%-everywhere dither, and without TAA it shows as a\n // pervasive dot pattern. Hash now gates only on texture/material alpha.\n let combined_alpha = material.alpha * tex_s.a;\n if (combined_alpha < hashed_alpha_threshold(input.restPos)) { discard; }\n\n // \u2550\u2550\u2550 NPR MASK \u2550\u2550\u2550 TEX_COORD.Generated \u2192 Mapping(Rot=0,\u03C0/2,\u03C0/2, Loc=(1,1,1)) \u2192 Gradient.\n // The Blender mapping reduces to gradient.x = 1 - input.y (rot swaps axes, loc offsets).\n // We approximate Generated with UV since Y-up PMX has no object bbox in pipeline state.\n let gen_coord = vec3f(input.uv, 0.0);\n let mapped = mapping_point(gen_coord, vec3f(1.0), vec3f(0.0, 1.5708, 1.5708), vec3f(1.0));\n let gradient = tex_gradient_linear(mapped);\n\n // Ramp.001 LINEAR [0\u2192black, 0.5\u2192white, 1.0\u2192black] \u2014 triangular peak at 0.5\n let ramp001 = 1.0 - abs(2.0 * gradient - 1.0);\n let ramp002 = ramp_ease_s(ramp001, 0.0, STOCK_RAMP002_P1);\n\n let facing = layer_weight_facing(STOCK_LW_BLEND, n, v);\n let ramp_face = ramp_ease_s(facing, 0.0, STOCK_RAMPFACE_P1);\n\n // Mix.001: MIX blend Fac=0.5, A=white, B=ramp_face\n let mix001 = mix(1.0, ramp_face, 0.5);\n // Mix: LIGHTEN blend Fac=0.5, A=mix001, B=ramp002\n let lighten = max(mix001, ramp002);\n let mask = mix(mix001, lighten, 0.5);\n\n // \u2550\u2550\u2550 EMISSION SHADER \u2550\u2550\u2550 Hue=0.5 (identity), Sat=1.0, Val=5.0 (5\u00D7 brightness), Fac=1.\n let emission = hue_sat_id(1.0, 5.0, 1.0, tex_rgb);\n\n // \u2550\u2550\u2550 PRINCIPLED BSDF with sheen \u2550\u2550\u2550 metallic=0.1, sheen=0.7, sheen_tint=0.5.\n let principled = eval_principled(\n PrincipledIn(tex_rgb, STOCK_METALLIC, STOCK_SPECULAR, STOCK_ROUGHNESS, 1e30, STOCK_SHEEN, STOCK_SHEEN_TINT),\n n, l, v, sun, amb, shadow\n );\n\n // MIX SHADER: Shader=Emission, Shader_001=Principled, Fac=mask\n let final_color = mix(emission, principled, mask);\n\n var out: FSOut;\n out.color = vec4f(final_color, 1.0);\n out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);\n return out;\n}\n\n";
2
+ //# sourceMappingURL=stockings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stockings.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/stockings.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,qBAAqB,g/qCAmHjC,CAAA"}
@@ -0,0 +1,122 @@
1
+ // M_Stockings — 仿深空之眼渲染预设v1.0_by_小绿毛猫 "M_Stockings". A bbox-gradient ×
2
+ // facing-rim mask drives a Mix Shader between an HSV-boosted emission and a sheen
3
+ // Principled BSDF. Wyman hashed-alpha testing replaces the graph's Alpha=0.95 (which
4
+ // would require TAA to hide the dither dots across every pixel).
5
+ import { NODES_WGSL } from "./nodes";
6
+ import { COMMON_MATERIAL_PRELUDE_WGSL } from "./common";
7
+ export const STOCKINGS_SHADER_WGSL = /* wgsl */ `
8
+
9
+ ${NODES_WGSL}
10
+ ${COMMON_MATERIAL_PRELUDE_WGSL}
11
+
12
+ // Principled params from dump (Alpha=0.95 intentionally dropped — see hash note below).
13
+ const STOCK_METALLIC: f32 = 0.1;
14
+ const STOCK_SPECULAR: f32 = 1.0;
15
+ const STOCK_ROUGHNESS: f32 = 0.5;
16
+ const STOCK_SHEEN: f32 = 0.7017999887466431;
17
+ const STOCK_SHEEN_TINT: f32 = 0.5;
18
+ // NPR mask ramps
19
+ const STOCK_RAMP002_P1: f32 = 0.9565; // EASE [0→black, 0.9565→white]
20
+ const STOCK_RAMPFACE_P1: f32 = 0.5435; // EASE [0→black, 0.5435→white]
21
+ const STOCK_LW_BLEND: f32 = 0.4; // Layer Weight Blend
22
+
23
+ // Wyman & McGuire "Hashed Alpha Testing" (2017) — object-space hash with derivative-aware
24
+ // pixel-scale selection, matches Blender EEVEE prepass_frag.glsl::hashed_alpha_threshold.
25
+ // Key property: dither pattern is stable in object space (doesn't swim) and stays at
26
+ // one-pixel frequency regardless of view distance, which makes it tolerable without TAA.
27
+ // The hash grid is fed the bind-pose restPos (not worldPos), so the dots stay pinned to
28
+ // the fabric even under root (センター) motion — worldPos would crawl across the surface.
29
+ // Since MMD skinning is rigid, length(dpdx(restPos)) == length(dpdx(worldPos)), so the
30
+ // pixel-scale selection is unaffected by the space change.
31
+ // Grid samples reuse the PCG _hash33 from nodes.ts (integer-lattice inputs, Metal-friendly)
32
+ // instead of a fract(sin()) hash — sin compiles to a slow transcendental on Apple's Metal
33
+ // backend and this runs per-fragment on every stockings pixel.
34
+ fn _hash3d_wm(a: vec3f) -> f32 {
35
+ return _hash33(a).x * 0.5 + 0.5;
36
+ }
37
+ fn hashed_alpha_threshold(co: vec3f) -> f32 {
38
+ let alphaHashScale: f32 = 1.0;
39
+ let max_deriv = max(length(dpdx(co)), length(dpdy(co)));
40
+ let pix_scale = 1.0 / max(alphaHashScale * max_deriv, 1e-6);
41
+ let pix_scale_log = log2(pix_scale);
42
+ let px_lo = exp2(floor(pix_scale_log));
43
+ let px_hi = exp2(ceil(pix_scale_log));
44
+ let a_lo = _hash3d_wm(floor(px_lo * co));
45
+ let a_hi = _hash3d_wm(floor(px_hi * co));
46
+ let fac = fract(pix_scale_log);
47
+ let x = mix(a_lo, a_hi, fac);
48
+ // CDF remap so discard-probability = (1 - alpha) uniformly across scale transitions
49
+ let a = min(fac, 1.0 - fac);
50
+ let one_a = 1.0 - a;
51
+ let denom = 1.0 / max(2.0 * a * one_a, 1e-6);
52
+ let one_x = 1.0 - x;
53
+ let case_lo = (x * x) * denom;
54
+ let case_mid = (x - 0.5 * a) / max(one_a, 1e-6);
55
+ let case_hi = 1.0 - (one_x * one_x) * denom;
56
+ var threshold = select(case_hi, select(case_lo, case_mid, x >= a), x < one_a);
57
+ return clamp(threshold, 1e-6, 1.0);
58
+ }
59
+
60
+ // Smoothstep-based EASE ramp (Blender VALTORGB EASE) — 2 stops, saturate+smoothstep between
61
+ fn ramp_ease_s(f: f32, p0: f32, p1: f32) -> f32 {
62
+ let t = saturate((f - p0) / max(p1 - p0, 1e-6));
63
+ return t * t * (3.0 - 2.0 * t);
64
+ }
65
+
66
+ @fragment fn fs(input: VertexOutput) -> FSOut {
67
+ let n = safe_normal(input.normal);
68
+ let v = normalize(camera.viewPos - input.worldPos);
69
+ let l = -light.lights[0].direction.xyz;
70
+ let sun = light.lights[0].color.xyz * light.lights[0].color.w;
71
+ let amb = light.ambientColor.xyz;
72
+ let shadow = sampleShadow(input.worldPos, n);
73
+
74
+ let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);
75
+ let tex_rgb = tex_s.rgb;
76
+ // Alpha HASHED (Blender EEVEE "Hashed" blend mode) per preset author's note — self-overlap
77
+ // on the stockings produces sort cracks under alpha blend. Wyman-style object-space hash +
78
+ // depth-write is sort-independent. NOTE: Principled.Alpha=0.95 from the dump is DROPPED;
79
+ // it relies on TAA to smooth the 5%-everywhere dither, and without TAA it shows as a
80
+ // pervasive dot pattern. Hash now gates only on texture/material alpha.
81
+ let combined_alpha = material.alpha * tex_s.a;
82
+ if (combined_alpha < hashed_alpha_threshold(input.restPos)) { discard; }
83
+
84
+ // ═══ NPR MASK ═══ TEX_COORD.Generated → Mapping(Rot=0,π/2,π/2, Loc=(1,1,1)) → Gradient.
85
+ // The Blender mapping reduces to gradient.x = 1 - input.y (rot swaps axes, loc offsets).
86
+ // We approximate Generated with UV since Y-up PMX has no object bbox in pipeline state.
87
+ let gen_coord = vec3f(input.uv, 0.0);
88
+ let mapped = mapping_point(gen_coord, vec3f(1.0), vec3f(0.0, 1.5708, 1.5708), vec3f(1.0));
89
+ let gradient = tex_gradient_linear(mapped);
90
+
91
+ // Ramp.001 LINEAR [0→black, 0.5→white, 1.0→black] — triangular peak at 0.5
92
+ let ramp001 = 1.0 - abs(2.0 * gradient - 1.0);
93
+ let ramp002 = ramp_ease_s(ramp001, 0.0, STOCK_RAMP002_P1);
94
+
95
+ let facing = layer_weight_facing(STOCK_LW_BLEND, n, v);
96
+ let ramp_face = ramp_ease_s(facing, 0.0, STOCK_RAMPFACE_P1);
97
+
98
+ // Mix.001: MIX blend Fac=0.5, A=white, B=ramp_face
99
+ let mix001 = mix(1.0, ramp_face, 0.5);
100
+ // Mix: LIGHTEN blend Fac=0.5, A=mix001, B=ramp002
101
+ let lighten = max(mix001, ramp002);
102
+ let mask = mix(mix001, lighten, 0.5);
103
+
104
+ // ═══ EMISSION SHADER ═══ Hue=0.5 (identity), Sat=1.0, Val=5.0 (5× brightness), Fac=1.
105
+ let emission = hue_sat_id(1.0, 5.0, 1.0, tex_rgb);
106
+
107
+ // ═══ PRINCIPLED BSDF with sheen ═══ metallic=0.1, sheen=0.7, sheen_tint=0.5.
108
+ let principled = eval_principled(
109
+ PrincipledIn(tex_rgb, STOCK_METALLIC, STOCK_SPECULAR, STOCK_ROUGHNESS, 1e30, STOCK_SHEEN, STOCK_SHEEN_TINT),
110
+ n, l, v, sun, amb, shadow
111
+ );
112
+
113
+ // MIX SHADER: Shader=Emission, Shader_001=Principled, Fac=mask
114
+ let final_color = mix(emission, principled, mask);
115
+
116
+ var out: FSOut;
117
+ out.color = vec4f(final_color, 1.0);
118
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
119
+ return out;
120
+ }
121
+
122
+ `;
@@ -0,0 +1,2 @@
1
+ export declare const PHYSICS_DEBUG_SHADER_WGSL = "\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _pad: f32,\n};\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n\noverride SHAPE_KIND: u32 = 0u;\noverride SOLID_ALPHA: f32 = 1.0;\n\nstruct VsIn {\n @location(0) unit: vec4f, // xyz = unit pos; w = capsule axial anchor\n @location(1) modelCol0: vec4f,\n @location(2) modelCol1: vec4f,\n @location(3) modelCol2: vec4f,\n @location(4) modelCol3: vec4f,\n @location(5) size: vec4f, // xyz = size; w unused\n @location(6) color: vec4f,\n};\n\nstruct VsOut {\n @builtin(position) pos: vec4f,\n @location(0) color: vec4f,\n};\n\n@vertex\nfn vsMain(in: VsIn) -> VsOut {\n let model = mat4x4f(in.modelCol0, in.modelCol1, in.modelCol2, in.modelCol3);\n var local: vec3f;\n if (SHAPE_KIND == 1u) {\n local = in.unit.xyz * in.size.xyz;\n } else if (SHAPE_KIND == 2u) {\n // PMX capsule: size.x = radius, size.y = full cylinder height. Bullet 2.x's\n // btCapsuleShape(radius, height) stores height/2 internally, so the actual\n // collision cylinder spans \u00B1size.y/2 \u2014 halve here to match.\n let r = in.size.x;\n let halfH = in.size.y * 0.5;\n local = vec3f(in.unit.x * r, in.unit.y * r + halfH * in.unit.w, in.unit.z * r);\n } else {\n local = in.unit.xyz * in.size.x;\n }\n let world = model * vec4f(local, 1.0);\n var out: VsOut;\n out.pos = camera.projection * camera.view * world;\n out.color = in.color;\n return out;\n}\n\n@fragment\nfn fsMain(in: VsOut) -> @location(0) vec4f {\n return vec4f(in.color.rgb, in.color.a * SOLID_ALPHA);\n}\n";
2
+ //# sourceMappingURL=physics-debug.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"physics-debug.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/physics-debug.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,yBAAyB,2kDAuDrC,CAAA"}
@@ -0,0 +1,69 @@
1
+ // Wireframe + solid overlay for physics rigid bodies (sphere / box / capsule).
2
+ // Rendered in its own pass after composite, straight to the swapchain — no
3
+ // MSAA, no depth, no MRT — so it sits cleanly on top of the tonemapped scene
4
+ // regardless of camera angle, model occlusion, or the composite alpha gate.
5
+ //
6
+ // SHAPE_KIND 0 = sphere → unit-sphere line/tri primitive scaled by size.x (radius)
7
+ // 1 = box → unit cube ±1 scaled by size.xyz (half-extents)
8
+ // 2 = capsule → unit cap+ring with per-vertex `axial` (-1/0/+1)
9
+ // that lifts cap rings/arcs ±halfHeight along Y;
10
+ // XZ scales by size.x (radius), Y by size.y/2.
11
+ // SOLID_ALPHA fragment alpha multiplier. Wireframe pipelines leave this at
12
+ // 1.0 for crisp edges; solid pipelines set it to ~0.2 so the fill
13
+ // stays gentle under stacked bodies.
14
+ export const PHYSICS_DEBUG_SHADER_WGSL = /* wgsl */ `
15
+ struct CameraUniforms {
16
+ view: mat4x4f,
17
+ projection: mat4x4f,
18
+ viewPos: vec3f,
19
+ _pad: f32,
20
+ };
21
+
22
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
23
+
24
+ override SHAPE_KIND: u32 = 0u;
25
+ override SOLID_ALPHA: f32 = 1.0;
26
+
27
+ struct VsIn {
28
+ @location(0) unit: vec4f, // xyz = unit pos; w = capsule axial anchor
29
+ @location(1) modelCol0: vec4f,
30
+ @location(2) modelCol1: vec4f,
31
+ @location(3) modelCol2: vec4f,
32
+ @location(4) modelCol3: vec4f,
33
+ @location(5) size: vec4f, // xyz = size; w unused
34
+ @location(6) color: vec4f,
35
+ };
36
+
37
+ struct VsOut {
38
+ @builtin(position) pos: vec4f,
39
+ @location(0) color: vec4f,
40
+ };
41
+
42
+ @vertex
43
+ fn vsMain(in: VsIn) -> VsOut {
44
+ let model = mat4x4f(in.modelCol0, in.modelCol1, in.modelCol2, in.modelCol3);
45
+ var local: vec3f;
46
+ if (SHAPE_KIND == 1u) {
47
+ local = in.unit.xyz * in.size.xyz;
48
+ } else if (SHAPE_KIND == 2u) {
49
+ // PMX capsule: size.x = radius, size.y = full cylinder height. Bullet 2.x's
50
+ // btCapsuleShape(radius, height) stores height/2 internally, so the actual
51
+ // collision cylinder spans ±size.y/2 — halve here to match.
52
+ let r = in.size.x;
53
+ let halfH = in.size.y * 0.5;
54
+ local = vec3f(in.unit.x * r, in.unit.y * r + halfH * in.unit.w, in.unit.z * r);
55
+ } else {
56
+ local = in.unit.xyz * in.size.x;
57
+ }
58
+ let world = model * vec4f(local, 1.0);
59
+ var out: VsOut;
60
+ out.pos = camera.projection * camera.view * world;
61
+ out.color = in.color;
62
+ return out;
63
+ }
64
+
65
+ @fragment
66
+ fn fsMain(in: VsOut) -> @location(0) vec4f {
67
+ return vec4f(in.color.rgb, in.color.a * SOLID_ALPHA);
68
+ }
69
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.61.5",
3
+ "version": "0.61.7",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/engine.ts CHANGED
@@ -183,10 +183,17 @@ export type MaterialPresetMap = Partial<Record<MaterialPreset, string[]>>
183
183
 
184
184
  // Substring hints mapping common PMX material names (JP/CN/EN) to a style category,
185
185
  // tried when a material isn't in the caller's explicit override map. Ordered: more
186
- // specific families first (靴下 must hit stockings before 靴 hits cloth). A material
186
+ // specific families first (靴下 must hit socks before 靴 hits cloth). A material
187
187
  // matching nothing resolves to null — it stays ungrouped (neutral default).
188
+ //
189
+ // SOCKS ARE CLOTH, STOCKINGS ARE SHEER. The game draws a white sock as opaque
190
+ // fabric on the ordinary cloth ramp; only a sheer stocking gets the stockings
191
+ // look, whose see-through blend made white socks glow flat or wash out. So the
192
+ // sock words go to rough cloth ahead of the stocking words — 袜子 before the
193
+ // bare 袜 that 丝袜 (silk stockings) still reaches.
188
194
  const PRESET_NAME_HINTS: Array<[MaterialPreset, string[]]> = [
189
- ["stockings", ["靴下", "ソックス", "タイツ", "ニーソ", "袜", "stocking", "socks", "tights"]],
195
+ ["cloth_rough", ["靴下", "ソックス", "ニーソ", "袜子", "短袜", "棉袜", "socks", "sock"]],
196
+ ["stockings", ["タイツ", "ストッキング", "袜", "stocking", "tights", "pantyhose"]],
190
197
  [
191
198
  "eye",
192
199
  ["白目", "目影", "二重", "睫", "まつげ", "まゆ", "眉", "目", "瞳", "眼", "eye", "iris", "pupil", "lash", "brow"],
@@ -1657,6 +1664,8 @@ interface EffectInstance {
1657
1664
  /** This effect IS a mirror — see the `#mirror` directive. Draws no shader;
1658
1665
  * the engine reads its dials and folds the scene through the plane. */
1659
1666
  hasMirror: boolean
1667
+ /** `#stepped` — the cast it is aimed at moves on twos. */
1668
+ stepped: boolean
1660
1669
  /** Mounted under the scene. */
1661
1670
  hasBackground: boolean
1662
1671
  /** Mounted over the finished frame — and the reason the scene pass has to
@@ -2659,6 +2668,12 @@ export class Engine {
2659
2668
  private readonly pipelineDescs = new WeakMap<GPURenderPipeline, GPURenderPipelineDescriptor>()
2660
2669
  private readonly singleSided = new WeakMap<GPURenderPipeline, GPURenderPipeline>()
2661
2670
  private readonly singleSidedMirror = new WeakMap<GPURenderPipeline, GPURenderPipeline>()
2671
+ // Stepped motion (#stepped). The models whose pose is being HELD this frame —
2672
+ // their skinning and morph uploads wait for the stepped clock's next tick —
2673
+ // and the tick each was last shown at. Empty while no effect declares it.
2674
+ private readonly steppedHeld = new Set<string>()
2675
+ private readonly steppedTick = new Map<string, number>()
2676
+ private steppedLastClock = -1
2662
2677
  private bloomBlitUniformBuffer!: GPUBuffer
2663
2678
  private bloomUpsampleUniformBuffer!: GPUBuffer
2664
2679
  private readonly bloomBlitUniformData = new Float32Array(4)
@@ -3436,6 +3451,55 @@ export class Engine {
3436
3451
  * Gated on `weight`, so a scheduled mirror appears and leaves with its strip
3437
3452
  * and a faded-out one costs no reflection pass at all.
3438
3453
  */
3454
+ /** The dials a stepped effect is read by, by name — MIRROR_DIALS' pattern. */
3455
+ private static readonly STEPPED_DIALS = {
3456
+ /** Poses per second of scene time: 12 is 24fps animation on twos, 6 on fours. */
3457
+ fps: "FPS",
3458
+ } as const
3459
+
3460
+ /**
3461
+ * Decide which models hold their pose this frame.
3462
+ *
3463
+ * Nothing underneath is slowed: animation and physics run every frame as they
3464
+ * always do, so the pose that lands on each tick is the true one for that
3465
+ * moment and a hold never drifts behind the music. What steps is only when it
3466
+ * reaches the GPU — the skinning upload and the morph dispatch wait for the
3467
+ * next tick of a clock running at FPS on the scene clock, which is what an
3468
+ * export steps.
3469
+ *
3470
+ * FIRST match wins, and only the models it is aimed at hold: a stage, a prop
3471
+ * and every effect keep moving on ones, which is what makes the cast read as
3472
+ * animated rather than the playback as broken.
3473
+ */
3474
+ private syncSteppedFromEffects(): void {
3475
+ this.steppedHeld.clear()
3476
+ let fx: EffectInstance | null = null
3477
+ for (const e of this.effects) {
3478
+ if (e.stepped && e.weight > 0) {
3479
+ fx = e
3480
+ break
3481
+ }
3482
+ }
3483
+ if (!fx) {
3484
+ this.steppedTick.clear()
3485
+ return
3486
+ }
3487
+ // Paused, nothing holds: time is not moving, so there is nothing to step,
3488
+ // and a pose edited on a stopped timeline has to show as it is edited.
3489
+ const paused = this.sceneClock === this.steppedLastClock
3490
+ this.steppedLastClock = this.sceneClock
3491
+ if (paused) return
3492
+ const D = Engine.STEPPED_DIALS
3493
+ const fps = Math.max(this.effectDial(fx, D.fps, 6), 1)
3494
+ const tick = Math.floor(this.sceneClock * fps + 1e-6)
3495
+ this.forEachInstance((inst) => {
3496
+ const slot = this.castSlotOf.get(inst.name)
3497
+ if (slot === undefined || !(fx!.subjectMask & (1 << slot))) return
3498
+ if (this.steppedTick.get(inst.name) === tick) this.steppedHeld.add(inst.name)
3499
+ else this.steppedTick.set(inst.name, tick)
3500
+ })
3501
+ }
3502
+
3439
3503
  private syncMirrorFromEffects(): void {
3440
3504
  let fx: EffectInstance | null = null
3441
3505
  for (const e of this.effects) {
@@ -4787,14 +4851,15 @@ export class Engine {
4787
4851
  // #mirror is a mount on the same footing, and for the same reason: a
4788
4852
  // reflection re-renders the scene from a folded camera, which is a pass and
4789
4853
  // not a shader, so the effect declares it and draws nothing itself.
4790
- if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl) && !d.mirror) {
4854
+ // #stepped too: it decides when a pose reaches the screen, not what is drawn.
4855
+ if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl) && !d.mirror && !d.stepped) {
4791
4856
  return { ok: false, diagnostics: [
4792
4857
  "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
4793
4858
  "fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
4794
4859
  "the particle trio (particleInit/particleStep/particleShade), " +
4795
4860
  "the ribbon pair (trailWidth/trailShade), " +
4796
4861
  "fn lightEmit(i: u32) -> RzLight with #lights <n>, " +
4797
- "or #mirror",
4862
+ "#mirror, or #stepped",
4798
4863
  ], mounts: noMounts, params: [], duration: 0, readsCast: false }
4799
4864
  }
4800
4865
  const mounts = { background: hasBackground, foreground: hasForeground }
@@ -5106,6 +5171,7 @@ export class Engine {
5106
5171
  paramsBuffer,
5107
5172
  paramsData,
5108
5173
  hasMirror: d.mirror,
5174
+ stepped: d.stepped,
5109
5175
  hasBackground,
5110
5176
  hasForeground,
5111
5177
  // The author's OWN source, not the assembled module: the assembled one
@@ -12709,6 +12775,8 @@ export class Engine {
12709
12775
  for (const inst of this.modelInstances.values()) {
12710
12776
  const gm = inst.gpuMorph
12711
12777
  if (!gm || !gm.dispatchNeeded) continue
12778
+ // The face holds with the body (see syncSteppedFromEffects).
12779
+ if (this.steppedHeld.has(inst.name)) continue
12712
12780
  if (!pass) {
12713
12781
  pass = encoder.beginComputePass({ label: "morph compute", timestampWrites: this.stamps("morph") })
12714
12782
  pass.setPipeline(this.morphComputePipeline)
@@ -16127,6 +16195,7 @@ export class Engine {
16127
16195
  }
16128
16196
 
16129
16197
  const hasModels = this.modelInstances.size > 0
16198
+ this.syncSteppedFromEffects()
16130
16199
  if (hasModels) {
16131
16200
  this.updateInstances(deltaTime)
16132
16201
  this.updateSkinMatrices()
@@ -18113,6 +18182,9 @@ export class Engine {
18113
18182
  // re-uploading bones×64 bytes for scenery that never moves is the one
18114
18183
  // per-frame cost a stage would otherwise still pay in full.
18115
18184
  if (!inst.skinMatricesDirty) return
18185
+ // Held on a stepped effect's tick: stays dirty, so the pose that is current
18186
+ // when the next tick comes is the one uploaded.
18187
+ if (this.steppedHeld.has(inst.name)) return
18116
18188
  const skinMatrices = inst.model.getSkinMatrices()
18117
18189
  this.device.queue.writeBuffer(
18118
18190
  inst.skinMatrixBuffer,