use crate::csm::{CASCADE_COUNT, SHADOW_MAP_RES};
use crate::gpu_types::{LightData, PostProcessUniforms, SceneUniforms};
use gizmo_math::{Mat4, Vec3};
pub const MAX_LIGHTS: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CameraFrame {
pub view_proj: Mat4,
pub position: Vec3,
pub forward: Vec3,
pub near: f32,
pub far: f32,
pub exposure: f32,
}
impl Default for CameraFrame {
fn default() -> Self {
Self {
view_proj: Mat4::IDENTITY,
position: Vec3::ZERO,
forward: Vec3::new(0.0, 0.0, -1.0),
near: 0.1,
far: 2000.0,
exposure: 1.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SunFrame {
pub direction: Vec3,
pub color: [f32; 4],
pub present: bool,
}
impl Default for SunFrame {
fn default() -> Self {
Self { direction: Vec3::new(0.0, -1.0, 0.0), color: [1.0, 1.0, 1.0, 0.0], present: false }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShadowFrame {
pub cascade_view_projs: [Mat4; CASCADE_COUNT],
pub cascade_splits: [f32; CASCADE_COUNT],
pub point_caster: Option<u32>,
pub point_shadows_enabled: bool,
}
impl Default for ShadowFrame {
fn default() -> Self {
Self {
cascade_view_projs: [Mat4::IDENTITY; CASCADE_COUNT],
cascade_splits: [1.0, 10.0, 50.0, 500.0],
point_caster: None,
point_shadows_enabled: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct EnvironmentFrame {
pub preset: u32,
pub preset_2: u32,
pub blend_t: f32,
pub shading_mode: u32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SceneFrame {
pub camera: CameraFrame,
pub sun: SunFrame,
pub lights: [LightData; MAX_LIGHTS],
pub num_lights: u32,
pub shadows: ShadowFrame,
pub environment: EnvironmentFrame,
pub elapsed_time: f32,
}
impl Default for SceneFrame {
fn default() -> Self {
Self {
camera: CameraFrame::default(),
sun: SunFrame::default(),
lights: [LightData::default(); MAX_LIGHTS],
num_lights: 0,
shadows: ShadowFrame::default(),
environment: EnvironmentFrame::default(),
elapsed_time: 0.0,
}
}
}
impl SceneUniforms {
#[must_use]
pub fn new(frame: &SceneFrame) -> Self {
let SceneFrame { camera, sun, lights, num_lights, shadows, environment, elapsed_time } =
frame;
Self {
view_proj: camera.view_proj.to_cols_array_2d(),
camera_pos: camera.position.extend(1.0).to_array(),
sun_direction: sun.direction.extend(if sun.present { 1.0 } else { 0.0 }).to_array(),
sun_color: sun.color,
lights: *lights,
light_view_proj: shadows.cascade_view_projs.map(|m| m.to_cols_array_2d()),
cascade_splits: shadows.cascade_splits,
camera_forward: camera.forward.extend(0.0).to_array(),
cascade_params: [
camera.near,
1.0 / SHADOW_MAP_RES as f32,
*elapsed_time,
shadows.point_caster.map_or(0.0, |i| (i + 1) as f32),
],
num_lights: *num_lights,
exposure: camera.exposure,
_pre_align_pad: [0; 2],
_align_pad: [0; 3],
environment_blend_t: environment.blend_t,
environment_preset: environment.preset,
point_shadows_enabled: u32::from(shadows.point_shadows_enabled),
environment_preset_2: environment.preset_2,
shading_mode: environment.shading_mode,
inv_view_proj: camera.view_proj.inverse().to_cols_array_2d(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct UnderwaterFog {
pub color: [f32; 3],
pub density: f32,
}
impl Default for PostProcessUniforms {
fn default() -> Self {
Self {
bloom_intensity: 0.8,
bloom_threshold: 0.85,
exposure: 1.15,
chromatic_aberration: 0.0,
vignette_intensity: 0.25,
film_grain_intensity: 0.012,
dof_focus_dist: 15.0,
dof_focus_range: 25.0,
dof_blur_size: 0.0,
cam_near: 0.1,
cam_far: 2000.0,
underwater: 0.0,
fog_r: 0.0,
fog_g: 0.0,
fog_b: 0.0,
fog_density: 0.0,
}
}
}
impl PostProcessUniforms {
#[must_use]
pub fn with_camera(mut self, camera: &CameraFrame) -> Self {
self.cam_near = camera.near;
self.cam_far = camera.far;
self
}
#[must_use]
pub fn with_underwater(mut self, fog: Option<UnderwaterFog>) -> Self {
match fog {
Some(f) => {
self.underwater = 1.0;
self.fog_r = f.color[0];
self.fog_g = f.color[1];
self.fog_b = f.color[2];
self.fog_density = f.density;
}
None => {
self.underwater = 0.0;
self.fog_r = 0.0;
self.fog_g = 0.0;
self.fog_b = 0.0;
self.fog_density = 0.0;
}
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
fn a_camera() -> CameraFrame {
CameraFrame {
view_proj: Mat4::perspective_rh(1.0, 1.6, 0.25, 900.0)
* Mat4::look_at_rh(Vec3::new(3.0, 4.0, 5.0), Vec3::ZERO, Vec3::Y),
position: Vec3::new(3.0, 4.0, 5.0),
forward: Vec3::new(0.0, 0.0, -1.0),
near: 0.25,
far: 900.0,
exposure: 1.4,
}
}
#[test]
fn cascade_params_slots_carry_what_common_wgsl_says_they_do() {
let frame = SceneFrame {
camera: a_camera(),
elapsed_time: 12.5,
shadows: ShadowFrame { point_caster: Some(3), ..Default::default() },
..Default::default()
};
let u = SceneUniforms::new(&frame);
assert_eq!(u.cascade_params[0], 0.25, "x = the camera's z-near, not a literal 0.1");
assert_eq!(u.cascade_params[1], 1.0 / SHADOW_MAP_RES as f32, "y = PCF texel size");
assert_eq!(u.cascade_params[2], 12.5, "z = elapsed time; 0.0 here freezes the water");
assert_eq!(u.cascade_params[3], 4.0, "w = caster index + 1, so 0 can mean 'none'");
let none = SceneUniforms::new(&SceneFrame::default());
assert_eq!(none.cascade_params[3], 0.0, "no caster must encode as 0, not as light 0");
}
#[test]
fn a_scene_without_a_sun_says_so_in_the_w_component() {
let dark = SceneUniforms::new(&SceneFrame::default());
assert_eq!(dark.sun_direction[3], 0.0);
let lit = SceneUniforms::new(&SceneFrame {
sun: SunFrame { direction: Vec3::new(0.0, -1.0, 0.0), color: [1.0; 4], present: true },
..Default::default()
});
assert_eq!(lit.sun_direction[3], 1.0);
}
#[test]
fn inv_view_proj_inverts_the_matrix_that_was_written() {
let u = SceneUniforms::new(&SceneFrame { camera: a_camera(), ..Default::default() });
let vp = Mat4::from_cols_array_2d(&u.view_proj);
let inv = Mat4::from_cols_array_2d(&u.inv_view_proj);
let round_trip = vp * inv;
for (i, col) in Mat4::IDENTITY.to_cols_array().iter().enumerate() {
assert!(
(round_trip.to_cols_array()[i] - col).abs() < 1e-3,
"view_proj * inv_view_proj is not the identity: {round_trip:?}"
);
}
}
#[test]
fn the_padding_is_zeroed_and_the_block_is_the_size_the_shaders_expect() {
let u = SceneUniforms::new(&SceneFrame { camera: a_camera(), ..Default::default() });
assert_eq!(u._pre_align_pad, [0; 2]);
assert_eq!(u._align_pad, [0; 3]);
assert_eq!(std::mem::size_of::<SceneUniforms>(), 1168);
assert_eq!(std::mem::offset_of!(SceneUniforms, inv_view_proj), 1104);
assert_eq!(std::mem::size_of_val(&u.lights) / std::mem::size_of::<LightData>(), MAX_LIGHTS);
}
#[test]
fn point_shadows_are_off_unless_the_caller_rendered_the_cube() {
let off = SceneUniforms::new(&SceneFrame::default());
assert_eq!(off.point_shadows_enabled, 0);
let on = SceneUniforms::new(&SceneFrame {
shadows: ShadowFrame { point_shadows_enabled: true, ..Default::default() },
..Default::default()
});
assert_eq!(on.point_shadows_enabled, 1);
}
#[test]
fn post_process_takes_its_depth_range_from_the_camera() {
let p = PostProcessUniforms::default().with_camera(&a_camera());
assert_eq!((p.cam_near, p.cam_far), (0.25, 900.0));
assert_eq!(
p.exposure,
PostProcessUniforms::default().exposure,
"with_camera must not touch exposure — the editor and the demos own that knob"
);
}
#[test]
fn underwater_fog_is_all_or_nothing() {
let dry = PostProcessUniforms::default()
.with_underwater(Some(UnderwaterFog { color: [0.1, 0.3, 0.4], density: 0.05 }))
.with_underwater(None);
assert_eq!((dry.underwater, dry.fog_r, dry.fog_density), (0.0, 0.0, 0.0));
let wet = PostProcessUniforms::default()
.with_underwater(Some(UnderwaterFog { color: [0.1, 0.3, 0.4], density: 0.05 }));
assert_eq!(wet.underwater, 1.0);
assert_eq!([wet.fog_r, wet.fog_g, wet.fog_b], [0.1, 0.3, 0.4]);
assert_eq!(wet.fog_density, 0.05);
}
#[test]
fn no_hand_filled_uniform_literals_outside_the_constructor() {
let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("crates/gizmo-renderer sits two levels below the workspace root")
.to_path_buf();
if !workspace.join("crates/gizmo-studio").is_dir() {
return;
}
let mut sources = Vec::new();
collect_rs_files(&workspace.join("crates"), &mut sources);
collect_rs_files(&workspace.join("demo"), &mut sources);
assert!(sources.len() > 100, "source walk found only {} files", sources.len());
let this_file = std::path::Path::new(file!()).file_name().unwrap();
let mut offenders = Vec::new();
for path in sources {
if path.file_name() == Some(this_file) {
continue;
}
let text = std::fs::read_to_string(&path).unwrap_or_default();
let lines: Vec<&str> = text.lines().collect();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || line.contains("struct ") {
continue;
}
for block in ["SceneUniforms {", "PostProcessUniforms {"] {
if !line.contains(block) {
continue;
}
let partial = lines[i..]
.iter()
.take_while(|l| !l.trim_start().starts_with('}'))
.any(|l| l.trim_start().starts_with(".."));
if !partial {
offenders.push(format!(
"{}:{} — exhaustive `{block}` literal",
path.strip_prefix(&workspace).unwrap_or(&path).display(),
i + 1
));
}
}
}
}
assert!(
offenders.is_empty(),
"hand-filled uniform literals found — build them with `SceneUniforms::new(&SceneFrame \
{{ .. }})` or `PostProcessUniforms::default()` so a field added later reaches every \
call site:\n {}",
offenders.join("\n ")
);
}
fn collect_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if path.file_name().is_some_and(|n| n == "target") {
continue;
}
collect_rs_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
}