reze-engine 0.10.2 → 0.11.1

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 (62) hide show
  1. package/README.md +90 -13
  2. package/dist/engine.d.ts +177 -34
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +1204 -318
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/shaders/body.d.ts +2 -0
  9. package/dist/shaders/body.d.ts.map +1 -0
  10. package/dist/shaders/body.js +232 -0
  11. package/dist/shaders/classify.d.ts +4 -0
  12. package/dist/shaders/classify.d.ts.map +1 -0
  13. package/dist/shaders/classify.js +12 -0
  14. package/dist/shaders/cloth_rough.d.ts +2 -0
  15. package/dist/shaders/cloth_rough.d.ts.map +1 -0
  16. package/dist/shaders/cloth_rough.js +190 -0
  17. package/dist/shaders/cloth_smooth.d.ts +2 -0
  18. package/dist/shaders/cloth_smooth.d.ts.map +1 -0
  19. package/dist/shaders/cloth_smooth.js +186 -0
  20. package/dist/shaders/default.d.ts +2 -0
  21. package/dist/shaders/default.d.ts.map +1 -0
  22. package/dist/shaders/default.js +185 -0
  23. package/dist/shaders/dfg_lut.d.ts +3 -0
  24. package/dist/shaders/dfg_lut.d.ts.map +1 -0
  25. package/dist/shaders/dfg_lut.js +129 -0
  26. package/dist/shaders/eye.d.ts +2 -0
  27. package/dist/shaders/eye.d.ts.map +1 -0
  28. package/dist/shaders/eye.js +159 -0
  29. package/dist/shaders/face.d.ts +2 -0
  30. package/dist/shaders/face.d.ts.map +1 -0
  31. package/dist/shaders/face.js +235 -0
  32. package/dist/shaders/hair.d.ts +2 -0
  33. package/dist/shaders/hair.d.ts.map +1 -0
  34. package/dist/shaders/hair.js +196 -0
  35. package/dist/shaders/ltc_mag_lut.d.ts +3 -0
  36. package/dist/shaders/ltc_mag_lut.d.ts.map +1 -0
  37. package/dist/shaders/ltc_mag_lut.js +1033 -0
  38. package/dist/shaders/metal.d.ts +2 -0
  39. package/dist/shaders/metal.d.ts.map +1 -0
  40. package/dist/shaders/metal.js +187 -0
  41. package/dist/shaders/nodes.d.ts +2 -0
  42. package/dist/shaders/nodes.d.ts.map +1 -0
  43. package/dist/shaders/nodes.js +465 -0
  44. package/dist/shaders/stockings.d.ts +2 -0
  45. package/dist/shaders/stockings.d.ts.map +1 -0
  46. package/dist/shaders/stockings.js +244 -0
  47. package/package.json +1 -1
  48. package/src/engine.ts +1412 -385
  49. package/src/index.ts +12 -2
  50. package/src/shaders/body.ts +234 -0
  51. package/src/shaders/classify.ts +25 -0
  52. package/src/shaders/cloth_rough.ts +192 -0
  53. package/src/shaders/cloth_smooth.ts +188 -0
  54. package/src/shaders/default.ts +186 -0
  55. package/src/shaders/dfg_lut.ts +131 -0
  56. package/src/shaders/eye.ts +160 -0
  57. package/src/shaders/face.ts +237 -0
  58. package/src/shaders/hair.ts +198 -0
  59. package/src/shaders/ltc_mag_lut.ts +1035 -0
  60. package/src/shaders/metal.ts +189 -0
  61. package/src/shaders/nodes.ts +466 -0
  62. package/src/shaders/stockings.ts +246 -0
