@bornengine/engine 0.4.16

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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -0
  3. package/native/android/Cargo.lock +1848 -0
  4. package/native/android/Cargo.toml +24 -0
  5. package/native/android/src/lib.rs +702 -0
  6. package/native/ios/Cargo.lock +1690 -0
  7. package/native/ios/Cargo.toml +32 -0
  8. package/native/ios/src/lib.rs +1267 -0
  9. package/native/linux/Cargo.lock +3279 -0
  10. package/native/linux/Cargo.toml +29 -0
  11. package/native/linux/src/lib.rs +1331 -0
  12. package/native/macos/Cargo.lock +3310 -0
  13. package/native/macos/Cargo.toml +46 -0
  14. package/native/macos/src/lib.rs +1302 -0
  15. package/native/shared/Cargo.lock +1899 -0
  16. package/native/shared/Cargo.toml +62 -0
  17. package/native/shared/assets/default_font.ttf +0 -0
  18. package/native/shared/build.rs +270 -0
  19. package/native/shared/shaders/common/clouds.wgsl +122 -0
  20. package/native/shared/shaders/common/fog.wgsl +16 -0
  21. package/native/shared/shaders/common/foliage_wind.wgsl +98 -0
  22. package/native/shared/shaders/common/imposter.wgsl +112 -0
  23. package/native/shared/shaders/common/pbr.wgsl +186 -0
  24. package/native/shared/shaders/common/shadows.wgsl +186 -0
  25. package/native/shared/shaders/common/sky.wgsl +8 -0
  26. package/native/shared/shaders/common/tonemap.wgsl +25 -0
  27. package/native/shared/shaders/impulse_field.wgsl +57 -0
  28. package/native/shared/shaders/material_abi.wgsl +383 -0
  29. package/native/shared/shaders/materials/test_minimal.wgsl +42 -0
  30. package/native/shared/src/anim_mixer.rs +61 -0
  31. package/native/shared/src/attach.rs +263 -0
  32. package/native/shared/src/audio/decode.rs +123 -0
  33. package/native/shared/src/audio/mod.rs +863 -0
  34. package/native/shared/src/audio/render.rs +892 -0
  35. package/native/shared/src/audio/spsc.rs +156 -0
  36. package/native/shared/src/audio/stream.rs +226 -0
  37. package/native/shared/src/custom_shaders.rs +104 -0
  38. package/native/shared/src/decals.rs +245 -0
  39. package/native/shared/src/drs.rs +211 -0
  40. package/native/shared/src/engine.rs +261 -0
  41. package/native/shared/src/ffi.rs +116 -0
  42. package/native/shared/src/ffi_core/assets.rs +388 -0
  43. package/native/shared/src/ffi_core/audio_ffi.rs +184 -0
  44. package/native/shared/src/ffi_core/draw.rs +334 -0
  45. package/native/shared/src/ffi_core/game_loop.rs +577 -0
  46. package/native/shared/src/ffi_core/input.rs +234 -0
  47. package/native/shared/src/ffi_core/mod.rs +127 -0
  48. package/native/shared/src/ffi_core/models.rs +1154 -0
  49. package/native/shared/src/ffi_core/ragdoll_ffi.rs +261 -0
  50. package/native/shared/src/ffi_core/scene.rs +626 -0
  51. package/native/shared/src/ffi_core/vfx.rs +212 -0
  52. package/native/shared/src/ffi_core/visual.rs +691 -0
  53. package/native/shared/src/frame_callbacks.rs +122 -0
  54. package/native/shared/src/geometry.rs +236 -0
  55. package/native/shared/src/handles.rs +182 -0
  56. package/native/shared/src/input.rs +448 -0
  57. package/native/shared/src/jolt_sys.rs +822 -0
  58. package/native/shared/src/lib.rs +55 -0
  59. package/native/shared/src/models.rs +1093 -0
  60. package/native/shared/src/models_gltf.rs +1280 -0
  61. package/native/shared/src/particles.rs +391 -0
  62. package/native/shared/src/physics_jolt.rs +1908 -0
  63. package/native/shared/src/picking.rs +298 -0
  64. package/native/shared/src/postfx.rs +345 -0
  65. package/native/shared/src/profiler.rs +492 -0
  66. package/native/shared/src/ragdoll.rs +474 -0
  67. package/native/shared/src/renderer/atmosphere_lut.rs +573 -0
  68. package/native/shared/src/renderer/brdf_lut.rs +154 -0
  69. package/native/shared/src/renderer/draw2d.rs +143 -0
  70. package/native/shared/src/renderer/formats.rs +822 -0
  71. package/native/shared/src/renderer/froxel.rs +421 -0
  72. package/native/shared/src/renderer/gi_bake.rs +653 -0
  73. package/native/shared/src/renderer/graph.rs +462 -0
  74. package/native/shared/src/renderer/hiz.rs +269 -0
  75. package/native/shared/src/renderer/hot_reload.rs +390 -0
  76. package/native/shared/src/renderer/impulse_field.rs +456 -0
  77. package/native/shared/src/renderer/lighting.rs +154 -0
  78. package/native/shared/src/renderer/material_instancing.rs +171 -0
  79. package/native/shared/src/renderer/material_pipeline.rs +700 -0
  80. package/native/shared/src/renderer/material_system.rs +1996 -0
  81. package/native/shared/src/renderer/material_system_tests.rs +601 -0
  82. package/native/shared/src/renderer/material_system_wasm.rs +41 -0
  83. package/native/shared/src/renderer/mod.rs +12556 -0
  84. package/native/shared/src/renderer/model_draw.rs +641 -0
  85. package/native/shared/src/renderer/occlusion.rs +429 -0
  86. package/native/shared/src/renderer/planar_pass.rs +593 -0
  87. package/native/shared/src/renderer/planar_reflection.rs +499 -0
  88. package/native/shared/src/renderer/post_pass.rs +249 -0
  89. package/native/shared/src/renderer/postfx_chain.rs +728 -0
  90. package/native/shared/src/renderer/pt_pass.rs +577 -0
  91. package/native/shared/src/renderer/scene_pass.rs +607 -0
  92. package/native/shared/src/renderer/shader_include.rs +205 -0
  93. package/native/shared/src/renderer/shader_library.rs +135 -0
  94. package/native/shared/src/renderer/shaders/ao.rs +570 -0
  95. package/native/shared/src/renderer/shaders/core.rs +1243 -0
  96. package/native/shared/src/renderer/shaders/env.rs +907 -0
  97. package/native/shared/src/renderer/shaders/gi.rs +810 -0
  98. package/native/shared/src/renderer/shaders/mod.rs +19 -0
  99. package/native/shared/src/renderer/shaders/post.rs +1558 -0
  100. package/native/shared/src/renderer/shaders/pt.rs +1859 -0
  101. package/native/shared/src/renderer/shaders/ssgi.rs +1586 -0
  102. package/native/shared/src/renderer/shadow_pass.rs +731 -0
  103. package/native/shared/src/renderer/ssgi_pass.rs +392 -0
  104. package/native/shared/src/renderer/ssr_pass.rs +188 -0
  105. package/native/shared/src/renderer/texture_store.rs +473 -0
  106. package/native/shared/src/renderer/transient.rs +591 -0
  107. package/native/shared/src/renderer/types.rs +941 -0
  108. package/native/shared/src/renderer/util.rs +152 -0
  109. package/native/shared/src/scene.rs +1362 -0
  110. package/native/shared/src/sdf_cache.rs +274 -0
  111. package/native/shared/src/shadows.rs +1036 -0
  112. package/native/shared/src/staging.rs +102 -0
  113. package/native/shared/src/string_header.rs +266 -0
  114. package/native/shared/src/text_renderer.rs +502 -0
  115. package/native/shared/src/textures.rs +197 -0
  116. package/native/tvos/Cargo.lock +1693 -0
  117. package/native/tvos/Cargo.toml +36 -0
  118. package/native/tvos/metal-patched/Cargo.toml +178 -0
  119. package/native/tvos/metal-patched/LICENSE-APACHE +201 -0
  120. package/native/tvos/metal-patched/LICENSE-MIT +25 -0
  121. package/native/tvos/metal-patched/src/acceleration_structure.rs +667 -0
  122. package/native/tvos/metal-patched/src/acceleration_structure_pass.rs +108 -0
  123. package/native/tvos/metal-patched/src/argument.rs +366 -0
  124. package/native/tvos/metal-patched/src/blitpass.rs +102 -0
  125. package/native/tvos/metal-patched/src/buffer.rs +71 -0
  126. package/native/tvos/metal-patched/src/capturedescriptor.rs +76 -0
  127. package/native/tvos/metal-patched/src/capturemanager.rs +113 -0
  128. package/native/tvos/metal-patched/src/commandbuffer.rs +192 -0
  129. package/native/tvos/metal-patched/src/commandqueue.rs +44 -0
  130. package/native/tvos/metal-patched/src/computepass.rs +107 -0
  131. package/native/tvos/metal-patched/src/constants.rs +152 -0
  132. package/native/tvos/metal-patched/src/counters.rs +119 -0
  133. package/native/tvos/metal-patched/src/depthstencil.rs +190 -0
  134. package/native/tvos/metal-patched/src/device.rs +2134 -0
  135. package/native/tvos/metal-patched/src/drawable.rs +39 -0
  136. package/native/tvos/metal-patched/src/encoder.rs +2041 -0
  137. package/native/tvos/metal-patched/src/heap.rs +281 -0
  138. package/native/tvos/metal-patched/src/indirect_encoder.rs +344 -0
  139. package/native/tvos/metal-patched/src/lib.rs +657 -0
  140. package/native/tvos/metal-patched/src/library.rs +902 -0
  141. package/native/tvos/metal-patched/src/mps.rs +575 -0
  142. package/native/tvos/metal-patched/src/pipeline/compute.rs +475 -0
  143. package/native/tvos/metal-patched/src/pipeline/mod.rs +71 -0
  144. package/native/tvos/metal-patched/src/pipeline/render.rs +762 -0
  145. package/native/tvos/metal-patched/src/renderpass.rs +443 -0
  146. package/native/tvos/metal-patched/src/resource.rs +182 -0
  147. package/native/tvos/metal-patched/src/sampler.rs +165 -0
  148. package/native/tvos/metal-patched/src/sync.rs +178 -0
  149. package/native/tvos/metal-patched/src/texture.rs +352 -0
  150. package/native/tvos/metal-patched/src/types.rs +90 -0
  151. package/native/tvos/metal-patched/src/vertexdescriptor.rs +250 -0
  152. package/native/tvos/src/audio_backend.rs +197 -0
  153. package/native/tvos/src/lib.rs +1891 -0
  154. package/native/visionos/Cargo.lock +1693 -0
  155. package/native/visionos/Cargo.toml +40 -0
  156. package/native/visionos/src/audio_backend.rs +197 -0
  157. package/native/visionos/src/lib.rs +1887 -0
  158. package/native/watchos/Cargo.lock +16 -0
  159. package/native/watchos/Cargo.toml +19 -0
  160. package/native/watchos/shaders/bloom_postfx.metal +99 -0
  161. package/native/watchos/src/BloomWatchApp.swift +1267 -0
  162. package/native/watchos/src/BloomWatchAudio.swift +179 -0
  163. package/native/watchos/src/audio.rs +55 -0
  164. package/native/watchos/src/draw_list.rs +229 -0
  165. package/native/watchos/src/ffi_stubs.rs +915 -0
  166. package/native/watchos/src/ffi_stubs_manual.rs +35 -0
  167. package/native/watchos/src/lib.rs +1124 -0
  168. package/native/watchos/src/models.rs +746 -0
  169. package/native/watchos/src/postfx.rs +95 -0
  170. package/native/watchos/src/scene.rs +534 -0
  171. package/native/watchos/src/textures.rs +184 -0
  172. package/native/web/Cargo.lock +1657 -0
  173. package/native/web/Cargo.toml +43 -0
  174. package/native/web/bloom_glue.js +695 -0
  175. package/native/web/build.sh +131 -0
  176. package/native/web/index.html +35 -0
  177. package/native/web/jolt_bridge.js +1519 -0
  178. package/native/web/src/input_ffi.rs +286 -0
  179. package/native/web/src/lib.rs +1796 -0
  180. package/native/web/src/material_ffi.rs +710 -0
  181. package/native/web/src/parity_ffi.rs +343 -0
  182. package/native/web/src/physics_ffi.rs +643 -0
  183. package/native/web/src/ragdoll_ffi.rs +250 -0
  184. package/native/web/src/render_settings.rs +98 -0
  185. package/native/windows/Cargo.lock +1815 -0
  186. package/native/windows/Cargo.toml +68 -0
  187. package/native/windows/src/lib.rs +1486 -0
  188. package/package.json +4279 -0
  189. package/src/audio/index.ts +315 -0
  190. package/src/core/colors.ts +63 -0
  191. package/src/core/index.ts +1206 -0
  192. package/src/core/keys.ts +63 -0
  193. package/src/core/types.ts +104 -0
  194. package/src/index.ts +171 -0
  195. package/src/math/index.ts +516 -0
  196. package/src/mobile/index.ts +294 -0
  197. package/src/models/index.ts +1258 -0
  198. package/src/physics/index.ts +1134 -0
  199. package/src/scene/index.ts +698 -0
  200. package/src/shapes/index.ts +120 -0
  201. package/src/text/index.ts +48 -0
  202. package/src/textures/index.ts +187 -0
  203. package/src/vfx/index.ts +191 -0
  204. package/src/world/index.ts +24 -0
  205. package/src/world/loader.ts +423 -0
  206. package/src/world/prefab.ts +217 -0
  207. package/src/world/render.ts +172 -0
  208. package/src/world/saver.ts +108 -0
  209. package/src/world/serialize.ts +301 -0
  210. package/src/world/terrain.ts +355 -0
  211. package/src/world/types.ts +160 -0
  212. package/src/world/validate.ts +319 -0
  213. package/src/world/version.ts +114 -0
