reze-engine 0.12.2 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +20 -20
  2. package/dist/engine.d.ts +9 -4
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +62 -18
  5. package/dist/shaders/body.d.ts +1 -1
  6. package/dist/shaders/body.d.ts.map +1 -1
  7. package/dist/shaders/body.js +7 -28
  8. package/dist/shaders/cloth_rough.d.ts +1 -1
  9. package/dist/shaders/cloth_rough.d.ts.map +1 -1
  10. package/dist/shaders/cloth_rough.js +4 -16
  11. package/dist/shaders/cloth_smooth.d.ts +1 -1
  12. package/dist/shaders/cloth_smooth.d.ts.map +1 -1
  13. package/dist/shaders/cloth_smooth.js +5 -17
  14. package/dist/shaders/default.d.ts +1 -1
  15. package/dist/shaders/default.d.ts.map +1 -1
  16. package/dist/shaders/eye.d.ts +1 -1
  17. package/dist/shaders/eye.d.ts.map +1 -1
  18. package/dist/shaders/face.d.ts +1 -1
  19. package/dist/shaders/face.d.ts.map +1 -1
  20. package/dist/shaders/face.js +21 -57
  21. package/dist/shaders/hair.d.ts +1 -1
  22. package/dist/shaders/hair.d.ts.map +1 -1
  23. package/dist/shaders/hair.js +7 -27
  24. package/dist/shaders/materials/body.d.ts +1 -1
  25. package/dist/shaders/materials/body.d.ts.map +1 -1
  26. package/dist/shaders/materials/body.js +1 -1
  27. package/dist/shaders/materials/cloth_rough.d.ts +1 -1
  28. package/dist/shaders/materials/cloth_rough.d.ts.map +1 -1
  29. package/dist/shaders/materials/cloth_rough.js +1 -1
  30. package/dist/shaders/materials/cloth_smooth.d.ts +1 -1
  31. package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -1
  32. package/dist/shaders/materials/cloth_smooth.js +1 -1
  33. package/dist/shaders/materials/common.d.ts +1 -1
  34. package/dist/shaders/materials/common.d.ts.map +1 -1
  35. package/dist/shaders/materials/common.js +19 -3
  36. package/dist/shaders/materials/default.d.ts +1 -1
  37. package/dist/shaders/materials/default.d.ts.map +1 -1
  38. package/dist/shaders/materials/default.js +1 -1
  39. package/dist/shaders/materials/eye.d.ts +1 -1
  40. package/dist/shaders/materials/eye.d.ts.map +1 -1
  41. package/dist/shaders/materials/eye.js +1 -1
  42. package/dist/shaders/materials/face.d.ts +1 -1
  43. package/dist/shaders/materials/face.d.ts.map +1 -1
  44. package/dist/shaders/materials/face.js +1 -1
  45. package/dist/shaders/materials/hair.d.ts +1 -1
  46. package/dist/shaders/materials/hair.d.ts.map +1 -1
  47. package/dist/shaders/materials/hair.js +1 -1
  48. package/dist/shaders/materials/metal.d.ts +1 -1
  49. package/dist/shaders/materials/metal.d.ts.map +1 -1
  50. package/dist/shaders/materials/metal.js +1 -1
  51. package/dist/shaders/materials/stockings.d.ts +1 -1
  52. package/dist/shaders/materials/stockings.d.ts.map +1 -1
  53. package/dist/shaders/materials/stockings.js +1 -1
  54. package/dist/shaders/metal.d.ts +1 -1
  55. package/dist/shaders/metal.d.ts.map +1 -1
  56. package/dist/shaders/metal.js +4 -17
  57. package/dist/shaders/nodes.d.ts +1 -1
  58. package/dist/shaders/nodes.d.ts.map +1 -1
  59. package/dist/shaders/nodes.js +0 -9
  60. package/dist/shaders/passes/bloom.d.ts +1 -1
  61. package/dist/shaders/passes/bloom.d.ts.map +1 -1
  62. package/dist/shaders/passes/bloom.js +7 -4
  63. package/dist/shaders/passes/composite.d.ts +1 -1
  64. package/dist/shaders/passes/composite.d.ts.map +1 -1
  65. package/dist/shaders/passes/composite.js +11 -4
  66. package/dist/shaders/passes/ground.d.ts +1 -1
  67. package/dist/shaders/passes/ground.d.ts.map +1 -1
  68. package/dist/shaders/passes/ground.js +6 -2
  69. package/dist/shaders/passes/outline.d.ts +1 -1
  70. package/dist/shaders/passes/outline.d.ts.map +1 -1
  71. package/dist/shaders/passes/outline.js +2 -2
  72. package/dist/shaders/stockings.d.ts +1 -1
  73. package/dist/shaders/stockings.d.ts.map +1 -1
  74. package/package.json +1 -1
  75. package/src/engine.ts +60 -18
  76. package/src/shaders/materials/body.ts +1 -1
  77. package/src/shaders/materials/cloth_rough.ts +1 -1
  78. package/src/shaders/materials/cloth_smooth.ts +1 -1
  79. package/src/shaders/materials/common.ts +19 -3
  80. package/src/shaders/materials/default.ts +1 -1
  81. package/src/shaders/materials/eye.ts +1 -1
  82. package/src/shaders/materials/face.ts +1 -1
  83. package/src/shaders/materials/hair.ts +1 -1
  84. package/src/shaders/materials/metal.ts +1 -1
  85. package/src/shaders/materials/stockings.ts +1 -1
  86. package/src/shaders/passes/bloom.ts +7 -4
  87. package/src/shaders/passes/composite.ts +11 -4
  88. package/src/shaders/passes/ground.ts +6 -2
  89. package/src/shaders/passes/outline.ts +2 -2
  90. package/dist/bezier-interpolate.d.ts +0 -15
  91. package/dist/bezier-interpolate.d.ts.map +0 -1
  92. package/dist/bezier-interpolate.js +0 -40
  93. package/dist/engine_ts.d.ts +0 -143
  94. package/dist/engine_ts.d.ts.map +0 -1
  95. package/dist/engine_ts.js +0 -1575
  96. package/dist/ik.d.ts +0 -32
  97. package/dist/ik.d.ts.map +0 -1
  98. package/dist/ik.js +0 -337
  99. package/dist/player.d.ts +0 -64
  100. package/dist/player.d.ts.map +0 -1
  101. package/dist/player.js +0 -220
  102. package/dist/pool-scene.d.ts +0 -52
  103. package/dist/pool-scene.d.ts.map +0 -1
  104. package/dist/pool-scene.js +0 -1122
  105. package/dist/pool.d.ts +0 -38
  106. package/dist/pool.d.ts.map +0 -1
  107. package/dist/pool.js +0 -422
  108. package/dist/rzm-converter.d.ts +0 -12
  109. package/dist/rzm-converter.d.ts.map +0 -1
  110. package/dist/rzm-converter.js +0 -40
  111. package/dist/rzm-loader.d.ts +0 -24
  112. package/dist/rzm-loader.d.ts.map +0 -1
  113. package/dist/rzm-loader.js +0 -488
  114. package/dist/rzm-writer.d.ts +0 -27
  115. package/dist/rzm-writer.d.ts.map +0 -1
  116. package/dist/rzm-writer.js +0 -701