@@ -0,0 +1,186 @@
1
+ // Blender 3.6 Principled BSDF defaults + Filmic "Medium High Contrast" tone mapping.
2
+ // Metallic=0, Specular=0.5 (F0=0.04), Roughness=0.5.
3
+ // Tone mapping via LUT sampled from Blender's OCIO pipeline (exposure -0.3 baked in).
4
+
5
+ export const DEFAULT_SHADER_WGSL = /* wgsl */ `
6
+
7
+ const PI: f32 = 3.141592653589793;
8
+ const F0_DIELECTRIC: f32 = 0.04;
9
+ const ROUGHNESS: f32 = 0.5;
10
+
11
+ struct CameraUniforms {
12
+ view: mat4x4f,
13
+ projection: mat4x4f,
14
+ viewPos: vec3f,
15
+ _padding: f32,
16
+ };
17
+
18
+ struct Light {
19
+ direction: vec4f,
20
+ color: vec4f,
21
+ };
22
+
23
+ struct LightUniforms {
24
+ ambientColor: vec4f,
25
+ lights: array<Light, 4>,
26
+ };
27
+
28
+ // Per-material uniforms. Add fields here only when a shader actually reads them;
29
+ // preset-specific shaders (face.ts, future hair.ts) share this struct so the
30
+ // engine can use one material bind-group layout.
31
+ struct MaterialUniforms {
32
+ diffuseColor: vec3f, // tint; multiplies sampled albedo (unused by current fs, reserved)
33
+ alpha: f32, // 0 → discard; <1 → transparent draw call
34
+ };
35
+
36
+ struct VertexOutput {
37
+ @builtin(position) position: vec4f,
38
+ @location(0) normal: vec3f,
39
+ @location(1) uv: vec2f,
40
+ @location(2) worldPos: vec3f,
41
+ };
42
+
43
+ struct LightVP { viewProj: mat4x4f, };
44
+
45
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
46
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
47
+ @group(0) @binding(2) var diffuseSampler: sampler;
48
+ @group(0) @binding(3) var shadowMap: texture_depth_2d;
49
+ @group(0) @binding(4) var shadowSampler: sampler_comparison;
50
+ @group(0) @binding(5) var<uniform> lightVP: LightVP;
51
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
52
+ @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
53
+ @group(2) @binding(1) var<uniform> material: MaterialUniforms;
54
+
55
+ // ─── GGX specular helpers ───────────────────────────────────────────
56
+
57
+ fn ggx_d(ndoth: f32, a2: f32) -> f32 {
58
+ let denom = ndoth * ndoth * (a2 - 1.0) + 1.0;
59
+ return a2 / (PI * denom * denom);
60
+ }
61
+
62
+ fn smith_g1(ndotx: f32, a2: f32) -> f32 {
63
+ return 2.0 * ndotx / (ndotx + sqrt(a2 + (1.0 - a2) * ndotx * ndotx));
64
+ }
65
+
66
+ fn fresnel_schlick(cosTheta: f32, f0: f32) -> f32 {
67
+ let m = 1.0 - cosTheta;
68
+ let m2 = m * m;
69
+ return f0 + (1.0 - f0) * (m2 * m2 * m);
70
+ }
71
+
72
+ // ─── Filmic tone mapping (LUT extracted from Blender 3.6 OCIO) ─────
73
+ // View transform = Filmic, Look = Medium High Contrast, Exposure = -0.3.
74
+ // 14 samples at integer log2 stops from -10 to +3 (inclusive).
75
+ // Extracted via scripts/extract_filmic_lut.py → probe image through scene
76
+ // color management. Input: linear scene-referred. Output: sRGB display.
77
+
78
+ fn filmic(x: f32) -> f32 {
79
+ var lut = array<f32, 14>(
80
+ 0.0067, 0.0141, 0.0272, 0.0499, 0.0885, 0.1512, 0.2462,
81
+ 0.3753, 0.5273, 0.6776, 0.8031, 0.8929, 0.9495, 0.9814
82
+ );
83
+ let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
84
+ let i = u32(t);
85
+ let j = min(i + 1u, 13u);
86
+ return mix(lut[i], lut[j], t - f32(i));
87
+ }
88
+
89
+ fn tonemap(hdr: vec3f) -> vec3f {
90
+ return vec3f(filmic(hdr.x), filmic(hdr.y), filmic(hdr.z));
91
+ }
92
+
93
+ // ─── Shadow sampling (3×3 PCF) ──────────────────────────────────────
94
+
95
+ fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
96
+ // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.
97
+ if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
98
+ let biasedPos = worldPos + n * 0.08;
99
+ let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);
100
+ let ndc = lclip.xyz / max(lclip.w, 1e-6);
101
+ let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
102
+ let cmpZ = ndc.z - 0.001;
103
+ let ts = 1.0 / 2048.0;
104
+ // 3x3 PCF unrolled — Safari's Metal backend doesn't unroll nested shadow loops reliably.
105
+ let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);
106
+ let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);
107
+ let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);
108
+ let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);
109
+ let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);
110
+ let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);
111
+ let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);
112
+ let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);
113
+ let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);
114
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
115
+ }
116
+
117
+ // ─── Vertex / Fragment ──────────────────────────────────────────────
118
+
119
+ @vertex fn vs(
120
+ @location(0) position: vec3f,
121
+ @location(1) normal: vec3f,
122
+ @location(2) uv: vec2f,
123
+ @location(3) joints0: vec4<u32>,
124
+ @location(4) weights0: vec4<f32>
125
+ ) -> VertexOutput {
126
+ var output: VertexOutput;
127
+ let pos4 = vec4f(position, 1.0);
128
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
129
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
130
+ let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
131
+ var skinnedPos = vec4f(0.0);
132
+ var skinnedNrm = vec3f(0.0);
133
+ for (var i = 0u; i < 4u; i++) {
134
+ let m = skinMats[joints0[i]];
135
+ let w = nw[i];
136
+ skinnedPos += (m * pos4) * w;
137
+ skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;
138
+ }
139
+ output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);
140
+ // Skip VS normalize — interpolation denormalizes anyway, and FS always does normalize(input.normal).
141
+ output.normal = skinnedNrm;
142
+ output.uv = uv;
143
+ output.worldPos = skinnedPos.xyz;
144
+ return output;
145
+ }
146
+
147
+ struct FSOut {
148
+ @location(0) color: vec4f,
149
+ @location(1) mask: f32,
150
+ };
151
+
152
+ @fragment fn fs(input: VertexOutput) -> FSOut {
153
+ let alpha = material.alpha;
154
+ if (alpha < 0.001) { discard; }
155
+
156
+ let n = normalize(input.normal);
157
+ let v = normalize(camera.viewPos - input.worldPos);
158
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
159
+
160
+ let l = -light.lights[0].direction.xyz;
161
+ let sunColor = light.lights[0].color.xyz * light.lights[0].color.w;
162
+ let h = normalize(l + v);
163
+
164
+ let ndotl = max(dot(n, l), 0.0);
165
+ let ndotv = max(dot(n, v), 0.001);
166
+ let ndoth = max(dot(n, h), 0.0);
167
+ let vdoth = max(dot(v, h), 0.0);
168
+
169
+ let a2 = ROUGHNESS * ROUGHNESS;
170
+ let D = ggx_d(ndoth, a2);
171
+ let G = smith_g1(ndotl, a2) * smith_g1(ndotv, a2);
172
+ let F = fresnel_schlick(vdoth, F0_DIELECTRIC);
173
+ let spec = (D * G * F) / max(4.0 * ndotl * ndotv, 0.001);
174
+
175
+ let shadow = sampleShadow(input.worldPos, n);
176
+ let kd = (1.0 - F) * albedo / PI;
177
+ let direct = (kd + spec) * sunColor * ndotl * shadow;
178
+ let ambient = albedo * light.ambientColor.xyz;
179
+
180
+ var out: FSOut;
181
+ out.color = vec4f(ambient + direct, alpha);
182
+ out.mask = 1.0;
183
+ return out;
184
+ }
185
+
186
+ `
@@ -0,0 +1,131 @@
1
+ // One-shot bake pass that produces the combined EEVEE BRDF LUT.
2
+ // Output: 64×64 rgba8unorm — .rg = split-sum DFG (Blender bsdf_lut_frag.glsl,
3
+ // Karis convention: tint = f0·x + f90·y), .ba = Heitz 2016 LTC magnitude
4
+ // (ltc_mag_ggx from eevee_lut.c), sampled from a temp rg16float source texture
5
+ // passed in at bake time.
6
+ //
7
+ // Packing both LUTs into one texture lets runtime shaders do a SINGLE texture
8
+ // fetch per fragment to get everything needed for F_brdf_multi_scatter AND
9
+ // ltc_brdf_scale. Was 3 taps (dfg in brdf_lut_baked + dfg+ltc in ltc_brdf_scale);
10
+ // now 1. Big win on Apple GPUs where fragment-stage texture fetches are the
11
+ // dominant cost with MSAA.
12
+ //
13
+ // rgba8unorm (vs rgba16float) is a deliberate precision drop: DFG values live in
14
+ // [0,1], LTC magnitude in [0,1], 1/255 quantization is below the perceptual
15
+ // threshold for direct-spec energy compensation. Halves bandwidth per sample.
16
+
17
+ export const BRDF_LUT_SIZE = 64
18
+ const BAKE_SAMPLE_COUNT = 32
19
+
20
+ export const BRDF_LUT_BAKE_WGSL = /* wgsl */ `
21
+ const LUT_SIZE: f32 = ${BRDF_LUT_SIZE}.0;
22
+ const SAMPLE_COUNT: u32 = ${BAKE_SAMPLE_COUNT}u;
23
+ const M_2PI: f32 = 6.283185307179586;
24
+
25
+ // Temp LTC magnitude source (rg16float, uploaded from eevee_lut.c ltc_mag_ggx).
26
+ // Sampled 1:1 by pixel — bake coord mapping matches runtime sample coord mapping.
27
+ @group(0) @binding(0) var ltcSrc: texture_2d<f32>;
28
+
29
+ @vertex fn vs(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4f {
30
+ let x = f32((vid << 1u) & 2u) * 2.0 - 1.0;
31
+ let y = f32(vid & 2u) * 2.0 - 1.0;
32
+ return vec4f(x, y, 0.0, 1.0);
33
+ }
34
+
35
+ fn orthonormal_basis(N: vec3f) -> mat2x3f {
36
+ let up = select(vec3f(1.0, 0.0, 0.0), vec3f(0.0, 0.0, 1.0), abs(N.z) < 0.99999);
37
+ let T = normalize(cross(up, N));
38
+ let B = cross(N, T);
39
+ return mat2x3f(T, B);
40
+ }
41
+
42
+ fn sample_ggx_vndf(rand: vec3f, alpha: f32, Vt: vec3f) -> vec3f {
43
+ let Vh = normalize(vec3f(alpha * Vt.xy, Vt.z));
44
+ let tb = orthonormal_basis(Vh);
45
+ let Th = tb[0];
46
+ let Bh = tb[1];
47
+ let r = sqrt(rand.x);
48
+ let x = r * rand.y;
49
+ var y = r * rand.z;
50
+ let s = 0.5 * (1.0 + Vh.z);
51
+ y = (1.0 - s) * sqrt(1.0 - x * x) + s * y;
52
+ let z = sqrt(saturate(1.0 - x * x - y * y));
53
+ let Hh = x * Th + y * Bh + z * Vh;
54
+ return normalize(vec3f(alpha * Hh.xy, saturate(Hh.z)));
55
+ }
56
+
57
+ fn G1_Smith_GGX_opti(NX: f32, a2: f32) -> f32 {
58
+ return NX + sqrt(NX * (NX - NX * a2) + a2);
59
+ }
60
+
61
+ fn F_eta(eta: f32, cos_theta: f32) -> f32 {
62
+ let c = abs(cos_theta);
63
+ var g = eta * eta - 1.0 + c * c;
64
+ if (g > 0.0) {
65
+ g = sqrt(g);
66
+ let A = (g - c) / (g + c);
67
+ let B = (c * (g + c) - 1.0) / (c * (g - c) + 1.0);
68
+ return 0.5 * A * A * (1.0 + B * B);
69
+ }
70
+ return 1.0;
71
+ }
72
+
73
+ fn f0_from_ior(eta: f32) -> f32 {
74
+ let A = (eta - 1.0) / (eta + 1.0);
75
+ return A * A;
76
+ }
77
+
78
+ fn F_color_blend_zero(eta: f32, fresnel: f32) -> f32 {
79
+ let f0 = f0_from_ior(eta);
80
+ return saturate((fresnel - f0) / (1.0 - f0));
81
+ }
82
+
83
+ @fragment fn fs(@builtin(position) frag: vec4f) -> @location(0) vec4f {
84
+ let y_uv = floor(frag.y) / (LUT_SIZE - 1.0);
85
+ let x_uv = floor(frag.x) / (LUT_SIZE - 1.0);
86
+
87
+ let NV = clamp(1.0 - y_uv * y_uv, 1e-4, 0.9999);
88
+ let a = x_uv * x_uv;
89
+ let a2 = clamp(a * a, 1e-4, 0.9999);
90
+
91
+ let V = vec3f(sqrt(1.0 - NV * NV), 0.0, NV);
92
+
93
+ let eta = (2.0 / (1.0 - sqrt(0.08 * 1.0))) - 1.0;
94
+
95
+ var brdf_accum = 0.0;
96
+ var fresnel_accum = 0.0;
97
+ let sc_f = f32(SAMPLE_COUNT);
98
+ for (var j: u32 = 0u; j < SAMPLE_COUNT; j = j + 1u) {
99
+ for (var i: u32 = 0u; i < SAMPLE_COUNT; i = i + 1u) {
100
+ let ix = (f32(i) + 0.5) / sc_f;
101
+ let iy = (f32(j) + 0.5) / sc_f;
102
+ let Xi = vec3f(ix, cos(iy * M_2PI), sin(iy * M_2PI));
103
+
104
+ let H = sample_ggx_vndf(Xi, a, V);
105
+ let L = -reflect(V, H);
106
+ let NL = L.z;
107
+ if (NL > 0.0) {
108
+ let NH = max(H.z, 0.0);
109
+ let VH = max(dot(V, H), 0.0);
110
+
111
+ let G1v = G1_Smith_GGX_opti(NV, a2);
112
+ let G1l = G1_Smith_GGX_opti(NL, a2);
113
+ let G_smith = 4.0 * NV * NL / (G1v * G1l);
114
+
115
+ let brdf = (G_smith * VH) / (NH * NV);
116
+
117
+ let fresnel = F_eta(eta, VH);
118
+ let Fc = F_color_blend_zero(eta, fresnel);
119
+
120
+ brdf_accum = brdf_accum + (1.0 - Fc) * brdf;
121
+ fresnel_accum = fresnel_accum + Fc * brdf;
122
+ }
123
+ }
124
+ }
125
+ let n2 = sc_f * sc_f;
126
+ let dfg = vec2f(brdf_accum / n2, fresnel_accum / n2);
127
+ // Pack preloaded LTC magnitude at matching (roughness, sqrt(1-NV)) pixel.
128
+ let ltc = textureLoad(ltcSrc, vec2i(i32(frag.x), i32(frag.y)), 0).rg;
129
+ return vec4f(dfg, ltc);
130
+ }
131
+ `
@@ -0,0 +1,160 @@
1
+ // Eye preset — default Principled BSDF (F0=0.04, Roughness=0.5) + Emission socket set to albedo × 1.5.
2
+ // Matches the published preset's instruction: "keep eyes in the default nodegraph, add emission 1.5".
3
+ // Blender's Principled BSDF Emission socket is added on top of the shaded output (pre-tonemap, feeds bloom).
4
+
5
+ export const EYE_SHADER_WGSL = /* wgsl */ `
6
+
7
+ const PI: f32 = 3.141592653589793;
8
+ const F0_DIELECTRIC: f32 = 0.04;
9
+ const ROUGHNESS: f32 = 0.5;
10
+ const EYE_EMISSION_STRENGTH: f32 = 1.5;
11
+
12
+ struct CameraUniforms {
13
+ view: mat4x4f,
14
+ projection: mat4x4f,
15
+ viewPos: vec3f,
16
+ _padding: f32,
17
+ };
18
+
19
+ struct Light {
20
+ direction: vec4f,
21
+ color: vec4f,
22
+ };
23
+
24
+ struct LightUniforms {
25
+ ambientColor: vec4f,
26
+ lights: array<Light, 4>,
27
+ };
28
+
29
+ struct MaterialUniforms {
30
+ diffuseColor: vec3f,
31
+ alpha: f32,
32
+ };
33
+
34
+ struct VertexOutput {
35
+ @builtin(position) position: vec4f,
36
+ @location(0) normal: vec3f,
37
+ @location(1) uv: vec2f,
38
+ @location(2) worldPos: vec3f,
39
+ };
40
+
41
+ struct LightVP { viewProj: mat4x4f, };
42
+
43
+ @group(0) @binding(0) var<uniform> camera: CameraUniforms;
44
+ @group(0) @binding(1) var<uniform> light: LightUniforms;
45
+ @group(0) @binding(2) var diffuseSampler: sampler;
46
+ @group(0) @binding(3) var shadowMap: texture_depth_2d;
47
+ @group(0) @binding(4) var shadowSampler: sampler_comparison;
48
+ @group(0) @binding(5) var<uniform> lightVP: LightVP;
49
+ @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
50
+ @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
51
+ @group(2) @binding(1) var<uniform> material: MaterialUniforms;
52
+
53
+ fn ggx_d(ndoth: f32, a2: f32) -> f32 {
54
+ let denom = ndoth * ndoth * (a2 - 1.0) + 1.0;
55
+ return a2 / (PI * denom * denom);
56
+ }
57
+
58
+ fn smith_g1(ndotx: f32, a2: f32) -> f32 {
59
+ return 2.0 * ndotx / (ndotx + sqrt(a2 + (1.0 - a2) * ndotx * ndotx));
60
+ }
61
+
62
+ fn fresnel_schlick(cosTheta: f32, f0: f32) -> f32 {
63
+ let m = 1.0 - cosTheta;
64
+ let m2 = m * m;
65
+ return f0 + (1.0 - f0) * (m2 * m2 * m);
66
+ }
67
+
68
+ fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
69
+ // Back-facing to key light: direct contribution is zero anyway, skip 9 texture samples.
70
+ if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
71
+ let biasedPos = worldPos + n * 0.08;
72
+ let lclip = lightVP.viewProj * vec4f(biasedPos, 1.0);
73
+ let ndc = lclip.xyz / max(lclip.w, 1e-6);
74
+ let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
75
+ let cmpZ = ndc.z - 0.001;
76
+ let ts = 1.0 / 2048.0;
77
+ // 3x3 PCF unrolled — Safari's Metal backend doesn't unroll nested shadow loops reliably.
78
+ let s00 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);
79
+ let s10 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);
80
+ let s20 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, -ts), cmpZ);
81
+ let s01 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);
82
+ let s11 = textureSampleCompareLevel(shadowMap, shadowSampler, suv, cmpZ);
83
+ let s21 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);
84
+ let s02 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(-ts, ts), cmpZ);
85
+ let s12 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f(0.0, ts), cmpZ);
86
+ let s22 = textureSampleCompareLevel(shadowMap, shadowSampler, suv + vec2f( ts, ts), cmpZ);
87
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
88
+ }
89
+
90
+ @vertex fn vs(
91
+ @location(0) position: vec3f,
92
+ @location(1) normal: vec3f,
93
+ @location(2) uv: vec2f,
94
+ @location(3) joints0: vec4<u32>,
95
+ @location(4) weights0: vec4<f32>
96
+ ) -> VertexOutput {
97
+ var output: VertexOutput;
98
+ let pos4 = vec4f(position, 1.0);
99
+ let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
100
+ let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
101
+ let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
102
+ var skinnedPos = vec4f(0.0);
103
+ var skinnedNrm = vec3f(0.0);
104
+ for (var i = 0u; i < 4u; i++) {
105
+ let m = skinMats[joints0[i]];
106
+ let w = nw[i];
107
+ skinnedPos += (m * pos4) * w;
108
+ skinnedNrm += (mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * normal) * w;
109
+ }
110
+ output.position = camera.projection * camera.view * vec4f(skinnedPos.xyz, 1.0);
111
+ // Skip VS normalize — interpolation denormalizes anyway, and FS always does normalize(input.normal).
112
+ output.normal = skinnedNrm;
113
+ output.uv = uv;
114
+ output.worldPos = skinnedPos.xyz;
115
+ return output;
116
+ }
117
+
118
+ struct FSOut {
119
+ @location(0) color: vec4f,
120
+ @location(1) mask: f32,
121
+ };
122
+
123
+ @fragment fn fs(input: VertexOutput) -> FSOut {
124
+ let alpha = material.alpha;
125
+ if (alpha < 0.001) { discard; }
126
+
127
+ let n = normalize(input.normal);
128
+ let v = normalize(camera.viewPos - input.worldPos);
129
+ let albedo = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
130
+
131
+ let l = -light.lights[0].direction.xyz;
132
+ let sunColor = light.lights[0].color.xyz * light.lights[0].color.w;
133
+ let h = normalize(l + v);
134
+
135
+ let ndotl = max(dot(n, l), 0.0);
136
+ let ndotv = max(dot(n, v), 0.001);
137
+ let ndoth = max(dot(n, h), 0.0);
138
+ let vdoth = max(dot(v, h), 0.0);
139
+
140
+ let a2 = ROUGHNESS * ROUGHNESS;
141
+ let D = ggx_d(ndoth, a2);
142
+ let G = smith_g1(ndotl, a2) * smith_g1(ndotv, a2);
143
+ let F = fresnel_schlick(vdoth, F0_DIELECTRIC);
144
+ let spec = (D * G * F) / max(4.0 * ndotl * ndotv, 0.001);
145
+
146
+ let shadow = sampleShadow(input.worldPos, n);
147
+ let kd = (1.0 - F) * albedo / PI;
148
+ let direct = (kd + spec) * sunColor * ndotl * shadow;
149
+ let ambient = albedo * light.ambientColor.xyz;
150
+
151
+ // Principled Emission socket: emissive = emission_color × strength, added on top of shading.
152
+ let emission = albedo * EYE_EMISSION_STRENGTH;
153
+
154
+ var out: FSOut;
155
+ out.color = vec4f(ambient + direct + emission, alpha);
156
+ out.mask = 1.0;
157
+ return out;
158
+ }
159
+
160
+ `