@@ -0,0 +1,907 @@
1
+ //! Environment: GGX prefilter, sky pass, aerial perspective.
2
+ //! Split from renderer/shaders.rs.
3
+
4
+ pub(in crate::renderer) const PREFILTER_SHADER_WGSL: &str = "
5
+ struct PrefilterUniforms {
6
+ /// x = roughness (∈ [0, 1]), y = sample count, zw = mip resolution
7
+ /// in pixels (used for fragCoord → UV conversion).
8
+ params: vec4<f32>,
9
+ };
10
+
11
+ @group(0) @binding(0) var<uniform> u: PrefilterUniforms;
12
+ @group(0) @binding(1) var src_tex: texture_2d<f32>;
13
+ @group(0) @binding(2) var src_samp: sampler;
14
+
15
+ const PI: f32 = 3.14159265;
16
+
17
+ fn radical_inverse_vdc(in_bits: u32) -> f32 {
18
+ var bits = in_bits;
19
+ bits = (bits << 16u) | (bits >> 16u);
20
+ bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
21
+ bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
22
+ bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
23
+ bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
24
+ return f32(bits) * 2.3283064e-10;
25
+ }
26
+
27
+ fn hammersley(i: u32, n: u32) -> vec2<f32> {
28
+ return vec2<f32>(f32(i) / f32(n), radical_inverse_vdc(i));
29
+ }
30
+
31
+ fn importance_sample_ggx(xi: vec2<f32>, n: vec3<f32>, roughness: f32) -> vec3<f32> {
32
+ let a = roughness * roughness;
33
+ let phi = 2.0 * PI * xi.x;
34
+ let cos_theta = sqrt((1.0 - xi.y) / (1.0 + (a*a - 1.0) * xi.y));
35
+ let sin_theta = sqrt(max(1.0 - cos_theta * cos_theta, 0.0));
36
+ let h_local = vec3<f32>(sin_theta * cos(phi), sin_theta * sin(phi), cos_theta);
37
+
38
+ let up = select(vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(0.0, 0.0, 1.0), abs(n.z) < 0.999);
39
+ let t = normalize(cross(up, n));
40
+ let b = cross(n, t);
41
+ return normalize(t * h_local.x + b * h_local.y + n * h_local.z);
42
+ }
43
+
44
+ fn dir_to_uv(dir: vec3<f32>) -> vec2<f32> {
45
+ let d = normalize(dir);
46
+ let theta = acos(clamp(d.y, -1.0, 1.0));
47
+ let phi = atan2(d.z, d.x);
48
+ let raw_u = phi / (2.0 * PI);
49
+ return vec2<f32>(raw_u - floor(raw_u), theta / PI);
50
+ }
51
+
52
+ fn uv_to_dir(uv: vec2<f32>) -> vec3<f32> {
53
+ let phi = uv.x * 2.0 * PI;
54
+ let theta = uv.y * PI;
55
+ let sin_theta = sin(theta);
56
+ return vec3<f32>(sin_theta * cos(phi), cos(theta), sin_theta * sin(phi));
57
+ }
58
+
59
+ @vertex
60
+ fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
61
+ let x = f32((vid & 1u) * 4u) - 1.0;
62
+ let y = f32((vid >> 1u) * 4u) - 1.0;
63
+ return vec4<f32>(x, y, 0.0, 1.0);
64
+ }
65
+
66
+ @fragment
67
+ fn fs_main(@builtin(position) frag_pos: vec4<f32>) -> @location(0) vec4<f32> {
68
+ let mip_w = u.params.z;
69
+ let mip_h = u.params.w;
70
+ let uv = vec2<f32>(frag_pos.x / mip_w, frag_pos.y / mip_h);
71
+ let n = uv_to_dir(uv);
72
+ let v = n; // Karis simplification
73
+
74
+ let n_samples = u32(u.params.y);
75
+ let roughness = u.params.x;
76
+ var color = vec3<f32>(0.0);
77
+ var weight = 0.0;
78
+
79
+ for (var i = 0u; i < n_samples; i++) {
80
+ let xi = hammersley(i, n_samples);
81
+ let h = importance_sample_ggx(xi, n, roughness);
82
+ let l = normalize(2.0 * dot(v, h) * h - v);
83
+ let n_dot_l = max(dot(n, l), 0.0);
84
+ if (n_dot_l > 0.0) {
85
+ color += textureSampleLevel(src_tex, src_samp, dir_to_uv(l), 0.0).rgb * n_dot_l;
86
+ weight += n_dot_l;
87
+ }
88
+ }
89
+ return vec4<f32>(color / max(weight, 1e-4), 1.0);
90
+ }
91
+
92
+ // Diffuse irradiance convolution (cosine-weighted). Used to populate
93
+ // the env mip chain's smallest mip — the scene shader samples that
94
+ // mip for IBL diffuse. Cosine-weighted importance sampling means the
95
+ // per-sample weight (cos θ / π) cancels the PDF, so we can just
96
+ // average the env samples directly. Much closer to a proper diffuse
97
+ // irradiance map than 'GGX with roughness = 1' would be.
98
+ @fragment
99
+ fn fs_diffuse(@builtin(position) frag_pos: vec4<f32>) -> @location(0) vec4<f32> {
100
+ let mip_w = u.params.z;
101
+ let mip_h = u.params.w;
102
+ let uv = vec2<f32>(frag_pos.x / mip_w, frag_pos.y / mip_h);
103
+ let n = uv_to_dir(uv);
104
+
105
+ let up = select(vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(0.0, 0.0, 1.0), abs(n.z) < 0.999);
106
+ let t = normalize(cross(up, n));
107
+ let b = cross(n, t);
108
+
109
+ let n_samples = u32(u.params.y);
110
+ var color = vec3<f32>(0.0);
111
+
112
+ for (var i = 0u; i < n_samples; i++) {
113
+ let xi = hammersley(i, n_samples);
114
+ // Cosine-weighted hemisphere sample (Malley's method).
115
+ let phi = 2.0 * PI * xi.x;
116
+ let cos_theta = sqrt(1.0 - xi.y);
117
+ let sin_theta = sqrt(xi.y);
118
+ let l_local = vec3<f32>(sin_theta * cos(phi), sin_theta * sin(phi), cos_theta);
119
+ let l = t * l_local.x + b * l_local.y + n * l_local.z;
120
+ var sample = textureSampleLevel(src_tex, src_samp, dir_to_uv(l), 0.0).rgb;
121
+ // Firefly clamp per env sample. Raw outdoor.hdr has the sun
122
+ // disc at luma 1000+; at N samples per output texel the
123
+ // 1-in-N chance of a sample hitting the sun leaves a bright
124
+ // patch in the 'diffuse' irradiance map at every normal
125
+ // direction that aims near it. Capping each sample at luma
126
+ // 50 bounds the estimator's variance so the convolved output
127
+ // is actually smooth — which is what fs_main_scene expects
128
+ // when it multiplies by base_color for the IBL diffuse term.
129
+ // The Sponza floor's per-pixel normal-map variation was
130
+ // picking different 'bright spots' in the unsmoothed
131
+ // irradiance map, producing exactly the white speckle on
132
+ // the sunlit stone.
133
+ let sample_luma = dot(sample, vec3<f32>(0.2126, 0.7152, 0.0722));
134
+ let cap = 50.0;
135
+ if (sample_luma > cap) {
136
+ sample = sample * (cap / sample_luma);
137
+ }
138
+ color += sample;
139
+ }
140
+ return vec4<f32>(color / f32(n_samples), 1.0);
141
+ }
142
+ ";
143
+
144
+ pub(in crate::renderer) const SKY_SHADER_WGSL: &str = "
145
+ struct SkyUniforms {
146
+ // Camera world basis (right, up, forward) and screen scale.
147
+ // Reconstructing view direction from these is more numerically
148
+ // robust than inverting the VP matrix and divides — and avoids
149
+ // edge cases like degenerate w divisions at the far plane.
150
+ right: vec4<f32>, // xyz = right*tan(fovy/2)*aspect; w unused
151
+ up: vec4<f32>, // xyz = up*tan(fovy/2); w unused
152
+ forward: vec4<f32>, // xyz = forward (unit); w unused
153
+ intensity: vec4<f32>, // x = multiplier; yzw padding
154
+ };
155
+
156
+ @group(0) @binding(0) var<uniform> u: SkyUniforms;
157
+ @group(0) @binding(1) var env_tex: texture_2d<f32>;
158
+ @group(0) @binding(2) var env_samp: sampler;
159
+
160
+ const PI: f32 = 3.14159265;
161
+
162
+ struct VsOut {
163
+ @builtin(position) clip_pos: vec4<f32>,
164
+ @location(0) ndc: vec2<f32>,
165
+ };
166
+
167
+ @vertex
168
+ fn sky_vs(@builtin(vertex_index) vid: u32) -> VsOut {
169
+ // Single oversized triangle covering [-1,1]^2:
170
+ // vid 0 → (-1, -1)
171
+ // vid 1 → ( 3, -1)
172
+ // vid 2 → (-1, 3)
173
+ let x = f32((vid & 1u) * 4u) - 1.0;
174
+ let y = f32((vid >> 1u) * 4u) - 1.0;
175
+ var out: VsOut;
176
+ out.clip_pos = vec4<f32>(x, y, 1.0, 1.0);
177
+ out.ndc = vec2<f32>(x, y);
178
+ return out;
179
+ }
180
+
181
+ fn aces_tone(c: vec3<f32>) -> vec3<f32> {
182
+ let a = 2.51;
183
+ let b = 0.03;
184
+ let cc = 2.43;
185
+ let d = 0.59;
186
+ let e = 0.14;
187
+ return clamp((c * (c * a + b)) / (c * (c * cc + d) + e), vec3<f32>(0.0), vec3<f32>(1.0));
188
+ }
189
+
190
+ fn linear_to_srgb_v(c: vec3<f32>) -> vec3<f32> {
191
+ let cutoff = vec3<f32>(0.0031308);
192
+ let lo = c * 12.92;
193
+ let hi = 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - 0.055;
194
+ return select(hi, lo, c <= cutoff);
195
+ }
196
+
197
+ struct SkyOut {
198
+ @location(0) color: vec4<f32>,
199
+ @location(1) material: vec2<f32>,
200
+ @location(2) velocity: vec2<f32>,
201
+ @location(3) albedo: vec4<f32>,
202
+ };
203
+
204
+ @fragment
205
+ fn sky_fs(in: VsOut) -> SkyOut {
206
+ // View direction = forward + ndc.x * (right*tan*aspect)
207
+ // + ndc.y * (up*tan)
208
+ // The right/up vectors already have the scale baked in.
209
+ let dir = normalize(u.forward.xyz + in.ndc.x * u.right.xyz + in.ndc.y * u.up.xyz);
210
+
211
+ // Equirectangular UV — must match bloom-reference exactly:
212
+ // u = (phi / 2π) wrapped to [0, 1) → +X direction at u=0
213
+ // v = theta / π → +Y at v=0
214
+ // Earlier `phi/2π + 0.5` gave a 180° rotation that put cloud
215
+ // patterns on the wrong side of the helmet vs the reference.
216
+ //
217
+ // Below-horizon clamp: most outdoor HDRIs (including
218
+ // assets/outdoor.hdr) only contain sky in the upper
219
+ // hemisphere — the lower half mirrors the upper because the
220
+ // photographer captured from a flat/reflective floor. Without
221
+ // a clamp, the sky-dome render shows the mirror when the
222
+ // camera looks below the horizon (e.g. standing on the
223
+ // Sponza roof, any pitch-down shot). Clamp v at 0.5 so
224
+ // below-horizon directions re-sample the horizon ring,
225
+ // smoothly fading to a 'hazy ground' appearance.
226
+ let theta = acos(clamp(dir.y, -1.0, 1.0));
227
+ let phi = atan2(dir.z, dir.x);
228
+ let raw_u = phi / (2.0 * PI);
229
+ let u_coord = raw_u - floor(raw_u); // fract(); WGSL has no rem_euclid
230
+ let v_unclamped = theta / PI;
231
+ // Slightly below v=0.5 to sample the horizon ring and not
232
+ // clip to a single texel row; texture filter smooths over
233
+ // the last 2 % of vertical space.
234
+ let v_coord = min(v_unclamped, 0.5);
235
+
236
+ // Clamp u to half-texel inside [0,1] so the bilinear filter
237
+ // never reaches across the ±180° seam (u wraps 0↔1).
238
+ let tex_w = f32(textureDimensions(env_tex, 0).x);
239
+ let half_texel = 0.5 / tex_w;
240
+ let safe_u = clamp(u_coord, half_texel, 1.0 - half_texel);
241
+
242
+ let radiance = textureSample(env_tex, env_samp, vec2<f32>(safe_u, v_coord)).rgb * u.intensity.x;
243
+ // Output linear HDR radiance — the composite pass downstream does
244
+ // the ACES tonemap + sRGB encode in one place. Sky writes to the
245
+ // material G-buffer too: 0 metallic, 1 roughness — sky never
246
+ // reflects, never gets reflected from (well, it gets sampled by
247
+ // SSR via the HDR RT, but that's expected behavior).
248
+ // Sky is at infinity — zero velocity (stationary background).
249
+ // Sky albedo is zero — sky is the indirect-light source itself,
250
+ // so SSGI rays landing on sky pixels must not multiply by anything
251
+ // (otherwise the bounce would be tinted by background radiance,
252
+ // which is wrong for a directional distant light).
253
+ return SkyOut(vec4<f32>(radiance, 1.0), vec2<f32>(0.0, 1.0), vec2<f32>(0.0, 0.0), vec4<f32>(0.0));
254
+ }
255
+ ";
256
+
257
+ // EN-005 V2 — aerial-perspective 3D LUT bake.
258
+ //
259
+ // Each voxel (NDC.x, NDC.y, depth-slice) holds the integrated in-
260
+ // scattered radiance and accumulated transmittance from the camera
261
+ // to a sample point along the view ray. scene_compose samples this
262
+ // in place of its 16-step volumetric fog march when procedural sky
263
+ // is on, giving per-pixel angular variation (sunset side warmer than
264
+ // the opposite horizon) at a fraction of the per-frame cost.
265
+ //
266
+ // Engine units assumed metres; atmosphere math runs in kilometres.
267
+ // Camera height above sea level is treated as 0.5 km regardless of
268
+ // actual y — same approximation the sky-view shader uses, since the
269
+ // atmosphere is far thicker than typical scene height variation.
270
+
271
+ pub(in crate::renderer) const AERIAL_PERSPECTIVE_SHADER_WGSL: &str = "
272
+ struct AerialParams {
273
+ // xyz = camera world position (metres), w = max distance (km)
274
+ cam_pos: vec4<f32>,
275
+ inv_vp: mat4x4<f32>,
276
+ // xyz = sun direction (unit), w = sun intensity scale
277
+ sun: vec4<f32>,
278
+ // x = rayleigh density mult, y = mie density mult, zw unused
279
+ knobs: vec4<f32>,
280
+ };
281
+
282
+ @group(0) @binding(0) var<uniform> u: AerialParams;
283
+ @group(0) @binding(1) var transmittance_lut: texture_2d<f32>;
284
+ @group(0) @binding(2) var multi_scattering_lut: texture_2d<f32>;
285
+ @group(0) @binding(3) var lut_samp: sampler;
286
+ @group(0) @binding(4) var aerial_out: texture_storage_3d<rgba16float, write>;
287
+
288
+ const PI: f32 = 3.14159265;
289
+ const GROUND_R: f32 = 6360.0;
290
+ const ATMOS_TOP: f32 = 6460.0;
291
+ const RAY_H: f32 = 8.0;
292
+ const MIE_H: f32 = 1.2;
293
+ const RAY_SCAT: vec3<f32> = vec3<f32>(5.802e-3, 13.558e-3, 33.100e-3);
294
+ const MIE_SCAT: f32 = 3.996e-3;
295
+ const MIE_EXT: f32 = 4.440e-3;
296
+ const O3_ABS: vec3<f32> = vec3<f32>(0.650e-3, 1.881e-3, 0.085e-3);
297
+ const O3_PEAK: f32 = 25.0;
298
+ const O3_HALF: f32 = 15.0;
299
+
300
+ const MARCH_STEPS: u32 = 16u;
301
+
302
+ fn ray_density(alt: f32) -> f32 { return exp(-alt / RAY_H); }
303
+ fn mie_density(alt: f32) -> f32 { return exp(-alt / MIE_H); }
304
+ fn o3_density(alt: f32) -> f32 { return max(0.0, 1.0 - abs(alt - O3_PEAK) / O3_HALF); }
305
+
306
+ fn extinction(alt: f32) -> vec3<f32> {
307
+ let rd = ray_density(alt) * u.knobs.x;
308
+ let md = mie_density(alt) * u.knobs.y;
309
+ let od = o3_density(alt);
310
+ return RAY_SCAT * rd + vec3<f32>(MIE_EXT) * md + O3_ABS * od;
311
+ }
312
+
313
+ fn sample_transmittance(r: f32, mu: f32) -> vec3<f32> {
314
+ let v = clamp((r - GROUND_R) / (ATMOS_TOP - GROUND_R), 0.0, 1.0);
315
+ let uu = clamp((mu + 1.0) * 0.5, 0.0, 1.0);
316
+ return textureSampleLevel(transmittance_lut, lut_samp, vec2<f32>(uu, v), 0.0).rgb;
317
+ }
318
+
319
+ fn sample_multi_scattering(r: f32, mu_s: f32) -> vec3<f32> {
320
+ let v = clamp((r - GROUND_R) / (ATMOS_TOP - GROUND_R), 0.0, 1.0);
321
+ let uu = clamp((mu_s + 1.0) * 0.5, 0.0, 1.0);
322
+ return textureSampleLevel(multi_scattering_lut, lut_samp, vec2<f32>(uu, v), 0.0).rgb;
323
+ }
324
+
325
+ fn rayleigh_phase(cos_t: f32) -> f32 {
326
+ return (3.0 / (16.0 * PI)) * (1.0 + cos_t * cos_t);
327
+ }
328
+
329
+ fn mie_phase(cos_t: f32) -> f32 {
330
+ let g = 0.8;
331
+ let g2 = g * g;
332
+ let num = 3.0 * (1.0 - g2) * (1.0 + cos_t * cos_t);
333
+ let den = 8.0 * PI * (2.0 + g2) * pow(max(1.0 + g2 - 2.0 * g * cos_t, 1e-6), 1.5);
334
+ return num / den;
335
+ }
336
+
337
+ @compute @workgroup_size(8, 8, 1)
338
+ fn cs_main(@builtin(global_invocation_id) gid: vec3<u32>) {
339
+ let dims = textureDimensions(aerial_out);
340
+ if (gid.x >= dims.x || gid.y >= dims.y || gid.z >= dims.z) { return; }
341
+
342
+ // Voxel → NDC. Note: WebGPU NDC has Y up; our render target had
343
+ // Y down in pixel space but inv_vp produces world space directly,
344
+ // so we use NDC y = 1 - 2*v to match (top of screen = +Y in NDC).
345
+ let uv = (vec2<f32>(f32(gid.x), f32(gid.y)) + vec2<f32>(0.5))
346
+ / vec2<f32>(f32(dims.x), f32(dims.y));
347
+ let ndc_xy = vec2<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
348
+
349
+ // Reconstruct a far-plane world point through (ndc_xy, z=1). The
350
+ // ray from cam_pos through that point is the per-voxel view ray.
351
+ let far_h = u.inv_vp * vec4<f32>(ndc_xy, 1.0, 1.0);
352
+ let far_world = far_h.xyz / far_h.w;
353
+ let view_dir = normalize(far_world - u.cam_pos.xyz);
354
+
355
+ // Depth slicing — linear from camera out to max_dist_km. The
356
+ // (gid.z + 0.5) / dims.z maps 0..max evenly; near voxels resolve
357
+ // close haze, far voxels capture distant horizon scatter.
358
+ let max_dist_km = u.cam_pos.w;
359
+ let target_dist_km = (f32(gid.z) + 0.5) / f32(dims.z) * max_dist_km;
360
+
361
+ // March from camera to (camera + view_dir * target_dist_km).
362
+ // Atmosphere math is in km; the engine world-space view ray is
363
+ // unit-length so we scale step-distance directly.
364
+ let n_steps = MARCH_STEPS;
365
+ let step_km = target_dist_km / f32(n_steps);
366
+ let sun = normalize(u.sun.xyz);
367
+
368
+ // Sea-level approximation for camera radius — sky-view shader
369
+ // uses the same trick. Atmosphere is so much thicker than scene
370
+ // height variation that the per-altitude correction is < 1 LSB.
371
+ let r0 = GROUND_R + 0.5;
372
+ let mu = view_dir.y;
373
+ let mu_s_cam = sun.y;
374
+ let nu = dot(view_dir, sun);
375
+ let phase_r = rayleigh_phase(nu);
376
+ let phase_m = mie_phase(nu);
377
+
378
+ var transmittance = vec3<f32>(1.0);
379
+ var in_scatter = vec3<f32>(0.0);
380
+ for (var i = 0u; i < n_steps; i = i + 1u) {
381
+ let d = (f32(i) + 0.5) * step_km;
382
+ let r_d = sqrt(r0 * r0 + d * d + 2.0 * r0 * mu * d);
383
+ let alt = max(0.0, r_d - GROUND_R);
384
+
385
+ let mu_s_p = clamp((r0 * mu_s_cam + d * dot(view_dir, sun)) / r_d, -1.0, 1.0);
386
+
387
+ let rd = ray_density(alt) * u.knobs.x;
388
+ let md = mie_density(alt) * u.knobs.y;
389
+ let scat_r = RAY_SCAT * rd;
390
+ let scat_m = vec3<f32>(MIE_SCAT) * md;
391
+
392
+ let ext = extinction(alt);
393
+ let step_t = exp(-ext * step_km);
394
+
395
+ let t_sun = sample_transmittance(r_d, mu_s_p);
396
+ let single = (scat_r * phase_r + scat_m * phase_m) * t_sun;
397
+ let psi_ms = sample_multi_scattering(r_d, mu_s_p);
398
+ let multi = (scat_r + scat_m) * psi_ms * (1.0 / (4.0 * PI));
399
+
400
+ in_scatter = in_scatter + transmittance * (single + multi) * step_km;
401
+ transmittance = transmittance * step_t;
402
+ }
403
+
404
+ in_scatter = in_scatter * u.sun.w;
405
+
406
+ // Mean transmittance — scene_compose applies it as a single
407
+ // scalar attenuation. Per-channel transmittance would be tighter
408
+ // physics but pushes us off the existing fog composite shape.
409
+ let mean_t = (transmittance.x + transmittance.y + transmittance.z) / 3.0;
410
+
411
+ textureStore(
412
+ aerial_out,
413
+ vec3<i32>(i32(gid.x), i32(gid.y), i32(gid.z)),
414
+ vec4<f32>(in_scatter, mean_t),
415
+ );
416
+ }
417
+ ";
418
+
419
+ // ============================================================================
420
+ // EN-005 Phase 2 — Procedural sky (Hillaire 2020)
421
+ // ============================================================================
422
+ //
423
+ // Two shaders working off the LUTs baked in atmosphere_lut.rs:
424
+ //
425
+ // 1. SKY_VIEW_LUT_SHADER_WGSL — compute pass, dispatched on sun-move.
426
+ // Ray-marches every (azimuth, elevation) texel of a 2D radiance
427
+ // cache, accumulating Rayleigh + Mie + ozone single-scattering
428
+ // and reading the multi-scattering LUT for the bounce term.
429
+ //
430
+ // 2. PROCEDURAL_SKY_SHADER_WGSL — fullscreen render pass per frame.
431
+ // Reconstructs view direction per pixel, samples the sky-view
432
+ // LUT for sky radiance, draws the sun disk attenuated by
433
+ // transmittance. Slots into the existing HDR pass in place of
434
+ // `sky_pipeline` when procedural sky is enabled.
435
+ //
436
+ // Shared atmosphere parameters are inlined as `const`s in each shader
437
+ // rather than uniformed — they're physical constants, not user knobs.
438
+ // The runtime knobs (sun direction, density multipliers) live in the
439
+ // uniform blocks.
440
+
441
+ pub(in crate::renderer) const SKY_VIEW_LUT_SHADER_WGSL: &str = "
442
+ struct SkyViewParams {
443
+ // xyz = sun direction (world space, unit), w = sun intensity scale
444
+ sun: vec4<f32>,
445
+ // x = rayleigh density multiplier
446
+ // y = mie density multiplier
447
+ // z = ground albedo (grey)
448
+ // w = unused
449
+ knobs: vec4<f32>,
450
+ };
451
+
452
+ @group(0) @binding(0) var<uniform> u: SkyViewParams;
453
+ @group(0) @binding(1) var transmittance_lut: texture_2d<f32>;
454
+ @group(0) @binding(2) var multi_scattering_lut: texture_2d<f32>;
455
+ @group(0) @binding(3) var lut_samp: sampler;
456
+ @group(0) @binding(4) var sky_view_out: texture_storage_2d<rgba16float, write>;
457
+
458
+ // --- Atmosphere constants (must match atmosphere_lut.rs) ---
459
+ const PI: f32 = 3.14159265;
460
+ const GROUND_R: f32 = 6360.0;
461
+ const ATMOS_TOP: f32 = 6460.0;
462
+ const RAY_H: f32 = 8.0;
463
+ const MIE_H: f32 = 1.2;
464
+ const RAY_SCAT: vec3<f32> = vec3<f32>(5.802e-3, 13.558e-3, 33.100e-3);
465
+ const MIE_SCAT: f32 = 3.996e-3;
466
+ const MIE_EXT: f32 = 4.440e-3;
467
+ const O3_ABS: vec3<f32> = vec3<f32>(0.650e-3, 1.881e-3, 0.085e-3);
468
+ const O3_PEAK: f32 = 25.0;
469
+ const O3_HALF: f32 = 15.0;
470
+
471
+ const VIEW_STEPS: u32 = 32u;
472
+
473
+ fn ray_density(alt: f32) -> f32 { return exp(-alt / RAY_H); }
474
+ fn mie_density(alt: f32) -> f32 { return exp(-alt / MIE_H); }
475
+ fn o3_density(alt: f32) -> f32 { return max(0.0, 1.0 - abs(alt - O3_PEAK) / O3_HALF); }
476
+
477
+ fn extinction(alt: f32) -> vec3<f32> {
478
+ let rd = ray_density(alt) * u.knobs.x;
479
+ let md = mie_density(alt) * u.knobs.y;
480
+ let od = o3_density(alt);
481
+ return RAY_SCAT * rd + vec3<f32>(MIE_EXT) * md + O3_ABS * od;
482
+ }
483
+
484
+ fn ray_atmosphere_intersect(r: f32, mu: f32, sphere_r: f32) -> f32 {
485
+ // Returns the first positive intersection distance, or -1 if none.
486
+ let disc = r * r * (mu * mu - 1.0) + sphere_r * sphere_r;
487
+ if (disc < 0.0) { return -1.0; }
488
+ let sd = sqrt(disc);
489
+ let t1 = -r * mu - sd;
490
+ let t2 = -r * mu + sd;
491
+ if (t1 > 0.0) { return t1; }
492
+ if (t2 > 0.0) { return t2; }
493
+ return -1.0;
494
+ }
495
+
496
+ fn dist_to_boundary(r: f32, mu: f32) -> f32 {
497
+ let to_top = ray_atmosphere_intersect(r, mu, ATMOS_TOP);
498
+ let to_ground = ray_atmosphere_intersect(r, mu, GROUND_R);
499
+ if (to_ground > 0.0 && (to_top < 0.0 || to_ground < to_top)) {
500
+ return to_ground;
501
+ }
502
+ return max(to_top, 0.0);
503
+ }
504
+
505
+ // LUT mappings (must match atmosphere_lut.rs).
506
+ fn sample_transmittance(r: f32, mu: f32) -> vec3<f32> {
507
+ let v = clamp((r - GROUND_R) / (ATMOS_TOP - GROUND_R), 0.0, 1.0);
508
+ let uu = clamp((mu + 1.0) * 0.5, 0.0, 1.0);
509
+ return textureSampleLevel(transmittance_lut, lut_samp, vec2<f32>(uu, v), 0.0).rgb;
510
+ }
511
+
512
+ fn sample_multi_scattering(r: f32, mu_s: f32) -> vec3<f32> {
513
+ let v = clamp((r - GROUND_R) / (ATMOS_TOP - GROUND_R), 0.0, 1.0);
514
+ let uu = clamp((mu_s + 1.0) * 0.5, 0.0, 1.0);
515
+ return textureSampleLevel(multi_scattering_lut, lut_samp, vec2<f32>(uu, v), 0.0).rgb;
516
+ }
517
+
518
+ fn rayleigh_phase(cos_t: f32) -> f32 {
519
+ return (3.0 / (16.0 * PI)) * (1.0 + cos_t * cos_t);
520
+ }
521
+
522
+ fn mie_phase(cos_t: f32) -> f32 {
523
+ // Cornette-Shanks, g=0.8 — standard for atmospheric Mie.
524
+ let g = 0.8;
525
+ let g2 = g * g;
526
+ let num = 3.0 * (1.0 - g2) * (1.0 + cos_t * cos_t);
527
+ let den = 8.0 * PI * (2.0 + g2) * pow(max(1.0 + g2 - 2.0 * g * cos_t, 1e-6), 1.5);
528
+ return num / den;
529
+ }
530
+
531
+ // Decode (workgroup-local) coords into a world-space view direction.
532
+ // V1 uses linear (azimuth, elevation) — simple, slightly horizon-poor;
533
+ // future revision can switch to Hillaire's sin² mapping for sharper
534
+ // horizon detail.
535
+ fn sky_view_uv_to_dir(uv: vec2<f32>) -> vec3<f32> {
536
+ let azimuth = uv.x * 2.0 * PI;
537
+ let elevation = (uv.y - 0.5) * PI; // [-π/2, π/2]
538
+ let cos_e = cos(elevation);
539
+ return vec3<f32>(cos_e * cos(azimuth), sin(elevation), cos_e * sin(azimuth));
540
+ }
541
+
542
+ @compute @workgroup_size(8, 8, 1)
543
+ fn cs_main(@builtin(global_invocation_id) gid: vec3<u32>) {
544
+ let dims = textureDimensions(sky_view_out);
545
+ if (gid.x >= dims.x || gid.y >= dims.y) { return; }
546
+
547
+ let uv = (vec2<f32>(f32(gid.x), f32(gid.y)) + vec2<f32>(0.5)) / vec2<f32>(f32(dims.x), f32(dims.y));
548
+ let view_dir = sky_view_uv_to_dir(uv);
549
+
550
+ // V1: camera at sea level, atmosphere centred at origin. The y axis
551
+ // is up; r = GROUND_R + altitude. Phase 3 lifts this once the host
552
+ // can pass per-frame camera height.
553
+ let r0 = GROUND_R + 0.5; // 0.5 km above ground = typical eye height + atmospheric haze
554
+ let mu = view_dir.y; // cos(view-zenith)
555
+ let sun = normalize(u.sun.xyz);
556
+ let mu_s = sun.y;
557
+ let nu = dot(view_dir, sun); // cos(view-sun angle)
558
+
559
+ let total_dist = dist_to_boundary(r0, mu);
560
+ if (total_dist <= 0.0) {
561
+ textureStore(sky_view_out, vec2<i32>(i32(gid.x), i32(gid.y)), vec4<f32>(0.0, 0.0, 0.0, 1.0));
562
+ return;
563
+ }
564
+
565
+ let dx = total_dist / f32(VIEW_STEPS);
566
+ var radiance = vec3<f32>(0.0);
567
+ var transmittance = vec3<f32>(1.0);
568
+
569
+ let phase_r = rayleigh_phase(nu);
570
+ let phase_m = mie_phase(nu);
571
+
572
+ for (var i: u32 = 0u; i < VIEW_STEPS; i = i + 1u) {
573
+ let d = (f32(i) + 0.5) * dx;
574
+ // Radius at sample point.
575
+ let r_d = sqrt(r0 * r0 + d * d + 2.0 * r0 * mu * d);
576
+ let alt = max(0.0, r_d - GROUND_R);
577
+
578
+ // Sun zenith cosine at the sample point (parallel-sun
579
+ // approximation — sun direction stays fixed in world space).
580
+ let mu_s_p = clamp((r0 * mu_s + d * dot(view_dir, sun)) / r_d, -1.0, 1.0);
581
+
582
+ let rd = ray_density(alt) * u.knobs.x;
583
+ let md = mie_density(alt) * u.knobs.y;
584
+ let scat_r = RAY_SCAT * rd;
585
+ let scat_m = vec3<f32>(MIE_SCAT) * md;
586
+
587
+ let ext = extinction(alt);
588
+ let step_t = exp(-ext * dx);
589
+
590
+ // Sun radiance at sample after travelling to top of atmosphere.
591
+ let t_sun = sample_transmittance(r_d, mu_s_p);
592
+
593
+ // Single-scattering — Rayleigh + Mie with their phase functions.
594
+ let single = (scat_r * phase_r + scat_m * phase_m) * t_sun;
595
+
596
+ // Multi-scattering — isotropic (1/4π) scaled by the LUT-baked
597
+ // bounce coefficient. Hillaire's energy-conserving form.
598
+ let psi_ms = sample_multi_scattering(r_d, mu_s_p);
599
+ let multi = (scat_r + scat_m) * psi_ms * (1.0 / (4.0 * PI));
600
+
601
+ let in_scatter = (single + multi) * dx;
602
+ // Analytic in-scattering integral: contribution × accumulated transmittance.
603
+ radiance = radiance + transmittance * in_scatter;
604
+ transmittance = transmittance * step_t;
605
+ }
606
+
607
+ radiance = radiance * u.sun.w;
608
+
609
+ textureStore(
610
+ sky_view_out,
611
+ vec2<i32>(i32(gid.x), i32(gid.y)),
612
+ vec4<f32>(radiance, 1.0),
613
+ );
614
+ }
615
+ ";
616
+
617
+ // EN-005 Phase 3 — sky-view LUT → equirect HDR re-projection. Drives
618
+ // the existing GGX prefilter chain (which is the IBL specular source
619
+ // for PBR materials), so material reflections track the procedural
620
+ // sky as the sun moves.
621
+ //
622
+ // Sky-view LUT v-axis goes nadir→zenith (0 → 1); prefilter equirect
623
+ // v-axis goes zenith→nadir (0 → 1). The only correction needed is
624
+ // `sky_view_v = 1 - equirect_v`. Destination dimensions arrive in a
625
+ // small uniform so the shader can map gl_FragCoord to UV (the
626
+ // existing prefilter shader uses the same pattern).
627
+
628
+ pub(in crate::renderer) const EQUIRECT_FROM_SKY_VIEW_SHADER_WGSL: &str = "
629
+ struct BakeParams {
630
+ // x = dest width, y = dest height, zw unused
631
+ dims: vec4<f32>,
632
+ };
633
+
634
+ @group(0) @binding(0) var<uniform> u: BakeParams;
635
+ @group(0) @binding(1) var sky_view_lut: texture_2d<f32>;
636
+ @group(0) @binding(2) var lut_samp: sampler;
637
+
638
+ @vertex
639
+ fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
640
+ let x = f32((vid & 1u) * 4u) - 1.0;
641
+ let y = f32((vid >> 1u) * 4u) - 1.0;
642
+ return vec4<f32>(x, y, 0.0, 1.0);
643
+ }
644
+
645
+ @fragment
646
+ fn fs_main(@builtin(position) frag_pos: vec4<f32>) -> @location(0) vec4<f32> {
647
+ // Equirect destination UV (matches `prefilter`'s `dir_to_uv`
648
+ // convention: u = phi/(2π), v = theta/π where theta is from zenith).
649
+ let uv = vec2<f32>(frag_pos.x / u.dims.x, frag_pos.y / u.dims.y);
650
+
651
+ // Convert to sky-view LUT UV — only the v axis differs:
652
+ // sky-view: v ∈ [0, 1] ↔ elevation ∈ [-π/2, π/2] (0 = nadir, 1 = zenith)
653
+ // equirect: v ∈ [0, 1] ↔ theta ∈ [0, π] (0 = zenith, 1 = nadir)
654
+ // → sky_view_v = 1 - equirect_v, u stays the same.
655
+ let lut_uv = vec2<f32>(uv.x, 1.0 - uv.y);
656
+ return textureSampleLevel(sky_view_lut, lut_samp, lut_uv, 0.0);
657
+ }
658
+ ";
659
+
660
+ // The cloud deck is prepended verbatim rather than `#include`d: this shader is a
661
+ // raw source const compiled straight to wgpu, and does not run through the
662
+ // material preprocessor. Same file either way, so the sky and the ground cannot
663
+ // drift apart — which is exactly how they got out of sync in the first place.
664
+ pub(in crate::renderer) const PROCEDURAL_SKY_SHADER_WGSL: &str = concat!(
665
+ include_str!("../../../shaders/common/clouds.wgsl"),
666
+ r#"
667
+ struct SkyUniforms {
668
+ // xyz = camera basis (pre-scaled by tan(fovy/2) and aspect).
669
+ // w = camera world position, one component per row — the cloud deck is
670
+ // anchored in WORLD space, so the sky pass needs the camera's
671
+ // position and not merely its orientation. Packed into the spare
672
+ // .w lanes to avoid growing the bind group.
673
+ right: vec4<f32>,
674
+ up: vec4<f32>,
675
+ forward: vec4<f32>,
676
+ intensity: vec4<f32>, // x = scene-wide intensity multiplier, y = time (s)
677
+ cloud: vec4<f32>, // x = shadow strength, y = deck height, z = scale, w = drift speed
678
+ wind: vec4<f32>, // xy = wind direction in the XZ plane
679
+ };
680
+
681
+ struct SunUniforms {
682
+ sun: vec4<f32>, // xyz = sun direction, w = sun intensity
683
+ params: vec4<f32>, // x = sun angular radius (rad), y = limb darkening, zw unused
684
+ };
685
+
686
+ @group(0) @binding(0) var<uniform> u: SkyUniforms;
687
+ @group(0) @binding(1) var sky_view_lut: texture_2d<f32>;
688
+ @group(0) @binding(2) var lut_samp: sampler;
689
+ @group(0) @binding(3) var<uniform> sun_u: SunUniforms;
690
+ @group(0) @binding(4) var transmittance_lut: texture_2d<f32>;
691
+
692
+ const PI: f32 = 3.14159265;
693
+ const GROUND_R: f32 = 6360.0;
694
+ const ATMOS_TOP: f32 = 6460.0;
695
+
696
+ struct VsOut {
697
+ @builtin(position) clip_pos: vec4<f32>,
698
+ @location(0) ndc: vec2<f32>,
699
+ };
700
+
701
+ @vertex
702
+ fn sky_vs(@builtin(vertex_index) vid: u32) -> VsOut {
703
+ let x = f32((vid & 1u) * 4u) - 1.0;
704
+ let y = f32((vid >> 1u) * 4u) - 1.0;
705
+ var out: VsOut;
706
+ out.clip_pos = vec4<f32>(x, y, 1.0, 1.0);
707
+ out.ndc = vec2<f32>(x, y);
708
+ return out;
709
+ }
710
+
711
+ fn dir_to_sky_uv(dir: vec3<f32>) -> vec2<f32> {
712
+ // Inverse of sky_view_uv_to_dir in the compute shader. Matches
713
+ // the linear (azimuth, elevation) parameterization.
714
+ let elevation = asin(clamp(dir.y, -1.0, 1.0));
715
+ let azimuth = atan2(dir.z, dir.x);
716
+ var u_norm = azimuth / (2.0 * PI);
717
+ if (u_norm < 0.0) { u_norm = u_norm + 1.0; }
718
+ let v_norm = elevation / PI + 0.5;
719
+ return vec2<f32>(u_norm, v_norm);
720
+ }
721
+
722
+ // The cloud field itself (cloud_fbm / cloud_cover_view / cloud_shadow_at) is
723
+ // prepended from common/clouds.wgsl — the same file the world materials include
724
+ // for their shadow term.
725
+
726
+ fn sample_transmittance(r: f32, mu: f32) -> vec3<f32> {
727
+ let v = clamp((r - GROUND_R) / (ATMOS_TOP - GROUND_R), 0.0, 1.0);
728
+ let uu = clamp((mu + 1.0) * 0.5, 0.0, 1.0);
729
+ return textureSampleLevel(transmittance_lut, lut_samp, vec2<f32>(uu, v), 0.0).rgb;
730
+ }
731
+
732
+ struct SkyOut {
733
+ @location(0) color: vec4<f32>,
734
+ @location(1) material: vec2<f32>,
735
+ @location(2) velocity: vec2<f32>,
736
+ @location(3) albedo: vec4<f32>,
737
+ };
738
+
739
+ @fragment
740
+ fn sky_fs(in: VsOut) -> SkyOut {
741
+ let dir = normalize(u.forward.xyz + in.ndc.x * u.right.xyz + in.ndc.y * u.up.xyz);
742
+ let uv = dir_to_sky_uv(dir);
743
+ var radiance = textureSampleLevel(sky_view_lut, lut_samp, uv, 0.0).rgb;
744
+
745
+ // Sun disk: if view direction is within sun_angular_radius of sun
746
+ // direction, add the sun's radiance attenuated by atmospheric
747
+ // transmittance toward the sun. Below the horizon (sun.y < 0) we
748
+ // skip — sun has set, no disk.
749
+ let sun_dir = normalize(sun_u.sun.xyz);
750
+ let cos_to_sun = dot(dir, sun_dir);
751
+ let cos_radius = cos(sun_u.params.x);
752
+ if (cos_to_sun > cos_radius && sun_dir.y > -0.05) {
753
+ let r0 = GROUND_R + 0.5;
754
+ let t_sun = sample_transmittance(r0, sun_dir.y);
755
+ // Limb darkening: fade toward the disk edge by params.y.
756
+ let edge = (cos_to_sun - cos_radius) / (1.0 - cos_radius);
757
+ let limb = mix(1.0 - sun_u.params.y, 1.0, sqrt(max(edge, 0.0)));
758
+ // Sun radiance ~ 20 (linear) at unit intensity; t_sun colours it.
759
+ let sun_radiance = vec3<f32>(20.0) * sun_u.sun.w * limb * t_sun;
760
+ radiance = radiance + sun_radiance;
761
+ }
762
+
763
+ radiance = radiance * u.intensity.x;
764
+
765
+ // Procedural cloud layer, composited over the scaled sky radiance. Cloud
766
+ // colour is absolute HDR (puffy white in sun, cool grey in shadow) so the
767
+ // clouds read brighter than the sky behind them regardless of env intensity.
768
+ //
769
+ // The deck is sampled in WORLD space from the camera's actual position, so
770
+ // the cloud overhead is the one whose shadow the ground is sampling. It also
771
+ // means the clouds now hold still as the player walks under them, instead of
772
+ // sliding along pinned to the camera.
773
+ let cam = vec3<f32>(u.right.w, u.up.w, u.forward.w);
774
+ let cc = cloud_cover_view(cam, dir, sun_dir, u.wind.xy, u.intensity.y, u.cloud);
775
+ if (cc.x > 0.0) {
776
+ let lit = cc.y * cc.y;
777
+ let cloud_col = mix(vec3<f32>(0.62, 0.66, 0.76), vec3<f32>(2.6, 2.5, 2.35), lit);
778
+ radiance = mix(radiance, cloud_col, cc.x);
779
+ }
780
+
781
+ // EN-005 Phase 4 — sub-LSB dither to break up the banding that
782
+ // Rgba16Float storage produces in low-frequency regions like the
783
+ // zenith (where sky color changes < 1 lsb across many pixels).
784
+ // Pseudo-blue-noise via the integer bit-mash of fragment coords;
785
+ // amplitude scaled to ~1/2 lsb of the smallest channel so it
786
+ // disappears once quantised.
787
+ let dither_seed = vec2<u32>(u32(in.clip_pos.x), u32(in.clip_pos.y));
788
+ let h = (dither_seed.x * 1597u + dither_seed.y * 31337u) ^ (dither_seed.x * 71u + dither_seed.y * 113u);
789
+ let dither = (f32(h & 0xFFu) / 255.0 - 0.5) * (1.0 / 1024.0);
790
+ radiance = radiance + vec3<f32>(dither);
791
+
792
+ var out: SkyOut;
793
+ out.color = vec4<f32>(radiance, 1.0);
794
+ out.material = vec2<f32>(0.0, 0.0);
795
+ out.velocity = vec2<f32>(0.0, 0.0);
796
+ out.albedo = vec4<f32>(0.0, 0.0, 0.0, 0.0);
797
+ return out;
798
+ }
799
+ "#);
800
+
801
+ /// EN-011 — single-target reflection shader for rendering cached models
802
+ /// (trees, house, etc.) into a planar-reflection probe with a mirrored
803
+ /// view-projection. Deliberately lightweight (base colour × sun N·L + ambient,
804
+ /// alpha-cutout discard) — a water reflection doesn't need the full deferred
805
+ /// PBR/SSAO stack, and a single HDR colour target (vs the main 4-target MRT)
806
+ /// lets it draw straight into the probe RT. Group layouts are owned by the
807
+ /// renderer: g0 = dynamic per-draw model uniform, g1 = sun/ambient, g2 = the
808
+ /// scene material bind group (we only sample base colour + alpha).
809
+ pub(in crate::renderer) const REFLECT_SCENE_WGSL: &str = "
810
+ struct ReflectModelU { mvp: mat4x4<f32>, model: mat4x4<f32> };
811
+ @group(0) @binding(0) var<uniform> u: ReflectModelU;
812
+
813
+ struct ReflectLight {
814
+ sun_dir: vec4<f32>, // xyz dir (TO-sun), w = intensity
815
+ sun_color: vec4<f32>, // rgb, w unused
816
+ ambient: vec4<f32>, // rgb, w = intensity
817
+ cam_pos: vec4<f32>, // xyz = MAIN camera pos (cascade selection), w unused
818
+ shadow_splits: vec4<f32>, // xyz = cascade split distances, w = shadows-enabled
819
+ shadow_cascades: array<mat4x4<f32>, 3>,
820
+ };
821
+ @group(1) @binding(0) var<uniform> light: ReflectLight;
822
+ @group(1) @binding(1) var refl_shadow_0: texture_depth_2d;
823
+ @group(1) @binding(2) var refl_shadow_1: texture_depth_2d;
824
+ @group(1) @binding(3) var refl_shadow_2: texture_depth_2d;
825
+ @group(1) @binding(4) var refl_shadow_samp: sampler_comparison;
826
+
827
+ @group(2) @binding(0) var base_tex: texture_2d<f32>;
828
+ @group(2) @binding(1) var base_samp: sampler;
829
+
830
+ struct VsIn {
831
+ @location(0) position: vec3<f32>,
832
+ @location(1) normal: vec3<f32>,
833
+ @location(2) color: vec4<f32>,
834
+ @location(3) uv: vec2<f32>,
835
+ };
836
+ struct VsOut {
837
+ @builtin(position) pos: vec4<f32>,
838
+ @location(0) n: vec3<f32>,
839
+ @location(1) uv: vec2<f32>,
840
+ @location(2) col: vec4<f32>,
841
+ @location(3) wpos: vec3<f32>,
842
+ };
843
+
844
+ @vertex
845
+ fn vs_reflect(in: VsIn) -> VsOut {
846
+ var o: VsOut;
847
+ o.pos = u.mvp * vec4<f32>(in.position, 1.0);
848
+ o.n = normalize((u.model * vec4<f32>(in.normal, 0.0)).xyz);
849
+ o.uv = in.uv;
850
+ o.col = in.color;
851
+ o.wpos = (u.model * vec4<f32>(in.position, 1.0)).xyz;
852
+ return o;
853
+ }
854
+
855
+ fn srgb_lin(c: vec3<f32>) -> vec3<f32> {
856
+ let lo = c / 12.92;
857
+ let hi = pow(max((c + vec3<f32>(0.055)) / 1.055, vec3<f32>(0.0)), vec3<f32>(2.4));
858
+ return select(hi, lo, c <= vec3<f32>(0.04045));
859
+ }
860
+
861
+ // Single-tap cascade shadow for the reflection pass. Cascade selection uses
862
+ // distance from the MAIN camera (the cascades were fit around it; reflected
863
+ // geometry is the same world geometry). Same normal-offset receiver bias as
864
+ // the main pass — texel-proportional push along the surface normal — so the
865
+ // mirrored scene doesn't acne. One tap (no PCF) is enough: the probe is
866
+ // consumed through the water's perturbed half-res lookup.
867
+ fn refl_sun_shadow(wp: vec3<f32>, n: vec3<f32>) -> f32 {
868
+ if (light.shadow_splits.w < 0.5) { return 1.0; }
869
+ let dist = length(wp - light.cam_pos.xyz);
870
+ var c = 2;
871
+ if (dist <= light.shadow_splits.x) { c = 0; }
872
+ else if (dist <= light.shadow_splits.y) { c = 1; }
873
+ var fit = light.shadow_splits.z;
874
+ if (c == 0) { fit = light.shadow_splits.x; }
875
+ else if (c == 1) { fit = light.shadow_splits.y; }
876
+ let dim = f32(textureDimensions(refl_shadow_0).x);
877
+ let p = wp + n * (2.0 * fit / dim) * 1.5;
878
+ let clip = light.shadow_cascades[c] * vec4<f32>(p, 1.0);
879
+ let ndc = clip.xyz / clip.w;
880
+ if (ndc.x < -1.0 || ndc.x > 1.0 || ndc.y < -1.0 || ndc.y > 1.0 ||
881
+ ndc.z < 0.0 || ndc.z > 1.0) {
882
+ return 1.0;
883
+ }
884
+ let uv = vec2<f32>(ndc.x * 0.5 + 0.5, 1.0 - (ndc.y * 0.5 + 0.5));
885
+ let ref_d = ndc.z - 0.001;
886
+ if (c == 0) { return textureSampleCompareLevel(refl_shadow_0, refl_shadow_samp, uv, ref_d); }
887
+ if (c == 1) { return textureSampleCompareLevel(refl_shadow_1, refl_shadow_samp, uv, ref_d); }
888
+ return textureSampleCompareLevel(refl_shadow_2, refl_shadow_samp, uv, ref_d);
889
+ }
890
+
891
+ @fragment
892
+ fn fs_reflect(in: VsOut) -> @location(0) vec4<f32> {
893
+ let tex = textureSample(base_tex, base_samp, in.uv);
894
+ if (tex.a < 0.5) { discard; } // alpha-cutout foliage reflects its shape
895
+ let base = srgb_lin(tex.rgb) * in.col.rgb;
896
+ let n = normalize(in.n);
897
+ // sun_dir is direction-TO-sun (engine convention), so N.L uses +sun_dir.
898
+ let ndl = max(dot(n, normalize(light.sun_dir.xyz)), 0.0);
899
+ let shadow = refl_sun_shadow(in.wpos, n);
900
+ // Hemisphere tint on the ambient: up-facing surfaces see more sky.
901
+ let hemi = 0.75 + 0.25 * clamp(n.y, -1.0, 1.0);
902
+ let lit = base * (light.ambient.rgb * light.ambient.w * hemi
903
+ + light.sun_color.rgb * light.sun_dir.w * ndl * shadow);
904
+ return vec4<f32>(lit, 1.0); // alpha 1 = 'real reflection here' for the water blend
905
+ }
906
+ ";
907
+