@@ -1,2 +1,2 @@
1
- export declare const COMPOSITE_SHADER_WGSL = "\n// Pipeline-override constant: the engine creates two composite pipelines, one\n// with APPLY_GAMMA=false (gamma=1 fast path) and one with APPLY_GAMMA=true.\n// The 'if (APPLY_GAMMA)' below is resolved at pipeline-compile time \u2014 the\n// dead branch is dropped by the shader compiler (no runtime branch, no pow\n// invocation on Safari's Metal backend in the common case).\noverride APPLY_GAMMA: bool = true;\n\n@group(0) @binding(0) var hdrTex: texture_2d<f32>;\n@group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)\n@group(0) @binding(2) var bloomSamp: sampler;\n@group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;\n// viewU[0] = (exposure, invGamma, _, _); viewU[1] = (tint.rgb, intensity)\n// invGamma = 1/gamma precomputed on CPU \u2014 avoids a per-pixel divide.\n\nfn filmic(x: f32) -> f32 {\n // Re-fit against Blender 3.6 Filmic MHC anchors (sobotka/filmic-blender\n // look_medium-high-contrast.spi1d). Previous curve was compressed:\n // midtones too bright, highlights too dim \u2014 flattened contrast, read\n // as \"washed-out\" on saturated surfaces (hair especially).\n // Reference checkpoints: linear 0.18 \u2192 ~0.395, linear 1.0 \u2192 ~0.83.\n var lut = array<f32, 14>(\n 0.0028, 0.0068, 0.0151, 0.0313, 0.0610, 0.1120, 0.1920,\n 0.3060, 0.4590, 0.6310, 0.8200, 0.9070, 0.9620, 0.9890\n );\n let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);\n let i = u32(t);\n let j = min(i + 1u, 13u);\n return mix(lut[i], lut[j], t - f32(i));\n}\n\n@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {\n let x = f32((vi & 1u) << 2u) - 1.0;\n let y = f32((vi & 2u) << 1u) - 1.0;\n return vec4f(x, y, 0.0, 1.0);\n}\n\n@fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {\n let hdr = textureLoad(hdrTex, vec2<i32>(fragCoord.xy), 0);\n let a = max(hdr.a, 1e-6);\n let straight = hdr.rgb / a;\n let fullSz = vec2f(textureDimensions(hdrTex));\n let bloomSz = vec2f(textureDimensions(bloomTex));\n // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.\n // fragCoord.xy is already at pixel center (e.g. 0.5, 0.5 for first pixel).\n let bloomUv = fragCoord.xy / max(fullSz, vec2f(1.0));\n let tint = viewU[1].xyz;\n let intensity = viewU[1].w;\n let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;\n let combined = straight + bloom;\n let exposed = combined * exp2(viewU[0].x);\n let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));\n var disp = max(tm, vec3f(0.0));\n if (APPLY_GAMMA) {\n disp = pow(disp, vec3f(viewU[0].y));\n }\n return vec4f(disp * hdr.a, hdr.a);\n}\n";
1
+ export declare const COMPOSITE_SHADER_WGSL = "\n// Pipeline-override constant: the engine creates two composite pipelines, one\n// with APPLY_GAMMA=false (gamma=1 fast path) and one with APPLY_GAMMA=true.\n// The 'if (APPLY_GAMMA)' below is resolved at pipeline-compile time \u2014 the\n// dead branch is dropped by the shader compiler (no runtime branch, no pow\n// invocation on Safari's Metal backend in the common case).\noverride APPLY_GAMMA: bool = true;\n\n@group(0) @binding(0) var hdrTex: texture_2d<f32>;\n@group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)\n@group(0) @binding(2) var bloomSamp: sampler;\n@group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;\n// Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).\n// .g = accumulated canvas alpha (what hdr.a carried before the HDR format\n// became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then\n// re-premultiply the tonemapped color for output so the premultiplied canvas\n// alphaMode composites the WebGPU surface over the page background correctly.\n@group(0) @binding(4) var maskTex: texture_2d<f32>;\n// viewU[0] = (exposure, invGamma, _, _); viewU[1] = (tint.rgb, intensity)\n// invGamma = 1/gamma precomputed on CPU \u2014 avoids a per-pixel divide.\n\nfn filmic(x: f32) -> f32 {\n // Re-fit against Blender 3.6 Filmic MHC anchors (sobotka/filmic-blender\n // look_medium-high-contrast.spi1d). Previous curve was compressed:\n // midtones too bright, highlights too dim \u2014 flattened contrast, read\n // as \"washed-out\" on saturated surfaces (hair especially).\n // Reference checkpoints: linear 0.18 \u2192 ~0.395, linear 1.0 \u2192 ~0.83.\n var lut = array<f32, 14>(\n 0.0028, 0.0068, 0.0151, 0.0313, 0.0610, 0.1120, 0.1920,\n 0.3060, 0.4590, 0.6310, 0.8200, 0.9070, 0.9620, 0.9890\n );\n let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);\n let i = u32(t);\n let j = min(i + 1u, 13u);\n return mix(lut[i], lut[j], t - f32(i));\n}\n\n@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {\n let x = f32((vi & 1u) << 2u) - 1.0;\n let y = f32((vi & 2u) << 1u) - 1.0;\n return vec4f(x, y, 0.0, 1.0);\n}\n\n@fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {\n let coord = vec2<i32>(fragCoord.xy);\n let hdr = textureLoad(hdrTex, coord, 0);\n let alpha = textureLoad(maskTex, coord, 0).g;\n let a = max(alpha, 1e-6);\n let straight = hdr.rgb / a;\n let fullSz = vec2f(textureDimensions(hdrTex));\n // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.\n // fragCoord.xy is already at pixel center (e.g. 0.5, 0.5 for first pixel).\n let bloomUv = fragCoord.xy / max(fullSz, vec2f(1.0));\n let tint = viewU[1].xyz;\n let intensity = viewU[1].w;\n let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;\n let combined = straight + bloom;\n let exposed = combined * exp2(viewU[0].x);\n let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));\n var disp = max(tm, vec3f(0.0));\n if (APPLY_GAMMA) {\n disp = pow(disp, vec3f(viewU[0].y));\n }\n return vec4f(disp * alpha, alpha);\n}\n";
2
2
  //# sourceMappingURL=composite.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,qBAAqB,kqFA0DjC,CAAA"}
