@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,499 @@
1
+ //! EN-011 — Planar reflection probes.
2
+ //!
3
+ //! A planar reflection probe owns an off-screen RT (`Rgba16Float` HDR
4
+ //! colour + `Depth32Float` depth) into which the engine renders the
5
+ //! scene from a camera mirrored across a flat reflective plane. The
6
+ //! resulting texture is bound at `@group(2) @binding(12)` on materials
7
+ //! that opt in via `Renderer::set_material_reflection_probe`, so e.g.
8
+ //! a water shader can sample the *actual* trees / bridge above the
9
+ //! surface instead of just the static `env_tex` skybox.
10
+ //!
11
+ //! ## V1 design
12
+ //!
13
+ //! - **One probe per plane.** A river is one plane; lakes are one
14
+ //! plane. Multi-probe blending lives in V2.
15
+ //! - **Rebuilt every frame** at the requested resolution. Half of the
16
+ //! swapchain width × height is the typical caller-supplied number;
17
+ //! nothing in this module enforces that — the FFI just defaults to
18
+ //! it when the game passes 0.
19
+ //! - **Cull list = "everything in the opaque material bucket minus a
20
+ //! hardcoded exclude list".** Implemented in `Renderer` (this
21
+ //! module just owns the probe RT + plane parameters); the dispatch
22
+ //! loop walks `material_system.commands` and skips the same
23
+ //! material handles for every probe.
24
+ //!
25
+ //! ## Coordinate convention
26
+ //!
27
+ //! The reflection plane is defined by `plane_y` (a single y-value
28
+ //! offset from origin) and a unit `normal`. We mirror world-space
29
+ //! points across plane `n · p = d`, where `d = n · plane_origin` and
30
+ //! `plane_origin = (0, plane_y, 0)`. For the typical horizontal water
31
+ //! surface, `normal = (0, 1, 0)` and `d = plane_y`. Non-axis-aligned
32
+ //! planes work too — the math is general — but the FFI surface
33
+ //! exposes the simpler horizontal case explicitly.
34
+ //!
35
+ //! See `Renderer::dispatch_planar_reflections` for the per-frame
36
+ //! render-graph node that drives all registered probes.
37
+
38
+ use crate::renderer::util::{mat4_invert, mat4_mul_vec4, mat4_multiply};
39
+
40
+ /// One planar reflection probe + its dedicated RT pair.
41
+ ///
42
+ /// The texture views (`color_view`, `depth_view`) are stable for the
43
+ /// lifetime of the probe, so per-material bind groups built once at
44
+ /// `set_material_reflection_probe` time stay valid frame after frame
45
+ /// even as the engine repaints the texture each frame.
46
+ pub struct PlanarReflectionProbe {
47
+ /// World-space y of the reflective plane (only used to build `d`
48
+ /// when normal == +Y; otherwise carried for diagnostics).
49
+ pub plane_y: f32,
50
+ /// Unit normal of the plane in world space. The mirror matrix
51
+ /// reflects across `n · p = d`, where `d = n · (0, plane_y, 0)`.
52
+ pub normal: [f32; 3],
53
+ /// Texture extent — width = height for a square probe; both
54
+ /// dimensions get rounded to ≥ 16 px in `new` so a tiny RT can't
55
+ /// crash the renderer.
56
+ pub resolution: u32,
57
+
58
+ pub color_rt: wgpu::Texture,
59
+ pub color_view: wgpu::TextureView,
60
+ pub depth_rt: wgpu::Texture,
61
+ pub depth_view: wgpu::TextureView,
62
+
63
+ /// Dummy G-buffer attachments for the user-material probe pass.
64
+ /// Opaque-profile material pipelines target the full 4-attachment
65
+ /// opaque layout (hdr + material + velocity + albedo), so the
66
+ /// probe's material pass must present the same four attachments —
67
+ /// wgpu validates pipeline targets against pass attachments
68
+ /// exactly. Only the hdr result is kept; these three are cleared
69
+ /// each frame and their stores discarded.
70
+ pub aux_material_rt: wgpu::Texture,
71
+ pub aux_material_view: wgpu::TextureView,
72
+ pub aux_velocity_rt: wgpu::Texture,
73
+ pub aux_velocity_view: wgpu::TextureView,
74
+ pub aux_albedo_rt: wgpu::Texture,
75
+ pub aux_albedo_view: wgpu::TextureView,
76
+ }
77
+
78
+ impl PlanarReflectionProbe {
79
+ /// Allocate the colour + depth textures sized to `resolution²`.
80
+ /// Format choices match the engine's HDR pipeline so the probe
81
+ /// texture interoperates cleanly with the rest of `material_abi`:
82
+ ///
83
+ /// - colour: `Rgba16Float` (`HDR_FORMAT`) so emissive geometry
84
+ /// reflected into the probe doesn't clamp to LDR
85
+ /// - depth: `Depth32Float` so the mirrored draws can z-test
86
+ /// against each other without a separate downsample
87
+ pub fn new(
88
+ device: &wgpu::Device,
89
+ plane_y: f32,
90
+ normal: [f32; 3],
91
+ resolution: u32,
92
+ ) -> Self {
93
+ // Clamp absurd inputs — a 0-px texture is illegal in wgpu
94
+ // and a 16k-px probe would cost more than the rest of the
95
+ // frame combined. 16..=4096 covers every realistic case.
96
+ let res = resolution.clamp(16, 4096);
97
+
98
+ let color_rt = device.create_texture(&wgpu::TextureDescriptor {
99
+ label: Some("planar_reflection_color"),
100
+ size: wgpu::Extent3d { width: res, height: res, depth_or_array_layers: 1 },
101
+ mip_level_count: 1,
102
+ sample_count: 1,
103
+ dimension: wgpu::TextureDimension::D2,
104
+ format: super::formats::HDR_FORMAT,
105
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT
106
+ | wgpu::TextureUsages::TEXTURE_BINDING,
107
+ view_formats: &[],
108
+ });
109
+ let color_view = color_rt.create_view(&Default::default());
110
+
111
+ let depth_rt = device.create_texture(&wgpu::TextureDescriptor {
112
+ label: Some("planar_reflection_depth"),
113
+ size: wgpu::Extent3d { width: res, height: res, depth_or_array_layers: 1 },
114
+ mip_level_count: 1,
115
+ sample_count: 1,
116
+ dimension: wgpu::TextureDimension::D2,
117
+ format: super::formats::DEPTH_FORMAT,
118
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
119
+ view_formats: &[],
120
+ });
121
+ let depth_view = depth_rt.create_view(&Default::default());
122
+
123
+ // Aux G-buffer dummies — see the struct field comment. Formats
124
+ // must byte-match what `Renderer::compile_material` passes to
125
+ // the pipeline descriptors or the probe pass fails validation.
126
+ let make_aux = |label: &str, format: wgpu::TextureFormat| {
127
+ let tex = device.create_texture(&wgpu::TextureDescriptor {
128
+ label: Some(label),
129
+ size: wgpu::Extent3d { width: res, height: res, depth_or_array_layers: 1 },
130
+ mip_level_count: 1,
131
+ sample_count: 1,
132
+ dimension: wgpu::TextureDimension::D2,
133
+ format,
134
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
135
+ view_formats: &[],
136
+ });
137
+ let view = tex.create_view(&Default::default());
138
+ (tex, view)
139
+ };
140
+ let (aux_material_rt, aux_material_view) =
141
+ make_aux("planar_reflection_aux_material", super::formats::MATERIAL_FORMAT);
142
+ let (aux_velocity_rt, aux_velocity_view) =
143
+ make_aux("planar_reflection_aux_velocity", super::formats::VELOCITY_FORMAT);
144
+ let (aux_albedo_rt, aux_albedo_view) =
145
+ make_aux("planar_reflection_aux_albedo", wgpu::TextureFormat::Rgba8Unorm);
146
+
147
+ // Normalise the supplied normal — caller may pass a non-unit
148
+ // vector; downstream math (specifically the reflection
149
+ // matrix below) assumes |n| == 1.
150
+ let n = normalise(normal);
151
+
152
+ Self {
153
+ plane_y, normal: n, resolution: res,
154
+ color_rt, color_view, depth_rt, depth_view,
155
+ aux_material_rt, aux_material_view,
156
+ aux_velocity_rt, aux_velocity_view,
157
+ aux_albedo_rt, aux_albedo_view,
158
+ }
159
+ }
160
+ }
161
+
162
+ /// Build the world-space reflection matrix for plane (n, plane_y).
163
+ ///
164
+ /// Reflects a world-space point `p` across the plane `n · p = d`
165
+ /// where `d = n · (0, plane_y, 0) = n.y * plane_y`. The returned 4×4
166
+ /// matrix R has `R · p = p - 2 (n·p - d) n`, with R applied
167
+ /// post-multiply on column vectors (Bloom convention).
168
+ ///
169
+ /// Plug this into the view chain via `mirror_view = view * R` —
170
+ /// post-multiplying R into the view matrix means: world → mirror
171
+ /// (R) → camera (view).
172
+ pub fn reflection_matrix(plane_y: f32, normal: [f32; 3]) -> [[f32; 4]; 4] {
173
+ let n = normalise(normal);
174
+ let d = n[1] * plane_y;
175
+ // Standard Householder-style reflection across the plane.
176
+ // Column-major; multiplies p as `R * p` (post-mul).
177
+ let nx = n[0]; let ny = n[1]; let nz = n[2];
178
+ [
179
+ [1.0 - 2.0 * nx * nx, -2.0 * nx * ny, -2.0 * nx * nz, 0.0],
180
+ [-2.0 * ny * nx, 1.0 - 2.0 * ny * ny, -2.0 * ny * nz, 0.0],
181
+ [-2.0 * nz * nx, -2.0 * nz * ny, 1.0 - 2.0 * nz * nz, 0.0],
182
+ [2.0 * nx * d, 2.0 * ny * d, 2.0 * nz * d, 1.0],
183
+ ]
184
+ }
185
+
186
+ /// Compose a mirrored view matrix from the camera's current view
187
+ /// matrix and the reflection plane.
188
+ ///
189
+ /// In Bloom's column-major / post-mul convention, `view` transforms
190
+ /// world points into camera space (`p_cam = view * p_world`). To
191
+ /// render the mirror image we want `p_cam = view * R * p_world` —
192
+ /// reflect first, then apply the existing view. The returned matrix
193
+ /// is `view * R`.
194
+ ///
195
+ /// **Caller MUST flip front-face cull mode** when using this view —
196
+ /// reflection inverts triangle winding. The renderer handles that
197
+ /// by binding pipelines compiled with `cull_mode = None` for the
198
+ /// mirrored pass; we accept the small fragment-shader cost for V1.
199
+ pub fn mirrored_view(view: [[f32; 4]; 4], plane_y: f32, normal: [f32; 3]) -> [[f32; 4]; 4] {
200
+ let r = reflection_matrix(plane_y, normal);
201
+ mat4_multiply(view, r)
202
+ }
203
+
204
+ /// Reflect the camera's world position across the plane. Used for
205
+ /// `PerView.camera_pos` in the mirrored UBO so view-dependent shading
206
+ /// (Fresnel, specular, parallax) sees the mirror camera, not the
207
+ /// real one.
208
+ pub fn mirrored_camera_pos(pos: [f32; 3], plane_y: f32, normal: [f32; 3]) -> [f32; 3] {
209
+ let n = normalise(normal);
210
+ let d = n[1] * plane_y;
211
+ let dist = n[0] * pos[0] + n[1] * pos[1] + n[2] * pos[2] - d;
212
+ [
213
+ pos[0] - 2.0 * dist * n[0],
214
+ pos[1] - 2.0 * dist * n[1],
215
+ pos[2] - 2.0 * dist * n[2],
216
+ ]
217
+ }
218
+
219
+ /// Recompute `inv_proj` from a possibly-modified projection. After the
220
+ /// EN-011 V2 `oblique_proj` rewrite, the reflection pass uses a near-
221
+ /// plane-clipped projection that differs from the main camera's, so
222
+ /// the inverse needs to be recomputed for any view-space reconstruction
223
+ /// downstream (SSR, deferred unprojection).
224
+ pub fn inv_proj_for(proj: [[f32; 4]; 4]) -> [[f32; 4]; 4] {
225
+ mat4_invert(proj)
226
+ }
227
+
228
+ /// Transform a world-space plane `(Nx, Ny, Nz, d)` (such that
229
+ /// `N · p + d = 0` for points on the plane) into eye / view space
230
+ /// using the view matrix. Plane equations transform by the inverse-
231
+ /// transpose of the matrix; for a rigid view matrix this is the same
232
+ /// as the matrix itself, but we do it the general way to stay safe
233
+ /// for skewed views.
234
+ ///
235
+ /// For Bloom's column-major / post-mul convention, applying the
236
+ /// inverse-transpose of `view` to a plane `(N, d)` equates to the
237
+ /// standard direct-3D / OpenGL formulation.
238
+ pub fn world_plane_to_eye_space(
239
+ view: [[f32; 4]; 4],
240
+ plane_world: [f32; 4],
241
+ ) -> [f32; 4] {
242
+ let inv = mat4_invert(view);
243
+ // Inverse-transpose, column-major. Multiplying the *transpose* of
244
+ // `inv` by the plane vector is the same as multiplying `inv`
245
+ // post-multiplied by the row vector — but we have a column-vec
246
+ // mul helper so we transpose `inv` first by reading rows as cols.
247
+ let inv_t: [[f32; 4]; 4] = [
248
+ [inv[0][0], inv[1][0], inv[2][0], inv[3][0]],
249
+ [inv[0][1], inv[1][1], inv[2][1], inv[3][1]],
250
+ [inv[0][2], inv[1][2], inv[2][2], inv[3][2]],
251
+ [inv[0][3], inv[1][3], inv[2][3], inv[3][3]],
252
+ ];
253
+ mat4_mul_vec4(&inv_t, &plane_world)
254
+ }
255
+
256
+ /// EN-011 V2 — modify a projection matrix so its near plane is clipped
257
+ /// at the given eye-space plane. Used for planar reflection to prevent
258
+ /// geometry below the water from polluting the reflection along the
259
+ /// shoreline edge.
260
+ ///
261
+ /// Reference: Eric Lengyel, "Oblique View Frustum Depth Projection
262
+ /// and Clipping" (Journal of Game Development, 2005). The technique
263
+ /// shifts the projection's near plane to coincide with the supplied
264
+ /// plane (typically the water plane), then any geometry on the wrong
265
+ /// side gets clipped at the rasterizer.
266
+ ///
267
+ /// `proj` is the original column-major projection matrix.
268
+ /// `plane_eye_space` is `(Nx, Ny, Nz, d)` such that points on the
269
+ /// plane satisfy `N · p_eye + d = 0`. Use `world_plane_to_eye_space`
270
+ /// to convert from a world-space plane.
271
+ pub fn oblique_proj(
272
+ proj: [[f32; 4]; 4],
273
+ plane_eye_space: [f32; 4],
274
+ ) -> [[f32; 4]; 4] {
275
+ let c = plane_eye_space;
276
+
277
+ // Far-plane corner in clip space is in the direction of (sgn(c.x),
278
+ // sgn(c.y), 1, 1) — Lengyel §2. Pulled back into eye space by
279
+ // multiplying with `inv(proj)`.
280
+ let sx = if c[0] >= 0.0 { 1.0 } else { -1.0 };
281
+ let sy = if c[1] >= 0.0 { 1.0 } else { -1.0 };
282
+ let q_clip = [sx, sy, 1.0, 1.0];
283
+ let inv_p = mat4_invert(proj);
284
+ let q = mat4_mul_vec4(&inv_p, &q_clip);
285
+
286
+ // Scale `c` so the near-plane crosses through the supplied plane:
287
+ // M = (2 / dot(c, q)) · c
288
+ // Then the new third row of P (which controls the depth output) is
289
+ // P_row2 = M - P_row3
290
+ // (P_row3 is the standard perspective w-row, all stays the same.)
291
+ let denom = c[0] * q[0] + c[1] * q[1] + c[2] * q[2] + c[3] * q[3];
292
+ if denom.abs() < 1e-10 {
293
+ // Degenerate plane orientation w.r.t. the frustum — leave
294
+ // projection unchanged rather than divide-by-zero. The
295
+ // reflection still renders, just without near-plane clipping.
296
+ return proj;
297
+ }
298
+ let scale = 2.0 / denom;
299
+ let m = [c[0] * scale, c[1] * scale, c[2] * scale, c[3] * scale];
300
+
301
+ // proj is column-major: proj[col][row]. The "third row" we want
302
+ // to replace is at row index 2 across all four columns. The
303
+ // "fourth row" is at row index 3. New row-2 = M - row3 — but
304
+ // since wgpu's clip-space z range is [0, 1] (not [-1, 1] like
305
+ // OpenGL), the depth-rescaling pre-step is `M - P_row3` exactly
306
+ // as in Lengyel's original derivation; the [-1, 1] vs [0, 1]
307
+ // difference is absorbed by the scale.
308
+ let mut out = proj;
309
+ out[0][2] = m[0] - proj[0][3];
310
+ out[1][2] = m[1] - proj[1][3];
311
+ out[2][2] = m[2] - proj[2][3];
312
+ out[3][2] = m[3] - proj[3][3];
313
+ out
314
+ }
315
+
316
+ fn normalise(v: [f32; 3]) -> [f32; 3] {
317
+ let len_sq = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
318
+ if len_sq < 1e-10 {
319
+ // Default to +Y — a horizontal mirror like a calm lake.
320
+ return [0.0, 1.0, 0.0];
321
+ }
322
+ let inv = 1.0 / len_sq.sqrt();
323
+ [v[0] * inv, v[1] * inv, v[2] * inv]
324
+ }
325
+
326
+ // =====================================================================
327
+ // Tests
328
+ // =====================================================================
329
+
330
+ #[cfg(test)]
331
+ mod tests {
332
+ use super::*;
333
+ use crate::renderer::util::mat4_mul_vec4;
334
+
335
+ /// A point above a horizontal y=0 plane reflects to the mirror
336
+ /// position below it — y-coordinate flips sign, x and z preserved.
337
+ #[test]
338
+ fn reflection_matrix_mirrors_above_to_below() {
339
+ let r = reflection_matrix(0.0, [0.0, 1.0, 0.0]);
340
+ let p = [3.0_f32, 5.0, -7.0, 1.0];
341
+ let out = mat4_mul_vec4(&r, &p);
342
+ assert!((out[0] - 3.0).abs() < 1e-5, "x preserved (got {})", out[0]);
343
+ assert!((out[1] + 5.0).abs() < 1e-5, "y flipped (got {})", out[1]);
344
+ assert!((out[2] + 7.0).abs() < 1e-5, "z preserved (got {})", out[2]);
345
+ assert!((out[3] - 1.0).abs() < 1e-5, "w preserved");
346
+ }
347
+
348
+ /// A non-zero `plane_y` shifts the mirror — point at y=10 across
349
+ /// plane y=2 lands at y = 2 - (10 - 2) = -6.
350
+ #[test]
351
+ fn reflection_matrix_offset_plane() {
352
+ let r = reflection_matrix(2.0, [0.0, 1.0, 0.0]);
353
+ let p = [0.0_f32, 10.0, 0.0, 1.0];
354
+ let out = mat4_mul_vec4(&r, &p);
355
+ assert!((out[1] - (-6.0)).abs() < 1e-4, "y reflected across y=2 (got {})", out[1]);
356
+ }
357
+
358
+ /// Camera position helper agrees with applying the matrix to a
359
+ /// (cam_pos, 1) vec4 — sanity check.
360
+ #[test]
361
+ fn mirrored_camera_pos_matches_matrix() {
362
+ let plane_y = 0.5;
363
+ let n = [0.0, 1.0, 0.0];
364
+ let cam = [1.5_f32, 4.0, -2.0];
365
+
366
+ let helper_out = mirrored_camera_pos(cam, plane_y, n);
367
+ let r = reflection_matrix(plane_y, n);
368
+ let mat_out = mat4_mul_vec4(&r, &[cam[0], cam[1], cam[2], 1.0]);
369
+
370
+ assert!((helper_out[0] - mat_out[0]).abs() < 1e-5);
371
+ assert!((helper_out[1] - mat_out[1]).abs() < 1e-5);
372
+ assert!((helper_out[2] - mat_out[2]).abs() < 1e-5);
373
+ }
374
+
375
+ /// Reflection is its own inverse — applying it twice returns
376
+ /// the original point.
377
+ #[test]
378
+ fn reflection_is_involution() {
379
+ let r = reflection_matrix(0.5, [0.0, 1.0, 0.0]);
380
+ let p = [3.0_f32, 5.0, -7.0, 1.0];
381
+ let once = mat4_mul_vec4(&r, &p);
382
+ let twice = mat4_mul_vec4(&r, &once);
383
+ for i in 0..4 {
384
+ assert!((twice[i] - p[i]).abs() < 1e-4, "comp {} mismatch", i);
385
+ }
386
+ }
387
+
388
+ /// Headless wgpu device. Mirrors the EN-006 pattern from
389
+ /// `transient.rs` / `impulse_field.rs`. Returns `None` when no GPU
390
+ /// is available so the test skips gracefully on bare CI.
391
+ fn try_create_device() -> Option<(wgpu::Device, wgpu::Queue)> {
392
+ let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
393
+ backends: wgpu::Backends::all(),
394
+ ..wgpu::InstanceDescriptor::new_without_display_handle()
395
+ });
396
+ let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
397
+ power_preference: wgpu::PowerPreference::LowPower,
398
+ compatible_surface: None,
399
+ force_fallback_adapter: true,
400
+ })).ok()?;
401
+ let (device, queue) = pollster::block_on(adapter.request_device(
402
+ &wgpu::DeviceDescriptor {
403
+ label: Some("planar-reflection-test-device"),
404
+ required_features: wgpu::Features::empty(),
405
+ required_limits: wgpu::Limits::downlevel_defaults(),
406
+ ..Default::default()
407
+ },
408
+ )).ok()?;
409
+ Some((device, queue))
410
+ }
411
+
412
+ /// EN-011 — verify `PlanarReflectionProbe::new` allocates HDR
413
+ /// colour + Depth32 attachments at the requested resolution.
414
+ /// Pure construction test — render-graph integration lives in
415
+ /// `Renderer::dispatch_planar_reflections`.
416
+ #[test]
417
+ fn probe_creation_allocates_hdr_and_depth_attachments() {
418
+ let Some((device, _queue)) = try_create_device() else { return; };
419
+ let probe = PlanarReflectionProbe::new(&device, 0.5, [0.0, 1.0, 0.0], 256);
420
+ assert_eq!(probe.resolution, 256, "resolution clamped to caller value");
421
+ assert_eq!(probe.normal, [0.0, 1.0, 0.0], "normalised +Y stays +Y");
422
+ let color_size = probe.color_rt.size();
423
+ assert_eq!(color_size.width, 256);
424
+ assert_eq!(color_size.height, 256);
425
+ assert_eq!(probe.color_rt.format(), super::super::formats::HDR_FORMAT);
426
+ assert_eq!(probe.depth_rt.format(), super::super::formats::DEPTH_FORMAT);
427
+ }
428
+
429
+ /// Tiny resolutions are clamped up to 16 px to keep wgpu happy.
430
+ #[test]
431
+ fn probe_resolution_clamps_minimum() {
432
+ let Some((device, _queue)) = try_create_device() else { return; };
433
+ let probe = PlanarReflectionProbe::new(&device, 0.0, [0.0, 1.0, 0.0], 4);
434
+ assert_eq!(probe.resolution, 16, "clamps to 16 px floor");
435
+ }
436
+
437
+ /// EN-011 V2 — oblique projection clips a point on the wrong side
438
+ /// of the supplied plane. Point ABOVE the eye-space plane should
439
+ /// project inside the [-w, w] z range (clip-space-z divides to
440
+ /// [0, 1] in wgpu); point BELOW the plane projects with z > w
441
+ /// (i.e. ndc.z > 1 in wgpu) so the rasterizer clips it.
442
+ ///
443
+ /// Setup: identity view (eye-space == world-space), a horizontal
444
+ /// plane y = 0 (so eye-space plane = (0, 1, 0, 0)), and a perspective
445
+ /// projection. We then check a point ABOVE (y = +5) renders, and a
446
+ /// point BELOW (y = -5) gets clipped.
447
+ #[test]
448
+ fn oblique_proj_clips_below_plane() {
449
+ use crate::renderer::util::mat4_perspective;
450
+ // Standard perspective matching mat4_perspective conventions.
451
+ let proj = mat4_perspective(70.0_f32.to_radians(), 16.0/9.0, 0.1, 100.0);
452
+ // Horizontal plane y = 0 in eye-space, normal +Y, points above
453
+ // satisfy y > 0 → N·p + d > 0 (kept). The plane equation
454
+ // (Nx, Ny, Nz, d) for "y >= 0 is above" is (0, 1, 0, 0).
455
+ let plane_eye = [0.0_f32, 1.0, 0.0, 0.0];
456
+ let oblique = oblique_proj(proj, plane_eye);
457
+
458
+ // A point in eye space ABOVE the plane, in front of camera:
459
+ // y = +5 (above), z = -10 (in front, right-handed view).
460
+ let p_above = [3.0_f32, 5.0, -10.0, 1.0];
461
+ let clip_above = mat4_mul_vec4(&oblique, &p_above);
462
+ // wgpu / D3D / Metal clip space: visible when 0 <= z <= w.
463
+ // For a point above the plane, z/w should be inside [0, 1].
464
+ assert!(clip_above[3] > 0.0, "point above plane has positive w (got {})", clip_above[3]);
465
+ let ndc_z_above = clip_above[2] / clip_above[3];
466
+ assert!(ndc_z_above >= 0.0 && ndc_z_above <= 1.0,
467
+ "above-plane point ndc.z within [0,1] (got {})", ndc_z_above);
468
+
469
+ // A point BELOW the plane: y = -5 (below), z = -10 (in front).
470
+ // Oblique projection moves the near plane to coincide with
471
+ // y = 0, so this point is on the wrong side and should clip.
472
+ // wgpu's clip space requires 0 <= z <= w to be visible — any
473
+ // ndc.z outside [0, 1] (or w <= 0) means the rasterizer drops
474
+ // the fragment.
475
+ let p_below = [3.0_f32, -5.0, -10.0, 1.0];
476
+ let clip_below = mat4_mul_vec4(&oblique, &p_below);
477
+ let visible = clip_below[3] > 0.0
478
+ && clip_below[2] >= 0.0
479
+ && clip_below[2] <= clip_below[3];
480
+ assert!(!visible,
481
+ "below-plane point should be clipped; got clip = ({}, {}, {}, {})",
482
+ clip_below[0], clip_below[1], clip_below[2], clip_below[3]);
483
+ }
484
+
485
+ /// `world_plane_to_eye_space` round-trip: a horizontal world plane
486
+ /// y = 0 (normal +Y) under an identity view stays as (0, 1, 0, 0)
487
+ /// in eye space — sanity check the inverse-transpose plumbing.
488
+ #[test]
489
+ fn world_plane_to_eye_space_identity_view() {
490
+ use crate::renderer::util::IDENTITY_MAT4;
491
+ let plane_world = [0.0_f32, 1.0, 0.0, 0.0]; // y = 0 plane
492
+ let plane_eye = world_plane_to_eye_space(IDENTITY_MAT4, plane_world);
493
+ for i in 0..4 {
494
+ assert!((plane_eye[i] - plane_world[i]).abs() < 1e-5,
495
+ "identity view leaves plane unchanged (comp {}: {} vs {})",
496
+ i, plane_eye[i], plane_world[i]);
497
+ }
498
+ }
499
+ }