Skip to main content

euv_engine/raytracing/pipeline/render/
fn.rs

1use super::*;
2
3/// Builds the world-space direction for one supersample of one pixel.
4///
5/// The camera ray is
6/// `normalize(forward + right * (ndc_x * aspect * tan(fov/2)) + up *
7/// (ndc_y * tan(fov/2)))`. The `tan(fov/2)` factor is what makes the
8/// rendered vertical field of view exactly `fov`; omitting it renders
9/// wider than the camera asks for and no longer matches either shader,
10/// which both apply it.
11///
12/// Rows are addressed top-down, matching the WGSL fragment position and the
13/// `gl_FragCoord`-flipped WebGL2 ordering, so all three backends agree row
14/// for row.
15///
16/// # Arguments
17///
18/// - `&FrameBasis` - The validated camera basis.
19/// - `&PixelSample` - The pixel coordinates and the sub-sample's offset
20///   within that pixel.
21///
22/// # Returns
23///
24/// - `Vector3D` - The unit world-space ray direction.
25pub(crate) fn pixel_direction(basis: &FrameBasis, sample: &PixelSample) -> Vector3D {
26    let ndc_x: f64 =
27        ((sample.get_x() + sample.get_offset_x()) / basis.get_width() as f64) * 2.0 - 1.0;
28    let ndc_y: f64 =
29        1.0 - ((sample.get_y() + sample.get_offset_y()) / basis.get_height() as f64) * 2.0;
30    let tan_half: f64 = basis.get_tan_half_fov();
31    (basis.get_forward()
32        + basis
33            .get_right()
34            .scaled(ndc_x * basis.get_aspect() * tan_half)
35        + basis.get_up().scaled(ndc_y * tan_half))
36    .normalized()
37}
38
39/// Converts one linear channel to a gamma-corrected byte.
40///
41/// The clamp to `[0, 1]` happens before the curve and the result is
42/// rounded, matching both shaders' `pow(clamp(c, 0, 1), 1/2.2)`.
43///
44/// # Arguments
45///
46/// - `f64` - The linear channel, which may fall outside `[0, 1]`.
47///
48/// # Returns
49///
50/// - `u8` - The gamma-corrected byte, clamped to `[0, 255]`.
51pub fn gamma_byte(channel: f64) -> u8 {
52    let clamped: f64 = channel.clamp(0.0, 1.0);
53    let encoded: f64 = clamped.powf(1.0 / RAYTRACE_GAMMA);
54    (encoded * 255.0).round().clamp(0.0, 255.0) as u8
55}
56
57/// Renders a ray-traced RGBA frame on the CPU.
58///
59/// This is the CPU reference and the Canvas 2D backend's renderer. It does
60/// not re-implement traversal or shading: each pixel builds a [`Ray`] — a
61/// stack value, so no allocation happens per pixel, per bounce, or per
62/// frame — and hands it to [`RayTraceScene::trace_with_bounces`], which
63/// runs the scene's own bounding volume hierarchy and the real
64/// [`LightingUniforms`] shading. The two GPU backends implement the same
65/// math in shader form against the packed buffers; they are the only
66/// duplicates, and they must agree with this path rather than with each
67/// other.
68///
69/// The frame is described by a [`FrameBasis`], a stack value built by the
70/// same validator [`pack_raytrace_frame`] uses, so this path never packs a
71/// GPU buffer, never re-encodes the scene, and allocates nothing at all
72/// beyond the caller-owned `out`. A steady-state frame therefore performs
73/// exactly one heap operation: none.
74///
75/// Each pixel traces `samples_per_axis` squared supersampled rays, averages
76/// them, clamps the linear color to `[0, 1]`, applies the `1 / 2.2` gamma
77/// curve, and writes an opaque alpha. Rows are emitted top-down.
78///
79/// Frame inputs arrive through the same [`RayTraceFrame`] parameter type
80/// accepted by [`pack_raytrace_frame`]. The scene and mutable output remain
81/// separate, so the descriptor itself contains only read-only frame state.
82///
83/// # Arguments
84///
85/// - `&RayTraceScene` - The scene to render.
86/// - `&RayTraceFrame<'_>` - The camera, lights, settings, and dimensions
87///   to render.
88/// - `&mut Vec<u8>` - The destination RGBA buffer, cleared and reused.
89///
90/// # Returns
91///
92/// - `Result<(), String>` - `Ok` once the frame is written, or the reason
93///   the frame cannot be rendered.
94pub fn render_raytrace_rgba(
95    scene: &RayTraceScene,
96    frame: &RayTraceFrame<'_>,
97    out: &mut Vec<u8>,
98) -> Result<(), String> {
99    let lights: &LightingUniforms = frame.get_lights();
100    frame.get_settings().validate()?;
101    let basis: FrameBasis = frame_basis(frame.get_camera(), frame.get_width(), frame.get_height())?;
102    let pixel_count: usize = (basis.get_width() as usize) * (basis.get_height() as usize);
103    let byte_count: usize = match pixel_count.checked_mul(4) {
104        Some(count) => count,
105        None => return Err(RAYTRACE_ERROR_DIMENSION_OVERFLOW.to_string()),
106    };
107    let samples: u32 = frame.get_settings().get_samples_per_axis();
108    let sample_scale: f64 = 1.0 / samples as f64;
109    let rays: f64 = (samples * samples) as f64;
110    let bounces: u32 = frame.get_settings().get_max_bounces();
111    out.clear();
112    out.resize(byte_count, 0);
113    for y in 0..basis.get_height() {
114        for x in 0..basis.get_width() {
115            let mut accumulator: Vector3D = Vector3D::zero();
116            for sy in 0..samples {
117                for sx in 0..samples {
118                    let sample: PixelSample = PixelSample {
119                        x: x as f64,
120                        y: y as f64,
121                        offset_x: (sx as f64 + 0.5) * sample_scale,
122                        offset_y: (sy as f64 + 0.5) * sample_scale,
123                    };
124                    let direction: Vector3D = pixel_direction(&basis, &sample);
125                    let ray: Ray = Ray::new(basis.get_eye(), direction)
126                        .with_range(basis.get_near_t(), basis.get_far_t());
127                    accumulator += scene.trace_with_bounces(ray, lights, bounces);
128                }
129            }
130            let linear: Vector3D = accumulator.scaled(1.0 / rays);
131            let offset: usize = ((y as usize) * (basis.get_width() as usize) + x as usize) * 4;
132            out[offset] = gamma_byte(linear.get_x());
133            out[offset + 1] = gamma_byte(linear.get_y());
134            out[offset + 2] = gamma_byte(linear.get_z());
135            out[offset + 3] = 255;
136        }
137    }
138    Ok(())
139}