topazcube 0.1.31 → 0.1.35

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 (103) hide show
  1. package/LICENSE.txt +0 -0
  2. package/README.md +0 -0
  3. package/dist/Renderer.cjs +20844 -0
  4. package/dist/Renderer.cjs.map +1 -0
  5. package/dist/Renderer.js +20827 -0
  6. package/dist/Renderer.js.map +1 -0
  7. package/dist/client.cjs +91 -260
  8. package/dist/client.cjs.map +1 -1
  9. package/dist/client.js +68 -215
  10. package/dist/client.js.map +1 -1
  11. package/dist/server.cjs +165 -432
  12. package/dist/server.cjs.map +1 -1
  13. package/dist/server.js +117 -370
  14. package/dist/server.js.map +1 -1
  15. package/dist/terminal.cjs +113 -200
  16. package/dist/terminal.cjs.map +1 -1
  17. package/dist/terminal.js +50 -51
  18. package/dist/terminal.js.map +1 -1
  19. package/dist/utils-CRhi1BDa.cjs +259 -0
  20. package/dist/utils-CRhi1BDa.cjs.map +1 -0
  21. package/dist/utils-D7tXt6-2.js +260 -0
  22. package/dist/utils-D7tXt6-2.js.map +1 -0
  23. package/package.json +19 -15
  24. package/src/{client.ts → network/client.js} +170 -403
  25. package/src/{compress-browser.ts → network/compress-browser.js} +2 -4
  26. package/src/{compress-node.ts → network/compress-node.js} +8 -14
  27. package/src/{server.ts → network/server.js} +229 -317
  28. package/src/{terminal.js → network/terminal.js} +0 -0
  29. package/src/{topazcube.ts → network/topazcube.js} +2 -2
  30. package/src/network/utils.js +375 -0
  31. package/src/renderer/Camera.js +191 -0
  32. package/src/renderer/DebugUI.js +703 -0
  33. package/src/renderer/Geometry.js +1049 -0
  34. package/src/renderer/Material.js +64 -0
  35. package/src/renderer/Mesh.js +211 -0
  36. package/src/renderer/Node.js +112 -0
  37. package/src/renderer/Pipeline.js +645 -0
  38. package/src/renderer/Renderer.js +1496 -0
  39. package/src/renderer/Skin.js +792 -0
  40. package/src/renderer/Texture.js +584 -0
  41. package/src/renderer/core/AssetManager.js +394 -0
  42. package/src/renderer/core/CullingSystem.js +308 -0
  43. package/src/renderer/core/EntityManager.js +541 -0
  44. package/src/renderer/core/InstanceManager.js +343 -0
  45. package/src/renderer/core/ParticleEmitter.js +358 -0
  46. package/src/renderer/core/ParticleSystem.js +564 -0
  47. package/src/renderer/core/SpriteSystem.js +349 -0
  48. package/src/renderer/gltf.js +563 -0
  49. package/src/renderer/math.js +161 -0
  50. package/src/renderer/rendering/HistoryBufferManager.js +333 -0
  51. package/src/renderer/rendering/ProbeCapture.js +1495 -0
  52. package/src/renderer/rendering/ReflectionProbeManager.js +352 -0
  53. package/src/renderer/rendering/RenderGraph.js +2258 -0
  54. package/src/renderer/rendering/passes/AOPass.js +308 -0
  55. package/src/renderer/rendering/passes/AmbientCapturePass.js +593 -0
  56. package/src/renderer/rendering/passes/BasePass.js +101 -0
  57. package/src/renderer/rendering/passes/BloomPass.js +420 -0
  58. package/src/renderer/rendering/passes/CRTPass.js +724 -0
  59. package/src/renderer/rendering/passes/FogPass.js +445 -0
  60. package/src/renderer/rendering/passes/GBufferPass.js +730 -0
  61. package/src/renderer/rendering/passes/HiZPass.js +744 -0
  62. package/src/renderer/rendering/passes/LightingPass.js +753 -0
  63. package/src/renderer/rendering/passes/ParticlePass.js +841 -0
  64. package/src/renderer/rendering/passes/PlanarReflectionPass.js +456 -0
  65. package/src/renderer/rendering/passes/PostProcessPass.js +405 -0
  66. package/src/renderer/rendering/passes/ReflectionPass.js +157 -0
  67. package/src/renderer/rendering/passes/RenderPostPass.js +364 -0
  68. package/src/renderer/rendering/passes/SSGIPass.js +266 -0
  69. package/src/renderer/rendering/passes/SSGITilePass.js +305 -0
  70. package/src/renderer/rendering/passes/ShadowPass.js +2072 -0
  71. package/src/renderer/rendering/passes/TransparentPass.js +831 -0
  72. package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
  73. package/src/renderer/rendering/shaders/ao.wgsl +182 -0
  74. package/src/renderer/rendering/shaders/bloom.wgsl +97 -0
  75. package/src/renderer/rendering/shaders/bloom_blur.wgsl +80 -0
  76. package/src/renderer/rendering/shaders/crt.wgsl +455 -0
  77. package/src/renderer/rendering/shaders/depth_copy.wgsl +17 -0
  78. package/src/renderer/rendering/shaders/geometry.wgsl +580 -0
  79. package/src/renderer/rendering/shaders/hiz_reduce.wgsl +114 -0
  80. package/src/renderer/rendering/shaders/light_culling.wgsl +204 -0
  81. package/src/renderer/rendering/shaders/lighting.wgsl +932 -0
  82. package/src/renderer/rendering/shaders/lighting_common.wgsl +143 -0
  83. package/src/renderer/rendering/shaders/particle_render.wgsl +672 -0
  84. package/src/renderer/rendering/shaders/particle_simulate.wgsl +440 -0
  85. package/src/renderer/rendering/shaders/postproc.wgsl +293 -0
  86. package/src/renderer/rendering/shaders/render_post.wgsl +289 -0
  87. package/src/renderer/rendering/shaders/shadow.wgsl +117 -0
  88. package/src/renderer/rendering/shaders/ssgi.wgsl +266 -0
  89. package/src/renderer/rendering/shaders/ssgi_accumulate.wgsl +114 -0
  90. package/src/renderer/rendering/shaders/ssgi_propagate.wgsl +132 -0
  91. package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
  92. package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
  93. package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
  94. package/src/renderer/utils/BoundingSphere.js +439 -0
  95. package/src/renderer/utils/Frustum.js +281 -0
  96. package/src/renderer/utils/Raycaster.js +761 -0
  97. package/dist/client.d.cts +0 -211
  98. package/dist/client.d.ts +0 -211
  99. package/dist/server.d.cts +0 -120
  100. package/dist/server.d.ts +0 -120
  101. package/dist/terminal.d.cts +0 -64
  102. package/dist/terminal.d.ts +0 -64
  103. package/src/utils.ts +0 -403
