载入中...
搜索中...
未找到
wgsl_shaders.h
浏览该文件的文档.
1#pragma once
2// WGSL shader sources for the WebGPU graphics backend.
3// WebGPU has no push constants or combined image samplers, so every pipeline
4// binds uniform buffers / textures / samplers explicitly through bind groups.
5// The memory layouts match the Vulkan GLSL sources (std140) exactly so the
6// existing C++ UBO structs (Mesh3DUBO, ShadowUBO, Lighting2DUBO) are reused.
7
8namespace eve::graphics::webgpu {
9
10// ---- 2D solid (color) -----------------------------------------------------
11inline const char *kColorVertWgsl = R"wgsl(
13struct VSIn {
14 @location(0) pos: vec2f,
15 @location(1) color: vec4f,
16};
18struct VSOut {
19 @builtin(position) pos: vec4f,
20 @location(0) color: vec4f,
21};
22@vertex
24fn vs_main(in: VSIn) -> VSOut {
25 var out: VSOut;
26 out.pos = vec4f(in.pos, 0.0, 1.0);
27 out.color = in.color;
28 return out;
29}
30)wgsl";
31
32inline const char *kColorFragWgsl = R"wgsl(
34struct FSIn {
35 @location(0) color: vec4f,
36};
37@fragment
39fn fs_main(in: FSIn) -> @location(0) vec4f {
40 return in.color;
41}
42)wgsl";
43
44// ---- 2D textured ----------------------------------------------------------
45inline const char *kTexturedVertWgsl = R"wgsl(
47struct VSIn {
48 @location(0) pos: vec2f,
49 @location(1) color: vec4f,
50 @location(2) uv: vec2f,
51};
53struct VSOut {
54 @builtin(position) pos: vec4f,
55 @location(0) color: vec4f,
56 @location(1) uv: vec2f,
57};
58@vertex
60fn vs_main(in: VSIn) -> VSOut {
61 var out: VSOut;
62 out.pos = vec4f(in.pos, 0.0, 1.0);
63 out.color = in.color;
64 out.uv = in.uv;
65 return out;
66}
67)wgsl";
68
69inline const char *kTexturedFragWgsl = R"wgsl(
71struct FSIn {
72 @location(0) color: vec4f,
73 @location(1) uv: vec2f,
74};
75@group(0) @binding(0) var mainTex: texture_2d<f32>;
76@group(0) @binding(2) var mainSamp: sampler;
77@fragment
79fn fs_main(in: FSIn) -> @location(0) vec4f {
81 return textureSample(mainTex, mainSamp, in.uv) * in.color;
82}
83)wgsl";
84
85// ---- 2D custom / post (Externals UBO: float data[32] == Shader push block) -
86inline const char *kCustom2DFragWgsl = R"wgsl(
88struct FSIn {
89 @location(0) color: vec4f,
90 @location(1) uv: vec2f,
91};
93struct Externals {
94 data: array<f32, 32>,
95};
96@group(0) @binding(0) var mainTex: texture_2d<f32>;
97@group(0) @binding(2) var mainSamp: sampler;
98@group(0) @binding(4) var<uniform> u: Externals;
99@fragment
101fn fs_main(in: FSIn) -> @location(0) vec4f {
103 return textureSample(mainTex, mainSamp, in.uv) * in.color;
104}
105)wgsl";
106
107// ---- 2D lit ---------------------------------------------------------------
108inline const char *kLit2DVertWgsl = R"wgsl(
110struct VSIn {
111 @location(0) pos: vec2f,
112 @location(1) color: vec4f,
113 @location(2) uv: vec2f,
114};
116struct VSOut {
117 @builtin(position) pos: vec4f,
118 @location(0) color: vec4f,
119 @location(1) uv: vec2f,
120 @location(2) ndc: vec2f,
121};
122@vertex
124fn vs_main(in: VSIn) -> VSOut {
125 var out: VSOut;
126 out.pos = vec4f(in.pos, 0.0, 1.0);
127 out.color = in.color;
128 out.uv = in.uv;
129 out.ndc = in.pos;
130 return out;
131}
132)wgsl";
133
134inline const char *kLit2DFragWgsl = R"wgsl(
136struct FSIn {
137 @location(0) color: vec4f,
138 @location(1) uv: vec2f,
139 @location(2) ndc: vec2f,
140};
142struct Light2D {
143 posRadius: vec4f,
144 color: vec4f,
145 spot: vec4f, // xy = beam dir; z/w = cos(outer/inner); z <= -1.5 => no cone
146};
148struct Lighting2D {
149 ambient: vec4f, // rgb ambient
150 lightInfo: vec4f, // x = count, y = viewW, z = viewH
151 lights: array<Light2D, 8>,
152};
153@group(0) @binding(0) var albedoTex: texture_2d<f32>;
154@group(0) @binding(1) var normalTex: texture_2d<f32>;
155@group(0) @binding(2) var mainSamp: sampler;
156@group(0) @binding(4) var<uniform> u: Lighting2D;
157
159fn applyNormalMap2D(mapSample: vec3f, logical: vec2f, uv: vec2f) -> vec3f {
160 var mapN = mapSample * 2.0 - vec3f(1.0);
161 mapN.z = max(mapN.z, 0.05);
162 let N = vec3f(0.0, 0.0, 1.0);
163 let dp1 = vec3f(dpdx(logical), 0.0);
164 let dp2 = vec3f(dpdy(logical), 0.0);
165 let duv1 = dpdx(uv);
166 let duv2 = dpdy(uv);
167 let det = duv1.x * duv2.y - duv2.x * duv1.y;
168 // Scale-aware singularity: |det| / (|duv1||duv2|) ≈ |sin θ| of the UV basis.
169 // Absolute 1e-6 rejects ordinary atlas regions (e.g. 32px in a 1024 atlas).
170 let uvScale = length(duv1) * length(duv2);
171 if (uvScale < 1e-20 || abs(det) < uvScale * 1e-3) {
173 return normalize(vec3f(mapN.xy, mapN.z));
174 }
175 let invDet = 1.0 / det;
176 var T = (dp1 * duv2.y - dp2 * duv1.y) * invDet;
177 var B = (dp2 * duv1.x - dp1 * duv2.x) * invDet;
178 T = T - N * dot(N, T);
179 let tLen = length(T);
180 let bLen = length(B);
181 if (tLen < 1e-4 || bLen < 1e-4) {
183 return normalize(vec3f(mapN.xy, mapN.z));
184 }
185 T = T / tLen;
186 B = normalize(B - N * dot(N, B) - T * dot(T, B));
187 if (abs(dot(T, B)) > 0.35) {
189 return normalize(vec3f(mapN.xy, mapN.z));
190 }
192 return normalize(mat3x3f(T, B, N) * mapN);
193}
194
195@fragment
197fn fs_main(in: FSIn) -> @location(0) vec4f {
198 let base = textureSample(albedoTex, mainSamp, in.uv) * in.color;
199 // WebGPU upload flips clip-space Y; undo it for the engine's Y-down logical coordinates.
200 let logicalNdc = vec2f(in.ndc.x, -in.ndc.y);
201 let logical = (logicalNdc * 0.5 + 0.5) * u.lightInfo.yz;
202 let normalSample = textureSample(normalTex, mainSamp, in.uv).xyz;
203 let normal = applyNormalMap2D(normalSample, logical, in.uv);
204 var lit = u.ambient.rgb;
205 let count = i32(u.lightInfo.x + 0.5);
206 for (var i = 0; i < 8; i = i + 1) {
207 if (i >= count) { break; }
208 let l = u.lights[i];
209 var contribution = 0.0;
210 if (l.posRadius.w <= 0.0) {
211 // Zero-range spots must not fall through as directional (position ≠ beam dir).
212 if (l.spot.z > -1.5) {
213 contribution = 0.0;
214 } else {
215 let lightDirection = normalize(vec3f(l.posRadius.xy, 0.35));
216 contribution = max(dot(normal, lightDirection), 0.0);
217 }
218 } else {
219 let toLight = l.posRadius.xy - logical;
220 let distance = length(toLight);
221 var attenuation = clamp(1.0 - distance / max(l.posRadius.w, 1.0), 0.0, 1.0);
222 attenuation *= attenuation;
223 if (l.spot.z > -1.5) {
224 let fromLight = logical - l.posRadius.xy;
225 let fl = length(fromLight);
226 var spotAttenuation = 1.0;
227 if (fl > 1e-4) {
228 let beamLen = length(l.spot.xy);
229 if (beamLen > 1e-6) {
230 let cosTheta = dot(fromLight / fl, l.spot.xy / beamLen);
231 spotAttenuation = smoothstep(l.spot.z, l.spot.w, cosTheta);
232 } else {
233 spotAttenuation = 0.0;
234 }
235 }
236 attenuation *= spotAttenuation;
237 }
238 let lightDirection = normalize(vec3f(toLight, l.posRadius.w * 0.35));
239 contribution = max(dot(normal, lightDirection), 0.0) * attenuation;
240 }
241 lit += l.color.rgb * contribution;
242 }
244 return vec4f(base.rgb * lit, base.a);
245}
246)wgsl";
247
248// ---- Mesh3D (full PBR, layout matches Mesh3DUBO / ShadowUBO) --------------
250inline const char* kMesh3DVertWgsl = R"wgsl(
251@group(0) @binding(21) var<storage, read> skinBones: array<mat4x4f>;
252
254struct VSIn {
255 @location(0) pos: vec3f,
256 @location(1) normal: vec3f,
257 @location(2) uv: vec2f,
258 @location(3) joints: vec4u,
259 @location(4) weights: vec4f,
260 @location(5) color: vec4f,
261};
263struct Light3D {
264 posRadius: vec4f,
265 color: vec4f,
266 spot: vec4f,
267};
269struct Frame {
270 mvp: mat4x4f,
271 model: mat4x4f,
272 lightDir: vec4f,
273 lightColor: vec4f,
274 tint: vec4f,
275 cameraPos: vec4f,
276 ambient: vec4f,
277 lights: array<Light3D, 8>,
278 texBomb: vec4f,
279 parallax: vec4f,
280 surface: vec4f,
281 view: mat4x4f,
282 clipInfo: vec4f,
283 cloud: vec4f,
284 cloudWind: vec4f,
285 virtualTexture: vec4f,
286 virtualAtlas: vec4f,
287 envProbeCenter: vec4f,
288 envProbeExtent: vec4f,
289 skinInfo: vec4f,
290 reflectionProbeCenter: array<vec4f, 2>,
291 reflectionProbeExtent: array<vec4f, 2>,
292 diffuseProbeSh: array<vec4f, 9>,
293 diffuseProbeInfo: vec4f,
294 diffuseVolumePosition: array<vec4f, 8>,
295 diffuseVolumeExtent: array<vec4f, 8>,
296 diffuseVolumeSh: array<vec4f, 72>,
297 diffuseVolumeInfo: vec4f,
298 lodFade: vec4f,
299};
300
302struct VSOut {
303 @builtin(position) pos: vec4f,
304 @location(0) vNormal: vec3f,
305 @location(1) vUV: vec2f,
306 @location(2) vTint: vec4f,
307 @location(3) vWorldPos: vec3f,
308 @location(4) vCameraPos: vec3f,
309 @location(5) vViewPos: vec3f,
310};
311@group(0) @binding(0) var<uniform> ubo: Frame;
312// WGSL has no stdlib inverse(); implement 3x3 inverse for the normal matrix.
314fn inverse3x3(m: mat3x3f) -> mat3x3f {
315 let a = m[0].x; let b = m[1].x; let c = m[2].x;
316 let d = m[0].y; let e = m[1].y; let f = m[2].y;
317 let g = m[0].z; let h = m[1].z; let i = m[2].z;
318 let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
320 return mat3x3f(
322 vec3f((e * i - f * h) / det, (f * g - d * i) / det, (d * h - e * g) / det),
324 vec3f((c * h - b * i) / det, (a * i - c * g) / det, (b * g - a * h) / det),
326 vec3f((b * f - c * e) / det, (c * d - a * f) / det, (a * e - b * d) / det),
327 );
328}
329@vertex
331fn vs_main(in: VSIn) -> VSOut {
332 var out: VSOut;
333 var localPos = vec4f(in.pos, 1.0);
334 var localNormal = in.normal;
335 if (ubo.skinInfo.x > 0.5) {
336 var weights = in.weights;
337 if (ubo.skinInfo.y < 3.5) { weights.z = 0.0; weights.w = 0.0; }
338 if (ubo.skinInfo.y < 1.5) { weights.y = 0.0; }
339 let weightSum = dot(weights, vec4f(1.0));
340 if (weightSum > 1e-8) { weights /= weightSum; }
341 let skin = weights.x * skinBones[in.joints.x]
342 + weights.y * skinBones[in.joints.y]
343 + weights.z * skinBones[in.joints.z]
344 + weights.w * skinBones[in.joints.w];
345 localPos = skin * localPos;
346 localNormal = mat3x3f(skin[0].xyz, skin[1].xyz, skin[2].xyz) * localNormal;
347 }
348 out.pos = ubo.mvp * localPos;
349 // WebGPU NDC is Y-up; mirror the Vulkan-convention clip Y.
350 out.pos.y = -out.pos.y;
351 let world = ubo.model * localPos;
352 out.vWorldPos = world.xyz;
353 out.vViewPos = (ubo.view * world).xyz;
354 let nrm = transpose(inverse3x3(mat3x3f(ubo.model[0].xyz, ubo.model[1].xyz, ubo.model[2].xyz))) * localNormal;
355 out.vNormal = normalize(nrm);
356 out.vUV = in.uv;
357 out.vTint = ubo.tint * in.color;
358 out.vCameraPos = ubo.cameraPos.xyz;
359 return out;
360}
361)wgsl";
362
363inline const char *kMesh3DFragWgsl = R"wgsl(
365struct Light3D {
366 posRadius: vec4f,
367 color: vec4f,
368 spot: vec4f,
369};
371struct Frame {
372 mvp: mat4x4f,
373 model: mat4x4f,
374 lightDir: vec4f,
375 lightColor: vec4f,
376 tint: vec4f,
377 cameraPos: vec4f,
378 ambient: vec4f,
379 lights: array<Light3D, 8>,
380 texBomb: vec4f,
381 parallax: vec4f,
382 surface: vec4f,
383 view: mat4x4f,
384 clipInfo: vec4f,
385 cloud: vec4f,
386 cloudWind: vec4f,
387 virtualTexture: vec4f,
388 virtualAtlas: vec4f,
389 envProbeCenter: vec4f,
390 envProbeExtent: vec4f,
391 skinInfo: vec4f,
392 reflectionProbeCenter: array<vec4f, 2>,
393 reflectionProbeExtent: array<vec4f, 2>,
394 diffuseProbeSh: array<vec4f, 9>,
395 diffuseProbeInfo: vec4f,
396 diffuseVolumePosition: array<vec4f, 8>,
397 diffuseVolumeExtent: array<vec4f, 8>,
398 diffuseVolumeSh: array<vec4f, 72>,
399 diffuseVolumeInfo: vec4f,
400 lodFade: vec4f,
401};
403struct ShadowFrame {
404 lightVP: array<mat4x4f, 3>,
405 splits: vec4f,
406 bias: vec4f,
407 cascadeBias: vec4f,
408 cascadeTexel: vec4f,
409 localVP: array<mat4x4f, 4>,
410 localSlot01: vec4f,
411 localSlot23: vec4f,
412 localBias: vec4f,
413 localMeta: vec4f,
414};
416struct FSIn {
417 @location(0) vNormal: vec3f,
418 @location(1) vUV: vec2f,
419 @location(2) vTint: vec4f,
420 @location(3) vWorldPos: vec3f,
421 @location(4) vCameraPos: vec3f,
422 @location(5) vViewPos: vec3f,
423 @builtin(position) fragCoord: vec4f,
424};
425@group(0) @binding(0) var<uniform> ubo: Frame;
426@group(0) @binding(1) var albedoSampler: texture_2d<f32>;
427@group(0) @binding(2) var normalSampler: texture_2d<f32>;
428@group(0) @binding(3) var envSampler: texture_cube<f32>;
429@group(0) @binding(4) var<uniform> shadow: ShadowFrame;
430@group(0) @binding(5) var shadowMap: texture_depth_2d_array;
431@group(0) @binding(6) var heightSampler: texture_2d<f32>;
432@group(0) @binding(7) var mainSamp: sampler;
433@group(0) @binding(8) var shadowSamp: sampler_comparison;
434@group(0) @binding(10) var aoTex: texture_2d<f32>;
435@group(0) @binding(11) var aoSamp: sampler;
436@group(0) @binding(12) var decalAlbedoLayer: texture_2d<f32>;
437@group(0) @binding(13) var decalNormalLayer: texture_2d<f32>;
438@group(0) @binding(14) var decalParamsLayer: texture_2d<f32>;
439
441fn sampleVirtualTexture(atlas: texture_2d<f32>, sourceUv: vec2f,
442 uvDx: vec2f, uvDy: vec2f) -> vec4f {
443 let uv = fract(sourceUv);
444 let pageCounts = max(ubo.virtualTexture.yz, vec2f(1.0));
445 let slots = max(ubo.virtualAtlas.xy, vec2f(1.0));
446 let virtualCoord = uv * pageCounts;
447 let pageLimit = vec2i(textureDimensions(heightSampler)) - vec2i(1);
448 let page = clamp(vec2i(floor(virtualCoord)), vec2i(0), pageLimit);
449 let entry = textureLoad(heightSampler, page, 0);
450 var payloadUv = uv;
451 var derivativePages = vec2f(1.0);
452 if (entry.b > 0.5) {
453 payloadUv = fract(virtualCoord);
454 derivativePages = pageCounts;
455 }
456 let slot = floor(entry.rg * slots);
457 let gutter = clamp(ubo.virtualTexture.w, 0.0, 0.499);
458 let physicalUv = (slot + mix(vec2f(gutter), vec2f(1.0 - gutter), payloadUv)) / slots;
459 let derivativeScale = derivativePages * (1.0 - 2.0 * gutter) / slots;
461 return textureSampleGrad(atlas, mainSamp, physicalUv,
462 uvDx * derivativeScale, uvDy * derivativeScale);
463}
464
465const PI: f32 = 3.14159265359;
466
468fn distGGX(n: vec3f, h: vec3f, rough: f32) -> f32 {
469 let a = max(rough * rough, 0.002);
470 let a2 = a * a;
471 let ndh = max(dot(n, h), 0.0);
472 let denom = (ndh * ndh * (a2 - 1.0) + 1.0);
473 return a2 / max(PI * denom * denom, 1e-4);
474}
476fn geomSchlick(ndv: f32, rough: f32) -> f32 {
477 let r = rough + 1.0;
478 let k = (r * r) / 8.0;
479 return ndv / max(ndv * (1.0 - k) + k, 1e-4);
480}
482fn geomSmith(n: vec3f, v: vec3f, l: vec3f, rough: f32) -> f32 {
484 return geomSchlick(max(dot(n, v), 0.0), rough) * geomSchlick(max(dot(n, l), 0.0), rough);
485}
487fn fresnelSchlick(cosT: f32, f0: vec3f) -> vec3f {
488 return f0 + (1.0 - f0) * pow(clamp(1.0 - cosT, 0.0, 1.0), 5.0);
489}
491fn texBombHash22(p: vec2f) -> vec2f {
492 var p3 = fract(vec3f(p.x, p.y, p.x) * vec3f(0.1031, 0.1030, 0.0973));
493 p3 += dot(p3, p3.yzx + 33.33);
495 return fract((p3.xx + p3.yz) * p3.zy);
496}
498fn texBombRotate(v: vec2f, angle: f32) -> vec2f {
499 let s = sin(angle);
500 let c = cos(angle);
502 return vec2f(c * v.x - s * v.y, s * v.x + c * v.y);
503}
505fn textureCellBomb(tex: texture_2d<f32>, uv: vec2f, cellScale: f32, strength: f32,
506 rotAmount: f32, dx: vec2f, dy: vec2f) -> vec4f {
507 if (strength < 1e-4) { return textureSample(tex, mainSamp, uv); }
508 let scale = max(cellScale, 1e-3);
509 let p = uv * scale;
510 let cell = floor(p);
511 let f = fract(p);
512 let w = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
513 var accumulated = vec4f(0.0);
514 for (var j = 0; j <= 1; j = j + 1) {
515 for (var i = 0; i <= 1; i = i + 1) {
516 let ij = vec2f(f32(i), f32(j));
517 let cellIJ = cell + ij;
518 let random = texBombHash22(cellIJ);
519 let randomB = texBombHash22(cellIJ + vec2f(19.0, 47.0));
520 let offset = (random * 2.0 - 1.0) * (strength / scale);
521 let angle = (randomB.x * 2.0 - 1.0) * PI * clamp(rotAmount, 0.0, 1.0) * strength;
522 let center = (cellIJ + vec2f(0.5)) / scale;
523 let sampleUV = center + texBombRotate(uv - center, angle) + offset;
524 let weight = mix(1.0 - w.x, w.x, f32(i)) * mix(1.0 - w.y, w.y, f32(j));
525 accumulated += textureSampleGrad(tex, mainSamp, sampleUV, dx, dy) * weight;
526 }
527 }
528 return accumulated;
529}
531fn surfaceTBN(nInput: vec3f, dp1: vec3f, dp2: vec3f, duv1: vec2f,
532 duv2: vec2f) -> mat3x3f {
533 let n = normalize(nInput);
534 let det = duv1.x * duv2.y - duv2.x * duv1.y;
535 if (abs(det) < 1e-8) { return mat3x3f(vec3f(0.0), vec3f(0.0), n); }
536 let tangent = normalize(cross(dp2, n) * duv1.x + cross(n, dp1) * duv2.x);
537 let bitangent = normalize(cross(dp2, n) * duv1.y + cross(n, dp1) * duv2.y);
538 if (length(tangent) < 1e-4 || length(bitangent) < 1e-4) {
540 return mat3x3f(vec3f(0.0), vec3f(0.0), n);
541 }
543 return mat3x3f(tangent, bitangent, n);
544}
546fn applyNormalMap(nInput: vec3f, mapSample: vec3f, dp1: vec3f, dp2: vec3f,
547 duv1: vec2f, duv2: vec2f) -> vec3f {
548 let mapN = mapSample * 2.0 - 1.0;
549 if (length(mapN.xy) < 0.04 && mapN.z > 0.85) { return normalize(nInput); }
550 let n = normalize(nInput);
551 let det = duv1.x * duv2.y - duv2.x * duv1.y;
552 if (abs(det) < 1e-6) { return n; }
553 let invDet = 1.0 / det;
554 var tangent = (dp1 * duv2.y - dp2 * duv1.y) * invDet;
555 var bitangent = (dp2 * duv1.x - dp1 * duv2.x) * invDet;
556 tangent -= n * dot(n, tangent);
557 let tangentLength = length(tangent);
558 let bitangentLength = length(bitangent);
559 if (tangentLength < 1e-4 || bitangentLength < 1e-4) { return n; }
560 tangent /= tangentLength;
561 bitangent = normalize(bitangent - n * dot(n, bitangent) - tangent * dot(tangent, bitangent));
562 if (abs(dot(tangent, bitangent)) > 0.35) { return n; }
564 return normalize(mat3x3f(tangent, bitangent, n) * mapN);
565}
567fn parallaxMappedUV(uv: vec2f, n: vec3f, v: vec3f, scale: f32,
568 minLayers: f32, maxLayers: f32, worldDx: vec3f, worldDy: vec3f,
569 uvDx: vec2f, uvDy: vec2f) -> vec2f {
570 if (scale < 1e-5) { return uv; }
571 let tbn = surfaceTBN(n, worldDx, worldDy, uvDx, uvDy);
572 if (length(tbn[0]) < 1e-4) { return uv; }
573 let viewTS = normalize(transpose(tbn) * v);
574 let layers = clamp(mix(max(maxLayers, 1.0), max(minLayers, 1.0),
576 clamp(abs(viewTS.z), 0.0, 1.0)), 1.0, 64.0);
577 let layerDepth = 1.0 / layers;
578 let deltaUV = ((viewTS.xy / max(abs(viewTS.z), 0.08)) * scale) / layers;
579 var currentUV = uv;
580 var currentDepth = 0.0;
581 var mapDepth = 1.0 - textureSampleGrad(heightSampler, mainSamp, currentUV, uvDx, uvDy).r;
582 for (var i = 0; i < 64; i = i + 1) {
583 if (currentDepth >= mapDepth || f32(i) >= layers) { break; }
584 currentUV -= deltaUV;
585 mapDepth = 1.0 - textureSampleGrad(heightSampler, mainSamp, currentUV, uvDx, uvDy).r;
586 currentDepth += layerDepth;
587 }
588 let previousUV = currentUV + deltaUV;
589 let after = mapDepth - currentDepth;
590 let before = (1.0 - textureSampleGrad(heightSampler, mainSamp, previousUV, uvDx, uvDy).r) -
591 (currentDepth - layerDepth);
592 let denominator = after - before;
593 var weight = 0.5;
594 if (abs(denominator) >= 1e-5) { weight = clamp(after / denominator, 0.0, 1.0); }
596 return mix(currentUV, previousUV, weight);
597}
599fn shadeLight(n: vec3f, v: vec3f, albedo: vec3f, metallic: f32, rough: f32, l: vec3f, rad: vec3f) -> vec3f {
600 let ndl = max(dot(n, l), 0.0);
601 let diffuse = mix(ndl, ndl * 0.5 + 0.5, 0.25);
602 let h = normalize(v + l);
603 let f0 = mix(vec3f(0.04), albedo, metallic);
604 let ndf = distGGX(n, h, rough);
605 let g = geomSmith(n, v, l, rough);
606 let f = fresnelSchlick(max(dot(h, v), 0.0), f0);
607 let spec = (ndf * g * f) / max(4.0 * max(dot(n, v), 0.0) * max(ndl, 0.001), 1e-3);
608 let kd = (vec3f(1.0) - f) * (1.0 - metallic);
609 return (kd * albedo * diffuse + spec * ndl) * rad;
610}
612fn cloudHash(p: vec2f) -> f32 {
613 let ip = vec2i(floor(p % 64.0));
614 var h = (u32(ip.x) * 374761393u) ^ (u32(ip.y) * 668265263u);
615 h = (h ^ (h >> 13)) * 1274126177u;
616 h = h ^ (h >> 16);
618 return f32(h & 0x00FFFFFFu) / f32(0x00FFFFFFu);
619}
621fn cloudNoise(p: vec2f) -> f32 {
622 let i = floor(p);
623 var f = fract(p);
624 f = f * f * (3.0 - 2.0 * f);
625 let a = cloudHash(i);
626 let b = cloudHash(i + vec2f(1.0, 0.0));
627 let c = cloudHash(i + vec2f(0.0, 1.0));
628 let d = cloudHash(i + vec2f(1.0, 1.0));
630 return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
631}
633fn cloudFbm(p: vec2f) -> f32 {
634 var sum = 0.0;
635 var amp = 0.5;
636 var freq = 1.0;
637 for (var i = 0; i < 4; i = i + 1) {
638 sum += amp * cloudNoise(p * freq);
639 amp *= 0.5;
640 freq *= 2.0;
641 }
642 return sum * 2.0;
643}
645fn cloudShadowFactor(worldPos: vec3f) -> f32 {
646 if (ubo.cloud.x < 1e-4) { return 1.0; }
647 let cell = 1.0 / max(ubo.cloud.y, 1e-4);
648 let drift = (ubo.cloudWind.xy * cell) * ubo.cloud.z;
649 let p = worldPos.xz * cell - drift;
650 let f = cloudFbm(p);
651 let c = smoothstep(ubo.cloudWind.z - 0.1, ubo.cloudWind.z + 0.1, f);
652 let d = cloudNoise(p * 3.0 + vec2f(11.7, 5.3));
653 let covered = mix(c, c * d, ubo.cloudWind.w);
654 return 1.0 - clamp(covered, 0.0, 1.0) * clamp(ubo.cloud.x, 0.0, 1.0);
655}
657fn sampleShadowCascade(worldPos: vec3f, cascade: i32, bias: f32) -> f32 { let lightClip = shadow.lightVP[cascade] * vec4f(worldPos, 1.0);
658 let ndc = lightClip.xyz / max(lightClip.w, 1e-6);
659 // Shadow-map vertices mirror clip Y for WebGPU. Undo that mirror when
660 // projecting the shared Vulkan-convention light matrix for sampling.
661 let uv = vec2f(ndc.x, -ndc.y) * 0.5 + 0.5;
662 let depth = ndc.z;
663 if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 || depth < 0.0 || depth > 1.0) {
664 return 1.0;
665 }
666 let texel = 1.0 / vec2f(textureDimensions(shadowMap));
667 var sum: f32 = 0.0;
668 let zref = depth - bias;
669 for (var i = 0; i < 9; i = i + 1) {
670 var s: vec2f;
671 if (i == 0) { s = uv + vec2f(-0.5, -0.5) * texel; }
672 else if (i == 1) { s = uv + vec2f(0.0, -0.6) * texel; }
673 else if (i == 2) { s = uv + vec2f(0.5, -0.5) * texel; }
674 else if (i == 3) { s = uv + vec2f(-0.6, 0.0) * texel; }
675 else if (i == 4) { s = uv; }
676 else if (i == 5) { s = uv + vec2f(0.6, 0.0) * texel; }
677 else if (i == 6) { s = uv + vec2f(-0.5, 0.5) * texel; }
678 else if (i == 7) { s = uv + vec2f(0.0, 0.6) * texel; }
679 else { s = uv + vec2f(0.5, 0.5) * texel; }
680 if (s.x >= 0.0 && s.x <= 1.0 && s.y >= 0.0 && s.y <= 1.0) {
681 sum += textureSampleCompareLevel(shadowMap, shadowSamp, s, cascade, zref);
682 }
683 }
684 return sum / 9.0;
685}
687fn cascadeNdcBias(cascade: i32) -> f32 {
688 var bias: f32;
689 if (cascade == 0) { bias = shadow.cascadeBias.x; }
690 else if (cascade == 1) { bias = shadow.cascadeBias.y; }
691 else { bias = shadow.cascadeBias.z; }
693 return select(bias, shadow.bias.x, bias < 1e-8);
694}
696fn slopeScaledBias(cascade: i32, ndl: f32) -> f32 {
698 return cascadeNdcBias(cascade) * mix(0.75, 1.0, clamp(ndl, 0.0, 1.0));
699}
701fn sampleShadowPCF(worldPos: vec3f, n: vec3f, viewDepth: f32, ndl: f32) -> f32 {
702 if (shadow.bias.y < 0.5 || shadow.bias.z < 0.5 || shadow.splits.w < 1e-4) {
703 return 1.0;
704 }
705 var cascade: i32 = 2;
706 if (viewDepth < shadow.splits.x) { cascade = 0; }
707 else if (viewDepth < shadow.splits.y) { cascade = 1; }
708 var tw: f32;
709 if (cascade == 0) { tw = shadow.cascadeTexel.x; }
710 else if (cascade == 1) { tw = shadow.cascadeTexel.y; }
711 else { tw = shadow.cascadeTexel.z; }
712 let p = worldPos + n * ((2.0 * max(tw, 1e-6)) / max(ndl, 0.2));
713 var vis = sampleShadowCascade(p, cascade, slopeScaledBias(cascade, ndl));
714
715 var hi: f32;
716 var lo: f32;
717 if (cascade == 0) {
718 hi = shadow.splits.x;
719 lo = 0.0;
720 } else if (cascade == 1) {
721 hi = shadow.splits.y;
722 lo = shadow.splits.x;
723 } else {
724 hi = shadow.splits.z;
725 lo = shadow.splits.y;
726 }
727 let band = max(0.5, (hi - lo) * 0.1);
728 let toPrev = 1.0 - clamp((viewDepth - lo) / band, 0.0, 1.0);
729 let toNext = 1.0 - clamp((hi - viewDepth) / band, 0.0, 1.0);
730 if (toPrev > 0.0 && cascade > 0) {
731 let previous = sampleShadowCascade(p, cascade - 1, slopeScaledBias(cascade - 1, ndl));
732 vis = mix(vis, previous, toPrev);
733 }
734 if (toNext > 0.0 && cascade < 2) {
735 let next = sampleShadowCascade(p, cascade + 1, slopeScaledBias(cascade + 1, ndl));
736 vis = mix(vis, next, toNext);
737 }
738 vis = mix(1.0, vis, clamp(shadow.splits.w, 0.0, 1.0));
740 return mix(0.04, 1.0, vis);
741}
742@fragment
744fn fs_main(in: FSIn) -> @location(0) vec4f {
745 // Evaluate derivatives before any per-fragment branch; WGSL requires uniform control flow.
746 let uvDx = dpdx(in.vUV);
747 let uvDy = dpdy(in.vUV);
748 let worldDx = dpdx(in.vWorldPos);
749 let worldDy = dpdy(in.vWorldPos);
750 if (ubo.lodFade.z > 0.5) {
751 let lodHash = fract(dot(floor(in.fragCoord.xy), vec2f(0.06711056, 0.00583715)));
752 let rejected = select((lodHash < 1.0 - ubo.lodFade.x),
753 (lodHash >= ubo.lodFade.x), ubo.lodFade.y < 0.5);
754 if (rejected) { discard; }
755 }
756 var nGeom = normalize(in.vNormal);
757 let v = normalize(in.vCameraPos - in.vWorldPos);
758 if (dot(nGeom, v) < 0.0) { nGeom = -nGeom; }
759 var uv = in.vUV;
760 if (ubo.virtualTexture.x < 0.5) {
761 uv = parallaxMappedUV(in.vUV, nGeom, v, ubo.parallax.x,
762 ubo.parallax.y, ubo.parallax.z, worldDx, worldDy, uvDx, uvDy);
763 }
764 var base: vec4f;
765 if (ubo.virtualTexture.x > 0.5) {
766 base = sampleVirtualTexture(albedoSampler, uv, uvDx, uvDy) * in.vTint;
767 } else {
768 base = textureCellBomb(albedoSampler, uv, ubo.texBomb.x, ubo.texBomb.y,
769 ubo.texBomb.z, uvDx, uvDy) * in.vTint;
770 }
771 if (ubo.surface.x > 0.5 && ubo.surface.x < 1.5 && base.a < ubo.surface.y) {
772 discard;
773 }
774 // Screen-space primary; UV secondary so coverage still varies if fragment
775 // position is degenerate (Dawn Metal CI once kept every pixel with fragCoord-only).
776 let alphaHash = fract(dot(floor(in.fragCoord.xy), vec2f(0.06711056, 0.00583715)) +
778 dot(uv * 64.0, vec2f(0.7548777, 0.5698403)));
779 if (ubo.surface.x > 2.5 && base.a < alphaHash) { discard; }
780 var albedo = base.rgb;
781 var metallic = clamp(ubo.ambient.w, 0.0, 1.0);
782 var rough = clamp(ubo.cameraPos.w, 0.04, 1.0);
783 let count = i32(ubo.lightDir.w + 0.5);
784 var packedNormal: vec4f;
785 if (ubo.virtualTexture.x > 0.5) {
786 packedNormal = sampleVirtualTexture(normalSampler, uv, uvDx, uvDy);
787 } else {
788 packedNormal = textureCellBomb(normalSampler, uv, ubo.texBomb.x, ubo.texBomb.y,
789 ubo.texBomb.z, uvDx, uvDy);
790 }
791 var nSmp = packedNormal.xyz;
792 var terrainAo = 1.0;
793 if (ubo.virtualAtlas.z > 0.5) {
794 let normalXY = packedNormal.rg * 2.0 - 1.0;
795 nSmp = vec3f(packedNormal.rg, sqrt(1.0 - clamp(dot(normalXY, normalXY), 0.0, 1.0)) * 0.5 + 0.5);
796 terrainAo = clamp(packedNormal.b, 0.0, 1.0);
797 rough = clamp(1.0 - packedNormal.a, 0.04, 1.0);
798 }
799 var n = nGeom;
800 if (length(nSmp - vec3f(0.5, 0.5, 1.0)) > 0.04) {
801 n = applyNormalMap(n, nSmp, worldDx, worldDy, uvDx, uvDy);
802 }
803 var emissive = vec3f(0.0);
804 let decalPos = clamp(vec2<i32>(in.fragCoord.xy), vec2<i32>(0),
805 vec2<i32>(textureDimensions(decalAlbedoLayer)) - vec2<i32>(1));
806 let decalA = textureLoad(decalAlbedoLayer, decalPos, 0);
807 let decalCoverage = clamp(decalA.a, 0.0, 1.0);
808 if (decalCoverage > 0.001) {
809 albedo = mix(albedo, decalA.rgb, decalCoverage);
810 let decalN = textureLoad(decalNormalLayer, decalPos, 0);
811 let normalWeight = clamp(decalN.a, 0.0, 1.0);
812 if (normalWeight > 0.001) {
813 n = normalize(mix(n, normalize(decalN.rgb * 2.0 - 1.0), normalWeight));
814 }
815 let decalP = textureLoad(decalParamsLayer, decalPos, 0);
816 let paramsWeight = clamp(decalP.a, 0.0, 1.0);
817 if (paramsWeight > 0.001) {
818 rough = mix(rough, decalP.r, paramsWeight);
819 metallic = mix(metallic, decalP.g, paramsWeight);
820 emissive += decalA.rgb * decalP.b;
821 }
822 }
823 var lo = vec3f(0.0);
824 let viewDepth = max(-in.vViewPos.z, 0.0);
825 let primaryL = normalize(ubo.lightDir.xyz);
826 let shadowVis = sampleShadowPCF(in.vWorldPos, n, viewDepth, max(dot(n, primaryL), 0.0));
827 if (length(ubo.lightColor.rgb) > 1e-6) {
828 lo += shadeLight(n, v, albedo, metallic, rough, primaryL, ubo.lightColor.rgb) * shadowVis *
830 cloudShadowFactor(in.vWorldPos);
831 }
832 for (var i = 0; i < 8; i = i + 1) {
833 if (i >= count) { break; }
834 let lgt = ubo.lights[i];
835 var rad = lgt.color.rgb;
836 var l: vec3f;
837 if (lgt.posRadius.w <= 0.0) {
838 l = normalize(lgt.posRadius.xyz);
839 if (length(l - primaryL) < 1e-3) { continue; }
840 } else {
841 let toL = lgt.posRadius.xyz - in.vWorldPos;
842 let dist = length(toL);
843 l = toL / max(dist, 1e-4);
844 var atten = clamp(1.0 - dist / max(lgt.posRadius.w, 1e-3), 0.0, 1.0);
845 atten = atten * atten;
846 if (lgt.spot.w > 0.0) {
847 let beamLen = length(lgt.spot.xyz);
848 if (beamLen > 1e-6) {
849 let cosTheta = dot(normalize(-l), lgt.spot.xyz / beamLen);
850 atten = atten * clamp(cosTheta * lgt.spot.w + lgt.color.a, 0.0, 1.0);
851 // Local spot shadow slot encoded as |beam| = 1 + (slot+1)/100.
852 if (beamLen > 1.005 && shadow.bias.z > 0.5) {
853 let si = i32(round((beamLen - 1.0) * 100.0)) - 1;
854 if (si >= 0 && si <= 3) {
855 let lc = shadow.localVP[si] * vec4f(in.vWorldPos, 1.0);
856 let ndc = lc.xyz / max(lc.w, 1e-4);
857 let uv = vec2f(ndc.x, -ndc.y) * 0.5 + 0.5;
858 let zref = ndc.z - shadow.localBias[si];
859 var vis = 1.0;
860 if (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0 && lc.w > 0.0) {
861 vis = textureSampleCompareLevel(shadowMap, shadowSamp, uv, 3 + si, zref);
862 }
863 atten = atten * mix(1.0 - shadow.splits.w, 1.0, vis);
864 }
865 }
866 } else {
867 atten = 0.0;
868 }
869 }
870 rad *= atten;
871 }
872 lo += shadeLight(n, v, albedo, metallic, rough, l, rad);
873 }
874 let hemi = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
875 let skyIrr = ubo.ambient.rgb * 1.1 + ubo.lightColor.rgb * 0.12;
876 let gndIrr = ubo.ambient.rgb * vec3f(0.72, 0.62, 0.52);
877 var irr = mix(gndIrr, skyIrr, hemi);
878 let shBasis = array<f32, 9>(0.2820947918, 0.4886025119 * n.y, 0.4886025119 * n.z,
879 0.4886025119 * n.x, 1.0925484306 * n.x * n.y, 1.0925484306 * n.y * n.z,
880 0.3153915653 * (3.0 * n.z * n.z - 1.0), 1.0925484306 * n.x * n.z,
881 0.5462742153 * (n.x * n.x - n.y * n.y));
882 if (ubo.diffuseVolumeInfo.x > 0.5) {
883 var volumeIrradiance = vec3f(0.0);
884 var volumeWeight = 0.0;
885 var cellMin = ubo.diffuseVolumePosition[0].xyz;
886 var cellMax = cellMin;
887 if (ubo.diffuseVolumeInfo.y > 0.5) {
888 for (var probe = 1; probe < 8; probe += 1) {
889 if (probe >= i32(ubo.diffuseVolumeInfo.x + 0.5)) { break; }
890 cellMin = min(cellMin, ubo.diffuseVolumePosition[probe].xyz);
891 cellMax = max(cellMax, ubo.diffuseVolumePosition[probe].xyz);
892 }
893 }
894 let cellT = clamp((in.vWorldPos - cellMin) / max(cellMax - cellMin, vec3f(0.0001)),
896 vec3f(0.0), vec3f(1.0));
897 for (var probe = 0; probe < 8; probe += 1) {
898 if (probe >= i32(ubo.diffuseVolumeInfo.x + 0.5)) { break; }
899 let extent = max(ubo.diffuseVolumeExtent[probe].xyz, vec3f(0.0001));
900 let delta = abs(in.vWorldPos - ubo.diffuseVolumePosition[probe].xyz);
901 if (ubo.diffuseVolumeInfo.y > 0.5 || all(delta <= extent)) {
902 var weight: f32;
903 if (ubo.diffuseVolumeInfo.y > 0.5) {
904 let activeAxis = (cellMax - cellMin) > vec3f(0.0001);
905 let lowerCorner = abs(ubo.diffuseVolumePosition[probe].xyz - cellMax) > vec3f(0.0001);
906 var axisWeight = select(cellT, vec3f(1.0) - cellT, lowerCorner);
907 axisWeight = select(vec3f(1.0), axisWeight, activeAxis);
908 weight = axisWeight.x * axisWeight.y * axisWeight.z;
909 } else {
910 weight = 1.0 / max(length(delta / extent), 0.05);
911 }
912 var probeIrradiance = vec3f(0.0);
913 for (var coefficient = 0; coefficient < 9; coefficient += 1) {
914 probeIrradiance += ubo.diffuseVolumeSh[probe * 9 + coefficient].rgb *
915 shBasis[coefficient];
916 }
917 volumeIrradiance += max(probeIrradiance, vec3f(0.0)) * weight;
918 volumeWeight += weight;
919 }
920 }
921 if (volumeWeight > 0.0) { irr = volumeIrradiance / volumeWeight; }
922 } else if (ubo.diffuseProbeInfo.x > 0.5) {
923 irr = vec3f(0.0);
924 for (var coefficient = 0; coefficient < 9; coefficient += 1) {
925 irr += ubo.diffuseProbeSh[coefficient].rgb * shBasis[coefficient];
926 }
927 irr = max(irr, vec3f(0.0));
928 }
929 var color = albedo * irr * (1.0 - metallic) * terrainAo + lo;
930 let wrap = max(dot(n, primaryL) * 0.5 + 0.5, 0.0);
931 color += albedo * ubo.lightColor.rgb * (wrap * wrap) * 0.06 * (1.0 - metallic) * terrainAo;
932 let envIntensity = ubo.lightColor.w;
933 if (envIntensity > 1e-4) {
934 let r = reflect(-v, n);
935 let envSpec = textureSampleLevel(envSampler, mainSamp, r, rough * 5.0).rgb * envIntensity;
936 let f0 = mix(vec3f(0.04), albedo, metallic);
937 let f = fresnelSchlick(max(dot(n, v), 0.0), f0);
938 color += envSpec * f;
939 let irr2 = textureSampleLevel(envSampler, mainSamp, n, 5.0).rgb * envIntensity;
940 color += albedo * irr2 * (1.0 - metallic) * (1.0 - f) * 0.45 * terrainAo;
941 }
942 // Screen-space ambient occlusion (G-buffer SSAO pass output; strength in
943 // texBomb.w is 0 when AO is disabled via RenderControl, which also keeps
944 // the binding white in that case).
945 // AO is rendered at half the scene resolution; map the full-res fragment
946 // coordinate into AO texel space before normalizing, otherwise the UV
947 // overruns into [0,2] and clamps to the AO edge (misaligned occlusion).
948 let aoUV = (in.fragCoord.xy * 0.5) / vec2f(textureDimensions(aoTex));
949 let ao = textureSampleLevel(aoTex, aoSamp, aoUV, 0.0).r;
950 color *= mix(1.0, ao, clamp(ubo.surface.z, 0.0, 1.0));
951 color += emissive;
952 let outputAlpha = select(1.0, base.a, ubo.surface.x > 1.5 && ubo.surface.x < 2.5);
954 return vec4f(color, outputAlpha);
955}
956)wgsl";
957
958// ---- Mesh3D clustered forward (matches Mesh3DClusteredUBO) -----------------
959inline const char *kMesh3DClusteredVertWgsl = R"wgsl(
961struct VSIn {
962 @location(0) pos: vec3f,
963 @location(1) normal: vec3f,
964 @location(2) uv: vec2f,
965 @location(5) color: vec4f,
966};
968struct Frame {
969 mvp: mat4x4f,
970 model: mat4x4f,
971 view: mat4x4f,
972 lightDir: vec4f,
973 lightColor: vec4f,
974 tint: vec4f,
975 cameraPos: vec4f,
976 ambient: vec4f,
977 gridInfo: vec4f,
978 clipInfo: vec4f,
979 texBomb: vec4f,
980 parallax: vec4f,
981 surface: vec4f,
982 virtualTexture: vec4f,
983 virtualAtlas: vec4f,
984 envProbeCenter: vec4f,
985 envProbeExtent: vec4f,
986 reflectionProbeCenter: array<vec4f, 2>,
987 reflectionProbeExtent: array<vec4f, 2>,
988};
990struct VSOut {
991 @builtin(position) pos: vec4f,
992 @location(0) vNormal: vec3f,
993 @location(1) vUV: vec2f,
994 @location(2) vTint: vec4f,
995 @location(3) vWorldPos: vec3f,
996 @location(4) vCameraPos: vec3f,
997 @location(5) vViewPos: vec3f,
998};
999@group(0) @binding(0) var<uniform> ubo: Frame;
1001fn inverse3x3(m: mat3x3f) -> mat3x3f {
1002 let a = m[0].x; let b = m[1].x; let c = m[2].x;
1003 let d = m[0].y; let e = m[1].y; let f = m[2].y;
1004 let g = m[0].z; let h = m[1].z; let i = m[2].z;
1005 let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
1007 return mat3x3f(
1009 vec3f((e * i - f * h) / det, (f * g - d * i) / det, (d * h - e * g) / det),
1011 vec3f((c * h - b * i) / det, (a * i - c * g) / det, (b * g - a * h) / det),
1013 vec3f((b * f - c * e) / det, (c * d - a * f) / det, (a * e - b * d) / det),
1014 );
1015}
1016@vertex
1018fn vs_main(in: VSIn) -> VSOut {
1019 var out: VSOut;
1020 out.pos = ubo.mvp * vec4f(in.pos, 1.0);
1021 // WebGPU NDC is Y-up; mirror the Vulkan-convention clip Y.
1022 out.pos.y = -out.pos.y;
1023 let world = ubo.model * vec4f(in.pos, 1.0);
1024 out.vWorldPos = world.xyz;
1025 out.vViewPos = (ubo.view * world).xyz;
1026 let nrm = transpose(inverse3x3(mat3x3f(ubo.model[0].xyz, ubo.model[1].xyz, ubo.model[2].xyz))) * in.normal;
1027 out.vNormal = normalize(nrm);
1028 out.vUV = in.uv;
1029 out.vTint = ubo.tint * in.color;
1030 out.vCameraPos = ubo.cameraPos.xyz;
1031 return out;
1032}
1033)wgsl";
1034
1035inline const char *kMesh3DClusteredFragWgsl = R"wgsl(
1037struct Light3D {
1038 posRadius: vec4f,
1039 color: vec4f,
1040 spot: vec4f,
1041};
1043struct Frame {
1044 mvp: mat4x4f,
1045 model: mat4x4f,
1046 view: mat4x4f,
1047 lightDir: vec4f,
1048 lightColor: vec4f,
1049 tint: vec4f,
1050 cameraPos: vec4f,
1051 ambient: vec4f,
1052 gridInfo: vec4f,
1053 clipInfo: vec4f,
1054 texBomb: vec4f,
1055 parallax: vec4f,
1056 surface: vec4f,
1057 virtualTexture: vec4f,
1058 virtualAtlas: vec4f,
1059};
1061struct ShadowFrame {
1062 lightVP: array<mat4x4f, 3>,
1063 splits: vec4f,
1064 bias: vec4f,
1065 cascadeBias: vec4f,
1066 cascadeTexel: vec4f,
1067 localVP: array<mat4x4f, 4>,
1068 localSlot01: vec4f,
1069 localSlot23: vec4f,
1070 localBias: vec4f,
1071 localMeta: vec4f,
1072};
1074struct FSIn {
1075 @location(0) vNormal: vec3f,
1076 @location(1) vUV: vec2f,
1077 @location(2) vTint: vec4f,
1078 @location(3) vWorldPos: vec3f,
1079 @location(4) vCameraPos: vec3f,
1080 @location(5) vViewPos: vec3f,
1081 @builtin(position) fragCoord: vec4f,
1082};
1083@group(0) @binding(0) var<uniform> ubo: Frame;
1084@group(0) @binding(1) var albedoSampler: texture_2d<f32>;
1085@group(0) @binding(2) var normalSampler: texture_2d<f32>;
1086@group(0) @binding(3) var envSampler: texture_cube<f32>;
1087@group(0) @binding(4) var<uniform> shadow: ShadowFrame;
1088@group(0) @binding(5) var shadowMap: texture_depth_2d_array;
1089@group(0) @binding(6) var heightSampler: texture_2d<f32>;
1090@group(0) @binding(7) var mainSamp: sampler;
1091@group(0) @binding(8) var shadowSamp: sampler_comparison;
1092@group(0) @binding(9) var sceneDepth: texture_depth_2d;
1093@group(0) @binding(10) var<storage, read> lights: array<Light3D>;
1094@group(0) @binding(11) var<storage, read> clusterTable: array<vec2u>;
1095@group(0) @binding(12) var<storage, read> lightIndices: array<u32>;
1096@group(0) @binding(13) var aoTex: texture_2d<f32>;
1097@group(0) @binding(14) var aoSamp: sampler;
1098@group(0) @binding(15) var decalAlbedoLayer: texture_2d<f32>;
1099@group(0) @binding(16) var decalNormalLayer: texture_2d<f32>;
1100@group(0) @binding(17) var decalParamsLayer: texture_2d<f32>;
1101
1103fn sampleClusteredVirtualTexture(atlas: texture_2d<f32>, sourceUv: vec2f,
1104 uvDx: vec2f, uvDy: vec2f) -> vec4f {
1105 let uv = fract(sourceUv);
1106 let pageCounts = max(ubo.virtualTexture.yz, vec2f(1.0));
1107 let slots = max(ubo.virtualAtlas.xy, vec2f(1.0));
1108 let virtualCoord = uv * pageCounts;
1109 let pageLimit = vec2i(textureDimensions(heightSampler)) - vec2i(1);
1110 let page = clamp(vec2i(floor(virtualCoord)), vec2i(0), pageLimit);
1111 let entry = textureLoad(heightSampler, page, 0);
1112 var payloadUv = uv;
1113 var derivativePages = vec2f(1.0);
1114 if (entry.b > 0.5) {
1115 payloadUv = fract(virtualCoord);
1116 derivativePages = pageCounts;
1117 }
1118 let slot = floor(entry.rg * slots);
1119 let gutter = clamp(ubo.virtualTexture.w, 0.0, 0.499);
1120 let physicalUv = (slot + mix(vec2f(gutter), vec2f(1.0 - gutter), payloadUv)) / slots;
1121 let derivativeScale = derivativePages * (1.0 - 2.0 * gutter) / slots;
1123 return textureSampleGrad(atlas, mainSamp, physicalUv,
1124 uvDx * derivativeScale, uvDy * derivativeScale);
1125}
1126
1127const PI: f32 = 3.14159265359;
1128
1130fn distGGX(n: vec3f, h: vec3f, rough: f32) -> f32 {
1131 let a = max(rough * rough, 0.002);
1132 let a2 = a * a;
1133 let ndh = max(dot(n, h), 0.0);
1134 let denom = (ndh * ndh * (a2 - 1.0) + 1.0);
1135 return a2 / max(PI * denom * denom, 1e-4);
1136}
1138fn geomSchlick(ndv: f32, rough: f32) -> f32 {
1139 let r = rough + 1.0;
1140 let k = (r * r) / 8.0;
1141 return ndv / max(ndv * (1.0 - k) + k, 1e-4);
1142}
1144fn geomSmith(n: vec3f, v: vec3f, l: vec3f, rough: f32) -> f32 {
1146 return geomSchlick(max(dot(n, v), 0.0), rough) * geomSchlick(max(dot(n, l), 0.0), rough);
1147}
1149fn fresnelSchlick(cosT: f32, f0: vec3f) -> vec3f {
1150 return f0 + (1.0 - f0) * pow(clamp(1.0 - cosT, 0.0, 1.0), 5.0);
1151}
1153fn shadeLight(n: vec3f, v: vec3f, albedo: vec3f, metallic: f32, rough: f32, l: vec3f, rad: vec3f) -> vec3f {
1154 let ndl = max(dot(n, l), 0.0);
1155 let diffuse = mix(ndl, ndl * 0.5 + 0.5, 0.25);
1156 let h = normalize(v + l);
1157 let f0 = mix(vec3f(0.04), albedo, metallic);
1158 let ndf = distGGX(n, h, rough);
1159 let g = geomSmith(n, v, l, rough);
1160 let f = fresnelSchlick(max(dot(h, v), 0.0), f0);
1161 let spec = (ndf * g * f) / max(4.0 * max(dot(n, v), 0.0) * max(ndl, 0.001), 1e-3);
1162 let kd = (vec3f(1.0) - f) * (1.0 - metallic);
1163 return (kd * albedo * diffuse + spec * ndl) * rad;
1164}
1166fn sampleShadowCascade(worldPos: vec3f, cascade: i32, bias: f32) -> f32 {
1167 let lightClip = shadow.lightVP[cascade] * vec4f(worldPos, 1.0);
1168 let ndc = lightClip.xyz / max(lightClip.w, 1e-6);
1169 // Shadow-map vertices mirror clip Y for WebGPU. Undo that mirror when
1170 // projecting the shared Vulkan-convention light matrix for sampling.
1171 let uv = vec2f(ndc.x, -ndc.y) * 0.5 + 0.5;
1172 let depth = ndc.z;
1173 if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 || depth < 0.0 || depth > 1.0) {
1174 return 1.0;
1175 }
1176 let texel = 1.0 / vec2f(textureDimensions(shadowMap));
1177 var sum: f32 = 0.0;
1178 let zref = depth - bias;
1179 for (var i = 0; i < 9; i = i + 1) {
1180 var s: vec2f;
1181 if (i == 0) { s = uv + vec2f(-0.5, -0.5) * texel; }
1182 else if (i == 1) { s = uv + vec2f(0.0, -0.6) * texel; }
1183 else if (i == 2) { s = uv + vec2f(0.5, -0.5) * texel; }
1184 else if (i == 3) { s = uv + vec2f(-0.6, 0.0) * texel; }
1185 else if (i == 4) { s = uv; }
1186 else if (i == 5) { s = uv + vec2f(0.6, 0.0) * texel; }
1187 else if (i == 6) { s = uv + vec2f(-0.5, 0.5) * texel; }
1188 else if (i == 7) { s = uv + vec2f(0.0, 0.6) * texel; }
1189 else { s = uv + vec2f(0.5, 0.5) * texel; }
1190 if (s.x >= 0.0 && s.x <= 1.0 && s.y >= 0.0 && s.y <= 1.0) {
1191 sum += textureSampleCompareLevel(shadowMap, shadowSamp, s, cascade, zref);
1192 }
1193 }
1194 return sum / 9.0;
1195}
1197fn cascadeNdcBias(cascade: i32) -> f32 {
1198 var bias: f32;
1199 if (cascade == 0) { bias = shadow.cascadeBias.x; }
1200 else if (cascade == 1) { bias = shadow.cascadeBias.y; }
1201 else { bias = shadow.cascadeBias.z; }
1203 return select(bias, shadow.bias.x, bias < 1e-8);
1204}
1206fn slopeScaledBias(cascade: i32, ndl: f32) -> f32 {
1208 return cascadeNdcBias(cascade) * mix(0.75, 1.0, clamp(ndl, 0.0, 1.0));
1209}
1211fn sampleShadowPCF(worldPos: vec3f, n: vec3f, viewDepth: f32, ndl: f32) -> f32 {
1212 if (shadow.bias.y < 0.5 || shadow.bias.z < 0.5 || shadow.splits.w < 1e-4) {
1213 return 1.0;
1214 }
1215 var cascade: i32 = 2;
1216 if (viewDepth < shadow.splits.x) { cascade = 0; }
1217 else if (viewDepth < shadow.splits.y) { cascade = 1; }
1218 var tw: f32;
1219 if (cascade == 0) { tw = shadow.cascadeTexel.x; }
1220 else if (cascade == 1) { tw = shadow.cascadeTexel.y; }
1221 else { tw = shadow.cascadeTexel.z; }
1222 let p = worldPos + n * ((2.0 * max(tw, 1e-6)) / max(ndl, 0.2));
1223 var vis = sampleShadowCascade(p, cascade, slopeScaledBias(cascade, ndl));
1224
1225 var hi: f32;
1226 var lo: f32;
1227 if (cascade == 0) {
1228 hi = shadow.splits.x;
1229 lo = 0.0;
1230 } else if (cascade == 1) {
1231 hi = shadow.splits.y;
1232 lo = shadow.splits.x;
1233 } else {
1234 hi = shadow.splits.z;
1235 lo = shadow.splits.y;
1236 }
1237 let band = max(0.5, (hi - lo) * 0.1);
1238 let toPrev = 1.0 - clamp((viewDepth - lo) / band, 0.0, 1.0);
1239 let toNext = 1.0 - clamp((hi - viewDepth) / band, 0.0, 1.0);
1240 if (toPrev > 0.0 && cascade > 0) {
1241 let previous = sampleShadowCascade(p, cascade - 1, slopeScaledBias(cascade - 1, ndl));
1242 vis = mix(vis, previous, toPrev);
1243 }
1244 if (toNext > 0.0 && cascade < 2) {
1245 let next = sampleShadowCascade(p, cascade + 1, slopeScaledBias(cascade + 1, ndl));
1246 vis = mix(vis, next, toNext);
1247 }
1248 vis = mix(1.0, vis, clamp(shadow.splits.w, 0.0, 1.0));
1250 return mix(0.04, 1.0, vis);
1251}
1253fn clusterIndex(frag: vec2f, viewDepth: f32) -> u32 {
1254 let tilesX = i32(ubo.gridInfo.x + 0.5);
1255 let tilesY = i32(ubo.gridInfo.y + 0.5);
1256 let slices = i32(ubo.gridInfo.z + 0.5);
1257 let nearZ = max(ubo.clipInfo.x, 1e-3);
1258 let farZ = max(ubo.clipInfo.y, nearZ + 1e-3);
1259 let screenW = max(ubo.clipInfo.z, 1.0);
1260 let screenH = max(ubo.clipInfo.w, 1.0);
1261 let tx = clamp(i32(floor(frag.x / screenW * f32(tilesX))), 0, tilesX - 1);
1262 let ty = clamp(i32(floor(frag.y / screenH * f32(tilesY))), 0, tilesY - 1);
1263 let depth = max(viewDepth, nearZ);
1264 let sz = clamp(i32(floor((depth - nearZ) / (farZ - nearZ) * f32(slices))), 0, slices - 1);
1266 return u32((sz * tilesY + ty) * tilesX + tx);
1267}
1269fn applyClusteredNormalMap(nInput: vec3f, mapSample: vec3f, dp1: vec3f, dp2: vec3f,
1270 duv1: vec2f, duv2: vec2f) -> vec3f {
1271 let mapN = mapSample * 2.0 - 1.0;
1272 let n = normalize(nInput);
1273 let det = duv1.x * duv2.y - duv2.x * duv1.y;
1274 if (abs(det) < 1e-6) { return n; }
1275 let invDet = 1.0 / det;
1276 var tangent = (dp1 * duv2.y - dp2 * duv1.y) * invDet;
1277 var bitangent = (dp2 * duv1.x - dp1 * duv2.x) * invDet;
1278 tangent -= n * dot(n, tangent);
1279 if (length(tangent) < 1e-4 || length(bitangent) < 1e-4) { return n; }
1280 tangent = normalize(tangent);
1281 bitangent = normalize(bitangent - n * dot(n, bitangent) - tangent * dot(tangent, bitangent));
1283 return normalize(mat3x3f(tangent, bitangent, n) * mapN);
1284}
1285@fragment
1287fn fs_main(in: FSIn) -> @location(0) vec4f {
1288 let uvDx = dpdx(in.vUV);
1289 let uvDy = dpdy(in.vUV);
1290 var nGeom = normalize(in.vNormal);
1291 let v = normalize(in.vCameraPos - in.vWorldPos);
1292 if (dot(nGeom, v) < 0.0) { nGeom = -nGeom; }
1293 var base = textureSample(albedoSampler, mainSamp, in.vUV) * in.vTint;
1294 if (ubo.virtualTexture.x > 0.5) {
1295 base = sampleClusteredVirtualTexture(albedoSampler, in.vUV, uvDx, uvDy) * in.vTint;
1296 }
1297 if (ubo.surface.x > 0.5 && ubo.surface.x < 1.5 && base.a < ubo.surface.y) {
1298 discard;
1299 }
1300 // Screen-space primary; UV secondary so coverage still varies if fragment
1301 // position is degenerate (Dawn Metal CI once kept every pixel with fragCoord-only).
1302 let alphaHash = fract(dot(floor(in.fragCoord.xy), vec2f(0.06711056, 0.00583715)) +
1304 dot(in.vUV * 64.0, vec2f(0.7548777, 0.5698403)));
1305 if (ubo.surface.x > 2.5 && base.a < alphaHash) { discard; }
1306 var albedo = base.rgb;
1307 var metallic = clamp(ubo.ambient.w, 0.0, 1.0);
1308 var rough = clamp(ubo.cameraPos.w, 0.04, 1.0);
1309 var packedNormal = textureSample(normalSampler, mainSamp, in.vUV);
1310 if (ubo.virtualTexture.x > 0.5) {
1311 packedNormal = sampleClusteredVirtualTexture(normalSampler, in.vUV, uvDx, uvDy);
1312 }
1313 var nSmp = packedNormal.xyz;
1314 var terrainAo = 1.0;
1315 if (ubo.virtualAtlas.z > 0.5) {
1316 let normalXY = packedNormal.rg * 2.0 - 1.0;
1317 nSmp = vec3f(packedNormal.rg, sqrt(1.0 - clamp(dot(normalXY, normalXY), 0.0, 1.0)) * 0.5 + 0.5);
1318 terrainAo = clamp(packedNormal.b, 0.0, 1.0);
1319 rough = clamp(1.0 - packedNormal.a, 0.04, 1.0);
1320 }
1321 var n = nGeom;
1322 if (length(nSmp - vec3f(0.5, 0.5, 1.0)) > 0.04) {
1323 n = applyClusteredNormalMap(n, nSmp, dpdx(in.vWorldPos), dpdy(in.vWorldPos), uvDx, uvDy);
1324 }
1325 var emissive = vec3f(0.0);
1326 let decalPos = clamp(vec2<i32>(in.fragCoord.xy), vec2<i32>(0),
1327 vec2<i32>(textureDimensions(decalAlbedoLayer)) - vec2<i32>(1));
1328 let decalA = textureLoad(decalAlbedoLayer, decalPos, 0);
1329 let decalCoverage = clamp(decalA.a, 0.0, 1.0);
1330 if (decalCoverage > 0.001) {
1331 albedo = mix(albedo, decalA.rgb, decalCoverage);
1332 let decalN = textureLoad(decalNormalLayer, decalPos, 0);
1333 let normalWeight = clamp(decalN.a, 0.0, 1.0);
1334 if (normalWeight > 0.001) {
1335 n = normalize(mix(n, normalize(decalN.rgb * 2.0 - 1.0), normalWeight));
1336 }
1337 let decalP = textureLoad(decalParamsLayer, decalPos, 0);
1338 let paramsWeight = clamp(decalP.a, 0.0, 1.0);
1339 if (paramsWeight > 0.001) {
1340 rough = mix(rough, decalP.r, paramsWeight);
1341 metallic = mix(metallic, decalP.g, paramsWeight);
1342 emissive += decalA.rgb * decalP.b;
1343 }
1344 }
1345 var lo = vec3f(0.0);
1346 let viewDepth = max(-in.vViewPos.z, 0.0);
1347 let primaryL = normalize(ubo.lightDir.xyz);
1348 let shadowVis = sampleShadowPCF(in.vWorldPos, n, viewDepth, max(dot(n, primaryL), 0.0));
1349 if (ubo.lightDir.w > 0.5 && length(ubo.lightColor.rgb) > 1e-6) {
1350 lo += shadeLight(n, v, albedo, metallic, rough, primaryL, ubo.lightColor.rgb) * shadowVis;
1351 }
1352 let cid = clusterIndex(in.fragCoord.xy, viewDepth);
1353 let entry = clusterTable[cid];
1354 let count = min(entry.y, 32u);
1355 for (var i = 0u; i < count; i = i + 1u) {
1356 let li = lightIndices[entry.x + i];
1357 let lgt = lights[li];
1358 let toL = lgt.posRadius.xyz - in.vWorldPos;
1359 let dist = length(toL);
1360 let l = toL / max(dist, 1e-4);
1361 var atten = clamp(1.0 - dist / max(lgt.posRadius.w, 1e-3), 0.0, 1.0);
1362 atten = atten * atten;
1363 if (lgt.spot.w > 0.0) {
1364 let beamLen = length(lgt.spot.xyz);
1365 if (beamLen > 1e-6) {
1366 let cosTheta = dot(normalize(-l), lgt.spot.xyz / beamLen);
1367 atten = atten * clamp(cosTheta * lgt.spot.w + lgt.color.a, 0.0, 1.0);
1368 if (beamLen > 1.005 && shadow.bias.z > 0.5) {
1369 let si = i32(round((beamLen - 1.0) * 100.0)) - 1;
1370 if (si >= 0 && si <= 3) {
1371 let lc = shadow.localVP[si] * vec4f(in.vWorldPos, 1.0);
1372 let ndc = lc.xyz / max(lc.w, 1e-4);
1373 let uv = vec2f(ndc.x, -ndc.y) * 0.5 + 0.5;
1374 let zref = ndc.z - shadow.localBias[si];
1375 var vis = 1.0;
1376 if (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0 && lc.w > 0.0) {
1377 vis = textureSampleCompareLevel(shadowMap, shadowSamp, uv, 3 + si, zref);
1378 }
1379 atten = atten * mix(1.0 - shadow.splits.w, 1.0, vis);
1380 }
1381 }
1382 } else {
1383 atten = 0.0;
1384 }
1385 }
1386 lo += shadeLight(n, v, albedo, metallic, rough, l, lgt.color.rgb * atten);
1387 }
1388 let hemi = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
1389 let skyIrr = ubo.ambient.rgb * 1.1 + ubo.lightColor.rgb * 0.12;
1390 let gndIrr = ubo.ambient.rgb * vec3f(0.72, 0.62, 0.52);
1391 let irr = mix(gndIrr, skyIrr, hemi);
1392 var color = albedo * irr * (1.0 - metallic) * terrainAo + lo + emissive;
1393 let envIntensity = ubo.lightColor.w;
1394 if (envIntensity > 1e-4) {
1395 let r = reflect(-v, n);
1396 let envSpec = textureSampleLevel(envSampler, mainSamp, r, rough * 5.0).rgb * envIntensity;
1397 let f0 = mix(vec3f(0.04), albedo, metallic);
1398 let f = fresnelSchlick(max(dot(n, v), 0.0), f0);
1399 color += envSpec * f;
1400 let irr2 = textureSampleLevel(envSampler, mainSamp, n, 5.0).rgb * envIntensity;
1401 color += albedo * irr2 * (1.0 - metallic) * (1.0 - f) * 0.45 * terrainAo;
1402 }
1403 let aoUV = (in.fragCoord.xy * 0.5) / vec2f(textureDimensions(aoTex));
1404 let ao = textureSampleLevel(aoTex, aoSamp, aoUV, 0.0).r;
1405 color *= mix(1.0, ao, clamp(ubo.surface.z, 0.0, 1.0));
1406 let white = 0.85;
1407 let over = max(color - vec3f(white), vec3f(0.0));
1408 color = min(color, vec3f(white)) + vec3f(1.0 - white) * (over / (over + vec3f(1.0)));
1409 let nearZ = max(ubo.clipInfo.x, 1e-4);
1410 let farZ = max(ubo.clipInfo.y, nearZ + 1e-3);
1411 let viewZ = max(-in.vViewPos.z, 0.0);
1412 let linearDepth = clamp((viewZ - nearZ) / (farZ - nearZ), 0.0, 1.0);
1414 return vec4f(color, linearDepth);
1415}
1416)wgsl";
1417
1418// ---- Mesh3D depth-only shadow --------------------------------------------------
1420inline const char* kMesh3DShadowVertWgsl = R"wgsl(
1421@group(0) @binding(3) var<storage, read> skinBones: array<mat4x4f>;
1422
1424struct VSIn {
1425 @location(0) pos: vec3f,
1426 @location(1) normal: vec3f,
1427 @location(2) uv: vec2f,
1428 @location(3) joints: vec4u,
1429 @location(4) weights: vec4f,
1430};
1432struct Push {
1433 mvp: mat4x4f,
1434 model: mat4x4f,
1435 clip: vec4f,
1436 skinInfo: vec4f,
1437};
1438@group(0) @binding(0) var<uniform> pc: Push;
1439@vertex
1441fn vs_main(in: VSIn) -> @builtin(position) vec4f {
1442 var localPos = vec4f(in.pos, 1.0);
1443 if (pc.skinInfo.x > 0.5) {
1444 var weights = in.weights;
1445 if (pc.skinInfo.y < 3.5) { weights.z = 0.0; weights.w = 0.0; }
1446 if (pc.skinInfo.y < 1.5) { weights.y = 0.0; }
1447 let weightSum = dot(weights, vec4f(1.0));
1448 if (weightSum > 1e-8) { weights /= weightSum; }
1449 let skin = weights.x * skinBones[in.joints.x]
1450 + weights.y * skinBones[in.joints.y]
1451 + weights.z * skinBones[in.joints.z]
1452 + weights.w * skinBones[in.joints.w];
1453 localPos = skin * localPos;
1454 }
1455 let clipPos = pc.mvp * localPos;
1456 // WebGPU NDC is Y-up; mirror the Vulkan-convention clip Y.
1458 return vec4f(clipPos.x, -clipPos.y, clipPos.z, clipPos.w);
1459}
1460)wgsl";
1461
1463inline const char* kMesh3DShadowAlphaVertWgsl = R"wgsl(
1464@group(0) @binding(3) var<storage, read> skinBones: array<mat4x4f>;
1465
1467struct VSIn {
1468 @location(0) pos: vec3f,
1469 @location(1) normal: vec3f,
1470 @location(2) uv: vec2f,
1471 @location(3) joints: vec4u,
1472 @location(4) weights: vec4f,
1473};
1475struct Push {
1476 mvp: mat4x4f,
1477 model: mat4x4f,
1478 clip: vec4f,
1479 skinInfo: vec4f,
1480};
1482struct VSOut {
1483 @builtin(position) pos: vec4f,
1484 @location(0) uv: vec2f,
1485};
1486@group(0) @binding(0) var<uniform> pc: Push;
1487@vertex
1489fn vs_main(in: VSIn) -> VSOut {
1490 var out: VSOut;
1491 var localPos = vec4f(in.pos, 1.0);
1492 if (pc.skinInfo.x > 0.5) {
1493 var weights = in.weights;
1494 if (pc.skinInfo.y < 3.5) { weights.z = 0.0; weights.w = 0.0; }
1495 if (pc.skinInfo.y < 1.5) { weights.y = 0.0; }
1496 let weightSum = dot(weights, vec4f(1.0));
1497 if (weightSum > 1e-8) { weights /= weightSum; }
1498 let skin = weights.x * skinBones[in.joints.x]
1499 + weights.y * skinBones[in.joints.y]
1500 + weights.z * skinBones[in.joints.z]
1501 + weights.w * skinBones[in.joints.w];
1502 localPos = skin * localPos;
1503 }
1504 let clipPos = pc.mvp * localPos;
1505 out.pos = vec4f(clipPos.x, -clipPos.y, clipPos.z, clipPos.w);
1506 out.uv = in.uv;
1507 return out;
1508}
1509)wgsl";
1510
1511inline const char *kMesh3DShadowAlphaFragWgsl = R"wgsl(
1513struct Push {
1514 mvp: mat4x4f,
1515 model: mat4x4f,
1516 lodFade: vec4f,
1517 skinInfo: vec4f,
1518};
1519@group(0) @binding(0) var<uniform> pc: Push;
1520@group(0) @binding(1) var albedoTexture: texture_2d<f32>;
1521@group(0) @binding(2) var albedoSampler: sampler;
1522@fragment
1524fn fs_main(@location(0) uv: vec2f, @builtin(position) fragCoord: vec4f) {
1525 if (textureSample(albedoTexture, albedoSampler, uv).a < 0.05) { discard; }
1526 if (pc.lodFade.z > 0.5) {
1527 let h = fract(dot(floor(fragCoord.xy), vec2f(0.06711056, 0.00583715)));
1528 let rejected = select((h < 1.0 - pc.lodFade.x), (h >= pc.lodFade.x),
1529 pc.lodFade.y < 0.5);
1530 if (rejected) { discard; }
1531 }
1532}
1533)wgsl";
1534
1535// ---- GBuffer pass (normal / linear-depth / albedo) -------------------------
1537inline const char* kMesh3DGbufferVertWgsl = R"wgsl(
1538@group(0) @binding(3) var<storage, read> skinBones: array<mat4x4f>;
1539
1541struct VSIn {
1542 @location(0) pos: vec3f,
1543 @location(1) normal: vec3f,
1544 @location(2) uv: vec2f,
1545 @location(3) joints: vec4u,
1546 @location(4) weights: vec4f,
1547};
1549struct Push {
1550 mvp: mat4x4f,
1551 model: mat4x4f,
1552 clip: vec4f,
1553 skinInfo: vec4f,
1554};
1556struct VSOut {
1557 @builtin(position) pos: vec4f,
1558 @location(0) vNormal: vec3f,
1559 @location(1) vNdcZ: f32,
1560 @location(2) vUV: vec2f,
1561};
1562@group(0) @binding(0) var<uniform> pc: Push;
1564fn inverseGbufferModel(m: mat3x3f) -> mat3x3f {
1565 let a = m[0].x; let b = m[1].x; let c = m[2].x;
1566 let d = m[0].y; let e = m[1].y; let f = m[2].y;
1567 let g = m[0].z; let h = m[1].z; let i = m[2].z;
1568 let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
1570 return mat3x3f(
1572 vec3f((e * i - f * h) / det, (f * g - d * i) / det, (d * h - e * g) / det),
1574 vec3f((c * h - b * i) / det, (a * i - c * g) / det, (b * g - a * h) / det),
1576 vec3f((b * f - c * e) / det, (c * d - a * f) / det, (a * e - b * d) / det));
1577}
1578@vertex
1580fn vs_main(in: VSIn) -> VSOut {
1581 var out: VSOut;
1582 var localPos = vec4f(in.pos, 1.0);
1583 var localNormal = in.normal;
1584 if (pc.skinInfo.x > 0.5) {
1585 var weights = in.weights;
1586 if (pc.skinInfo.y < 3.5) { weights.z = 0.0; weights.w = 0.0; }
1587 if (pc.skinInfo.y < 1.5) { weights.y = 0.0; }
1588 let weightSum = dot(weights, vec4f(1.0));
1589 if (weightSum > 1e-8) { weights /= weightSum; }
1590 let skin = weights.x * skinBones[in.joints.x]
1591 + weights.y * skinBones[in.joints.y]
1592 + weights.z * skinBones[in.joints.z]
1593 + weights.w * skinBones[in.joints.w];
1594 localPos = skin * localPos;
1595 localNormal = mat3x3f(skin[0].xyz, skin[1].xyz, skin[2].xyz) * localNormal;
1596 }
1597 out.pos = pc.mvp * localPos;
1598 // WebGPU NDC is Y-up; mirror the Vulkan-convention clip Y.
1599 out.pos.y = -out.pos.y;
1600 let model3 = mat3x3f(pc.model[0].xyz, pc.model[1].xyz, pc.model[2].xyz);
1601 out.vNormal = normalize(transpose(inverseGbufferModel(model3)) * localNormal);
1602 out.vNdcZ = out.pos.z / max(out.pos.w, 1e-6);
1603 out.vUV = in.uv;
1604 return out;
1605}
1606)wgsl";
1607
1609inline const char* kMesh3DGbufferFragWgsl = R"wgsl(
1611struct FSIn {
1612 @builtin(position) position: vec4f,
1613 @location(0) vNormal: vec3f,
1614 @location(1) vNdcZ: f32,
1615 @location(2) vUV: vec2f,
1616};
1618struct Push {
1619 mvp: mat4x4f,
1620 model: mat4x4f,
1621 clip: vec4f,
1622 skinInfo: vec4f,
1623};
1625struct GBufOut {
1626 @location(0) normal: vec4f,
1627 @location(1) depthColor: vec4f,
1628 @location(2) albedo: vec4f,
1629 @location(3) pbrParams: vec4f,
1630 @location(4) emissive: vec4f,
1631};
1632@group(0) @binding(0) var<uniform> pc: Push;
1633@group(0) @binding(1) var albedoSampler: texture_2d<f32>;
1634@group(0) @binding(2) var mainSamp: sampler;
1635@fragment
1637fn fs_main(in: FSIn) -> GBufOut {
1638 var out: GBufOut;
1639 let nearZ = max(pc.clip.x, 1e-4);
1640 let farZ = max(pc.clip.y, nearZ + 1e-3);
1641 // NDC z -> linear [0,1] over the clip range (matches scene color A).
1642 let ndc = clamp(in.position.z, 0.0, 1.0);
1643 let eyeZ = nearZ * farZ / max(farZ - ndc * (farZ - nearZ), 1e-6);
1644 let linear = clamp((eyeZ - nearZ) / (farZ - nearZ), 0.0, 1.0);
1645 let packedMotion = u32(pc.clip.w + 0.5);
1646 let motion = (vec2f(f32(packedMotion & 4095u), f32((packedMotion >> 12u) & 4095u)) - 2047.0) / 2047.0;
1647 let packedTint = bitcast<u32>(pc.clip.z);
1648 // Phase B packing: tint RGB6 | rough7 | metal7 (matches Vulkan drawMeshGBuffer).
1649 let roughness = f32((packedTint >> 18u) & 127u) / 127.0;
1650 let metallic = f32((packedTint >> 25u) & 127u) / 127.0;
1651 let pbrLegacy = (u32(roughness * 7.0 + 0.5) & 7u) | ((u32(metallic * 7.0 + 0.5) & 7u) << 3u);
1652 out.normal = vec4f(in.vNormal * 0.5 + 0.5, f32(pbrLegacy) / 255.0);
1653 out.depthColor = vec4f(linear, clamp(motion * 0.5 + 0.5, vec2f(0.0), vec2f(1.0)), 1.0);
1654 let tint = vec3f(f32(packedTint & 63u), f32((packedTint >> 6u) & 63u),
1656 f32((packedTint >> 12u) & 63u)) / 63.0;
1657 out.albedo = vec4f(textureSample(albedoSampler, mainSamp, in.vUV).rgb * tint, linear);
1658 out.pbrParams = vec4f(metallic, roughness, 1.0, 1.0);
1659 out.emissive = vec4f(0.0);
1660 return out;
1661}
1662)wgsl";
1663
1665inline const char* kMesh3DGbufferAlphaFragWgsl = R"wgsl(
1667struct FSIn {
1668 @builtin(position) position: vec4f,
1669 @location(0) vNormal: vec3f,
1670 @location(1) vNdcZ: f32,
1671 @location(2) vUV: vec2f,
1672};
1674struct Push {
1675 mvp: mat4x4f,
1676 model: mat4x4f,
1677 clip: vec4f,
1678 skinInfo: vec4f,
1679};
1681struct GBufOut {
1682 @location(0) normal: vec4f,
1683 @location(1) depthColor: vec4f,
1684 @location(2) albedo: vec4f,
1685 @location(3) pbrParams: vec4f,
1686 @location(4) emissive: vec4f,
1687};
1688@group(0) @binding(0) var<uniform> pc: Push;
1689@group(0) @binding(1) var albedoTexture: texture_2d<f32>;
1690@group(0) @binding(2) var albedoSampler: sampler;
1691@fragment
1693fn fs_main(in: FSIn) -> GBufOut {
1694 let sampled = textureSample(albedoTexture, albedoSampler, in.vUV);
1695 if (sampled.a < 0.05) { discard; }
1696 var out: GBufOut;
1697 let nearZ = max(pc.clip.x, 1e-4);
1698 let farZ = max(pc.clip.y, nearZ + 1e-3);
1699 let ndc = clamp(in.position.z, 0.0, 1.0);
1700 let eyeZ = nearZ * farZ / max(farZ - ndc * (farZ - nearZ), 1e-6);
1701 let linear = clamp((eyeZ - nearZ) / (farZ - nearZ), 0.0, 1.0);
1702 let packedMotion = u32(pc.clip.w + 0.5);
1703 let motion = (vec2f(f32(packedMotion & 4095u), f32((packedMotion >> 12u) & 4095u)) - 2047.0) / 2047.0;
1704 let packedTint = bitcast<u32>(pc.clip.z);
1705 let roughness = f32((packedTint >> 18u) & 127u) / 127.0;
1706 let metallic = f32((packedTint >> 25u) & 127u) / 127.0;
1707 let pbrLegacy = (u32(roughness * 7.0 + 0.5) & 7u) | ((u32(metallic * 7.0 + 0.5) & 7u) << 3u);
1708 out.normal = vec4f(in.vNormal * 0.5 + 0.5, f32(pbrLegacy) / 255.0);
1709 out.depthColor = vec4f(linear, clamp(motion * 0.5 + 0.5, vec2f(0.0), vec2f(1.0)), 1.0);
1710 let tint = vec3f(f32(packedTint & 63u), f32((packedTint >> 6u) & 63u),
1712 f32((packedTint >> 12u) & 63u)) / 63.0;
1713 out.albedo = vec4f(sampled.rgb * tint, linear);
1714 out.pbrParams = vec4f(metallic, roughness, 1.0, 1.0);
1715 out.emissive = vec4f(0.0);
1716 return out;
1717}
1718)wgsl";
1719
1720// ---- Screen-space decal layer ---------------------------------------------
1721inline const char *kDecalVertWgsl = R"wgsl(
1722@vertex
1724fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> @builtin(position) vec4f {
1725 let positions = array<vec2f, 3>(
1727 vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
1729 return vec4f(positions[vertexIndex], 0.0, 1.0);
1730}
1731)wgsl";
1732
1733inline const char *kDecalFragWgsl = R"wgsl(
1735struct DecalUniforms {
1736 invViewProj: mat4x4f,
1737 invModel: mat4x4f,
1738 modelR0: vec4f,
1739 modelR1: vec4f,
1740 modelR2: vec4f,
1741 uvRect: vec4f,
1742 fadeParams: vec4f,
1743 extraParams: vec4f,
1744 surfaceParams: vec4f,
1745 texel: vec4f,
1746};
1748struct DecalOut {
1749 @location(0) albedo: vec4f,
1750 @location(1) normal: vec4f,
1751 @location(2) params: vec4f,
1752};
1753@group(0) @binding(0) var<uniform> u: DecalUniforms;
1754@group(0) @binding(1) var decalAlbedo: texture_2d<f32>;
1755@group(0) @binding(2) var decalNormal: texture_2d<f32>;
1756@group(0) @binding(3) var decalParams: texture_2d<f32>;
1757@group(0) @binding(4) var hwDepth: texture_depth_2d;
1758@group(0) @binding(5) var gbNormal: texture_2d<f32>;
1759@group(0) @binding(6) var mainSamp: sampler;
1760
1762fn sampleAtlas(tex: texture_2d<f32>, localUV: vec2f) -> vec4f {
1763 let atl = u.uvRect.xy + clamp(localUV, vec2f(0.0), vec2f(1.0)) * u.uvRect.zw;
1765 return textureSample(tex, mainSamp, atl);
1766}
1767
1768fn projectionUV(localUV: vec2f, wrapMode: i32) -> vec2f {
1769 if (wrapMode == 2) { return fract(localUV); }
1770 if (wrapMode == 1) { return vec2f(fract(localUV.x), clamp(localUV.y, 0.0, 1.0)); }
1771 return clamp(localUV, vec2f(0.0), vec2f(1.0));
1772}
1773
1774fn sampleHeight(localUV: vec2f, wrapMode: i32) -> f32 {
1775 let atl = u.uvRect.xy + projectionUV(localUV, wrapMode) * u.uvRect.zw;
1776 return textureSampleLevel(decalParams, mainSamp, atl, 0.0).a;
1777}
1778
1779fn parallaxUV(baseUV: vec2f, viewTS: vec3f, wrapMode: i32) -> vec2f {
1780 let scale = u.surfaceParams.x;
1781 if (scale <= 0.0) { return baseUV; }
1782 let minLayers = clamp(u.surfaceParams.y, 1.0, 64.0);
1783 let maxLayers = clamp(u.surfaceParams.z, minLayers, 64.0);
1784 let layers = mix(maxLayers, minLayers, clamp(abs(viewTS.z), 0.0, 1.0));
1785 let layerDepth = 1.0 / layers;
1786 let delta = (viewTS.xy / max(abs(viewTS.z), 0.08)) * scale / layers;
1787 var currentUV = baseUV;
1788 var currentDepth = 0.0;
1789 for (var index = 0; index < 64; index = index + 1) {
1790 let surfaceDepth = 1.0 - sampleHeight(currentUV, wrapMode);
1791 if (currentDepth >= surfaceDepth || f32(index) >= layers) { break; }
1792 currentUV = currentUV - delta;
1793 currentDepth = currentDepth + layerDepth;
1794 }
1795 return currentUV;
1796}
1797
1798fn worldNormalFromLocalBasis(packed: vec4f, tangentLocal: vec3f, bitangentLocal: vec3f,
1799 normalLocal: vec3f) -> vec3f {
1800 let model3 = mat3x3f(u.modelR0.xyz, u.modelR1.xyz, u.modelR2.xyz);
1801 let normalWorld = normalize(model3 * normalLocal);
1802 var tangentWorld = normalize(model3 * tangentLocal);
1803 tangentWorld = normalize(tangentWorld - normalWorld * dot(normalWorld, tangentWorld));
1804 var bitangentWorld = normalize(model3 * bitangentLocal);
1805 if (dot(cross(tangentWorld, bitangentWorld), normalWorld) < 0.0) {
1806 bitangentWorld = -bitangentWorld;
1807 }
1808 let tangentNormal = packed.xyz * 2.0 - 1.0;
1809 return normalize(tangentWorld * tangentNormal.x + bitangentWorld * tangentNormal.y +
1810 normalWorld * tangentNormal.z);
1811}
1812
1813fn worldNormalFromWorldBasis(packed: vec4f, tangentWorld: vec3f, bitangentWorld: vec3f,
1814 normalWorld: vec3f) -> vec3f {
1815 let tangentNormal = packed.xyz * 2.0 - 1.0;
1816 return normalize(tangentWorld * tangentNormal.x + bitangentWorld * tangentNormal.y +
1817 normalWorld * tangentNormal.z);
1818}
1819
1820fn edgeMask2(coordinates: vec2f) -> f32 {
1821 let width = clamp(u.surfaceParams.w, 0.0, 0.49);
1822 if (width <= 0.0) { return 1.0; }
1823 let edge = smoothstep(vec2f(0.0), vec2f(width), coordinates) *
1824 smoothstep(vec2f(1.0), vec2f(1.0 - width), coordinates);
1825 return edge.x * edge.y;
1826}
1827
1828fn edgeMask3(coordinates: vec3f) -> f32 {
1829 let width = clamp(u.surfaceParams.w, 0.0, 0.49);
1830 if (width <= 0.0) { return 1.0; }
1831 let edge = smoothstep(vec3f(0.0), vec3f(width), coordinates) *
1832 smoothstep(vec3f(1.0), vec3f(1.0 - width), coordinates);
1833 return edge.x * edge.y * edge.z;
1834}
1835
1836@fragment
1838fn fs_main(@builtin(position) pos: vec4f) -> DecalOut {
1839 let uv = pos.xy * u.texel.xy;
1840 let texelPos = vec2<i32>(pos.xy);
1841 let ndcZ = textureLoad(hwDepth, texelPos, 0);
1842 if (ndcZ <= 0.0 || ndcZ >= 1.0) { discard; }
1843
1844 let clip = vec4f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, ndcZ, 1.0);
1845 let wp = u.invViewProj * clip;
1846 let worldPos = wp.xyz / max(abs(wp.w), 1e-6);
1847 let lp = u.invModel * vec4f(worldPos, 1.0);
1848 let local = lp.xyz / max(abs(lp.w), 1e-6);
1849 if (any(abs(local) > vec3f(0.5))) { discard; }
1850
1851 let surfaceN = textureLoad(gbNormal, texelPos, 0).xyz * 2.0 - 1.0;
1852 let decalFwd = normalize(mat3x3f(u.modelR0.xyz, u.modelR1.xyz, u.modelR2.xyz) *
1854 vec3f(0.0, 0.0, 1.0));
1855 let projectionMode = i32(u.extraParams.z + 0.5);
1856 let useTriplanar = projectionMode == 1;
1857 let useSpherical = projectionMode == 2;
1858 let useWorld = projectionMode == 3;
1859 let facing = dot(surfaceN, decalFwd);
1860 if (useSpherical || useTriplanar || useWorld) {
1861 if (facing < -0.05) { discard; }
1862 } else if (facing < 0.1) {
1863 discard;
1864 }
1865
1866 var alb: vec4f;
1867 var nrm: vec4f;
1868 var prm: vec4f;
1869 var edgeFade: f32;
1870 let nearClip = vec4f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
1871 let nearWorld = u.invViewProj * nearClip;
1872 let nearPos = nearWorld.xyz / max(abs(nearWorld.w), 1e-6);
1873 let invR = mat3x3f(u.invModel[0].xyz, u.invModel[1].xyz, u.invModel[2].xyz);
1874 let viewWorld = normalize(nearPos - worldPos);
1875 let viewLocal = normalize(invR * viewWorld);
1876
1877 if (!useTriplanar && !useSpherical && !useWorld) {
1878 let decalUV = parallaxUV(local.xy + 0.5, viewLocal, 0);
1879 if (any(decalUV < vec2f(0.0)) || any(decalUV > vec2f(1.0))) { discard; }
1880 alb = sampleAtlas(decalAlbedo, decalUV);
1881 let sampledNormal = sampleAtlas(decalNormal, decalUV);
1882 let normalWorld = worldNormalFromLocalBasis(sampledNormal, vec3f(1.0, 0.0, 0.0),
1883 vec3f(0.0, 1.0, 0.0), vec3f(0.0, 0.0, 1.0));
1884 nrm = vec4f(normalWorld * 0.5 + 0.5, sampledNormal.a);
1885 prm = sampleAtlas(decalParams, decalUV);
1886 edgeFade = edgeMask2(decalUV);
1887 } else if (useTriplanar) {
1888 let nLocal = normalize(invR * surfaceN);
1889 let sharpness = max(u.extraParams.w, 1.0);
1890 var w = pow(abs(nLocal), vec3f(sharpness));
1891 w = w / max(w.x + w.y + w.z, 1e-5);
1892
1893 let uvYZ = parallaxUV(local.yz + 0.5, vec3f(viewLocal.yz, viewLocal.x), 0);
1894 let uvXZ = parallaxUV(local.xz + 0.5, vec3f(viewLocal.xz, viewLocal.y), 0);
1895 let uvXY = parallaxUV(local.xy + 0.5, viewLocal, 0);
1896 alb = sampleAtlas(decalAlbedo, uvYZ) * w.x +
1898 sampleAtlas(decalAlbedo, uvXZ) * w.y +
1900 sampleAtlas(decalAlbedo, uvXY) * w.z;
1901 let nrmX = sampleAtlas(decalNormal, uvYZ);
1902 let nrmY = sampleAtlas(decalNormal, uvXZ);
1903 let nrmZ = sampleAtlas(decalNormal, uvXY);
1904 let sx = select(1.0, -1.0, nLocal.x < 0.0);
1905 let sy = select(1.0, -1.0, nLocal.y < 0.0);
1906 let sz = select(1.0, -1.0, nLocal.z < 0.0);
1907 let blendedNormal =
1908 worldNormalFromLocalBasis(nrmX, vec3f(0.0, 1.0, 0.0), vec3f(0.0, 0.0, sx), vec3f(sx, 0.0, 0.0)) * w.x +
1909 worldNormalFromLocalBasis(nrmY, vec3f(1.0, 0.0, 0.0), vec3f(0.0, 0.0, -sy), vec3f(0.0, sy, 0.0)) * w.y +
1910 worldNormalFromLocalBasis(nrmZ, vec3f(1.0, 0.0, 0.0), vec3f(0.0, sz, 0.0), vec3f(0.0, 0.0, sz)) * w.z;
1911 nrm = vec4f(normalize(blendedNormal) * 0.5 + 0.5,
1912 nrmX.a * w.x + nrmY.a * w.y + nrmZ.a * w.z);
1913 prm = sampleAtlas(decalParams, uvYZ) * w.x +
1915 sampleAtlas(decalParams, uvXZ) * w.y +
1917 sampleAtlas(decalParams, uvXY) * w.z;
1918
1919 let t = local + 0.5;
1920 edgeFade = edgeMask3(t);
1921 } else if (useWorld) {
1922 let sharpness = max(u.extraParams.w, 1.0);
1923 var w = pow(abs(normalize(surfaceN)), vec3f(sharpness));
1924 w = w / max(w.x + w.y + w.z, 1e-5);
1925 let worldScale = 1.0;
1926 let uvYZ = fract(parallaxUV(fract(worldPos.yz * worldScale), vec3f(viewWorld.yz, viewWorld.x), 2));
1927 let uvXZ = fract(parallaxUV(fract(worldPos.xz * worldScale), vec3f(viewWorld.xz, viewWorld.y), 2));
1928 let uvXY = fract(parallaxUV(fract(worldPos.xy * worldScale), viewWorld, 2));
1929 alb = sampleAtlas(decalAlbedo, uvYZ) * w.x +
1930 sampleAtlas(decalAlbedo, uvXZ) * w.y +
1931 sampleAtlas(decalAlbedo, uvXY) * w.z;
1932 let nrmX = sampleAtlas(decalNormal, uvYZ);
1933 let nrmY = sampleAtlas(decalNormal, uvXZ);
1934 let nrmZ = sampleAtlas(decalNormal, uvXY);
1935 let sx = select(1.0, -1.0, surfaceN.x < 0.0);
1936 let sy = select(1.0, -1.0, surfaceN.y < 0.0);
1937 let sz = select(1.0, -1.0, surfaceN.z < 0.0);
1938 let blendedNormal =
1939 worldNormalFromWorldBasis(nrmX, vec3f(0.0, 1.0, 0.0), vec3f(0.0, 0.0, sx), vec3f(sx, 0.0, 0.0)) * w.x +
1940 worldNormalFromWorldBasis(nrmY, vec3f(1.0, 0.0, 0.0), vec3f(0.0, 0.0, -sy), vec3f(0.0, sy, 0.0)) * w.y +
1941 worldNormalFromWorldBasis(nrmZ, vec3f(1.0, 0.0, 0.0), vec3f(0.0, sz, 0.0), vec3f(0.0, 0.0, sz)) * w.z;
1942 nrm = vec4f(normalize(blendedNormal) * 0.5 + 0.5,
1943 nrmX.a * w.x + nrmY.a * w.y + nrmZ.a * w.z);
1944 prm = sampleAtlas(decalParams, uvYZ) * w.x +
1945 sampleAtlas(decalParams, uvXZ) * w.y +
1946 sampleAtlas(decalParams, uvXY) * w.z;
1947 let t = local + 0.5;
1948 edgeFade = edgeMask3(t);
1949 } else {
1950 let direction = normalize(local + vec3f(1e-7));
1951 let invPi = 0.31830988618;
1952 var decalUV = vec2f(atan2(direction.x, direction.z) * (0.5 * invPi) + 0.5,
1953 asin(clamp(direction.y, -1.0, 1.0)) * invPi + 0.5);
1954 let tangent = normalize(vec3f(direction.z, 0.0, -direction.x) + vec3f(1e-7));
1955 let bitangent = normalize(cross(direction, tangent));
1956 let sphericalView = vec3f(dot(viewLocal, tangent), dot(viewLocal, bitangent),
1957 dot(viewLocal, direction));
1958 decalUV = parallaxUV(decalUV, sphericalView, 1);
1959 decalUV.x = fract(decalUV.x);
1960 if (decalUV.y < 0.0 || decalUV.y > 1.0) { discard; }
1961 alb = sampleAtlas(decalAlbedo, decalUV);
1962 let sampledNormal = sampleAtlas(decalNormal, decalUV);
1963 let normalWorld = worldNormalFromLocalBasis(sampledNormal, tangent, bitangent, direction);
1964 nrm = vec4f(normalWorld * 0.5 + 0.5, sampledNormal.a);
1965 prm = sampleAtlas(decalParams, decalUV);
1966 let t = local + 0.5;
1967 edgeFade = edgeMask3(t);
1968 }
1969
1970 var coverage = alb.a * clamp(u.fadeParams.x, 0.0, 1.0) * edgeFade;
1971 if (coverage <= 0.001) { discard; }
1972
1973 var out: DecalOut;
1974 out.albedo = vec4f(alb.rgb, coverage);
1975 out.normal = vec4f(nrm.rgb * step(0.001, u.fadeParams.y),
1977 coverage * clamp(u.fadeParams.y, 0.0, 1.0));
1978 out.params = vec4f(prm.r * clamp(u.fadeParams.z, 0.0, 1.0),
1979 prm.g * clamp(u.fadeParams.w, 0.0, 1.0),
1980 prm.b * clamp(u.extraParams.x, 0.0, 1.0), coverage);
1981 return out;
1982}
1983)wgsl";
1984
1985// ---- SSAO (G-buffer depth based, cheap screen-space) -----------------------
1986inline const char *kSSAOFragWgsl = R"wgsl(
1988struct VSOut {
1989 @builtin(position) pos: vec4f,
1990 @location(1) uv: vec2f,
1991};
1993struct AOUniforms {
1994 params: vec4f, // x = radius (UV offset scale), y = power, z = nearZ (hw), w = farZ (hw)
1995 intensity: f32, // 0 = off
1996 invScale: f32, // AO target size / depth size (0.5 = half-res AO)
1997 _pad: f32,
1998};
1999@group(0) @binding(0) var<uniform> ubo: AOUniforms;
2000@group(0) @binding(1) var depthTex: texture_depth_2d;
2001@group(0) @binding(2) var samp: sampler;
2002
2004fn hash12(p: vec2f) -> f32 {
2006 return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453);
2007}
2008
2010fn linearizeDepth(z: f32) -> f32 {
2011 let nearZ = ubo.params.z;
2012 let farZ = ubo.params.w;
2013 return nearZ * farZ / max(farZ - z * (farZ - nearZ), 1e-6);
2014}
2015
2016@fragment
2018fn fs_main(in: VSOut) -> @location(0) vec4f {
2019 let dims = vec2f(textureDimensions(depthTex));
2020 // AO runs at half resolution; the upsampled bilinear fetch in the mesh
2021 // pass acts as a cheap 2x2 blur, which removes the per-pixel grain.
2022 let uv = (in.pos.xy + vec2f(0.5)) / (dims * ubo.invScale);
2023 // Hardware depth (Depth32Float, NDC z in [0,1], 0 = near, 1 = far) —
2024 // linearized to world units so the occlusion delta is not crushed by
2025 // perspective (hw depth deltas near the far plane are ~1e-4).
2026 let rawZ = textureLoad(depthTex, vec2<i32>(uv * dims), 0);
2027 let centerDepth = linearizeDepth(rawZ);
2028 let nearZ = ubo.params.z;
2029 let farZ = ubo.params.w;
2030 if (centerDepth >= farZ * 0.99 || centerDepth <= nearZ * 1.02) {
2032 return vec4f(1.0, 1.0, 1.0, 1.0);
2033 }
2034 // Screen radius scales with the compressed depth: near surfaces span more
2035 // pixels for the same world-space radius.
2036 let rad = ubo.params.x / (rawZ + 0.05);
2037 var occ = 0.0;
2038 for (var i = 0; i < 12; i = i + 1) {
2039 let f = (f32(i) + 0.5) / 12.0;
2040 let ang = 6.2831853 * f + hash12(uv * 311.7 + vec2f(f32(i), 0.0)) * 0.9;
2041 let r = rad * (0.25 + 0.75 * f);
2042 let sampleUV = clamp(uv + vec2f(cos(ang), sin(ang)) * r, vec2f(0.001), vec2f(0.999));
2043 let sampleDepth = linearizeDepth(textureLoad(depthTex, vec2<i32>(sampleUV * dims), 0));
2044 if (sampleDepth >= farZ * 0.99) { continue; }
2045 // The sample is occluded when it is closer than the center surface
2046 // (positive diff in world units); smoothstep softens the hard cutoff
2047 // that turned tiny depth deltas into per-pixel speckle.
2048 let diff = centerDepth - sampleDepth;
2049 let delta = diff / max(centerDepth, 1e-4);
2050 occ += smoothstep(0.0, 0.4, delta * 35.0);
2051 }
2052 let ao = pow(clamp(1.0 - occ / 12.0, 0.0, 1.0), ubo.params.y);
2054 return vec4f(vec3f(mix(1.0, ao, ubo.intensity)), 1.0);
2055}
2056)wgsl";
2057
2058// ---- Voxel rect --------------------------------------------------------------
2059inline const char *kVoxelRectVertWgsl = R"wgsl(
2061struct VSIn {
2062 @location(0) corner: vec2f,
2063 @location(1) packed: u32,
2064 @location(2) aoWord: u32,
2065};
2067struct PC {
2068 viewProj: mat4x4f,
2069 chunkOrigin: vec4f,
2070 atlasInfo: vec4f,
2071 tint: vec4f,
2072};
2074struct VSOut {
2075 @builtin(position) pos: vec4f,
2076 @location(0) uv: vec2f,
2077 @location(1) tint: vec4f,
2078 @location(2) vAO: f32,
2079 @location(3) @interpolate(flat) atlasBase: vec2f,
2080 @location(4) @interpolate(flat) tileScale: f32,
2081};
2082@group(0) @binding(0) var<uniform> pc: PC;
2083@vertex
2085fn vs_main(in: VSIn) -> VSOut {
2086 var out: VSOut;
2087 // packed: x(5) y(5) z(5) w(5) h(5) tex(7)
2088 let px = f32(in.packed & 31u);
2089 let py = f32((in.packed >> 5u) & 31u);
2090 let pz = f32((in.packed >> 10u) & 31u);
2091 let w = f32((in.packed >> 15u) & 31u) + 1.0;
2092 let h = f32((in.packed >> 20u) & 31u) + 1.0;
2093 let tex = f32((in.packed >> 25u) & 127u);
2094 // Per-instance AO word: 2 bits per corner (0..3), Vulkan corner order.
2095 let ao0 = in.aoWord & 3u;
2096 let ao1 = (in.aoWord >> 2u) & 3u;
2097 let ao2 = (in.aoWord >> 4u) & 3u;
2098 let ao3 = (in.aoWord >> 6u) & 3u;
2099 let aoTop = select(ao1, ao0, in.corner.x < 0.5);
2100 let aoBot = select(ao2, ao3, in.corner.x < 0.5);
2101 let ao = select(aoBot, aoTop, in.corner.y < 0.5);
2102 var world: vec3f = pc.chunkOrigin.xyz + vec3f(px, py, pz);
2103 let face = i32(pc.chunkOrigin.w + 0.5);
2104 var u: f32 = in.corner.x;
2105 var v: f32 = in.corner.y;
2106 if (face == 0) { world += vec3f(1.0, v * h, u * w); }
2107 else if (face == 1) { world += vec3f(0.0, v * h, (1.0 - u) * w); }
2108 else if (face == 2) { world += vec3f(u * w, 1.0, v * h); }
2109 else if (face == 3) { world += vec3f(u * w, 0.0, (1.0 - v) * h); }
2110 else if (face == 4) { world += vec3f((1.0 - u) * w, v * h, 1.0); }
2111 else { world += vec3f(u * w, v * h, 0.0); }
2112 out.pos = pc.viewProj * vec4f(world, 1.0);
2113 // WebGPU NDC is Y-up; mirror the Vulkan-convention clip Y.
2114 out.pos.y = -out.pos.y;
2115 let tiles = max(pc.atlasInfo.x, 1.0);
2116 let tw = 1.0 / tiles;
2117 let col = tex - floor(tex / tiles) * tiles;
2118 let row = floor(tex / tiles);
2119 out.uv = in.corner * vec2f(w, h);
2120 out.atlasBase = vec2f(col, row);
2121 out.tileScale = tw;
2122 out.tint = pc.tint;
2123 out.vAO = f32(ao) / 3.0;
2124 return out;
2125}
2126)wgsl";
2127
2128inline const char *kVoxelRectFragWgsl = R"wgsl(
2130struct FSIn {
2131 @location(0) uv: vec2f,
2132 @location(1) tint: vec4f,
2133 @location(2) vAO: f32,
2134 @location(3) @interpolate(flat) atlasBase: vec2f,
2135 @location(4) @interpolate(flat) tileScale: f32,
2136};
2137@group(0) @binding(1) var atlas: texture_2d<f32>;
2138@group(0) @binding(2) var atlasSamp: sampler;
2139@fragment
2141fn fs_main(in: FSIn) -> @location(0) vec4f {
2142 // Vertex ambient occlusion (0..1): darkens corners near other voxels.
2143 let aoShade = 0.35 + 0.65 * in.vAO;
2144 let atlasUV = (in.atlasBase + fract(in.uv)) * in.tileScale;
2145 let dx = dpdx(in.uv) * in.tileScale;
2146 let dy = dpdy(in.uv) * in.tileScale;
2148 return textureSampleGrad(atlas, atlasSamp, atlasUV, dx, dy) * in.tint * aoShade;
2149}
2150)wgsl";
2151
2152// ---- Hybrid clustered deferred lighting (Phase D WebGPU parity) ------------
2154inline const char* kDeferredLightingVertWgsl = R"wgsl(
2156struct VSOut {
2157 @builtin(position) pos: vec4f,
2158};
2159@vertex
2161fn vs_main(@builtin(vertex_index) vid: u32) -> VSOut {
2162 // Fullscreen triangle in WebGPU NDC (Y-up).
2163 var p = vec2f(f32((vid << 1u) & 2u), f32(vid & 2u));
2164 var out: VSOut;
2165 out.pos = vec4f(p * 2.0 - 1.0, 0.0, 1.0);
2166 return out;
2167}
2168)wgsl";
2169
2175inline const char* kDeferredLightingFragWgsl = R"wgsl(
2177struct Light3D {
2178 posRadius: vec4f,
2179 color: vec4f,
2180 spot: vec4f,
2181};
2183struct DeferredFrame {
2184 invViewProj: mat4x4f,
2185 view: mat4x4f,
2186 lightDir: vec4f,
2187 lightColor: vec4f,
2188 cameraPos: vec4f,
2189 ambient: vec4f,
2190 gridInfo: vec4f,
2191 clipInfo: vec4f,
2192};
2194struct ShadowFrame {
2195 lightVP: array<mat4x4f, 3>,
2196 splits: vec4f,
2197 bias: vec4f,
2198 cascadeBias: vec4f,
2199 cascadeTexel: vec4f,
2200 localVP: array<mat4x4f, 4>,
2201 localSlot01: vec4f,
2202 localSlot23: vec4f,
2203 localBias: vec4f,
2204 localMeta: vec4f,
2205};
2207struct FSIn {
2208 @builtin(position) fragCoord: vec4f,
2209};
2211struct FSOut {
2212 @location(0) color: vec4f,
2213 @builtin(frag_depth) depth: f32,
2214};
2215
2216@group(0) @binding(0) var<uniform> ubo: DeferredFrame;
2217@group(0) @binding(1) var gbNormal: texture_2d<f32>;
2218@group(0) @binding(2) var gbDepthColor: texture_2d<f32>;
2219@group(0) @binding(3) var gbAlbedo: texture_2d<f32>;
2220@group(0) @binding(4) var gbPbrParams: texture_2d<f32>;
2221@group(0) @binding(5) var gbEmissive: texture_2d<f32>;
2222@group(0) @binding(6) var gbHwDepth: texture_depth_2d;
2223@group(0) @binding(7) var<storage, read> lights: array<Light3D>;
2224@group(0) @binding(8) var<storage, read> clusters: array<vec2u>;
2225@group(0) @binding(9) var<storage, read> lightIndices: array<u32>;
2226@group(0) @binding(10) var<uniform> shadow: ShadowFrame;
2227@group(0) @binding(11) var shadowMap: texture_depth_2d_array;
2228@group(0) @binding(12) var nearestSamp: sampler;
2229@group(0) @binding(13) var shadowSamp: sampler_comparison;
2230
2231const PI: f32 = 3.14159265359;
2232
2234fn distGGX(n: vec3f, h: vec3f, rough: f32) -> f32 {
2235 let a = max(rough * rough, 0.002);
2236 let a2 = a * a;
2237 let ndh = max(dot(n, h), 0.0);
2238 let denom = (ndh * ndh * (a2 - 1.0) + 1.0);
2239 return a2 / max(PI * denom * denom, 1e-4);
2240}
2242fn geomSchlick(ndv: f32, rough: f32) -> f32 {
2243 let r = rough + 1.0;
2244 let k = (r * r) / 8.0;
2245 return ndv / max(ndv * (1.0 - k) + k, 1e-4);
2246}
2248fn geomSmith(n: vec3f, v: vec3f, l: vec3f, rough: f32) -> f32 {
2250 return geomSchlick(max(dot(n, v), 0.0), rough) * geomSchlick(max(dot(n, l), 0.0), rough);
2251}
2253fn fresnelSchlick(cosT: f32, f0: vec3f) -> vec3f {
2254 return f0 + (1.0 - f0) * pow(clamp(1.0 - cosT, 0.0, 1.0), 5.0);
2255}
2257fn shadeLight(n: vec3f, v: vec3f, l: vec3f, rad: vec3f, albedo: vec3f, metallic: f32,
2258 rough: f32, specularFactor: f32) -> vec3f {
2259 let ndl = max(dot(n, l), 0.0);
2260 if (ndl <= 0.0) { return vec3f(0.0); }
2261 let h = normalize(v + l);
2262 let f0 = mix(vec3f(0.04 * specularFactor), albedo, metallic);
2263 let ndf = distGGX(n, h, rough);
2264 let g = geomSmith(n, v, l, rough);
2265 let f = fresnelSchlick(max(dot(h, v), 0.0), f0);
2266 let spec = (ndf * g * f) / max(4.0 * max(dot(n, v), 0.0) * ndl, 1e-4);
2267 let kd = (vec3f(1.0) - f) * (1.0 - metallic);
2268 return (kd * albedo / PI + spec) * rad * ndl;
2269}
2271fn sampleShadowCascade(worldPos: vec3f, cascade: i32, biasAmt: f32) -> f32 {
2272 let lightClip = shadow.lightVP[cascade] * vec4f(worldPos, 1.0);
2273 let ndc = lightClip.xyz / max(lightClip.w, 1e-6);
2274 let uv = vec2f(ndc.x, -ndc.y) * 0.5 + 0.5;
2275 let depth = ndc.z;
2276 if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 || depth < 0.0 || depth > 1.0) {
2277 return 1.0;
2278 }
2280 return textureSampleCompare(shadowMap, shadowSamp, uv, cascade, depth - biasAmt);
2281}
2283fn sampleShadowPCF(worldPos: vec3f, n: vec3f, viewDepth: f32, nDotL: f32) -> f32 {
2284 if (shadow.bias.y < 0.5 || shadow.bias.z < 0.5 || shadow.splits.w < 1e-4) {
2285 return 1.0;
2286 }
2287 var cascade = 2;
2288 if (viewDepth < shadow.splits.x) { cascade = 0; }
2289 else if (viewDepth < shadow.splits.y) { cascade = 1; }
2290 var b = select(shadow.cascadeBias.z,
2292 select(shadow.cascadeBias.y, shadow.cascadeBias.x, cascade == 0),
2293 cascade == 1);
2294 if (b < 1e-8) { b = shadow.bias.x; }
2295 b = b * mix(0.75, 1.0, clamp(nDotL, 0.0, 1.0));
2296 let tw = select(shadow.cascadeTexel.z,
2298 select(shadow.cascadeTexel.y, shadow.cascadeTexel.x, cascade == 0),
2299 cascade == 1);
2300 let p = worldPos + n * ((2.0 * max(tw, 1e-6)) / max(nDotL, 0.2));
2301 let vis = sampleShadowCascade(p, cascade, b);
2303 return mix(0.04, 1.0, mix(1.0, vis, clamp(shadow.splits.w, 0.0, 1.0)));
2304}
2306fn clusterIndex(viewDepth: f32, fragCoord: vec4f) -> u32 {
2307 let tilesX = i32(ubo.gridInfo.x + 0.5);
2308 let tilesY = i32(ubo.gridInfo.y + 0.5);
2309 let slices = i32(ubo.gridInfo.z + 0.5);
2310 let nearZ = ubo.clipInfo.x;
2311 let farZ = max(ubo.clipInfo.y, nearZ + 1e-3);
2312 let screenW = max(ubo.clipInfo.z, 1.0);
2313 let screenH = max(ubo.clipInfo.w, 1.0);
2314 let tx = clamp(i32(floor(fragCoord.x / screenW * f32(tilesX))), 0, tilesX - 1);
2315 let ty = clamp(i32(floor(fragCoord.y / screenH * f32(tilesY))), 0, tilesY - 1);
2316 let depth = max(viewDepth, nearZ);
2317 let sz = clamp(i32(floor((depth - nearZ) / (farZ - nearZ) * f32(slices))), 0, slices - 1);
2319 return u32((sz * tilesY + ty) * tilesX + tx);
2320}
2321
2322@fragment
2324fn fs_main(in: FSIn) -> FSOut {
2325 var out: FSOut;
2326 let screenW = max(ubo.clipInfo.z, 1.0);
2327 let screenH = max(ubo.clipInfo.w, 1.0);
2328 let uv = in.fragCoord.xy / vec2f(screenW, screenH);
2329 let dims = vec2i(textureDimensions(gbHwDepth));
2330 let px = clamp(vec2i(in.fragCoord.xy), vec2i(0), dims - vec2i(1));
2331 let hwZ = textureLoad(gbHwDepth, px, 0);
2332 if (hwZ >= 0.9999) {
2333 discard;
2334 }
2335 // WebGPU fragCoord origin is top-left (Y down); NDC is Y-up. GBuffer fill
2336 // also mirrors clip Y, so reconstruct with the same WebGPU NDC.
2337 let clip = vec4f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, hwZ, 1.0);
2338 let worldH = ubo.invViewProj * clip;
2339 let worldPos = worldH.xyz / max(worldH.w, 1e-6);
2340
2341 var n = normalize(textureSample(gbNormal, nearestSamp, uv).xyz * 2.0 - 1.0);
2342 let albedo = textureSample(gbAlbedo, nearestSamp, uv).rgb;
2343 let pbr = textureSample(gbPbrParams, nearestSamp, uv);
2344 let metallic = clamp(pbr.r, 0.0, 1.0);
2345 let roughness = clamp(pbr.g, 0.04, 1.0);
2346 let occlusion = clamp(pbr.b, 0.0, 1.0);
2347 let specularFactor = clamp(pbr.a, 0.0, 1.0);
2348 let emissive = textureSample(gbEmissive, nearestSamp, uv).rgb;
2349
2350 var v = normalize(ubo.cameraPos.xyz - worldPos);
2351 if (dot(n, v) < 0.0) { n = -n; }
2352
2353 let viewPos = (ubo.view * vec4f(worldPos, 1.0)).xyz;
2354 let viewDepth = max(-viewPos.z, 0.0);
2355
2356 var lo = vec3f(0.0);
2357 if (ubo.lightDir.w > 0.5) {
2358 let l = normalize(ubo.lightDir.xyz);
2359 let nDotL = max(dot(n, l), 0.0);
2360 let shadowVis = sampleShadowPCF(worldPos, n, viewDepth, nDotL);
2361 lo = lo + shadeLight(n, v, l, ubo.lightColor.rgb, albedo, metallic, roughness, specularFactor) * shadowVis;
2362 }
2363
2364 let ci = clusterIndex(viewDepth, in.fragCoord);
2365 let entry = clusters[ci];
2366 let count = min(entry.y, 32u);
2367 for (var i = 0u; i < count; i = i + 1u) {
2368 let li = lightIndices[entry.x + i];
2369 let light = lights[li];
2370 let toLight = light.posRadius.xyz - worldPos;
2371 let dist = length(toLight);
2372 let radius = max(light.posRadius.w, 1e-3);
2373 if (dist >= radius) { continue; }
2374 let l = toLight / max(dist, 1e-4);
2375 var atten = 1.0 - smoothstep(radius * 0.8, radius, dist);
2376 atten = atten / max(dist * dist, 1e-4);
2377 if (light.spot.w > 0.0) {
2378 let beamLen = length(light.spot.xyz);
2379 if (beamLen > 1e-6) {
2380 let cosTheta = dot(normalize(-l), light.spot.xyz / beamLen);
2381 atten = atten * clamp(cosTheta * light.spot.w + light.color.a, 0.0, 1.0);
2382 if (beamLen > 1.005) {
2383 let si = i32(round((beamLen - 1.0) * 100.0)) - 1;
2384 if (si >= 0 && si <= 3) {
2385 let lc = shadow.localVP[si] * vec4f(worldPos, 1.0);
2386 let ndc = lc.xyz / max(lc.w, 1e-4);
2387 let uv = vec2f(ndc.x, -ndc.y) * 0.5 + 0.5;
2388 let zref = ndc.z - shadow.localBias[si];
2389 var vis = 1.0;
2390 if (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0 && lc.w > 0.0) {
2391 vis = textureSampleCompare(shadowMap, shadowSamp, uv, 3 + si, zref);
2392 }
2393 atten = atten * mix(1.0 - shadow.splits.w, 1.0, vis);
2394 }
2395 }
2396 } else {
2397 atten = 0.0;
2398 }
2399 }
2400 lo = lo + shadeLight(n, v, l, light.color.rgb * atten, albedo, metallic, roughness, specularFactor);
2401 }
2402
2403 let ambient = ubo.ambient.rgb * albedo * (1.0 - metallic) * occlusion;
2404 out.color = vec4f(ambient + lo + emissive, 1.0);
2405 out.depth = hwZ;
2406 return out;
2407}
2408)wgsl";
2409
2410} // namespace eve::graphics::webgpu
const char * kDecalFragWgsl
const char * kColorVertWgsl
const char * kMesh3DFragWgsl
const char * kDecalVertWgsl
const char * kColorFragWgsl
const char * kTexturedVertWgsl
const char * kDeferredLightingVertWgsl
Immutable deferred-lighting vertex WGSL. @borrowed Static storage; valid for the process lifetime.
const char * kMesh3DVertWgsl
Immutable shader source. @borrowed Static storage; valid for the process lifetime.
const char * kMesh3DGbufferFragWgsl
Immutable GBuffer fill WGSL. @borrowed Static storage; valid for the process lifetime.
const char * kMesh3DShadowAlphaVertWgsl
Immutable shader source. @borrowed Static storage; valid for the process lifetime.
const char * kLit2DVertWgsl
const char * kMesh3DShadowVertWgsl
Immutable shader source. @borrowed Static storage; valid for the process lifetime.
const char * kSSAOFragWgsl
const char * kTexturedFragWgsl
const char * kLit2DFragWgsl
const char * kVoxelRectVertWgsl
const char * kMesh3DShadowAlphaFragWgsl
const char * kVoxelRectFragWgsl
const char * kMesh3DClusteredVertWgsl
const char * kMesh3DGbufferAlphaFragWgsl
Immutable GBuffer alpha-cutout WGSL. @borrowed Static storage; valid for the process lifetime.
const char * kMesh3DClusteredFragWgsl
const char * kMesh3DGbufferVertWgsl
Immutable shader source. @borrowed Static storage; valid for the process lifetime.
const char * kDeferredLightingFragWgsl
Immutable clustered deferred lighting fragment WGSL (core metallic-roughness PBR + CSM + clustered po...
const char * kCustom2DFragWgsl