1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
//! Camera buffers and matrices.
use awsm_renderer_core::buffers::{BufferDescriptor, BufferUsage};
use awsm_renderer_core::error::AwsmCoreError;
use awsm_renderer_core::renderer::AwsmRendererWebGpu;
use glam::{Mat4, Vec2, Vec3, Vec4};
use thiserror::Error;
use crate::bind_groups::BindGroups;
use crate::render_textures::RenderTextures;
use crate::{AwsmRenderer, AwsmRendererLogging};
const APPLY_JITTER: bool = false;
impl AwsmRenderer {
/// Updates the camera buffer with new matrices.
pub fn update_camera(&mut self, camera_matrices: CameraMatrices) -> Result<()> {
let (current_width, current_height) = self.gpu.current_context_texture_size()?;
self.camera.update(
camera_matrices,
&self.render_textures,
current_width as f32,
current_height as f32,
)?;
Ok(())
}
}
/// GPU camera buffer and cached state.
pub struct CameraBuffer {
pub(crate) raw_data: [u8; Self::BYTE_SIZE],
pub gpu_buffer: web_sys::GpuBuffer,
pub last_matrices: Option<CameraMatrices>,
camera_moved: bool,
gpu_dirty: bool,
uploader: crate::buffer::mapped_uploader::MappedUploader,
}
/// Camera matrices and parameters.
#[derive(Clone, Debug)]
pub struct CameraMatrices {
pub view: Mat4,
pub projection: Mat4,
pub position_world: Vec3,
/// Focus distance for depth of field (world units). Default: 10.0
pub focus_distance: f32,
/// Aperture f-stop for depth of field. Lower = more blur. Default: 5.6
pub aperture: f32,
}
impl CameraMatrices {
/// Right-handed perspective camera from eye/target/up + frustum params — the
/// common case, so a consumer doesn't hand-roll glam matrices. `fov_y` is in
/// radians; `aspect` = width / height. Depth-of-field defaults to focusing on
/// `target` at f/16 (tweak `focus_distance` / `aperture` afterward if needed).
pub fn perspective(
eye: Vec3,
target: Vec3,
up: Vec3,
fov_y: f32,
aspect: f32,
near: f32,
far: f32,
) -> Self {
Self {
view: Mat4::look_at_rh(eye, target, up),
projection: Mat4::perspective_rh(fov_y, aspect, near, far),
position_world: eye,
focus_distance: (target - eye).length().max(0.001),
aperture: 16.0,
}
}
/// Returns the combined view-projection matrix.
pub fn view_projection(&self) -> Mat4 {
self.projection * self.view
}
/// Returns the inverse view-projection matrix.
pub fn inv_view_projection(&self) -> Mat4 {
self.view_projection().inverse()
}
/// Returns true if the projection is orthographic.
pub fn is_orthographic(&self) -> bool {
// Orthographic projections have m[3][3] = 1.0 (no perspective divide)
// Perspective projections have m[3][3] = 0.0 (w' = -z for perspective divide)
// This is the definitive check for standard projection matrices.
self.projection.w_axis.w.abs() > 0.5
}
}
impl CameraBuffer {
// Layout (tightly packed, no implicit padding):
// view (mat4) 64 bytes
// projection (mat4) 64 bytes
// view_projection (mat4) 64 bytes
// inv_view_projection (mat4) 64 bytes
// inv_projection (mat4) 64 bytes
// inv_view (mat4) 64 bytes
// position (vec4, w=unused) 16 bytes
// frustum corner rays (4 * vec4) 64 bytes
// viewport (vec4) 16 bytes
// dof_params (vec4: focus_distance, aperture, unused, unused) 16 bytes
// Total = 496 bytes (all members 16-byte aligned, no implicit gaps)
//
// A `vec4<u32>` slot used to live between `position` and
// `frustum_rays` carrying `render_textures.frame_count()` as
// `frame_count_and_padding.x`. No WGSL ever read it; the monotonic
// frame counter now lives on the `frame_globals` uniform (see
// `crates/renderer/src/frame_globals`). The slot was removed —
// Camera is 16 bytes slimmer.
/// Byte size of the camera uniform buffer.
pub const BYTE_SIZE: usize = 496;
/// Creates a camera buffer on the GPU.
pub fn new(gpu: &AwsmRendererWebGpu) -> Result<Self> {
let gpu_buffer = gpu.create_buffer(
&BufferDescriptor::new(
Some("Camera"),
Self::BYTE_SIZE,
BufferUsage::new().with_uniform().with_copy_dst(),
)
.into(),
)?;
Ok(Self {
raw_data: [0; Self::BYTE_SIZE],
gpu_dirty: true,
last_matrices: None,
camera_moved: false,
gpu_buffer,
uploader: crate::buffer::mapped_uploader::MappedUploader::new("Camera"),
})
}
/// Mapped-ring upload telemetry for the camera buffer.
pub fn upload_stats(&self) -> crate::buffer::mapped_staging_ring::UploadStats {
self.uploader.stats()
}
// this is fast/cheap to call, so we can call it multiple times a frame
// it will only update the data in the buffer once per frame, at render time
pub(crate) fn update(
&mut self,
camera_matrices_orig: CameraMatrices,
render_textures: &RenderTextures,
screen_width: f32,
screen_height: f32,
) -> Result<()> {
let mut camera_matrices = camera_matrices_orig.clone();
self.camera_moved = match &self.last_matrices {
Some(last_matrices) => {
fn matrices_equal(a: Mat4, b: Mat4, epsilon: f32) -> bool {
for i in 0..16 {
if (a.to_cols_array()[i] - b.to_cols_array()[i]).abs() > epsilon {
return false;
}
}
true
}
// Check if matrices changed (with small epsilon for floating point comparison)
!matrices_equal(last_matrices.view, camera_matrices.view, 1e-6)
|| !matrices_equal(last_matrices.projection, camera_matrices.projection, 1e-6)
}
_ => true, // First frame, assume movement
};
if APPLY_JITTER {
let jitter_strength = if self.camera_moved { 0.2 } else { 0.8 };
// TAA jitter
let jitter = get_halton_jitter(render_textures.frame_count());
let jitter_ndc_x = (jitter.x / screen_width) * jitter_strength;
let jitter_ndc_y = (jitter.y / screen_height) * jitter_strength;
// Create jitter translation matrix
let jitter_matrix = Mat4::from_translation(Vec3::new(jitter_ndc_x, jitter_ndc_y, 0.0));
// Apply to your projection matrix
camera_matrices.projection = jitter_matrix * camera_matrices.projection;
}
// Layout written below (mirrors `CameraUniform` in WGSL). The additional inverse
// projection and frustum rays let compute passes reconstruct per-pixel view/world
// positions directly from the depth buffer.
//
// IMPORTANT: frustum_rays are for SCREEN-SPACE RECONSTRUCTION, NOT frustum culling!
// They are 4 normalized view-space ray directions at the near plane corners,
// used for unprojecting screen pixels to world space (deferred rendering, grids, etc.).
// For frustum culling, you need 6 frustum planes extracted from the view-proj matrix.
let inv_projection = camera_matrices.projection.inverse();
let inv_view_projection = camera_matrices.inv_view_projection();
let inv_view = camera_matrices.view.inverse();
let frustum_rays = compute_view_frustum_rays(inv_projection);
// let s = format!("CameraBuffer Update, inv_projection: {inv_projection:?} inv_view_projection: {inv_view_projection:?} inv_view: {inv_view:?} frustum rays: {frustum_rays:?}");
// debug_unique_string(1, &s, || tracing::info!("{s}"));
let mut offset = 0;
let view = camera_matrices.view.to_cols_array();
write_f32_slice(&mut self.raw_data, &mut offset, &view);
let projection = camera_matrices.projection.to_cols_array();
write_f32_slice(&mut self.raw_data, &mut offset, &projection);
let view_projection = camera_matrices.view_projection().to_cols_array();
write_f32_slice(&mut self.raw_data, &mut offset, &view_projection);
let inv_view_projection_cols = inv_view_projection.to_cols_array();
write_f32_slice(&mut self.raw_data, &mut offset, &inv_view_projection_cols);
let inv_projection_cols = inv_projection.to_cols_array();
write_f32_slice(&mut self.raw_data, &mut offset, &inv_projection_cols);
let inv_view_cols = inv_view.to_cols_array();
write_f32_slice(&mut self.raw_data, &mut offset, &inv_view_cols);
// Write position as vec4 (xyz + unused w component)
let position = camera_matrices.position_world.extend(0.0).to_array();
write_f32_slice(&mut self.raw_data, &mut offset, &position);
// The 16-byte `frame_count_and_padding` slot that used to sit
// here is removed — see `BYTE_SIZE`'s rationale. frustum_rays
// follows directly.
for ray in frustum_rays.iter() {
let ray_values = ray.to_array();
write_f32_slice(&mut self.raw_data, &mut offset, &ray_values);
}
//viewport
write_f32_slice(
&mut self.raw_data,
&mut offset,
&[0.0, 0.0, screen_width, screen_height],
);
// DoF parameters: focus_distance, aperture, and 2 unused floats
write_f32_slice(
&mut self.raw_data,
&mut offset,
&[
camera_matrices.focus_distance,
camera_matrices.aperture,
0.0,
0.0,
],
);
debug_assert_eq!(offset, Self::BYTE_SIZE, "Buffer layout mismatch!");
self.gpu_dirty = true;
// Store for next frame (unjittered versions)
self.last_matrices = Some(camera_matrices_orig);
Ok(())
}
/// Returns true if the camera was moved since the last update.
pub fn moved(&self) -> bool {
self.camera_moved
}
// writes to the GPU
/// Writes the camera buffer to the GPU when dirty.
pub fn write_gpu(
&mut self,
logging: &AwsmRendererLogging,
gpu: &AwsmRendererWebGpu,
_bind_groups: &BindGroups,
) -> Result<()> {
if self.gpu_dirty {
let _maybe_span_guard = if logging.render_timings.sub_frame() {
Some(tracing::span!(tracing::Level::INFO, "Camera GPU write").entered())
} else {
None
};
self.uploader.write_dirty_ranges(
gpu,
&self.gpu_buffer,
Self::BYTE_SIZE,
self.raw_data.as_slice(),
&[(0, Self::BYTE_SIZE)],
)?;
self.gpu_dirty = false;
}
Ok(())
}
}
fn get_halton_jitter(frame_count: u32) -> Vec2 {
let x = halton(frame_count, 2) - 0.5;
let y = halton(frame_count, 3) - 0.5;
Vec2::new(x, y)
}
fn halton(mut index: u32, base: u32) -> f32 {
let mut result = 0.0;
let mut f = 1.0;
while index > 0 {
f /= base as f32;
result += f * (index % base) as f32;
index /= base;
}
result
}
/// Compute 4 normalized view-space ray directions for the near plane corners.
///
/// These rays are used for screen-space reconstruction (unprojecting screen pixels to world space).
/// Shaders bilinearly interpolate these corner rays to get the ray direction for any pixel,
/// providing better numerical precision than doing full unprojection per-pixel.
///
/// **NOT for frustum culling** - culling needs 6 frustum planes extracted from view-proj matrix.
///
/// Order: [0]=bottom-left, [1]=bottom-right, [2]=top-left, [3]=top-right
fn compute_view_frustum_rays(inv_projection: Mat4) -> [Vec4; 4] {
// Reproject the clip-space corners of the near plane back into view space. These serve as
// canonical ray directions that the compute shader can bilinearly interpolate per pixel.
// Use z=0 (near plane in WebGPU NDC), not z=1 (far plane) to avoid infinities
let ndc_corners = [
Vec4::new(-1.0, -1.0, 0.0, 1.0),
Vec4::new(1.0, -1.0, 0.0, 1.0),
Vec4::new(-1.0, 1.0, 0.0, 1.0),
Vec4::new(1.0, 1.0, 0.0, 1.0),
];
let mut rays = [Vec4::ZERO; 4];
for (i, corner) in ndc_corners.iter().enumerate() {
let view_space = inv_projection * *corner;
let view_space = view_space / view_space.w;
// Normalize to get ray direction (not position)
let ray_dir = Vec3::new(view_space.x, view_space.y, view_space.z).normalize();
rays[i] = Vec4::new(ray_dir.x, ray_dir.y, ray_dir.z, 0.0);
}
rays
}
fn write_f32_slice(buffer: &mut [u8], offset: &mut usize, values: &[f32]) {
// All matrices/vectors in the camera buffer are tightly packed f32 arrays. Writing them this
// way keeps the CPU-side layout authoritative and avoids duplicating offset math.
let byte_len = std::mem::size_of_val(values);
// crate::debug::debug_unique_string(*offset as u32, &format!("{:?}", values), || {
// tracing::info!("[{}]: {:?}", offset, values);
// });
let bytes = unsafe { std::slice::from_raw_parts(values.as_ptr() as *const u8, byte_len) };
buffer[*offset..*offset + byte_len].copy_from_slice(bytes);
*offset += byte_len;
}
/// Result type for camera operations.
type Result<T> = std::result::Result<T, AwsmCameraError>;
/// Camera-related errors.
#[derive(Error, Debug)]
pub enum AwsmCameraError {
#[error("[camera] {0:?}")]
Core(#[from] AwsmCoreError),
}