@@ -0,0 +1,932 @@
1
+ const PI = 3.14159;
2
+ const MAX_LIGHTS = 768;
3
+ const CASCADE_COUNT = 3;
4
+ const MAX_LIGHTS_PER_TILE = 256;
5
+
6
+ struct VertexOutput {
7
+ @builtin(position) position: vec4<f32>,
8
+ @location(0) uv: vec2<f32>,
9
+ }
10
+
11
+ const MAX_SPOT_SHADOWS = 16;
12
+
13
+ struct Light {
14
+ enabled: u32,
15
+ position: vec3f,
16
+ color: vec4f,
17
+ direction: vec3f,
18
+ geom: vec4f, // x = radius, y = inner cone, z = outer cone
19
+ shadowIndex: i32, // -1 if no shadow, 0-7 for spot shadow slot
20
+ }
21
+
22
+ struct Uniforms {
23
+ inverseViewProjection: mat4x4<f32>,
24
+ inverseProjection: mat4x4<f32>, // For logarithmic depth reconstruction
25
+ inverseView: mat4x4<f32>, // For logarithmic depth reconstruction
26
+ cameraPosition: vec3f,
27
+ canvasSize: vec2f,
28
+ lightDir: vec3f,
29
+ lightColor: vec4f,
30
+ ambientColor: vec4f,
31
+ environmentParams: vec4f, // x = diffuse level, y = specular level, z = env mip count, a = exposure
32
+ shadowParams: vec4f, // x = bias, y = normalBias, z = shadow strength, w = shadow map size
33
+ cascadeSizes: vec4f, // x, y, z = cascade half-widths in meters
34
+ tileParams: vec4u, // x = tile size, y = tile count X, z = max lights per tile, w = actual light count
35
+ noiseParams: vec4f, // x = noise texture size, y = noise offset X, z = noise offset Y, w = env encoding (0=equirect, 1=octahedral)
36
+ cameraParams: vec4f, // x = near, y = far, z = reflection mode (flip env Y), w = direct specular multiplier
37
+ specularBoost: vec4f, // x = intensity, y = roughness cutoff, z = unused, w = unused
38
+ }
39
+
40
+ // Hard-coded spot shadow parameters (to avoid uniform buffer alignment issues)
41
+ const SPOT_ATLAS_WIDTH: f32 = 2048.0;
42
+ const SPOT_ATLAS_HEIGHT: f32 = 2048.0;
43
+ const SPOT_TILE_SIZE: f32 = 512.0;
44
+ const SPOT_TILES_PER_ROW: i32 = 4;
45
+ const SPOT_FADE_START: f32 = 25.0;
46
+ const SPOT_MAX_DISTANCE: f32 = 30.0;
47
+ const SPOT_MIN_SHADOW: f32 = 0.5;
48
+
49
+ // Storage buffer for spotlight matrices (avoids uniform buffer size limits)
50
+ struct SpotShadowMatrices {
51
+ matrices: array<mat4x4<f32>, MAX_SPOT_SHADOWS>,
52
+ }
53
+
54
+ // Storage buffer for cascade matrices
55
+ struct CascadeMatrices {
56
+ matrices: array<mat4x4<f32>, CASCADE_COUNT>,
57
+ }
58
+
59
+
60
+ @group(0) @binding(0) var<uniform> uniforms: Uniforms;
61
+ @group(0) @binding(12) var<storage, read> spotMatrices: SpotShadowMatrices;
62
+ @group(0) @binding(13) var<storage, read> cascadeMatrices: CascadeMatrices;
63
+ @group(0) @binding(14) var<storage, read> tileLightIndices: array<u32>;
64
+ @group(0) @binding(15) var<storage, read> lights: array<Light, MAX_LIGHTS>;
65
+ @group(0) @binding(1) var gAlbedo: texture_2d<f32>;
66
+ @group(0) @binding(2) var gNormal: texture_2d<f32>;
67
+ @group(0) @binding(3) var gArm: texture_2d<f32>;
68
+ @group(0) @binding(4) var gEmission: texture_2d<f32>;
69
+ @group(0) @binding(5) var gDepth: texture_depth_2d;
70
+ @group(0) @binding(6) var env: texture_2d<f32>;
71
+ @group(0) @binding(7) var envSampler: sampler;
72
+ @group(0) @binding(8) var shadowMapArray: texture_depth_2d_array;
73
+ @group(0) @binding(9) var shadowSampler: sampler_comparison;
74
+ @group(0) @binding(10) var spotShadowAtlas: texture_depth_2d;
75
+ @group(0) @binding(11) var spotShadowSampler: sampler_comparison;
76
+ @group(0) @binding(16) var noiseTexture: texture_2d<f32>;
77
+ @group(0) @binding(17) var noiseSampler: sampler;
78
+ @group(0) @binding(18) var aoTexture: texture_2d<f32>;
79
+
80
+ fn SphToUV(n: vec3<f32>) -> vec2<f32> {
81
+ var uv: vec2<f32>;
82
+
83
+ uv.x = atan2(-n.x, n.z);
84
+ uv.x = (uv.x + PI / 2.0) / (PI * 2.0) + PI * (28.670 / 360.0);
85
+
86
+ uv.y = acos(n.y) / PI;
87
+
88
+ return uv;
89
+ }
90
+
91
+ // Octahedral encoding: direction to UV
92
+ // Maps sphere to square: yaw around edges, pitch from center (top) to edges (bottom)
93
+ fn octEncode(n: vec3<f32>) -> vec2<f32> {
94
+ var n2 = n / (abs(n.x) + abs(n.y) + abs(n.z));
95
+ if (n2.y < 0.0) {
96
+ let signX = select(-1.0, 1.0, n2.x >= 0.0);
97
+ let signZ = select(-1.0, 1.0, n2.z >= 0.0);
98
+ n2 = vec3f(
99
+ (1.0 - abs(n2.z)) * signX,
100
+ n2.y,
101
+ (1.0 - abs(n2.x)) * signZ
102
+ );
103
+ }
104
+ return n2.xz * 0.5 + 0.5;
105
+ }
106
+
107
+ // Octahedral decoding: UV to direction
108
+ fn octDecode(uv: vec2<f32>) -> vec3<f32> {
109
+ var uv2 = uv * 2.0 - 1.0;
110
+ var n = vec3f(uv2.x, 1.0 - abs(uv2.x) - abs(uv2.y), uv2.y);
111
+ if (n.y < 0.0) {
112
+ let signX = select(-1.0, 1.0, n.x >= 0.0);
113
+ let signZ = select(-1.0, 1.0, n.z >= 0.0);
114
+ n = vec3f(
115
+ (1.0 - abs(n.z)) * signX,
116
+ n.y,
117
+ (1.0 - abs(n.x)) * signZ
118
+ );
119
+ }
120
+ return normalize(n);
121
+ }
122
+
123
+ // Sample noise texture at screen position with optional animation offset
124
+ // Uses textureLoad to avoid uniform control flow issues
125
+ fn sampleNoise(screenPos: vec2f) -> f32 {
126
+ let noiseSize = i32(uniforms.noiseParams.x);
127
+ let noiseOffsetX = i32(uniforms.noiseParams.y * f32(noiseSize));
128
+ let noiseOffsetY = i32(uniforms.noiseParams.z * f32(noiseSize));
129
+
130
+ // Tile the noise across screen space using modulo
131
+ let texCoord = vec2i(
132
+ (i32(screenPos.x) + noiseOffsetX) % noiseSize,
133
+ (i32(screenPos.y) + noiseOffsetY) % noiseSize
134
+ );
135
+ return textureLoad(noiseTexture, texCoord, 0).r;
136
+ }
137
+
138
+ // Get 2D jitter offset from noise (-1 to 1 range)
139
+ // Uses textureLoad to avoid uniform control flow issues
140
+ fn getNoiseJitter(screenPos: vec2f) -> vec2f {
141
+ let noiseSize = i32(uniforms.noiseParams.x);
142
+ let noiseOffsetX = i32(uniforms.noiseParams.y * f32(noiseSize));
143
+ let noiseOffsetY = i32(uniforms.noiseParams.z * f32(noiseSize));
144
+
145
+ // Load at two offset positions for X and Y
146
+ let texCoord1 = vec2i(
147
+ (i32(screenPos.x) + noiseOffsetX) % noiseSize,
148
+ (i32(screenPos.y) + noiseOffsetY) % noiseSize
149
+ );
150
+ let texCoord2 = vec2i(
151
+ (i32(screenPos.x) + 37 + noiseOffsetX) % noiseSize,
152
+ (i32(screenPos.y) + 17 + noiseOffsetY) % noiseSize
153
+ );
154
+
155
+ let n1 = textureLoad(noiseTexture, texCoord1, 0).r;
156
+ let n2 = textureLoad(noiseTexture, texCoord2, 0).r;
157
+
158
+ return vec2f(n1, n2) * 2.0 - 1.0;
159
+ }
160
+
161
+ // Get 4-channel noise for IBL multisampling
162
+ fn getNoise4(screenPos: vec2f) -> vec4f {
163
+ let noiseSize = i32(uniforms.noiseParams.x);
164
+ let noiseOffsetX = i32(uniforms.noiseParams.y * f32(noiseSize));
165
+ let noiseOffsetY = i32(uniforms.noiseParams.z * f32(noiseSize));
166
+
167
+ let texCoord = vec2i(
168
+ (i32(screenPos.x) + noiseOffsetX) % noiseSize,
169
+ (i32(screenPos.y) + noiseOffsetY) % noiseSize
170
+ );
171
+ return textureLoad(noiseTexture, texCoord, 0);
172
+ }
173
+
174
+ // Vogel disk sample pattern - gives good 2D distribution
175
+ fn vogelDiskSample(sampleIndex: i32, numSamples: i32, rotation: f32) -> vec2f {
176
+ let goldenAngle = 2.399963229728653; // pi * (3 - sqrt(5))
177
+ let r = sqrt((f32(sampleIndex) + 0.5) / f32(numSamples));
178
+ let theta = f32(sampleIndex) * goldenAngle + rotation;
179
+ return vec2f(r * cos(theta), r * sin(theta));
180
+ }
181
+
182
+ // Build orthonormal basis around a direction (for cone sampling)
183
+ fn buildOrthonormalBasis(n: vec3f) -> mat3x3<f32> {
184
+ // Choose a vector not parallel to n
185
+ let up = select(vec3f(1.0, 0.0, 0.0), vec3f(0.0, 1.0, 0.0), abs(n.y) < 0.999);
186
+ let tangent = normalize(cross(up, n));
187
+ let bitangent = cross(n, tangent);
188
+ return mat3x3<f32>(tangent, bitangent, n);
189
+ }
190
+
191
+ fn clampedDot(x: vec3<f32>, y: vec3<f32>) -> f32 {
192
+ return clamp(dot(x, y), 0.0, 1.0);
193
+ }
194
+
195
+ fn F_Schlick(f0: vec3<f32>, f90: vec3<f32>, VdotH: f32) -> vec3<f32> {
196
+ return f0 + (f90 - f0) * pow(clamp(1.0 - VdotH, 0.0, 1.0), 5.0);
197
+ }
198
+
199
+ fn V_GGX(NdotL: f32, NdotV: f32, alphaRoughness: f32) -> f32 {
200
+ let alphaRoughnessSq = alphaRoughness * alphaRoughness;
201
+
202
+ let GGXV = NdotL * sqrt(NdotV * NdotV * (1.0 - alphaRoughnessSq) + alphaRoughnessSq);
203
+ let GGXL = NdotV * sqrt(NdotL * NdotL * (1.0 - alphaRoughnessSq) + alphaRoughnessSq);
204
+
205
+ let GGX = GGXV + GGXL;
206
+ if (GGX > 0.0) {
207
+ return 0.5 / GGX;
208
+ }
209
+ return 0.0;
210
+ }
211
+
212
+ fn D_GGX(NdotH: f32, alphaRoughness: f32) -> f32 {
213
+ let alphaRoughnessSq = alphaRoughness * alphaRoughness;
214
+ let f = (NdotH * NdotH) * (alphaRoughnessSq - 1.0) + 1.0;
215
+ return alphaRoughnessSq / (3.14159 * f * f);
216
+ }
217
+
218
+ fn BRDF_lambertian(f0: vec3<f32>, f90: vec3<f32>, diffuseColor: vec3<f32>, specularWeight: f32, VdotH: f32) -> vec3<f32> {
219
+ return (1.0 - specularWeight * F_Schlick(f0, f90, VdotH)) * (diffuseColor / 3.14159);
220
+ }
221
+
222
+ fn BRDF_specularGGX(f0: vec3<f32>, f90: vec3<f32>, alphaRoughness: f32, specularWeight: f32, VdotH: f32, NdotL: f32, NdotV: f32, NdotH: f32) -> vec3<f32> {
223
+ let F = F_Schlick(f0, f90, VdotH);
224
+ let Vis = V_GGX(NdotL, NdotV, alphaRoughness);
225
+ let D = D_GGX(NdotH, alphaRoughness);
226
+
227
+ return specularWeight * F * Vis * D;
228
+ }
229
+
230
+ // Get environment UV based on encoding type (0=equirectangular, 1=octahedral)
231
+ fn getEnvUV(dir: vec3<f32>) -> vec2<f32> {
232
+ if (uniforms.noiseParams.w > 0.5) {
233
+ return octEncode(dir);
234
+ }
235
+ return SphToUV(dir);
236
+ }
237
+
238
+ fn getIBLSample(reflection: vec3<f32>, lod: f32) -> vec3<f32> {
239
+ let envRGBE = textureSampleLevel(env, envSampler, getEnvUV(reflection), lod);
240
+ // Both equirectangular and octahedral textures are RGBE encoded
241
+ let envColor = envRGBE.rgb * pow(2.0, envRGBE.a * 255.0 - 128.0);
242
+ return envColor;
243
+ }
244
+
245
+ // IBL sample with noise-jittered UV to reduce banding
246
+ fn getIBLSampleJittered(reflection: vec3<f32>, lod: f32, screenPos: vec2f) -> vec3<f32> {
247
+ let baseUV = getEnvUV(reflection);
248
+
249
+ // Get 2D jitter from blue noise
250
+ let jitter = getNoiseJitter(screenPos);
251
+
252
+ // Jitter by 0.5 texels of the environment texture
253
+ let envSize = vec2f(textureDimensions(env, 0));
254
+ let jitterScale = 16 / envSize;
255
+ let jitteredUV = baseUV + jitter * jitterScale;
256
+
257
+ let envRGBE = textureSampleLevel(env, envSampler, jitteredUV, lod);
258
+ // Both equirectangular and octahedral textures are RGBE encoded
259
+ let envColor = envRGBE.rgb * pow(2.0, envRGBE.a * 255.0 - 128.0);
260
+ return envColor;
261
+ }
262
+
263
+ // IBL specular with multisampled cone jitter based on roughness
264
+ // Uses Vogel disk sampling with blue noise rotation (same technique as planar reflections)
265
+ fn getIBLRadianceGGX(n: vec3<f32>, v: vec3<f32>, roughness: f32, F0: vec3<f32>, specularWeight: f32, screenPos: vec2f) -> vec3<f32> {
266
+ let NdotV = clampedDot(n, v);
267
+ let lod = roughness * (uniforms.environmentParams.z - 1);
268
+ let reflection = normalize(reflect(-v, n));
269
+
270
+ // Fresnel calculation
271
+ let Fr = max(vec3<f32>(1.0 - roughness), F0) - F0;
272
+ let k_S = F0 + Fr * pow(1.0 - NdotV, 5.0);
273
+
274
+ // For very smooth surfaces (roughness < 0.1), use single sample
275
+ if (roughness < 0.1) {
276
+ let specularSample = getIBLSampleJittered(reflection, lod, screenPos);
277
+ return specularWeight * specularSample * k_S;
278
+ }
279
+
280
+ // Multisample with roughness-based cone jitter
281
+ let numSamples = 4;
282
+
283
+ // Get blue noise for rotation and radius jitter
284
+ let noise = getNoise4(screenPos);
285
+ let rotationAngle = noise.r * 6.283185307; // 0 to 2*PI rotation
286
+ let radiusJitter = noise.g;
287
+
288
+ // Build orthonormal basis around reflection direction
289
+ let basis = buildOrthonormalBasis(reflection);
290
+
291
+ // Cone angle based on roughness (roughness^2 scaling, max ~30 degrees)
292
+ // Similar to planar reflection blur scaling
293
+ let maxConeAngle = 0.5; // ~30 degrees in radians
294
+ let coneAngle = roughness * roughness * maxConeAngle;
295
+
296
+ var colorSum = vec3f(0.0);
297
+
298
+ for (var i = 0; i < numSamples; i++) {
299
+ // Get Vogel disk sample position (unit disk, rotated by blue noise)
300
+ let diskSample = vogelDiskSample(i, numSamples, rotationAngle);
301
+
302
+ // Apply radius jitter per-sample
303
+ let sampleRadius = mix(0.5, 1.0, fract(radiusJitter + f32(i) * 0.618033988749895));
304
+
305
+ // Convert disk position to cone offset
306
+ let offset2D = diskSample * sampleRadius * coneAngle;
307
+
308
+ // Perturb reflection direction within cone
309
+ let perturbedDir = normalize(
310
+ basis[2] + // reflection direction (z-axis of basis)
311
+ basis[0] * offset2D.x + // tangent
312
+ basis[1] * offset2D.y // bitangent
313
+ );
314
+
315
+ // Sample environment at perturbed direction
316
+ let sampleColor = getIBLSample(perturbedDir, lod);
317
+ colorSum += sampleColor;
318
+ }
319
+
320
+ let specularLight = colorSum / f32(numSamples);
321
+ return specularWeight * specularLight * k_S;
322
+ }
323
+
324
+ // Sample shadow from a specific cascade with blue noise jittered PCF
325
+ // Returns: x = shadow value (0=shadow, 1=lit), y = 1 if in bounds, 0 if out of bounds
326
+ fn sampleCascadeShadowWithBounds(worldPos: vec3<f32>, normal: vec3<f32>, cascadeIndex: i32, screenPos: vec2f) -> vec2f {
327
+ let baseBias = uniforms.shadowParams.x;
328
+ let baseNormalBias = uniforms.shadowParams.y;
329
+ let shadowMapSize = uniforms.shadowParams.w;
330
+
331
+ // Slightly increase bias for cascade 2 (larger coverage, lower resolution)
332
+ var biasScale = 1.0;
333
+ if (cascadeIndex == 2) {
334
+ biasScale = 1.5;
335
+ }
336
+ let bias = baseBias * biasScale;
337
+ let normalBias = baseNormalBias * biasScale;
338
+
339
+ // Apply normal bias
340
+ let biasedPos = worldPos + normal * normalBias;
341
+
342
+ // Get cascade matrix from storage buffer
343
+ let lightMatrix = cascadeMatrices.matrices[cascadeIndex];
344
+
345
+ // Transform to light space
346
+ let lightSpacePos = lightMatrix * vec4f(biasedPos, 1.0);
347
+ let projCoords = lightSpacePos.xyz / lightSpacePos.w;
348
+
349
+ // Transform to [0,1] UV space
350
+ let shadowUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);
351
+ let currentDepth = projCoords.z - bias;
352
+
353
+ // Check bounds
354
+ let inBoundsX = shadowUV.x >= 0.0 && shadowUV.x <= 1.0;
355
+ let inBoundsY = shadowUV.y >= 0.0 && shadowUV.y <= 1.0;
356
+ let inBoundsZ = currentDepth >= 0.0 && currentDepth <= 1.0;
357
+ let inBounds = inBoundsX && inBoundsY && inBoundsZ;
358
+
359
+ if (!inBounds) {
360
+ return vec2f(1.0, 0.0); // Out of bounds
361
+ }
362
+
363
+ let clampedUV = clamp(shadowUV, vec2f(0.001), vec2f(0.999));
364
+ let clampedDepth = clamp(currentDepth, 0.001, 0.999);
365
+
366
+ // Get blue noise jitter for this pixel
367
+ let jitter = getNoiseJitter(screenPos);
368
+
369
+ // Blue noise jittered PCF - rotated disk pattern
370
+ // Use noise to rotate and offset sample positions
371
+ let texelSize = 1.0 / shadowMapSize;
372
+ // More blur for closer cascades, less for distant ones
373
+ var cascadeBlurScale = 1.0; // Default for cascade 2
374
+
375
+ if (cascadeIndex == 0) {
376
+ cascadeBlurScale = 2.0;
377
+ } else if (cascadeIndex == 1) {
378
+ cascadeBlurScale = 1.7;
379
+ }
380
+
381
+ let filterRadius = texelSize * cascadeBlurScale;
382
+
383
+ // Create rotation matrix from noise
384
+ let angle = jitter.x * PI;
385
+ let cosA = cos(angle);
386
+ let sinA = sin(angle);
387
+
388
+ // Poisson disk sample offsets (pre-computed, 8 samples)
389
+ let offsets = array<vec2f, 8>(
390
+ vec2f(-0.94201624, -0.39906216),
391
+ vec2f(0.94558609, -0.76890725),
392
+ vec2f(-0.094184101, -0.92938870),
393
+ vec2f(0.34495938, 0.29387760),
394
+ vec2f(-0.91588581, 0.45771432),
395
+ vec2f(-0.81544232, -0.87912464),
396
+ vec2f(-0.38277543, 0.27676845),
397
+ vec2f(0.97484398, 0.75648379)
398
+ );
399
+
400
+ var shadow = 0.0;
401
+ for (var i = 0; i < 8; i++) {
402
+ // Rotate offset by noise angle
403
+ let offset = offsets[i];
404
+ let rotatedOffset = vec2f(
405
+ offset.x * cosA - offset.y * sinA,
406
+ offset.x * sinA + offset.y * cosA
407
+ ) * filterRadius;
408
+
409
+ // Add subtle jitter offset
410
+ let jitteredOffset = rotatedOffset + jitter * texelSize * 0.15;
411
+
412
+ shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + jitteredOffset, cascadeIndex, clampedDepth);
413
+ }
414
+ shadow /= 8.0;
415
+
416
+ return vec2f(shadow, 1.0); // In bounds
417
+ }
418
+
419
+ // Wrapper for simple usage
420
+ fn sampleCascadeShadow(worldPos: vec3<f32>, normal: vec3<f32>, cascadeIndex: i32, screenPos: vec2f) -> f32 {
421
+ return sampleCascadeShadowWithBounds(worldPos, normal, cascadeIndex, screenPos).x;
422
+ }
423
+
424
+ // Squircle distance function for smooth fade at edges
425
+ // Returns 0-1 where 1 is at the edge of the squircle
426
+ fn squircleDistance(uv: vec2<f32>, power: f32) -> f32 {
427
+ // Squircle: |x|^n + |y|^n = 1 where n > 2 gives squircle shape
428
+ let centered = (uv - 0.5) * 2.0; // Convert to -1 to 1
429
+ let absCentered = abs(centered);
430
+ return pow(pow(absCentered.x, power) + pow(absCentered.y, power), 1.0 / power);
431
+ }
432
+
433
+ // Squircle distance - returns distance normalized to cascade size
434
+ // Power 4 gives nice rounded corners
435
+ fn squircleDistanceXZ(offset: vec2f, size: f32) -> f32 {
436
+ let normalized = offset / size;
437
+ let absNorm = abs(normalized);
438
+ return pow(pow(absNorm.x, 4.0) + pow(absNorm.y, 4.0), 0.25);
439
+ }
440
+
441
+ // Calculate cascaded shadow with smooth blending between cascades
442
+ fn calculateShadow(worldPos: vec3<f32>, normal: vec3<f32>, screenPos: vec2f) -> f32 {
443
+ let shadowStrength = uniforms.shadowParams.z;
444
+
445
+ // Calculate XZ offset from camera to world position
446
+ let camXZ = vec2f(uniforms.cameraPosition.x, uniforms.cameraPosition.z);
447
+ let posXZ = vec2f(worldPos.x, worldPos.z);
448
+ let offsetXZ = posXZ - camXZ;
449
+
450
+ // Cascade sizes from uniforms
451
+ let cascade0Size = uniforms.cascadeSizes.x;
452
+ let cascade1Size = uniforms.cascadeSizes.y;
453
+ let cascade2Size = uniforms.cascadeSizes.z;
454
+
455
+ // Use squircle distance for cascade selection - rounded square shape
456
+ let dist0 = squircleDistanceXZ(offsetXZ, cascade0Size);
457
+ let dist1 = squircleDistanceXZ(offsetXZ, cascade1Size);
458
+ let dist2 = squircleDistanceXZ(offsetXZ, cascade2Size);
459
+
460
+ // Determine which cascade(s) to sample based on squircle distance
461
+ var shadow = 1.0;
462
+ var cascadeUsed = -1;
463
+ var blendFactor = 0.0;
464
+
465
+ // Sample cascades with bounds checking (pass screen position for blue noise)
466
+ let sample0 = sampleCascadeShadowWithBounds(worldPos, normal, 0, screenPos);
467
+ let sample1 = sampleCascadeShadowWithBounds(worldPos, normal, 1, screenPos);
468
+ let sample2 = sampleCascadeShadowWithBounds(worldPos, normal, 2, screenPos);
469
+
470
+ let inBounds0 = sample0.y > 0.5;
471
+ let inBounds1 = sample1.y > 0.5;
472
+ let inBounds2 = sample2.y > 0.5;
473
+
474
+ // Cascade 0: highest detail, smallest area
475
+ if (dist0 < 1.0) {
476
+ if (inBounds0) {
477
+ // Blend towards cascade 1 at 90-100% of cascade 0 size
478
+ let blend0to1 = smoothstep(0.9, 1.0, dist0);
479
+ if (blend0to1 > 0.0 && inBounds1) {
480
+ shadow = mix(sample0.x, sample1.x, blend0to1);
481
+ } else {
482
+ shadow = sample0.x;
483
+ }
484
+ cascadeUsed = 0;
485
+ } else if (inBounds1) {
486
+ // Cascade 0 out of bounds, use cascade 1 100%
487
+ shadow = sample1.x;
488
+ cascadeUsed = 1;
489
+ } else if (inBounds2) {
490
+ // Cascade 0 and 1 out of bounds, use cascade 2 100%
491
+ shadow = sample2.x;
492
+ cascadeUsed = 2;
493
+ } else {
494
+ return mix(1.0 - shadowStrength, 1.0, 0.5);
495
+ }
496
+ }
497
+ // Cascade 1: medium detail
498
+ else if (dist1 < 1.0) {
499
+ if (inBounds1) {
500
+ // Blend towards cascade 2 at 90-100% of cascade 1 size
501
+ let blend1to2 = smoothstep(0.9, 1.0, dist1);
502
+ if (blend1to2 > 0.0 && inBounds2) {
503
+ shadow = mix(sample1.x, sample2.x, blend1to2);
504
+ } else {
505
+ shadow = sample1.x;
506
+ }
507
+ cascadeUsed = 1;
508
+ } else if (inBounds2) {
509
+ // Cascade 1 out of bounds, use cascade 2 100%
510
+ shadow = sample2.x;
511
+ cascadeUsed = 2;
512
+ } else {
513
+ return mix(1.0 - shadowStrength, 1.0, 0.5);
514
+ }
515
+ }
516
+ // Cascade 2: lowest detail, largest area
517
+ else if (dist2 < 1.0) {
518
+ if (inBounds2) {
519
+ shadow = sample2.x;
520
+ cascadeUsed = 2;
521
+
522
+ // Gradual fade for cascade 2:
523
+ // 50-95%: fade shadow towards half-lit
524
+ // 95-100%: fade from shadow to half-lit (0.5)
525
+ let lightnessFade = smoothstep(0.5, 0.95, dist2);
526
+ shadow = mix(shadow, 0.5, lightnessFade);
527
+
528
+ let edgeFade = smoothstep(0.95, 1.0, dist2);
529
+ shadow = mix(shadow, 0.5, edgeFade);
530
+ } else {
531
+ return mix(1.0 - shadowStrength, 1.0, 0.5);
532
+ }
533
+ }
534
+ // Beyond all cascades: half-lit
535
+ else {
536
+ return mix(1.0 - shadowStrength, 1.0, 0.5);
537
+ }
538
+
539
+ // Apply shadow strength
540
+ let shadowResult = mix(1.0 - shadowStrength, 1.0, shadow);
541
+ return shadowResult;
542
+ }
543
+
544
+ // Calculate spot light shadow from atlas using storage buffer for matrices
545
+ // lightPos: spotlight position (for calculating distance to camera)
546
+ // slotIndex: shadow atlas slot (-1 if no shadow)
547
+ fn calculateSpotShadow(worldPos: vec3<f32>, normal: vec3<f32>, lightPos: vec3<f32>, slotIndex: i32, screenPos: vec2f) -> f32 {
548
+ // Use hard-coded constants to avoid uniform buffer alignment issues
549
+ let fadeStart = SPOT_FADE_START;
550
+ let maxDist = SPOT_MAX_DISTANCE;
551
+ let minShadow = SPOT_MIN_SHADOW;
552
+ let bias = uniforms.shadowParams.x;
553
+ let normalBias = uniforms.shadowParams.y;
554
+ let shadowStrength = uniforms.shadowParams.z;
555
+
556
+ // Calculate distance from spotlight to camera (not light to pixel!)
557
+ let lightToCam = lightPos - uniforms.cameraPosition;
558
+ let lightDistance = length(lightToCam);
559
+
560
+ // Get atlas parameters from constants
561
+ let atlasWidth = SPOT_ATLAS_WIDTH;
562
+ let atlasHeight = SPOT_ATLAS_HEIGHT;
563
+ let tileSize = SPOT_TILE_SIZE;
564
+ let tilesPerRow = SPOT_TILES_PER_ROW;
565
+
566
+ // Check validity
567
+ let hasValidSlot = f32(slotIndex >= 0 && slotIndex < MAX_SPOT_SHADOWS);
568
+
569
+ // Clamp slot index to valid range for matrix access
570
+ let safeSlot = clamp(slotIndex, 0, MAX_SPOT_SHADOWS - 1);
571
+
572
+ // Calculate tile position in atlas
573
+ let col = safeSlot % tilesPerRow;
574
+ let row = safeSlot / tilesPerRow;
575
+
576
+ // Apply normal bias (increased for spot lights due to perspective projection)
577
+ let spotNormalBias = normalBias * 2.0;
578
+ let biasedPos = worldPos + normal * spotNormalBias;
579
+
580
+ // Get the light matrix from storage buffer
581
+ let lightMatrix = spotMatrices.matrices[safeSlot];
582
+
583
+ // Transform to light space
584
+ let lightSpacePos = lightMatrix * vec4f(biasedPos, 1.0);
585
+
586
+ // Perspective divide (avoid divide by zero)
587
+ let w = max(abs(lightSpacePos.w), 0.0001) * sign(lightSpacePos.w + 0.0001);
588
+ let projCoords = lightSpacePos.xyz / w;
589
+
590
+ // Calculate edge fade for soft frustum boundaries
591
+ // Use distance from center in clip space (max of abs x,y)
592
+ let edgeDist = max(abs(projCoords.x), abs(projCoords.y));
593
+ // Fade from 0.8 to 1.0 in clip space (soft edge)
594
+ let edgeFade = 1.0 - smoothstep(0.8, 1.0, edgeDist);
595
+ // Also fade based on depth (behind camera or too far)
596
+ let depthFade = smoothstep(-0.1, 0.1, projCoords.z) * (1.0 - smoothstep(0.9, 1.0, projCoords.z));
597
+ let frustumFade = edgeFade * depthFade;
598
+
599
+ // Transform to [0,1] UV space within the tile
600
+ let tileUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);
601
+
602
+ // Calculate UV in atlas (normalized to atlas size)
603
+ let tileOffsetX = f32(col) * tileSize;
604
+ let tileOffsetY = f32(row) * tileSize;
605
+ let atlasUV = vec2f(
606
+ (tileOffsetX + tileUV.x * tileSize) / atlasWidth,
607
+ (tileOffsetY + tileUV.y * tileSize) / atlasHeight
608
+ );
609
+
610
+ // Blue noise jittered PCF for spot shadows
611
+ let jitter = getNoiseJitter(screenPos);
612
+ let texelSize = 1.0 / tileSize;
613
+ let filterRadius = texelSize * 0.5; // Subtle filter for spot shadows
614
+
615
+ // Create rotation from noise
616
+ let angle = jitter.x * PI;
617
+ let cosA = cos(angle);
618
+ let sinA = sin(angle);
619
+
620
+ // 4-sample rotated PCF for spot shadows (less samples for performance)
621
+ let offsets = array<vec2f, 4>(
622
+ vec2f(-0.7, -0.7),
623
+ vec2f(0.7, -0.7),
624
+ vec2f(-0.7, 0.7),
625
+ vec2f(0.7, 0.7)
626
+ );
627
+
628
+ // Current depth with bias (increased for spot lights)
629
+ let spotBias = bias * 3.0;
630
+ let currentDepth = clamp(projCoords.z - spotBias, 0.001, 0.999);
631
+
632
+ var shadowSample = 0.0;
633
+ for (var i = 0; i < 4; i++) {
634
+ let offset = offsets[i];
635
+ let rotatedOffset = vec2f(
636
+ offset.x * cosA - offset.y * sinA,
637
+ offset.x * sinA + offset.y * cosA
638
+ ) * filterRadius;
639
+
640
+ let sampleUV = clamp(atlasUV + rotatedOffset, vec2f(0.001), vec2f(0.999));
641
+ shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, sampleUV, currentDepth);
642
+ }
643
+ shadowSample /= 4.0;
644
+
645
+ // Apply shadow strength
646
+ let shadowWithStrength = mix(1.0 - shadowStrength, 1.0, shadowSample);
647
+
648
+ // Apply frustum edge fade (fade to 1.0 = no shadow at edges)
649
+ let edgeFadedShadow = mix(1.0, shadowWithStrength, frustumFade);
650
+
651
+ // Apply distance-based fade
652
+ let fadeT = clamp((lightDistance - fadeStart) / (maxDist - fadeStart), 0.0, 1.0);
653
+ let fadedShadow = mix(edgeFadedShadow, minShadow, fadeT);
654
+
655
+ // Calculate result for no-slot case (distance-based constant shadow)
656
+ let noSlotFadeT = clamp((lightDistance - fadeStart) / (maxDist - fadeStart), 0.0, 1.0);
657
+ let noSlotResult = mix(1.0, minShadow, noSlotFadeT);
658
+
659
+ // Select final result based on slot validity (frustum fade already applied)
660
+ let shadowResult = select(noSlotResult, fadedShadow, hasValidSlot > 0.5);
661
+
662
+ // Debug visualization stored in global for fragment shader to use
663
+ // Using projCoords.z as a debug value
664
+
665
+ return shadowResult;
666
+ }
667
+
668
+ @vertex
669
+ fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
670
+ var output : VertexOutput;
671
+ let x = f32(vertexIndex & 1u) * 4.0 - 1.0;
672
+ let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;
673
+ output.position = vec4<f32>(x, y, 0.0, 1.0);
674
+ output.uv = vec2<f32>((x + 1.0) * 0.5, (1.0 - y) * 0.5);
675
+ return output;
676
+ }
677
+
678
+ @fragment
679
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
680
+ let cs = uniforms.canvasSize;
681
+ let fuv = vec2i(floor(input.uv * cs));
682
+ let baseColor = textureLoad(gAlbedo, fuv, 0);
683
+ let normal = normalize(textureLoad(gNormal, fuv, 0).xyz);
684
+ let arm = textureLoad(gArm, fuv, 0);
685
+ let emissive = textureLoad(gEmission, fuv, 0).rgb;
686
+ let depth = textureLoad(gDepth, fuv, 0);
687
+
688
+ // Unpack ARM values (Ambient, Roughness, Metallic, per-material SpecularBoost)
689
+ var ao = arm.r;
690
+ var roughness = arm.g;
691
+ var metallic = arm.b;
692
+ let materialSpecularBoost = arm.a; // Per-material boost (0-1)
693
+
694
+ // Sample SSAO texture - this affects only ambient/environment lighting
695
+ let ssao = textureLoad(aoTexture, fuv, 0).r;
696
+
697
+ var specular = vec3<f32>(0.0);
698
+ var diffuse = vec3<f32>(0.0);
699
+
700
+ let ior = 1.5;
701
+ var f0 = vec3<f32>(0.04);
702
+ let f90 = vec3<f32>(1.0);
703
+ let specularWeight = 1.0;
704
+
705
+ // Reconstruct position from linear depth and UV
706
+ // Linear depth: depth = (z - near) / (far - near), so z = near + depth * (far - near)
707
+ let near = uniforms.cameraParams.x;
708
+ let far = uniforms.cameraParams.y;
709
+ let linearDepth = near + depth * (far - near);
710
+
711
+ // Get view-space ray direction from UV
712
+ var iuv = input.uv;
713
+ iuv.y = 1.0 - iuv.y;
714
+ let ndc = vec4f(iuv * 2.0 - 1.0, 0.0, 1.0);
715
+ let viewRay = uniforms.inverseProjection * ndc;
716
+ let rayDir = normalize(viewRay.xyz / viewRay.w);
717
+
718
+ // Reconstruct view-space position using linear depth
719
+ let viewPos = rayDir * (linearDepth / -rayDir.z);
720
+
721
+ // Transform to world space
722
+ let worldPos4 = uniforms.inverseView * vec4f(viewPos, 1.0);
723
+ let position = worldPos4.xyz;
724
+ let toCamera = uniforms.cameraPosition.xyz - position;
725
+ let v = normalize(toCamera);
726
+
727
+ let n = normal;
728
+
729
+ f0 = mix(f0, baseColor.rgb, metallic);
730
+ roughness = max(roughness, 0.04);
731
+ let alphaRoughness = roughness * roughness;
732
+
733
+ let c_diff = mix(baseColor.rgb, vec3<f32>(0.0), metallic);
734
+
735
+ // Combine material AO with SSAO for ambient/environment lighting only
736
+ let combinedAO = ao * ssao;
737
+
738
+ diffuse += combinedAO * uniforms.ambientColor.rgb * uniforms.ambientColor.a * BRDF_lambertian(f0, f90, c_diff, specularWeight, 1.0);
739
+
740
+ // Use jittered IBL sampling to reduce banding
741
+ let screenPos = input.position.xy;
742
+ let iblSample = getIBLSampleJittered(n, uniforms.environmentParams.z - 2, screenPos);
743
+ diffuse += combinedAO * uniforms.environmentParams.x * iblSample.rgb * BRDF_lambertian(f0, f90, c_diff, specularWeight, 1.0);
744
+
745
+ let reflection = normalize(reflect(-v, n));
746
+ specular += combinedAO * uniforms.environmentParams.y * getIBLRadianceGGX(n, v, roughness, f0, specularWeight, screenPos);
747
+
748
+ // calculate lighting
749
+
750
+ let onormal = n;
751
+ let l = normalize(uniforms.lightDir.xyz);
752
+ let h = normalize(l + v);
753
+ let NdotV = clampedDot(n, v);
754
+ let ONdotV = clampedDot(onormal, v);
755
+ let ONdotL = clampedDot(onormal, l);
756
+ let NdotLF = dot(n, l);
757
+ let NdotL = clamp(NdotLF, 0.0, 1.0);
758
+ let NdotLF_adjusted = NdotLF * 0.5 + 0.5;
759
+ let NdotH = clampedDot(n, h);
760
+ let LdotH = clampedDot(l, h);
761
+ let VdotH = clampedDot(v, h);
762
+
763
+ let intensity = uniforms.lightColor.a;
764
+ // Calculate shadow outside of non-uniform control flow
765
+ let shadow = calculateShadow(position, n, screenPos);
766
+ let fallOff = smoothstep(-0.1, 0.3, NdotLF); // Soft gradual falloff
767
+
768
+ // Apply lighting with shadow (using select to avoid non-uniform branching)
769
+ let lightActive = select(0.0, 1.0, (NdotL > 0.0 || NdotV > 0.0) && intensity > 0.0);
770
+ diffuse += lightActive * intensity * uniforms.lightColor.rgb * fallOff * shadow * BRDF_lambertian(f0, f90, c_diff, specularWeight, VdotH);
771
+ specular += lightActive * intensity * uniforms.lightColor.rgb * fallOff * shadow * BRDF_specularGGX(f0, f90, alphaRoughness, specularWeight, VdotH, fallOff, NdotV, NdotH) * uniforms.cameraParams.w;
772
+
773
+ // Tiled lighting: get tile index from pixel position
774
+ let tileSize = uniforms.tileParams.x;
775
+ let tileCountX = uniforms.tileParams.y;
776
+ let maxLightsPerTile = uniforms.tileParams.z;
777
+ let actualLightCount = uniforms.tileParams.w;
778
+
779
+ // Calculate tileCountY from canvas size
780
+ let tileCountY = (u32(uniforms.canvasSize.y) + tileSize - 1u) / tileSize;
781
+
782
+ let tileX = u32(floor(input.uv.x * uniforms.canvasSize.x)) / tileSize;
783
+ // Flip Y because UV.y increases downward but NDC Y (used in compute shader) increases upward
784
+ let rawTileY = u32(floor(input.uv.y * uniforms.canvasSize.y)) / tileSize;
785
+ let tileY = tileCountY - 1u - rawTileY;
786
+ let tileIndex = tileY * tileCountX + tileX;
787
+ let tileDataOffset = tileIndex * (maxLightsPerTile + 1u);
788
+
789
+ // Read number of lights affecting this tile
790
+ let tileLightCount = min(tileLightIndices[tileDataOffset], maxLightsPerTile);
791
+
792
+ // Process only lights that affect this tile
793
+ for (var i = 0u; i < tileLightCount; i++) {
794
+ let lightIndex = tileLightIndices[tileDataOffset + 1u + i];
795
+ if (lightIndex >= actualLightCount) {
796
+ continue;
797
+ }
798
+
799
+ let light = lights[lightIndex];
800
+ let isEnabled = f32(light.enabled);
801
+
802
+ // Calculate light direction and distance
803
+ let lightVec = light.position - position;
804
+ let dist = length(lightVec);
805
+ let lightDir = normalize(lightVec);
806
+
807
+ // Distance fade from CPU culling (stored in geom.w) - smooth fade to avoid popping
808
+ let distanceFade = light.geom.w;
809
+
810
+ // Attenuation (inverse square with radius falloff)
811
+ let radius = max(light.geom.x, 0.001);
812
+ let attenuation = max(0.0, 1.0 - dist / radius);
813
+ let attenuationSq = attenuation * attenuation * distanceFade;
814
+
815
+ // Spot light cone attenuation
816
+ let innerCone = light.geom.y;
817
+ let outerCone = light.geom.z;
818
+ let spotCos = dot(-lightDir, normalize(light.direction + vec3f(0.0001)));
819
+ let spotAttenuation = select(1.0, smoothstep(outerCone, innerCone, spotCos), outerCone > 0.0);
820
+
821
+ // Spot shadow calculation (only for spotlights, not point lights)
822
+ let isSpotlight = outerCone > 0.0;
823
+ let spotShadow = calculateSpotShadow(position, n, light.position, light.shadowIndex, screenPos);
824
+ let lightShadow = select(1.0, spotShadow, isSpotlight); // Point lights: no shadow
825
+
826
+ // Calculate lighting vectors
827
+ let pl = lightDir;
828
+ let ph = normalize(pl + v);
829
+ let pNdotL = clampedDot(n, pl);
830
+ let pNdotH = clampedDot(n, ph);
831
+ let pVdotH = clampedDot(v, ph);
832
+
833
+ // Light intensity from color.a, masked by enabled, attenuation, and shadow
834
+ let pIntensity = isEnabled * light.color.a * attenuationSq * spotAttenuation * lightShadow * pNdotL;
835
+
836
+ // Add light contribution (always compute, multiply by intensity mask)
837
+ // Use simpler diffuse calculation for point lights
838
+ diffuse += pIntensity * light.color.rgb * c_diff / PI;
839
+ specular += pIntensity * light.color.rgb * BRDF_specularGGX(f0, f90, alphaRoughness, specularWeight, pVdotH, pNdotL, NdotV, pNdotH) * uniforms.cameraParams.w;
840
+ }
841
+
842
+ // Specular boost: 3 fake lights at 60° elevation, 120° apart
843
+ // Only affects materials with specularBoost >= 0.04 and roughness < cutoff, ignores shadows
844
+ let boostIntensity = uniforms.specularBoost.x;
845
+ let boostRoughnessCutoff = uniforms.specularBoost.y;
846
+
847
+ // Per-material specularBoost controls this effect (0 = disabled, 1 = full effect)
848
+ // Skip calculation entirely if material boost is below threshold (default 0 means no boost)
849
+ if (materialSpecularBoost >= 0.04 && boostIntensity > 0.0 && roughness < boostRoughnessCutoff && depth < 0.999999) {
850
+
851
+ // 3 light directions at 30° above horizon (sin30=0.5, cos30=0.866), 120° apart
852
+ // Pre-calculated normalized directions:
853
+ let boostDir0 = vec3f(0.0, 0.5, 0.866025); // 0° yaw
854
+ let boostDir1 = vec3f(0.75, 0.5, -0.433013); // 120° yaw
855
+ let boostDir2 = vec3f(-0.75, 0.5, -0.433013); // 240° yaw
856
+
857
+ // Fade based on roughness: full at 0, zero at cutoff
858
+ let roughnessFade = 1.0 - (roughness / boostRoughnessCutoff);
859
+ // Include per-material boost in final strength
860
+ let boostStrength = boostIntensity * roughnessFade * roughnessFade * materialSpecularBoost;
861
+
862
+ // Use sharper roughness for boost highlights (smaller, more concentrated)
863
+ // Square the roughness to make highlights much tighter
864
+ let boostRoughness = roughness * 0.5;
865
+ let boostAlphaRoughness = boostRoughness * 0.5;
866
+
867
+ // Calculate specular for each boost light
868
+ var boostSpecular = vec3f(0.0);
869
+
870
+ // Light 0
871
+ let bNdotL0 = max(dot(n, boostDir0), 0.0);
872
+ if (bNdotL0 > 0.0) {
873
+ let bH0 = normalize(boostDir0 + v);
874
+ let bNdotH0 = max(dot(n, bH0), 0.0);
875
+ let bVdotH0 = max(dot(v, bH0), 0.0);
876
+ boostSpecular += bNdotL0 * BRDF_specularGGX(f0, f90, boostAlphaRoughness, specularWeight, bVdotH0, bNdotL0, NdotV, bNdotH0);
877
+ }
878
+
879
+ // Light 1
880
+ let bNdotL1 = max(dot(n, boostDir1), 0.0);
881
+ if (bNdotL1 > 0.0) {
882
+ let bH1 = normalize(boostDir1 + v);
883
+ let bNdotH1 = max(dot(n, bH1), 0.0);
884
+ let bVdotH1 = max(dot(v, bH1), 0.0);
885
+ boostSpecular += bNdotL1 * BRDF_specularGGX(f0, f90, boostAlphaRoughness, specularWeight, bVdotH1, bNdotL1, NdotV, bNdotH1);
886
+ }
887
+
888
+ // Light 2
889
+ let bNdotL2 = max(dot(n, boostDir2), 0.0);
890
+ if (bNdotL2 > 0.0) {
891
+ let bH2 = normalize(boostDir2 + v);
892
+ let bNdotH2 = max(dot(n, bH2), 0.0);
893
+ let bVdotH2 = max(dot(v, bH2), 0.0);
894
+ boostSpecular += bNdotL2 * BRDF_specularGGX(f0, f90, boostAlphaRoughness, specularWeight, bVdotH2, bNdotL2, NdotV, bNdotH2);
895
+ }
896
+
897
+ // Add boost specular (white light, same intensity as main light would have)
898
+ specular += boostSpecular * boostStrength;
899
+ }
900
+
901
+ // calculate final color
902
+
903
+ let specularColor = specular + emissive;
904
+ let diffuseColor = diffuse;
905
+
906
+ var color = diffuseColor + specularColor;
907
+ color *= uniforms.environmentParams.a;
908
+
909
+ if (depth > 0.999999) {
910
+ var bgDir = v;
911
+ if (uniforms.noiseParams.w > 0.5) {
912
+ // Octahedral encoding: captured probe uses standard coordinate system
913
+ // Just negate view direction to get world direction
914
+ bgDir = -v;
915
+ } else {
916
+ // Equirectangular encoding: flip to match IBL lighting orientation
917
+ bgDir.x *= -1.0;
918
+ bgDir.z *= -1.0;
919
+ bgDir.y *= -1.0;
920
+ }
921
+ // In reflection mode, flip Y to mirror the sky
922
+ if (uniforms.cameraParams.z > 0.5) {
923
+ bgDir.y *= -1.0;
924
+ }
925
+ // Background sky: only apply exposure, NOT diffuse/specular IBL levels
926
+ // Those levels are for PBR lighting, not for displaying the actual sky
927
+ let bgSample = getIBLSample(bgDir, 0.0) * uniforms.environmentParams.a;
928
+ color = bgSample;
929
+ }
930
+
931
+ return vec4f(color, 1.0);
932
+ }