1
+ {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,qBAAqB,mnGAiEjC,CAAA"}
@@ -12,6 +12,12 @@ override APPLY_GAMMA: bool = true;
12
12
  @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
13
13
  @group(0) @binding(2) var bloomSamp: sampler;
14
14
  @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;
15
+ // Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).
16
+ // .g = accumulated canvas alpha (what hdr.a carried before the HDR format
17
+ // became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then
18
+ // re-premultiply the tonemapped color for output so the premultiplied canvas
19
+ // alphaMode composites the WebGPU surface over the page background correctly.
20
+ @group(0) @binding(4) var maskTex: texture_2d<f32>;
15
21
  // viewU[0] = (exposure, invGamma, _, _); viewU[1] = (tint.rgb, intensity)
16
22
  // invGamma = 1/gamma precomputed on CPU — avoids a per-pixel divide.
17
23
 
@@ -38,11 +44,12 @@ fn filmic(x: f32) -> f32 {
38
44
  }
39
45
 
40
46
  @fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
41
- let hdr = textureLoad(hdrTex, vec2<i32>(fragCoord.xy), 0);
42
- let a = max(hdr.a, 1e-6);
47
+ let coord = vec2<i32>(fragCoord.xy);
48
+ let hdr = textureLoad(hdrTex, coord, 0);
49
+ let alpha = textureLoad(maskTex, coord, 0).g;
50
+ let a = max(alpha, 1e-6);
43
51
  let straight = hdr.rgb / a;
44
52
  let fullSz = vec2f(textureDimensions(hdrTex));
45
- let bloomSz = vec2f(textureDimensions(bloomTex));
46
53
  // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
47
54
  // fragCoord.xy is already at pixel center (e.g. 0.5, 0.5 for first pixel).
48
55
  let bloomUv = fragCoord.xy / max(fullSz, vec2f(1.0));
@@ -56,6 +63,6 @@ fn filmic(x: f32) -> f32 {
56
63
  if (APPLY_GAMMA) {
57
64
  disp = pow(disp, vec3f(viewU[0].y));
58
65
  }
59
- return vec4f(disp * hdr.a, hdr.a);
66
+ return vec4f(disp * alpha, alpha);
60
67
  }
61
68
  `;
@@ -1,2 +1,2 @@
1
- export declare const GROUND_SHADOW_SHADER_WGSL = "\nstruct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };\nstruct Light { direction: vec4f, color: vec4f, };\nstruct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };\nstruct GroundShadowMat {\n diffuseColor: vec3f, fadeStart: f32,\n fadeEnd: f32, shadowStrength: f32, pcfTexel: f32, gridSpacing: f32,\n gridLineWidth: f32, gridLineOpacity: f32, noiseStrength: f32, _pad: f32,\n gridLineColor: vec3f, _pad2: f32,\n};\nstruct LightVP { viewProj: mat4x4f, };\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var shadowMap: texture_depth_2d;\n@group(0) @binding(3) var shadowSampler: sampler_comparison;\n@group(0) @binding(4) var<uniform> material: GroundShadowMat;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n\nfn hash2(p: vec2f) -> f32 {\n var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);\n p3 += dot(p3, vec3f(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33));\n return fract((p3.x + p3.y) * p3.z);\n}\nfn valueNoise(p: vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hash2(i), hash2(i + vec2f(1.0, 0.0)), u.x),\n mix(hash2(i + vec2f(0.0, 1.0)), hash2(i + vec2f(1.0, 1.0)), u.x), u.y);\n}\nfn fbmNoise(p: vec2f) -> f32 {\n var v = 0.0;\n var a = 0.5;\n var pp = p;\n for (var i = 0; i < 4; i++) {\n v += a * valueNoise(pp);\n pp *= 2.0;\n a *= 0.5;\n }\n return v;\n}\n\nstruct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @location(1) normal: vec3f, };\n@vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VO {\n var o: VO; o.worldPos = position; o.normal = normal;\n o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;\n}\nstruct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };\n@fragment fn fs(i: VO) -> FSOut {\n let n = normalize(i.normal);\n let centerDist = length(i.worldPos.xz);\n let edgeFade = 1.0 - smoothstep(0.0, 1.0, clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0));\n\n let lclip = lightVP.viewProj * vec4f(i.worldPos, 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 suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));\n let st = material.pcfTexel;\n let compareZ = ndc.z - 0.0035;\n var vis = 0.0;\n for (var y = -2; y <= 2; y++) {\n for (var x = -2; x <= 2; x++) {\n vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(f32(x), f32(y)) * st, compareZ);\n }\n }\n vis *= 0.04;\n\n // Frosted/matte micro-texture\n let noiseVal = fbmNoise(i.worldPos.xz * 3.0);\n let noiseTint = 1.0 + (noiseVal - 0.5) * material.noiseStrength;\n\n // Grid lines \u2014 anti-aliased via screen-space derivatives\n let gp = i.worldPos.xz / material.gridSpacing;\n let gridFrac = abs(fract(gp - 0.5) - 0.5);\n let gridDeriv = fwidth(gp);\n let halfLine = material.gridLineWidth * 0.5;\n let gridLine = 1.0 - min(\n smoothstep(halfLine - gridDeriv.x, halfLine + gridDeriv.x, gridFrac.x),\n smoothstep(halfLine - gridDeriv.y, halfLine + gridDeriv.y, gridFrac.y)\n );\n let sun = light.ambientColor.xyz + light.lights[0].color.xyz * light.lights[0].color.w * max(dot(n, -light.lights[0].direction.xyz), 0.0);\n let dark = (1.0 - vis) * material.shadowStrength;\n var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);\n baseColor *= noiseTint;\n let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);\n var out: FSOut;\n out.color = vec4f(finalColor * edgeFade, edgeFade);\n out.mask = 0.0;\n return out;\n}\n";
1
+ export declare const GROUND_SHADOW_SHADER_WGSL = "\nstruct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };\nstruct Light { direction: vec4f, color: vec4f, };\nstruct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };\nstruct GroundShadowMat {\n diffuseColor: vec3f, fadeStart: f32,\n fadeEnd: f32, shadowStrength: f32, pcfTexel: f32, gridSpacing: f32,\n gridLineWidth: f32, gridLineOpacity: f32, noiseStrength: f32, _pad: f32,\n gridLineColor: vec3f, _pad2: f32,\n};\nstruct LightVP { viewProj: mat4x4f, };\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var shadowMap: texture_depth_2d;\n@group(0) @binding(3) var shadowSampler: sampler_comparison;\n@group(0) @binding(4) var<uniform> material: GroundShadowMat;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n\nfn hash2(p: vec2f) -> f32 {\n var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);\n p3 += dot(p3, vec3f(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33));\n return fract((p3.x + p3.y) * p3.z);\n}\nfn valueNoise(p: vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hash2(i), hash2(i + vec2f(1.0, 0.0)), u.x),\n mix(hash2(i + vec2f(0.0, 1.0)), hash2(i + vec2f(1.0, 1.0)), u.x), u.y);\n}\nfn fbmNoise(p: vec2f) -> f32 {\n var v = 0.0;\n var a = 0.5;\n var pp = p;\n for (var i = 0; i < 4; i++) {\n v += a * valueNoise(pp);\n pp *= 2.0;\n a *= 0.5;\n }\n return v;\n}\n\nstruct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @location(1) normal: vec3f, };\n@vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VO {\n var o: VO; o.worldPos = position; o.normal = normal;\n o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;\n}\nstruct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };\n@fragment fn fs(i: VO) -> FSOut {\n let n = normalize(i.normal);\n let centerDist = length(i.worldPos.xz);\n let edgeFade = 1.0 - smoothstep(0.0, 1.0, clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0));\n\n let lclip = lightVP.viewProj * vec4f(i.worldPos, 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 suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));\n let st = material.pcfTexel;\n let compareZ = ndc.z - 0.0035;\n var vis = 0.0;\n for (var y = -2; y <= 2; y++) {\n for (var x = -2; x <= 2; x++) {\n vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(f32(x), f32(y)) * st, compareZ);\n }\n }\n vis *= 0.04;\n\n // Frosted/matte micro-texture\n let noiseVal = fbmNoise(i.worldPos.xz * 3.0);\n let noiseTint = 1.0 + (noiseVal - 0.5) * material.noiseStrength;\n\n // Grid lines \u2014 anti-aliased via screen-space derivatives\n let gp = i.worldPos.xz / material.gridSpacing;\n let gridFrac = abs(fract(gp - 0.5) - 0.5);\n let gridDeriv = fwidth(gp);\n let halfLine = material.gridLineWidth * 0.5;\n let gridLine = 1.0 - min(\n smoothstep(halfLine - gridDeriv.x, halfLine + gridDeriv.x, gridFrac.x),\n smoothstep(halfLine - gridDeriv.y, halfLine + gridDeriv.y, gridFrac.y)\n );\n let sun = light.ambientColor.xyz + light.lights[0].color.xyz * light.lights[0].color.w * max(dot(n, -light.lights[0].direction.xyz), 0.0);\n let dark = (1.0 - vis) * material.shadowStrength;\n var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);\n baseColor *= noiseTint;\n let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);\n var out: FSOut;\n out.color = vec4f(finalColor * edgeFade, edgeFade);\n // mask.r = 0: ground never contributes to bloom. mask.g = 1.0 with src.a =\n // edgeFade turns the aux blend into alpha-over, so the drawable alpha fades\n // from edgeFade at the center to 0 at the radial edge \u2014 letting the page\n // background show through under the premultiplied canvas alphaMode.\n out.mask = vec4f(0.0, 1.0, 0.0, edgeFade);\n return out;\n}\n";
2
2
  //# sourceMappingURL=ground.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ground.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,yBAAyB,2tHA0FrC,CAAA"}
1
+ {"version":3,"file":"ground.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,yBAAyB,ijIA8FrC,CAAA"}
@@ -47,7 +47,7 @@ struct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @l
47
47
  var o: VO; o.worldPos = position; o.normal = normal;
48
48
  o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
49
49
  }
50
- struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
50
+ struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
51
51
  @fragment fn fs(i: VO) -> FSOut {
52
52
  let n = normalize(i.normal);
53
53
  let centerDist = length(i.worldPos.xz);
@@ -87,7 +87,11 @@ struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
87
87
  let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);
88
88
  var out: FSOut;
89
89
  out.color = vec4f(finalColor * edgeFade, edgeFade);
90
- out.mask = 0.0;
90
+ // mask.r = 0: ground never contributes to bloom. mask.g = 1.0 with src.a =
91
+ // edgeFade turns the aux blend into alpha-over, so the drawable alpha fades
92
+ // from edgeFade at the center to 0 at the radial edge — letting the page
93
+ // background show through under the premultiplied canvas alphaMode.
94
+ out.mask = vec4f(0.0, 1.0, 0.0, edgeFade);
91
95
  return out;
92
96
  }
93
97
  `;
@@ -1,2 +1,2 @@
1
- export declare const OUTLINE_SHADER_WGSL = "\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct MaterialUniforms {\n edgeColor: vec4f,\n edgeSize: f32,\n _padding1: f32,\n _padding2: f32,\n _padding3: f32,\n};\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var<uniform> material: MaterialUniforms;\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n};\n\n@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\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\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 normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n\n var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);\n var skinnedNrm = vec3f(0.0, 0.0, 0.0);\n for (var i = 0u; i < 4u; i++) {\n let j = joints0[i];\n let w = normalizedWeights[i];\n let m = skinMats[j];\n skinnedPos += (m * pos4) * w;\n let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);\n skinnedNrm += (r3 * normal) * w;\n }\n let worldPos = skinnedPos.xyz;\n let worldNormal = normalize(skinnedNrm);\n\n // Screen-space outline extrusion \u2014 MMD-style pixel-stable edge line.\n // 1. Project position and normal-as-direction to clip space.\n // 2. Normalize the 2D clip-space normal, aspect-compensated so \"one pixel horizontally\"\n // matches \"one pixel vertically\" (otherwise wide viewports squash the outline in X).\n // 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w\n // so the perspective divide cancels out \u2192 offset stays constant in NDC regardless\n // of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.\n // 4. edgeScale is in NDC-y units per PMX edgeSize. \u2248 0.006 gives ~3px at 1080p; it's\n // tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.\n let viewProj = camera.projection * camera.view;\n let clipPos = viewProj * vec4f(worldPos, 1.0);\n let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;\n // projection is column-major: proj[0][0] = 1/(aspect\u00B7tan(fov/2)), proj[1][1] = 1/tan(fov/2).\n // Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).\n let aspect = camera.projection[1][1] / camera.projection[0][0];\n let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));\n let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);\n let edgeScale = 0.0016;\n let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;\n output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);\n return output;\n}\n\nstruct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };\n@fragment fn fs() -> FSOut {\n var out: FSOut;\n out.color = material.edgeColor;\n out.mask = 1.0;\n return out;\n}\n";
1
+ export declare const OUTLINE_SHADER_WGSL = "\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct MaterialUniforms {\n edgeColor: vec4f,\n edgeSize: f32,\n _padding1: f32,\n _padding2: f32,\n _padding3: f32,\n};\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var<uniform> material: MaterialUniforms;\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n};\n\n@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\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\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 normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n\n var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);\n var skinnedNrm = vec3f(0.0, 0.0, 0.0);\n for (var i = 0u; i < 4u; i++) {\n let j = joints0[i];\n let w = normalizedWeights[i];\n let m = skinMats[j];\n skinnedPos += (m * pos4) * w;\n let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);\n skinnedNrm += (r3 * normal) * w;\n }\n let worldPos = skinnedPos.xyz;\n let worldNormal = normalize(skinnedNrm);\n\n // Screen-space outline extrusion \u2014 MMD-style pixel-stable edge line.\n // 1. Project position and normal-as-direction to clip space.\n // 2. Normalize the 2D clip-space normal, aspect-compensated so \"one pixel horizontally\"\n // matches \"one pixel vertically\" (otherwise wide viewports squash the outline in X).\n // 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w\n // so the perspective divide cancels out \u2192 offset stays constant in NDC regardless\n // of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.\n // 4. edgeScale is in NDC-y units per PMX edgeSize. \u2248 0.006 gives ~3px at 1080p; it's\n // tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.\n let viewProj = camera.projection * camera.view;\n let clipPos = viewProj * vec4f(worldPos, 1.0);\n let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;\n // projection is column-major: proj[0][0] = 1/(aspect\u00B7tan(fov/2)), proj[1][1] = 1/tan(fov/2).\n // Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).\n let aspect = camera.projection[1][1] / camera.projection[0][0];\n let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));\n let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);\n let edgeScale = 0.0016;\n let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;\n output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);\n return output;\n}\n\nstruct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };\n@fragment fn fs() -> FSOut {\n var out: FSOut;\n out.color = material.edgeColor;\n out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);\n return out;\n}\n";
2
2
  //# sourceMappingURL=outline.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"outline.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/outline.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,mBAAmB,kjGAgF/B,CAAA"}
1
+ {"version":3,"file":"outline.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/outline.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,mBAAmB,klGAgF/B,CAAA"}
@@ -73,11 +73,11 @@ struct VertexOutput {
73
73
  return output;
74
74
  }
75
75
 
76
- struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
76
+ struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
77
77
  @fragment fn fs() -> FSOut {
78
78
  var out: FSOut;
79
79
  out.color = material.edgeColor;
80
- out.mask = 1.0;
80
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
81
81
  return out;
82
82
  }
83
83
  `;
@@ -1,2 +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 AMBIENT_OCCLUSION node (faked) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Real SSAO is a non-goal. We approximate: use the \"inside\" value from\n// concavity heuristic: 1.0 = fully lit, lower = occluded.\n// For now returns 1.0 (no darkening). Individual presets can override.\n\nfn ao_fake(n: vec3f, v: vec3f) -> f32 {\n return 1.0;\n}\n\n// \u2500\u2500\u2500 BUMP node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Screen-space bump from a scalar height field. Needs dFdx/dFdy which\n// WGSL provides as dpdx/dpdy.\n\nfn bump(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) + dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// LH engine + WebGPU fragment Y: flip dhdy contribution so height peaks read as outward bumps vs Blender reference\nfn bump_lh(strength: f32, height: f32, normal: vec3f, world_pos: vec3f) -> vec3f {\n let dhdx = dpdx(height);\n let dhdy = dpdy(height);\n let dpdx_pos = dpdx(world_pos);\n let dpdy_pos = dpdy(world_pos);\n let perturbed = normalize(normal) - strength * (dhdx * normalize(cross(dpdy_pos, normal)) - dhdy * normalize(cross(normal, dpdx_pos)));\n return normalize(perturbed);\n}\n\n// \u2500\u2500\u2500 NOISE texture (Perlin-style) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Simplified gradient noise matching Blender's default noise output.\n\n// PCG-style integer hash. Replaces the classic 'fract(sin(q) * LARGE)' trick because\n// WebKit's Metal backend compiles 'sin' to a full transcendental op (slow), while\n// Safari's Apple-GPU scalar ALU handles int muls/xors near free. Inputs arrive as\n// integer-valued floats (floor(p) + unit offsets) from _noise3, so vec3i cast is exact.\nfn _hash33(p: vec3f) -> vec3f {\n var h = vec3u(vec3i(p) + vec3i(32768));\n h = h * vec3u(1664525u, 1013904223u, 2654435761u);\n h = (h.yzx ^ h) * vec3u(2246822519u, 3266489917u, 668265263u);\n h = h ^ (h >> vec3u(16u));\n // Mask to 24 bits \u2014 above that f32 loses precision on the u32\u2192f32 convert.\n let hm = h & vec3u(16777215u);\n return vec3f(hm) * (2.0 / 16777216.0) - 1.0;\n}\n\nfn _noise3(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return mix(\n mix(\n mix(dot(_hash33(i + vec3f(0,0,0)), f - vec3f(0,0,0)),\n dot(_hash33(i + vec3f(1,0,0)), f - vec3f(1,0,0)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,0)), f - vec3f(0,1,0)),\n dot(_hash33(i + vec3f(1,1,0)), f - vec3f(1,1,0)), u.x), u.y),\n mix(\n mix(dot(_hash33(i + vec3f(0,0,1)), f - vec3f(0,0,1)),\n dot(_hash33(i + vec3f(1,0,1)), f - vec3f(1,0,1)), u.x),\n mix(dot(_hash33(i + vec3f(0,1,1)), f - vec3f(0,1,1)),\n dot(_hash33(i + vec3f(1,1,1)), f - vec3f(1,1,1)), u.x), u.y),\n u.z);\n}\n\nfn tex_noise(p: vec3f, scale: f32, detail: f32, roughness: f32, distortion: f32) -> f32 {\n var q = p;\n if (abs(distortion) > 1e-6) {\n let w = _noise3(p * scale * 1.37 + vec3f(2.31, 5.17, 8.09));\n q = p + (w * 2.0 - 1.0) * distortion;\n }\n let coords = q * scale;\n var value = 0.0;\n var amplitude = 1.0;\n var frequency = 1.0;\n var total_amp = 0.0;\n let octaves = i32(clamp(detail, 0.0, 15.0)) + 1;\n for (var i = 0; i < octaves; i++) {\n value += amplitude * _noise3(coords * frequency);\n total_amp += amplitude;\n amplitude *= roughness;\n frequency *= 2.0;\n }\n return value / max(total_amp, 1e-6) * 0.5 + 0.5;\n}\n\n// tex_noise specialization: detail=2.0 (3 octaves), roughness=0.5, distortion=0.\n// WebKit can't unroll tex_noise's for-loop because 'octaves' is a runtime value;\n// this variant is fully unrolled with constants folded (total_amp = 1.75).\nfn tex_noise_d2(p: vec3f, scale: f32) -> f32 {\n let c = p * scale;\n let v = _noise3(c) + 0.5 * _noise3(c * 2.0) + 0.25 * _noise3(c * 4.0);\n return v * (1.0 / 1.75) * 0.5 + 0.5;\n}\n\n// \u2500\u2500\u2500 TEX_GRADIENT (linear) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Stockings preset. Maps the input vector's X to a 0\u20131 gradient.\n\nfn tex_gradient_linear(uv: vec3f) -> f32 {\n return clamp(uv.x, 0.0, 1.0);\n}\n\n// \u2500\u2500\u2500 TEX_VORONOI (distance only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Metal preset. Simplified F1 cell noise.\n\nfn tex_voronoi_f1(p: vec3f, scale: f32) -> f32 {\n let coords = p * scale;\n let i = floor(coords);\n let f = fract(coords);\n var min_dist = 1e10;\n for (var z = -1; z <= 1; z++) {\n for (var y = -1; y <= 1; y++) {\n for (var x = -1; x <= 1; x++) {\n let neighbor = vec3f(f32(x), f32(y), f32(z));\n let point = _hash33(i + neighbor) * 0.5 + 0.5;\n let diff = neighbor + point - f;\n min_dist = min(min_dist, dot(diff, diff));\n }\n }\n }\n return sqrt(min_dist);\n}\n\n// \u2500\u2500\u2500 SEPXYZ node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn separate_xyz(v: vec3f) -> vec3f { return v; }\n\n// \u2500\u2500\u2500 VECT_MATH (cross product) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn vect_math_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }\n\n// \u2500\u2500\u2500 MAPPING node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Point-type mapping: scale, rotate (euler XYZ), translate.\n\nfn mapping_point(v: vec3f, loc: vec3f, rot: vec3f, scl: vec3f) -> vec3f {\n var p = v * scl;\n // simplified: skip rotation when all angles are zero (common case)\n if (abs(rot.x) + abs(rot.y) + abs(rot.z) > 1e-6) {\n let cx = cos(rot.x); let sx = sin(rot.x);\n let cy = cos(rot.y); let sy = sin(rot.y);\n let cz = cos(rot.z); let sz = sin(rot.z);\n let rx = vec3f(p.x, cx*p.y - sx*p.z, sx*p.y + cx*p.z);\n let ry = vec3f(cy*rx.x + sy*rx.z, rx.y, -sy*rx.x + cy*rx.z);\n p = vec3f(cz*ry.x - sz*ry.y, sz*ry.x + cz*ry.y, ry.z);\n }\n return p + loc;\n}\n\n// \u2500\u2500\u2500 NORMAL_MAP node (tangent-space) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Applies a tangent-space normal map. Requires TBN from vertex stage.\n\nfn normal_map(strength: f32, map_color: vec3f, normal: vec3f, tangent: vec3f, bitangent: vec3f) -> vec3f {\n let ts = map_color * 2.0 - 1.0;\n let perturbed = normalize(tangent * ts.x + bitangent * ts.y + normal * ts.z);\n return normalize(mix(normal, perturbed, strength));\n}\n\n// \u2500\u2500\u2500 EEVEE Principled BSDF primitives \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Ports from Blender 3.6 source/blender/draw/engines/eevee/shaders/\n// bsdf_common_lib.glsl + gpu_shader_material_principled.glsl.\n// Usage pattern (see material shaders): direct spec = bsdf_ggx \u00D7 sun \u00D7 shadow\n// (NL baked in, no F yet); ambient spec = probe_radiance; tint both with\n// reflection_color = F_brdf_multi_scatter(f0, f90, split_sum) AFTER summing.\n\nconst EEVEE_PI: f32 = 3.141592653589793;\n\n// Fused analytic GGX specular (direct lights). Returns BRDF \u00D7 NL.\n// 4\u00B7NL\u00B7NV is cancelled via G1_Smith reciprocal form \u2014 see bsdf_common_lib.glsl:115.\n// Caller passes NL, NV (already computed for diffuse + brdf_lut_sample) so WebKit\n// can reuse them instead of recomputing dot products across the function boundary.\nfn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32) -> f32 {\n let a = max(roughness, 1e-4);\n let a2 = a * a;\n let H = normalize(L + V);\n let NH = max(dot(N, H), 1e-8);\n let NL = max(NL_in, 1e-8);\n let NV = max(NV_in, 1e-8);\n // G1_Smith_GGX_opti reciprocal form \u2014 denominator piece only.\n let G1L = NL + sqrt(NL * (NL - NL * a2) + a2);\n let G1V = NV + sqrt(NV * (NV - NV * a2) + a2);\n let G = G1L * G1V;\n // D_ggx_opti = pi * denom\u00B2 \u2014 reciprocal D \u00D7 a\u00B2.\n let tmp = (NH * a2 - NH) * NH + 1.0;\n let D_opti = EEVEE_PI * tmp * tmp;\n return NL * a2 / (D_opti * G);\n}\n\n// Split-sum DFG LUT \u2014 Karis 2013 curve fit stand-in for the 64\u00D764 baked LUT.\n// Returns (lut.x, lut.y) in Blender convention: tint = f0\u00B7lut.x + f90\u00B7lut.y.\nfn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {\n let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);\n let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);\n let r = roughness * c0 + c1;\n let a004 = min(r.x * r.x, exp2(-9.28 * NV)) * r.x + r.y;\n return vec2f(-1.04, 1.04) * a004 + r.zw;\n}\n\n// Baked combined BRDF LUT \u2014 exact port of Blender bsdf_lut_frag.glsl packed with\n// ltc_mag_ggx from eevee_lut.c. Single sample returns DFG (.rg) and LTC mag (.ba).\n// Addressed as Blender's common_utiltex_lib.glsl:lut_coords:\n// coords = (roughness, sqrt(1 - NV)), then half-texel bias for filtering.\n// Requires group(0) binding(9) brdfLut + binding(2) diffuseSampler in the host shader.\nfn brdf_lut_sample(NV: f32, roughness: f32) -> vec4f {\n let LUT_SIZE: f32 = 64.0;\n var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));\n uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;\n return textureSampleLevel(brdfLut, diffuseSampler, uv, 0.0);\n}\n\nfn F_brdf_single_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n return lut.y * f90 + lut.x * f0;\n}\n\n// Fdez-Ag\u00FCera 2019 multi-scatter compensation (EEVEE do_multiscatter=1).\nfn F_brdf_multi_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n let FssEss = lut.y * f90 + lut.x * f0;\n let Ess = lut.x + lut.y;\n let Ems = 1.0 - Ess;\n let Favg = f0 + (1.0 - f0) / 21.0;\n let Fms = FssEss * Favg / (1.0 - (1.0 - Ess) * Favg);\n return FssEss + Fms * Ems;\n}\n\n// EEVEE direct-specular energy compensation factor \u2014 closure_eval_glossy_lib.glsl:79-81:\n// ltc_brdf_scale = (ltc.x + ltc.y) / (split_sum.x + split_sum.y)\n// Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum;\n// direct radiance is rescaled so total-energy matches the split-sum LUT.\n// Takes a pre-sampled vec4f from brdf_lut_sample() to share the fetch with\n// F_brdf_multi_scatter on the same fragment.\nfn ltc_brdf_scale_from_lut(lut: vec4f) -> f32 {\n return (lut.z + lut.w) / max(lut.x + lut.y, 1e-6);\n}\n\n// Luminance-normalized hue extraction \u2014 Blender tint_from_color (isolates hue+sat).\nfn tint_from_color(color: vec3f) -> vec3f {\n let lum = dot(color, vec3f(0.3, 0.6, 0.1));\n return select(vec3f(1.0), color / lum, lum > 0.0);\n}\n\n\n\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct Light {\n direction: vec4f,\n color: vec4f,\n};\n\nstruct LightUniforms {\n ambientColor: vec4f,\n lights: array<Light, 4>,\n};\n\nstruct MaterialUniforms {\n diffuseColor: vec3f,\n alpha: f32,\n};\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) normal: vec3f,\n @location(1) uv: vec2f,\n @location(2) worldPos: vec3f,\n};\n\nstruct LightVP { viewProj: mat4x4f, };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var diffuseSampler: sampler;\n@group(0) @binding(3) var shadowMap: texture_depth_2d;\n@group(0) @binding(4) var shadowSampler: sampler_comparison;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;\n@group(2) @binding(1) var<uniform> material: MaterialUniforms;\n\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\n // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.\n if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }\n let biasedPos = worldPos + n * 0.08;\n let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let cmpZ = ndc.z - 0.001;\n let ts = 1.0 / 2048.0;\n // 3x3 PCF unrolled \u2014 Safari's Metal backend doesn't unroll nested shadow loops reliably.\n let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);\n let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);\n let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);\n let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);\n let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);\n let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);\n let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);\n let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);\n let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);\n return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);\n}\n\nconst PI_S: f32 = 3.141592653589793;\n// Principled BSDF params from dump (Alpha=0.95 is intentionally dropped \u2014 see alpha-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// principled_sheen (gpu_shader_material_principled.glsl:8-14) \u2014 empirical NV curve\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// Wyman & McGuire \"Hashed Alpha Testing\" (2017) \u2014 world-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/world space (doesn't swim) and stays\n// at one-pixel frequency regardless of view distance, which makes it tolerable without TAA.\nfn _hash_wm(a: vec2f) -> f32 {\n return fract(1e4 * sin(17.0 * a.x + 0.1 * a.y) * (0.1 + abs(sin(13.0 * a.y + a.x))));\n}\nfn _hash3d_wm(a: vec3f) -> f32 {\n return _hash_wm(vec2f(_hash_wm(a.xy), a.z));\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 that 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@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\n @location(2) uv: vec2f,\n @location(3) joints0: vec4<u32>,\n @location(4) weights0: vec4<f32>\n) -> VertexOutput {\n var output: VertexOutput;\n let pos4 = vec4f(position, 1.0);\n let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n var skinnedPos = vec4f(0.0);\n var skinnedNrm = vec3f(0.0);\n for (var i = 0u; i < 4u; i++) {\n let m = skinMats[joints0[i]];\n let w = nw[i];\n skinnedPos += (m * pos4) * w;\n skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;\n }\n output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);\n // Skip VS normalize \u2014 interpolation denormalizes anyway, and FS always does normalize(input.normal).\n output.normal = skinnedNrm;\n output.uv = uv;\n output.worldPos = skinnedPos.xyz;\n return output;\n}\n\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) bloom_mask: f32,\n};\n\n@fragment fn fs(input: VertexOutput) -> FSOut {\n let n = normalize(input.normal);\n let v = normalize(camera.viewPos - input.worldPos);\n let l = -light.lights[0].direction.xyz;\n let sun = light.lights[0].color.xyz * light.lights[0].color.w;\n 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\n // self-overlap on the stockings produces sort cracks under alpha blend. Wyman-style\n // worldPos hash + depth-write is sort-independent. NOTE: Principled.Alpha=0.95 from\n // the dump is DROPPED here \u2014 it relies on TAA to smooth the resulting 5%-everywhere\n // dither, and without TAA it shows as a pervasive dot pattern. Hash now gates only\n // on texture/material alpha, so solid stockings regions stay fully opaque.\n let combined_alpha = material.alpha * tex_s.a;\n if (combined_alpha < hashed_alpha_threshold(input.worldPos)) { discard; }\n let out_alpha = 1.0;\n\n // \u2550\u2550\u2550 NPR MASK: TEX_COORD.Generated \u2192 Mapping(Rot=0,\u03C0/2,\u03C0/2, Loc=(1,1,1)) \u2192 Gradient Texture\n // The Blender mapping reduces to gradient.x = 1 - input.y (rot swaps axes, loc offsets by 1).\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 // Ramp.002 EASE [0\u2192black, 0.9565\u2192white]\n let ramp002 = ramp_ease_s(ramp001, 0.0, STOCK_RAMP002_P1);\n\n // Layer Weight.Facing (Blend=0.4) \u2192 Ramp EASE [0\u2192black, 0.5435\u2192white]\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 \u2192 (A,B) averaged 50/50\n let mix001 = mix(1.0, ramp_face, 0.5);\n // Mix: LIGHTEN blend Fac=0.5, A=mix001, B=ramp002 \u2192 A smoothly lightens toward max(A,B)\n let lighten = max(mix001, ramp002);\n let mask = mix(mix001, lighten, 0.5);\n\n // \u2550\u2550\u2550 EMISSION SHADER \u2550\u2550\u2550\n // Hue=0.5 (identity rotation), Sat=1.0, Val=5.0 (5\u00D7 brightness boost), Fac=1; Strength=1\n let emission = hue_sat_id(1.0, 5.0, 1.0, tex_rgb);\n\n // \u2550\u2550\u2550 PRINCIPLED BSDF (EEVEE port) \u2550\u2550\u2550\n // base_color_tint, metallic f0, sheen coarse approx (scales diffuse radiance).\n let NL = max(dot(n, l), 0.0);\n let NV = max(dot(n, v), 1e-4);\n\n // f0 = mix((0.08*spec)*dielectric_tint, base, metallic); dielectric_tint=1 since specular_tint=0.\n let dielectric_f0 = vec3f(0.08 * STOCK_SPECULAR);\n let f0 = mix(dielectric_f0, tex_rgb, STOCK_METALLIC);\n let f90 = mix(f0, vec3f(1.0), sqrt(STOCK_SPECULAR));\n let brdf_lut = brdf_lut_sample(NV, STOCK_ROUGHNESS);\n let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);\n\n let spec_direct = bsdf_ggx(n, l, v, NL, NV, STOCK_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);\n let spec_indirect = amb;\n let spec_radiance = (spec_direct + spec_indirect) * reflection_color;\n\n // Sheen coarse: diffuse_color += sheen * sheen_color * principled_sheen(NV).\n let base_tint = tint_from_color(tex_rgb);\n let sheen_color = mix(vec3f(1.0), base_tint, STOCK_SHEEN_TINT);\n let diffuse_color = tex_rgb + STOCK_SHEEN * sheen_color * principled_sheen(NV);\n\n // diffuse_weight = (1 - metallic). Indirect diffuse uses L_w (no \u03C0; see closure_eval_surface_lib:302).\n let diffuse_weight = 1.0 - STOCK_METALLIC;\n let diffuse_radiance = diffuse_color * (sun * NL * shadow / PI_S + amb) * diffuse_weight;\n let principled = diffuse_radiance + spec_radiance;\n\n // \u2550\u2550\u2550 MIX SHADER: Shader=Emission, Shader_001=Principled, Fac=mask \u2550\u2550\u2550\n let final_color = mix(emission, principled, mask);\n\n var out: FSOut;\n out.color = vec4f(final_color, out_alpha);\n out.bloom_mask = 1.0;\n return out;\n}\n\n";
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 (distance only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Used by Metal preset. Simplified F1 cell noise.\n\nfn tex_voronoi_f1(p: vec3f, scale: f32) -> f32 {\n let coords = p * scale;\n let i = floor(coords);\n let f = fract(coords);\n var min_dist = 1e10;\n for (var z = -1; z <= 1; z++) {\n for (var y = -1; y <= 1; y++) {\n for (var x = -1; x <= 1; x++) {\n let neighbor = vec3f(f32(x), f32(y), f32(z));\n let point = _hash33(i + neighbor) * 0.5 + 0.5;\n let diff = neighbor + point - f;\n min_dist = min(min_dist, dot(diff, diff));\n }\n }\n }\n return sqrt(min_dist);\n}\n\n// \u2500\u2500\u2500 SEPXYZ node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn separate_xyz(v: vec3f) -> vec3f { return v; }\n\n// \u2500\u2500\u2500 VECT_MATH (cross product) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfn vect_math_cross(a: vec3f, b: vec3f) -> vec3f { return cross(a, b); }\n\n// \u2500\u2500\u2500 MAPPING node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Point-type mapping: scale, rotate (euler XYZ), translate.\n\nfn mapping_point(v: vec3f, loc: vec3f, rot: vec3f, scl: vec3f) -> vec3f {\n var p = v * scl;\n // simplified: skip rotation when all angles are zero (common case)\n if (abs(rot.x) + abs(rot.y) + abs(rot.z) > 1e-6) {\n let cx = cos(rot.x); let sx = sin(rot.x);\n let cy = cos(rot.y); let sy = sin(rot.y);\n let cz = cos(rot.z); let sz = sin(rot.z);\n let rx = vec3f(p.x, cx*p.y - sx*p.z, sx*p.y + cx*p.z);\n let ry = vec3f(cy*rx.x + sy*rx.z, rx.y, -sy*rx.x + cy*rx.z);\n p = vec3f(cz*ry.x - sz*ry.y, sz*ry.x + cz*ry.y, ry.z);\n }\n return p + loc;\n}\n\n// \u2500\u2500\u2500 NORMAL_MAP node (tangent-space) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Applies a tangent-space normal map. Requires TBN from vertex stage.\n\nfn normal_map(strength: f32, map_color: vec3f, normal: vec3f, tangent: vec3f, bitangent: vec3f) -> vec3f {\n let ts = map_color * 2.0 - 1.0;\n let perturbed = normalize(tangent * ts.x + bitangent * ts.y + normal * ts.z);\n return normalize(mix(normal, perturbed, strength));\n}\n\n// \u2500\u2500\u2500 EEVEE Principled BSDF primitives \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Ports from Blender 3.6 source/blender/draw/engines/eevee/shaders/\n// bsdf_common_lib.glsl + gpu_shader_material_principled.glsl.\n// Usage pattern (see material shaders): direct spec = bsdf_ggx \u00D7 sun \u00D7 shadow\n// (NL baked in, no F yet); ambient spec = probe_radiance; tint both with\n// reflection_color = F_brdf_multi_scatter(f0, f90, split_sum) AFTER summing.\n\nconst EEVEE_PI: f32 = 3.141592653589793;\n\n// Fused analytic GGX specular (direct lights). Returns BRDF \u00D7 NL.\n// 4\u00B7NL\u00B7NV is cancelled via G1_Smith reciprocal form \u2014 see bsdf_common_lib.glsl:115.\n// Caller passes NL, NV (already computed for diffuse + brdf_lut_sample) so WebKit\n// can reuse them instead of recomputing dot products across the function boundary.\nfn bsdf_ggx(N: vec3f, L: vec3f, V: vec3f, NL_in: f32, NV_in: f32, roughness: f32) -> f32 {\n let a = max(roughness, 1e-4);\n let a2 = a * a;\n let H = normalize(L + V);\n let NH = max(dot(N, H), 1e-8);\n let NL = max(NL_in, 1e-8);\n let NV = max(NV_in, 1e-8);\n // G1_Smith_GGX_opti reciprocal form \u2014 denominator piece only.\n let G1L = NL + sqrt(NL * (NL - NL * a2) + a2);\n let G1V = NV + sqrt(NV * (NV - NV * a2) + a2);\n let G = G1L * G1V;\n // D_ggx_opti = pi * denom\u00B2 \u2014 reciprocal D \u00D7 a\u00B2.\n let tmp = (NH * a2 - NH) * NH + 1.0;\n let D_opti = EEVEE_PI * tmp * tmp;\n return NL * a2 / (D_opti * G);\n}\n\n// Split-sum DFG LUT \u2014 Karis 2013 curve fit stand-in for the 64\u00D764 baked LUT.\n// Returns (lut.x, lut.y) in Blender convention: tint = f0\u00B7lut.x + f90\u00B7lut.y.\nfn brdf_lut_approx(NV: f32, roughness: f32) -> vec2f {\n let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);\n let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);\n let r = roughness * c0 + c1;\n let a004 = min(r.x * r.x, exp2(-9.28 * NV)) * r.x + r.y;\n return vec2f(-1.04, 1.04) * a004 + r.zw;\n}\n\n// Baked combined BRDF LUT \u2014 exact port of Blender bsdf_lut_frag.glsl packed with\n// ltc_mag_ggx from eevee_lut.c. Single sample returns DFG (.rg) and LTC mag (.ba).\n// Addressed as Blender's common_utiltex_lib.glsl:lut_coords:\n// coords = (roughness, sqrt(1 - NV)), then half-texel bias for filtering.\n// Requires group(0) binding(9) brdfLut + binding(2) diffuseSampler in the host shader.\nfn brdf_lut_sample(NV: f32, roughness: f32) -> vec4f {\n let LUT_SIZE: f32 = 64.0;\n var uv = vec2f(saturate(roughness), sqrt(saturate(1.0 - NV)));\n uv = uv * ((LUT_SIZE - 1.0) / LUT_SIZE) + 0.5 / LUT_SIZE;\n return textureSampleLevel(brdfLut, diffuseSampler, uv, 0.0);\n}\n\nfn F_brdf_single_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n return lut.y * f90 + lut.x * f0;\n}\n\n// Fdez-Ag\u00FCera 2019 multi-scatter compensation (EEVEE do_multiscatter=1).\nfn F_brdf_multi_scatter(f0: vec3f, f90: vec3f, lut: vec2f) -> vec3f {\n let FssEss = lut.y * f90 + lut.x * f0;\n let Ess = lut.x + lut.y;\n let Ems = 1.0 - Ess;\n let Favg = f0 + (1.0 - f0) / 21.0;\n let Fms = FssEss * Favg / (1.0 - (1.0 - Ess) * Favg);\n return FssEss + Fms * Ems;\n}\n\n// EEVEE direct-specular energy compensation factor \u2014 closure_eval_glossy_lib.glsl:79-81:\n// ltc_brdf_scale = (ltc.x + ltc.y) / (split_sum.x + split_sum.y)\n// Blender evaluates direct lights via LTC (Heitz 2016) but indirect via split-sum;\n// direct radiance is rescaled so total-energy matches the split-sum LUT.\n// Takes a pre-sampled vec4f from brdf_lut_sample() to share the fetch with\n// F_brdf_multi_scatter on the same fragment.\nfn ltc_brdf_scale_from_lut(lut: vec4f) -> f32 {\n return (lut.z + lut.w) / max(lut.x + lut.y, 1e-6);\n}\n\n// Luminance-normalized hue extraction \u2014 Blender tint_from_color (isolates hue+sat).\nfn tint_from_color(color: vec3f) -> vec3f {\n let lum = dot(color, vec3f(0.3, 0.6, 0.1));\n return select(vec3f(1.0), color / lum, lum > 0.0);\n}\n\n\n\nstruct CameraUniforms {\n view: mat4x4f,\n projection: mat4x4f,\n viewPos: vec3f,\n _padding: f32,\n};\n\nstruct Light {\n direction: vec4f,\n color: vec4f,\n};\n\nstruct LightUniforms {\n ambientColor: vec4f,\n lights: array<Light, 4>,\n};\n\nstruct MaterialUniforms {\n diffuseColor: vec3f,\n alpha: f32,\n};\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) normal: vec3f,\n @location(1) uv: vec2f,\n @location(2) worldPos: vec3f,\n};\n\nstruct LightVP { viewProj: mat4x4f, };\n\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var diffuseSampler: sampler;\n@group(0) @binding(3) var shadowMap: texture_depth_2d;\n@group(0) @binding(4) var shadowSampler: sampler_comparison;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;\n@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;\n@group(2) @binding(1) var<uniform> material: MaterialUniforms;\n\nfn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {\n // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.\n if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }\n let biasedPos = worldPos + n * 0.08;\n let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let cmpZ = ndc.z - 0.001;\n let ts = 1.0 / 2048.0;\n // 3x3 PCF unrolled \u2014 Safari's Metal backend doesn't unroll nested shadow loops reliably.\n let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);\n let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);\n let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);\n let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);\n let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);\n let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);\n let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);\n let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);\n let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);\n return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);\n}\n\nconst PI_S: f32 = 3.141592653589793;\n// Principled BSDF params from dump (Alpha=0.95 is intentionally dropped \u2014 see alpha-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// principled_sheen (gpu_shader_material_principled.glsl:8-14) \u2014 empirical NV curve\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// Wyman & McGuire \"Hashed Alpha Testing\" (2017) \u2014 world-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/world space (doesn't swim) and stays\n// at one-pixel frequency regardless of view distance, which makes it tolerable without TAA.\nfn _hash_wm(a: vec2f) -> f32 {\n return fract(1e4 * sin(17.0 * a.x + 0.1 * a.y) * (0.1 + abs(sin(13.0 * a.y + a.x))));\n}\nfn _hash3d_wm(a: vec3f) -> f32 {\n return _hash_wm(vec2f(_hash_wm(a.xy), a.z));\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 that 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@vertex fn vs(\n @location(0) position: vec3f,\n @location(1) normal: vec3f,\n @location(2) uv: vec2f,\n @location(3) joints0: vec4<u32>,\n @location(4) weights0: vec4<f32>\n) -> VertexOutput {\n var output: VertexOutput;\n let pos4 = vec4f(position, 1.0);\n let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;\n let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);\n let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);\n var skinnedPos = vec4f(0.0);\n var skinnedNrm = vec3f(0.0);\n for (var i = 0u; i < 4u; i++) {\n let m = skinMats[joints0[i]];\n let w = nw[i];\n skinnedPos += (m * pos4) * w;\n skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;\n }\n output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);\n // Skip VS normalize \u2014 interpolation denormalizes anyway, and FS always does normalize(input.normal).\n output.normal = skinnedNrm;\n output.uv = uv;\n output.worldPos = skinnedPos.xyz;\n return output;\n}\n\nstruct FSOut {\n @location(0) color: vec4f,\n @location(1) bloom_mask: f32,\n};\n\n@fragment fn fs(input: VertexOutput) -> FSOut {\n let n = normalize(input.normal);\n let v = normalize(camera.viewPos - input.worldPos);\n let l = -light.lights[0].direction.xyz;\n let sun = light.lights[0].color.xyz * light.lights[0].color.w;\n 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\n // self-overlap on the stockings produces sort cracks under alpha blend. Wyman-style\n // worldPos hash + depth-write is sort-independent. NOTE: Principled.Alpha=0.95 from\n // the dump is DROPPED here \u2014 it relies on TAA to smooth the resulting 5%-everywhere\n // dither, and without TAA it shows as a pervasive dot pattern. Hash now gates only\n // on texture/material alpha, so solid stockings regions stay fully opaque.\n let combined_alpha = material.alpha * tex_s.a;\n if (combined_alpha < hashed_alpha_threshold(input.worldPos)) { discard; }\n let out_alpha = 1.0;\n\n // \u2550\u2550\u2550 NPR MASK: TEX_COORD.Generated \u2192 Mapping(Rot=0,\u03C0/2,\u03C0/2, Loc=(1,1,1)) \u2192 Gradient Texture\n // The Blender mapping reduces to gradient.x = 1 - input.y (rot swaps axes, loc offsets by 1).\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 // Ramp.002 EASE [0\u2192black, 0.9565\u2192white]\n let ramp002 = ramp_ease_s(ramp001, 0.0, STOCK_RAMP002_P1);\n\n // Layer Weight.Facing (Blend=0.4) \u2192 Ramp EASE [0\u2192black, 0.5435\u2192white]\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 \u2192 (A,B) averaged 50/50\n let mix001 = mix(1.0, ramp_face, 0.5);\n // Mix: LIGHTEN blend Fac=0.5, A=mix001, B=ramp002 \u2192 A smoothly lightens toward max(A,B)\n let lighten = max(mix001, ramp002);\n let mask = mix(mix001, lighten, 0.5);\n\n // \u2550\u2550\u2550 EMISSION SHADER \u2550\u2550\u2550\n // Hue=0.5 (identity rotation), Sat=1.0, Val=5.0 (5\u00D7 brightness boost), Fac=1; Strength=1\n let emission = hue_sat_id(1.0, 5.0, 1.0, tex_rgb);\n\n // \u2550\u2550\u2550 PRINCIPLED BSDF (EEVEE port) \u2550\u2550\u2550\n // base_color_tint, metallic f0, sheen coarse approx (scales diffuse radiance).\n let NL = max(dot(n, l), 0.0);\n let NV = max(dot(n, v), 1e-4);\n\n // f0 = mix((0.08*spec)*dielectric_tint, base, metallic); dielectric_tint=1 since specular_tint=0.\n let dielectric_f0 = vec3f(0.08 * STOCK_SPECULAR);\n let f0 = mix(dielectric_f0, tex_rgb, STOCK_METALLIC);\n let f90 = mix(f0, vec3f(1.0), sqrt(STOCK_SPECULAR));\n let brdf_lut = brdf_lut_sample(NV, STOCK_ROUGHNESS);\n let reflection_color = F_brdf_multi_scatter(f0, f90, brdf_lut.xy);\n\n let spec_direct = bsdf_ggx(n, l, v, NL, NV, STOCK_ROUGHNESS) * sun * shadow * ltc_brdf_scale_from_lut(brdf_lut);\n let spec_indirect = amb;\n let spec_radiance = (spec_direct + spec_indirect) * reflection_color;\n\n // Sheen coarse: diffuse_color += sheen * sheen_color * principled_sheen(NV).\n let base_tint = tint_from_color(tex_rgb);\n let sheen_color = mix(vec3f(1.0), base_tint, STOCK_SHEEN_TINT);\n let diffuse_color = tex_rgb + STOCK_SHEEN * sheen_color * principled_sheen(NV);\n\n // diffuse_weight = (1 - metallic). Indirect diffuse uses L_w (no \u03C0; see closure_eval_surface_lib:302).\n let diffuse_weight = 1.0 - STOCK_METALLIC;\n let diffuse_radiance = diffuse_color * (sun * NL * shadow / PI_S + amb) * diffuse_weight;\n let principled = diffuse_radiance + spec_radiance;\n\n // \u2550\u2550\u2550 MIX SHADER: Shader=Emission, Shader_001=Principled, Fac=mask \u2550\u2550\u2550\n let final_color = mix(emission, principled, mask);\n\n var out: FSOut;\n out.color = vec4f(final_color, out_alpha);\n out.bloom_mask = 1.0;\n return out;\n}\n\n";
2
2
  //# sourceMappingURL=stockings.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"stockings.d.ts","sourceRoot":"","sources":["../../src/shaders/stockings.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,qBAAqB,g/gCA8OjC,CAAA"}
1
+ {"version":3,"file":"stockings.d.ts","sourceRoot":"","sources":["../../src/shaders/stockings.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,qBAAqB,o+/BA8OjC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
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
@@ -247,14 +247,35 @@ export class Engine {
247
247
  private multisampleTexture!: GPUTexture
248
248
  private hdrResolveTexture!: GPUTexture
249
249
  private static readonly MULTISAMPLE_COUNT = 4
250
- private static readonly HDR_FORMAT: GPUTextureFormat = "rgba16float"
250
+ // HDR intermediate format. rg11b10ufloat when the adapter exposes the
251
+ // `rg11b10ufloat-renderable` feature (Chrome + Safari on Apple Silicon both
252
+ // do), else fall back to rgba16float.
253
+ //
254
+ // Why it matters — Apple TBDR tile memory: rgba16float is 8 bytes/texel, so
255
+ // 4× MSAA is 32 bytes/texel and does not fit Apple Silicon's tile memory at
256
+ // useful tile sizes. The driver then stores the full MSAA buffer to system
257
+ // memory every frame and resolves from there — ~300 MB/frame of extra
258
+ // bandwidth at 1920×1200 DPR=2, which is the dominant frame-pacing hit on
259
+ // Safari (visibly: shrinking the window made Safari smooth; Chrome was
260
+ // always smooth because Dawn apparently amortizes it). rg11b10ufloat at
261
+ // 4 bytes/texel → 16 bytes/texel at 4× MSAA → fits tile memory like
262
+ // rgba8unorm does, resolves in-tile, no system-memory round-trip. No alpha
263
+ // channel (the HDR path never needed one — alpha blending reads src.a from
264
+ // the fragment shader and treats missing dst.a as 1, so the blend math is
265
+ // unchanged).
266
+ private hdrFormat: GPUTextureFormat = "rgba16float"
251
267
  /** Stencil value stamped by eye draws so hair can stencil-test against it and
252
268
  * alpha-blend a second pass over eye silhouette pixels (see-through-hair effect). */
253
269
  private static readonly STENCIL_EYE_VALUE = 1
254
- /** Single-channel mask written alongside HDR color 1 = model geometry (contributes
255
- * to bloom), 0 = ground (never blooms). Sampled by the bloom blit pass to gate the
256
- * prefilter so ground brightness can't halo the scene. */
257
- private static readonly BLOOM_MASK_FORMAT: GPUTextureFormat = "r8unorm"
270
+ /** Aux MRT alongside HDR color. Two channels:
271
+ * .r bloom mask (1 = model geometry, 0 = ground; sampled by bloom blit to gate prefilter).
272
+ * .g accumulated alpha (the channel that used to live in hdr.a before the HDR format
273
+ * switched to rg11b10ufloat, which has no alpha). Sampled by composite/bloom to
274
+ * un-premultiply color for tonemap and to produce the canvas-drawable alpha used by
275
+ * the premultiplied alphaMode compositor (so the page background still shows through
276
+ * cleared / edge-faded regions like before).
277
+ * rg8unorm at 4× MSAA is 8 bytes/texel — still fits Apple TBDR tile memory comfortably. */
278
+ private static readonly BLOOM_MASK_FORMAT: GPUTextureFormat = "rg8unorm"
258
279
  private multisampleMaskTexture!: GPUTexture
259
280
  private maskResolveTexture!: GPUTexture
260
281
  private maskResolveView!: GPUTextureView
@@ -502,11 +523,17 @@ export class Engine {
502
523
  // Step 1: Get WebGPU device and context
503
524
  async init() {
504
525
  const adapter = await navigator.gpu?.requestAdapter()
505
- const device = await adapter?.requestDevice()
526
+ if (!adapter) throw new Error("WebGPU is not supported in this browser.")
527
+ const wantFeature: GPUFeatureName = "rg11b10ufloat-renderable"
528
+ const hasRg11b10 = adapter.features.has(wantFeature)
529
+ const device = await adapter.requestDevice({
530
+ requiredFeatures: hasRg11b10 ? [wantFeature] : [],
531
+ })
506
532
  if (!device) {
507
533
  throw new Error("WebGPU is not supported in this browser.")
508
534
  }
509
535
  this.device = device
536
+ if (hasRg11b10) this.hdrFormat = "rg11b10ufloat"
510
537
 
511
538
  const context = this.canvas.getContext("webgpu")
512
539
  if (!context) {
@@ -694,7 +721,7 @@ export class Engine {
694
721
  // composite pass writes the swapchain. Tonemap moved to composite so bloom
695
722
  // (added next) can run on linear HDR.
696
723
  const standardBlend: GPUColorTargetState = {
697
- format: Engine.HDR_FORMAT,
724
+ format: this.hdrFormat,
698
725
  blend: {
699
726
  color: {
700
727
  srcFactor: "src-alpha",
@@ -709,10 +736,21 @@ export class Engine {
709
736
  },
710
737
  }
711
738
 
712
- // Bloom mask target r8unorm has no alpha channel, so src-alpha blending is invalid.
713
- // Use replace mode: depth test already rejects occluded fragments, so last-writer-wins
714
- // on surviving pixels gives the right result (ground writes 0; models/outlines write 1).
715
- const maskBlend: GPUColorTargetState = { format: Engine.BLOOM_MASK_FORMAT }
739
+ // Aux target carrying (bloom mask, alpha). Src-alpha blend so the .g channel
740
+ // accumulates proper alpha-over (same semantic the old rgba16f hdr.a had).
741
+ // Materials write vec2f(mask, 1.0); ground writes vec2f(0.0, 1.0). With src.a
742
+ // coming from the fragment color.a, the blend equation produces
743
+ // out.g = 1·src.a + dst.g·(1-src.a) → premultiplied over operator on alpha.
744
+ // .r gets weighted by src.a too, which is fine: opaque pixels (α=1) give full
745
+ // mask, partially translucent fragments dilute mask proportionally — acceptable
746
+ // for the bloom-gate use.
747
+ const maskBlend: GPUColorTargetState = {
748
+ format: Engine.BLOOM_MASK_FORMAT,
749
+ blend: {
750
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
751
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
752
+ },
753
+ }
716
754
  const sceneTargets: GPUColorTargetState[] = [standardBlend, maskBlend]
717
755
 
718
756
  const shaderModule = this.device.createShaderModule({
@@ -1184,21 +1222,21 @@ export class Engine {
1184
1222
  label: "bloom blit pipeline",
1185
1223
  layout: bloomBlitLayout,
1186
1224
  vertex: { module: bloomBlitShader, entryPoint: "vs" },
1187
- fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1225
+ fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
1188
1226
  primitive: { topology: "triangle-list" },
1189
1227
  })
1190
1228
  this.bloomDownsamplePipeline = this.device.createRenderPipeline({
1191
1229
  label: "bloom downsample pipeline",
1192
1230
  layout: bloomDownLayout,
1193
1231
  vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
1194
- fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1232
+ fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
1195
1233
  primitive: { topology: "triangle-list" },
1196
1234
  })
1197
1235
  this.bloomUpsamplePipeline = this.device.createRenderPipeline({
1198
1236
  label: "bloom upsample pipeline",
1199
1237
  layout: bloomUpLayout,
1200
1238
  vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
1201
- fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
1239
+ fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
1202
1240
  primitive: { topology: "triangle-list" },
1203
1241
  })
1204
1242
 
@@ -1217,6 +1255,9 @@ export class Engine {
1217
1255
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1218
1256
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
1219
1257
  { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1258
+ // Aux mask/alpha texture — composite reads .g to reconstruct the alpha that
1259
+ // used to live in the HDR target before the rg11b10ufloat switch.
1260
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: {} },
1220
1261
  ],
1221
1262
  })
1222
1263
 
@@ -1341,14 +1382,14 @@ export class Engine {
1341
1382
  label: "multisample HDR render target",
1342
1383
  size: [width, height],
1343
1384
  sampleCount: Engine.MULTISAMPLE_COUNT,
1344
- format: Engine.HDR_FORMAT,
1385
+ format: this.hdrFormat,
1345
1386
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
1346
1387
  })
1347
1388
 
1348
1389
  this.hdrResolveTexture = this.device.createTexture({
1349
1390
  label: "HDR resolve target",
1350
1391
  size: [width, height],
1351
- format: Engine.HDR_FORMAT,
1392
+ format: this.hdrFormat,
1352
1393
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1353
1394
  })
1354
1395
 
@@ -1379,14 +1420,14 @@ export class Engine {
1379
1420
  label: "bloom down pyramid",
1380
1421
  size: [bw, bh],
1381
1422
  mipLevelCount: this.bloomMipCount,
1382
- format: Engine.HDR_FORMAT,
1423
+ format: this.hdrFormat,
1383
1424
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1384
1425
  })
1385
1426
  this.bloomUpTexture = this.device.createTexture({
1386
1427
  label: "bloom up pyramid",
1387
1428
  size: [bw, bh],
1388
1429
  mipLevelCount: Math.max(1, this.bloomMipCount - 1),
1389
- format: Engine.HDR_FORMAT,
1430
+ format: this.hdrFormat,
1390
1431
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1391
1432
  })
1392
1433
  this.bloomDownMipViews = []
@@ -1513,6 +1554,7 @@ export class Engine {
1513
1554
  { binding: 1, resource: compositeBloomView },
1514
1555
  { binding: 2, resource: this.bloomSampler },
1515
1556
  { binding: 3, resource: { buffer: this.compositeUniformBuffer } },
1557
+ { binding: 4, resource: this.maskResolveView },
1516
1558
  ],
1517
1559
  })
1518
1560
  }
@@ -83,7 +83,7 @@ fn ramp_ease(f: f32, p0: f32, c0: vec4f, p1: f32, c1: vec4f) -> vec4f {
83
83
 
84
84
  var out: FSOut;
85
85
  out.color = vec4f(final_color, alpha);
86
- out.mask = 1.0;
86
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
87
87
  return out;
88
88
  }
89
89
 
@@ -62,7 +62,7 @@ const CLOTH_R_SPEC_CLAMP: f32 = 10.0;
62
62
 
63
63
  var out: FSOut;
64
64
  out.color = vec4f(final_color, out_alpha);
65
- out.mask = 1.0;
65
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
66
66
  return out;
67
67
  }
68
68
 
@@ -56,7 +56,7 @@ const NPR_MIX_SHADER_FAC: f32 = 0.8999999761581421;
56
56
 
57
57
  var out: FSOut;
58
58
  out.color = vec4f(final_color, out_alpha);
59
- out.mask = 1.0;
59
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
60
60
  return out;
61
61
  }
62
62
 
@@ -135,14 +135,30 @@ export const COMMON_VS_WGSL = /* wgsl */ `
135
135
  `;
136
136
 
137
137
  // ─── FS output struct ───────────────────────────────────────────────
138
- // Location 0: final radiance+alpha. Location 1: bloom/highlight mask (single f32,
139
- // currently 1.0 everywhere slot is reserved for future per-material bloom control).
138
+ // Location 0: final radiance+alpha (blended into rg11b10ufloat; the HDR target
139
+ // has no alpha channel, but the blend equation still uses the .a you write here
140
+ // as the src-alpha factor that premultiplies rgb into the HDR target).
141
+ // Location 1: auxiliary rg8unorm carrying
142
+ // .r = bloom mask (1 = contributes to bloom, 0 = skip — e.g. ground).
143
+ // .g = accumulated canvas alpha — the channel that used to live in hdr.a
144
+ // before the switch to rg11b10ufloat. Sampled by composite to
145
+ // un-premultiply color for tonemap and to set the final drawable alpha
146
+ // (needed for the `premultiplied` canvas alphaMode that blends the
147
+ // WebGPU surface over the page background).
148
+ // FS output at location 1 must be vec4f — the blend state references src.a, and
149
+ // WebGPU requires the fragment output to provide an alpha component even though
150
+ // the rg8unorm target only stores .r and .g (extra components are discarded).
151
+ // Materials write mask = vec4f(1.0, 1.0, 0.0, color.a); ground writes
152
+ // vec4f(0.0, 1.0, 0.0, edgeFade). With src.a coming from the 4th component and
153
+ // src-alpha blending enabled:
154
+ // out.r = mask_r · src.a + dst.r · (1-src.a) (bloom mask, weighted by alpha)
155
+ // out.g = 1.0 · src.a + dst.g · (1-src.a) (canonical premultiplied alpha-over)
140
156
 
141
157
  export const COMMON_FS_OUT_WGSL = /* wgsl */ `
142
158
 
143
159
  struct FSOut {
144
160
  @location(0) color: vec4f,
145
- @location(1) mask: f32,
161
+ @location(1) mask: vec4f,
146
162
  };
147
163
 
148
164
  `;
@@ -33,7 +33,7 @@ const DEFAULT_ROUGHNESS: f32 = 0.5;
33
33
 
34
34
  var out: FSOut;
35
35
  out.color = vec4f(color, alpha);
36
- out.mask = 1.0;
36
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
37
37
  return out;
38
38
  }
39
39
 
@@ -36,7 +36,7 @@ const EYE_EMISSION_STRENGTH: f32 = 1.5;
36
36
 
37
37
  var out: FSOut;
38
38
  out.color = vec4f(shaded + emission, alpha);
39
- out.mask = 1.0;
39
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
40
40
  return out;
41
41
  }
42
42
 
@@ -82,7 +82,7 @@ const FACE_BRIGHT_TEX_THRESH: f32 = 0.9300000071525574;
82
82
 
83
83
  var out: FSOut;
84
84
  out.color = vec4f(final_color, alpha);
85
- out.mask = 1.0;
85
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
86
86
  return out;
87
87
  }
88
88
 
@@ -75,7 +75,7 @@ const HAIR_MIX_NPR: f32 = 0.2;
75
75
 
76
76
  var out: FSOut;
77
77
  out.color = vec4f(final_color, outAlpha);
78
- out.mask = 1.0;
78
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
79
79
  return out;
80
80
  }
81
81
 
@@ -72,7 +72,7 @@ const METAL_VORONOI_SCALE: f32 = 4.3;
72
72
 
73
73
  var out: FSOut;
74
74
  out.color = vec4f(final_color, out_alpha);
75
- out.mask = 1.0;
75
+ out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
76
76
  return out;
77
77
  }
78
78