diff --git a/Cargo.toml b/Cargo.toml index cd1da9e6..daf8d35c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["network-programming", "web-programming"] exclude.workspace = true [workspace.package] -version = "0.31.1" +version = "0.31.2" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] @@ -38,13 +38,13 @@ resolver = "3" crate-type = ["rlib"] [workspace.dependencies] -euv = { path = ".", version = "0.31.1" } -euv-ui = { path = "ui", version = "0.31.1" } -euv-cli = { path = "cli", version = "0.31.1" } -euv-core = { path = "core", version = "0.31.1" } -euv-engine = { path = "engine", version = "0.31.1" } -euv-macros = { path = "macros", version = "0.31.1" } -euv-example = { path = "example", version = "0.31.1" } +euv = { path = ".", version = "0.31.2" } +euv-ui = { path = "ui", version = "0.31.2" } +euv-cli = { path = "cli", version = "0.31.2" } +euv-core = { path = "core", version = "0.31.2" } +euv-engine = { path = "engine", version = "0.31.2" } +euv-macros = { path = "macros", version = "0.31.2" } +euv-example = { path = "example", version = "0.31.2" } log = "0.4.34" toml = "0.9.12" diff --git a/engine/src/lighting/fn.rs b/engine/src/lighting/fn.rs index d699b654..6f90cf28 100644 --- a/engine/src/lighting/fn.rs +++ b/engine/src/lighting/fn.rs @@ -26,47 +26,46 @@ use super::*; /// /// - `Vector3D` - The diffuse contribution of this light. pub fn compute_lambert(light: &Light, normal: Vector3D, material: &Material) -> Vector3D { - lambert_term( - light.get_color(), - light.get_intensity(), - light.get_direction(), - normal, - material.get_albedo(), - ) + let sample: LightSample = LightSample { + color: light.get_color(), + intensity: light.get_intensity(), + direction: light.get_direction(), + }; + lambert_term(&sample, normal, material) } /// The Lambertian term shared by [`compute_lambert`] and /// [`LightingUniforms::shade_with_visibility`]. /// -/// Unpacking the light into its colour, intensity, and direction is what -/// lets the shading loop evaluate a light once — one distance, one -/// direction, one falloff — instead of building a mutated copy of the -/// [`Light`] per term. +/// The light arrives as a [`LightSample`] rather than as a [`Light`] +/// because the shading loop has already resolved it — one distance, one +/// direction, one falloff — into the colour, intensity, and direction this +/// term reads. Passing that sample on keeps the [`Light`] un-cloned and +/// stops each term from re-deriving what the loop measured. /// /// # Arguments /// -/// - `Vector3D` - The light's RGB color. -/// - `f64` - The light's intensity, already scaled by any falloff. -/// - `Vector3D` - The unit direction from the surface toward the light. +/// - `&LightSample` - The resolved colour, intensity, and direction of the +/// light being evaluated. /// - `Vector3D` - The surface normal (unit length). -/// - `Vector3D` - The material's diffuse albedo. +/// - `&Material` - The material at the shaded point; its diffuse albedo is +/// read here. /// /// # Returns /// /// - `Vector3D` - `color * albedo * intensity * max(0, dot(normal, light_dir))`. pub(crate) fn lambert_term( - light_color: Vector3D, - light_intensity: f64, - light_dir: Vector3D, + sample: &LightSample, normal: Vector3D, - albedo: Vector3D, + material: &Material, ) -> Vector3D { - let cos: f64 = normal.dot(light_dir).max(0.0); - let k: f64 = light_intensity * cos; + let albedo: Vector3D = material.get_albedo(); + let cos: f64 = normal.dot(sample.get_direction()).max(0.0); + let k: f64 = sample.get_intensity() * cos; Vector3D::new( - light_color.get_x() * albedo.get_x() * k, - light_color.get_y() * albedo.get_y() * k, - light_color.get_z() * albedo.get_z() * k, + sample.get_color().get_x() * albedo.get_x() * k, + sample.get_color().get_y() * albedo.get_y() * k, + sample.get_color().get_z() * albedo.get_z() * k, ) } @@ -99,15 +98,12 @@ pub fn compute_phong( view_dir: Vector3D, material: &Material, ) -> Vector3D { - phong_specular_term( - light.get_color(), - light.get_intensity(), - light.get_direction(), - normal, - view_dir, - material.get_specular(), - material.get_shininess(), - ) + let sample: LightSample = LightSample { + color: light.get_color(), + intensity: light.get_intensity(), + direction: light.get_direction(), + }; + phong_specular_term(&sample, normal, view_dir, material) } /// The Phong specular term shared by [`compute_phong`] and @@ -115,34 +111,39 @@ pub fn compute_phong( /// unless the surface faces the light, so the lobe cannot survive on the /// back side of a surface. /// +/// Like [`lambert_term`], it reads a [`LightSample`] the caller already +/// resolved and takes the two material scalars through the [`Material`] +/// itself, so neither the light nor the material is rebuilt per term. +/// /// # Arguments /// -/// - `Vector3D` - The light's RGB color. -/// - `f64` - The light's intensity, already scaled by any falloff. -/// - `Vector3D` - The unit direction from the surface toward the light. +/// - `&LightSample` - The resolved colour, intensity, and direction of the +/// light being evaluated. /// - `Vector3D` - The surface normal (unit length). /// - `Vector3D` - The unit view direction (from the surface toward the eye). -/// - `f64` - The material's specular intensity. -/// - `f64` - The Phong specular exponent. +/// - `&Material` - The material at the shaded point; its specular intensity +/// and shininess are read here. /// /// # Returns /// /// - `Vector3D` - `color * intensity * specular * spec_factor`. pub(crate) fn phong_specular_term( - light_color: Vector3D, - light_intensity: f64, - light_dir: Vector3D, + sample: &LightSample, normal: Vector3D, view_dir: Vector3D, - material_specular: f64, - shininess: f64, + material: &Material, ) -> Vector3D { - let spec_factor: f64 = phong_specular_factor(light_dir, normal, view_dir, shininess); - let k: f64 = light_intensity * spec_factor * material_specular; + let spec_factor: f64 = phong_specular_factor( + sample.get_direction(), + normal, + view_dir, + material.get_shininess(), + ); + let k: f64 = sample.get_intensity() * spec_factor * material.get_specular(); Vector3D::new( - light_color.get_x() * k, - light_color.get_y() * k, - light_color.get_z() * k, + sample.get_color().get_x() * k, + sample.get_color().get_y() * k, + sample.get_color().get_z() * k, ) } @@ -476,9 +477,13 @@ pub fn soft_shadow_factor( if dist < EPSILON { return 1.0; } - let dir: Vector3D = to_light.scaled(1.0 / dist); + let segment: ShadowSegment = ShadowSegment { + origin, + direction: to_light.scaled(1.0 / dist), + distance: dist, + }; for &(center, radius) in occluders.iter() { - if segment_crosses_sphere(origin, dir, dist, center, radius) { + if segment_crosses_sphere(&segment, center, radius) { return 0.0; } } @@ -486,7 +491,7 @@ pub fn soft_shadow_factor( } /// Returns whether either ray-sphere root lands strictly inside the open -/// segment `(EPSILON, dist - EPSILON)`. +/// segment `(EPSILON, distance - EPSILON)`. /// /// **Both roots are tested, not just the near one.** A shadow ray is very /// often cast from the surface point of one of the occluders themselves: @@ -495,11 +500,14 @@ pub fn soft_shadow_factor( /// the nearest root would report the light as visible through the very /// sphere the ray starts inside. /// +/// The segment arrives as a [`ShadowSegment`] so the same start, unit +/// direction, and distance measured once by [`soft_shadow_factor`] reach +/// every occluder in the loop instead of being restated per call. +/// /// # Arguments /// -/// - `Vector3D` - The segment start (the shaded point). -/// - `Vector3D` - The unit direction toward the light. -/// - `f64` - The distance from the segment start to the light. +/// - `&ShadowSegment` - The segment start, unit direction toward the light, +/// and distance from the segment start to the light. /// - `Vector3D` - The sphere center. /// - `f64` - The sphere radius. /// @@ -507,14 +515,12 @@ pub fn soft_shadow_factor( /// /// - `bool` - `true` when the segment passes through the sphere's interior. pub(crate) fn segment_crosses_sphere( - origin: Vector3D, - dir: Vector3D, - dist: f64, + segment: &ShadowSegment, center: Vector3D, radius: f64, ) -> bool { - let oc: Vector3D = origin - center; - let b: f64 = oc.dot(dir); + let oc: Vector3D = segment.get_origin() - center; + let b: f64 = oc.dot(segment.get_direction()); let c: f64 = oc.dot(oc) - radius * radius; let disc: f64 = b * b - c; if disc < 0.0 { @@ -524,6 +530,6 @@ pub(crate) fn segment_crosses_sphere( let near: f64 = -b - sq; let far: f64 = -b + sq; let inside: f64 = EPSILON; - let outside: f64 = dist - EPSILON; + let outside: f64 = segment.get_distance() - EPSILON; (near > inside && near < outside) || (far > inside && far < outside) } diff --git a/engine/src/lighting/impl.rs b/engine/src/lighting/impl.rs index a5b883fa..4cc4850a 100644 --- a/engine/src/lighting/impl.rs +++ b/engine/src/lighting/impl.rs @@ -332,7 +332,6 @@ impl LightingUniforms { let mut color: Vector3D = self.get_ambient(); let view_dir: Vector3D = unit_vector(self.get_eye() - position); let material_kind: MaterialKind = material.get_kind(); - let albedo: Vector3D = material.get_albedo(); for light in self.get_lights().iter() { let kind: LightType = light.get_kind(); // Derived once per light and shared by every term below. The @@ -375,31 +374,30 @@ impl LightingUniforms { if shadow <= 0.0 { continue; } - let intensity: f64 = light.get_intensity() * falloff_factor; - let light_color: Vector3D = light.get_color(); + // One resolved light, shared by every term below: the diffuse + // and specular lobes and the fresnel scale all read this one + // colour, the one attenuated intensity, and the one derived + // direction, instead of each re-reading the `Light`. + let sample: LightSample = LightSample { + color: light.get_color(), + intensity: light.get_intensity() * falloff_factor, + direction: light_dir, + }; let diffuse: Vector3D = match material_kind { MaterialKind::Pbr => { let fresnel: f64 = apply_schlick_fresnel(light_dir, view_dir, normal); - lambert_term(light_color, intensity, light_dir, normal, albedo).scaled( + lambert_term(&sample, normal, material).scaled( LIGHTING_PBR_ENERGY_CONSERVATION + (1.0 - LIGHTING_PBR_ENERGY_CONSERVATION) * fresnel, ) } MaterialKind::Lambert | MaterialKind::Phong => { - lambert_term(light_color, intensity, light_dir, normal, albedo) + lambert_term(&sample, normal, material) } }; let specular: Vector3D = match material_kind { MaterialKind::Lambert | MaterialKind::Pbr => Vector3D::zero(), - MaterialKind::Phong => phong_specular_term( - light_color, - intensity, - light_dir, - normal, - view_dir, - material.get_specular(), - material.get_shininess(), - ), + MaterialKind::Phong => phong_specular_term(&sample, normal, view_dir, material), }; color += (diffuse + specular).scaled(shadow); } diff --git a/engine/src/lighting/struct.rs b/engine/src/lighting/struct.rs index adfdb01b..bdb2d1f8 100644 --- a/engine/src/lighting/struct.rs +++ b/engine/src/lighting/struct.rs @@ -92,6 +92,68 @@ pub struct Material { pub(crate) emissive: Vector3D, } +/// One light's contribution at a single shaded point, resolved once and +/// then shared by every term that light feeds. +/// +/// A [`Light`] stores the **authored** values: the intensity before any +/// falloff, and — for a spot light — the outward cone axis rather than the +/// direction toward the lamp. Shading needs different numbers: the +/// surface-to-light direction and the intensity after falloff. Deriving +/// them into locals keeps the [`Light`] itself un-cloned, but it leaves +/// each term reading a bare pair of `Vector3D` / `f64` parameters whose +/// roles no longer match the field names they came from. +/// +/// A `LightSample` names the derived pair next to the colour they apply +/// to, so [`lambert_term`] and [`phong_specular_term`] take one borrowed +/// argument instead of three loose ones and neither rebuilds what the +/// shading loop already measured. The caller still decides the values: +/// [`compute_lambert`] samples the authored direction and intensity +/// directly, while [`LightingUniforms::shade_with_visibility`] samples the +/// derived direction and the falloff-attenuated intensity. +/// +/// Every field is a `Copy` scalar or vector, so a sample is built on the +/// stack and never allocates. +#[derive(Clone, Copy, Debug, Getter)] +pub(crate) struct LightSample { + /// The light's RGB color. + #[get(pub(crate), type(copy))] + pub(crate) color: Vector3D, + /// The intensity, already scaled by any falloff the caller applied. + #[get(pub(crate), type(copy))] + pub(crate) intensity: f64, + /// The unit direction from the shaded surface toward the light. + #[get(pub(crate), type(copy))] + pub(crate) direction: Vector3D, +} + +/// The bounded segment a shadow ray travels: a start, a unit direction, and +/// the length of the leg it covers. +/// +/// [`soft_shadow_factor`] measures all three once per light and then tests +/// every occluder sphere against the same segment, which is what keeps the +/// open-interval bounds identical across the loop — [`EPSILON`] at the near +/// end and `distance - EPSILON` at the far end, so a root landing exactly +/// on an endpoint still counts as a miss. +/// +/// The triple is declared here rather than borrowed from the ray tracer. A +/// ray-tracing `Ray` carries `t_min` / `t_max` / `depth` for scene +/// traversal, while a shadow leg here is defined by the two endpoints the +/// light and the shaded point supply; naming that pair as one value keeps +/// `lighting` free of a dependency on `raytracing` and of the cycle a +/// shared `Ray` would risk forming. +#[derive(Clone, Copy, Debug, Getter)] +pub(crate) struct ShadowSegment { + /// The shaded point the segment starts from. + #[get(pub(crate), type(copy))] + pub(crate) origin: Vector3D, + /// The unit direction from `origin` toward the light. + #[get(pub(crate), type(copy))] + pub(crate) direction: Vector3D, + /// The distance from `origin` to the light. + #[get(pub(crate), type(copy))] + pub(crate) distance: f64, +} + /// Bundles of uniforms supplied to lighting and shading routines. #[derive(Clone, Data, Debug, New, PartialEq)] pub struct LightingUniforms { diff --git a/engine/src/raytracing/backend/data/impl.rs b/engine/src/raytracing/backend/data/impl.rs index 9afb76d8..e81b19cc 100644 --- a/engine/src/raytracing/backend/data/impl.rs +++ b/engine/src/raytracing/backend/data/impl.rs @@ -166,15 +166,15 @@ impl RayTraceRenderer { settings: &RayTraceSettings, ) -> Result<(), JsValue> { let mut pixels: Vec = mem::take(self.get_mut_pixels()); - let traced: Result<(), String> = render_raytrace_rgba( - self.get_scene(), + let frame: RayTraceFrame<'_> = RayTraceFrame { camera, lights, settings, - self.get_width(), - self.get_height(), - &mut pixels, - ); + width: self.get_width(), + height: self.get_height(), + }; + let traced: Result<(), String> = + render_raytrace_rgba(self.get_scene(), &frame, &mut pixels); let presented: Result<(), JsValue> = match self.get_mut_adapter().as_mut() { Some(adapter) => match traced { Ok(()) => adapter.present(&pixels), @@ -205,14 +205,14 @@ impl RayTraceRenderer { settings: &RayTraceSettings, ) -> Result<(), JsValue> { let mut frame: Vec = mem::take(self.get_mut_packed_frame()); - let packed: Result<(), String> = pack_raytrace_frame( - &mut frame, + let inputs: RayTraceFrame<'_> = RayTraceFrame { camera, lights, settings, - self.get_width(), - self.get_height(), - ); + width: self.get_width(), + height: self.get_height(), + }; + let packed: Result<(), String> = pack_raytrace_frame(&mut frame, &inputs); let drawn: Result<(), JsValue> = match (packed, self.get_mut_adapter().as_mut()) { (Ok(()), Some(adapter)) => adapter.draw(&frame), (Ok(()), None) => Err(backend_error(ERROR_DISPOSED, ERROR_DISPOSED.to_string())), diff --git a/engine/src/raytracing/backend/webgl/fn.rs b/engine/src/raytracing/backend/webgl/fn.rs index db83cf4b..48982433 100644 --- a/engine/src/raytracing/backend/webgl/fn.rs +++ b/engine/src/raytracing/backend/webgl/fn.rs @@ -439,10 +439,9 @@ pub(crate) fn bind_data_texture( /// /// # Arguments /// -/// - `&WebGl2RenderingContext` - The context to upload through. +/// - `&WebGlPackedUpload` - The context, the unit, and the packed block +/// the write is described by. /// - `&WebGlRayTraceTexture` - The destination texture. -/// - `u32` - The texture unit the texture is bound to. -/// - `&[f32]` - The packed block. /// - `&mut Vec` - The scratch buffer the upload is built in, reused /// across frames. /// @@ -450,12 +449,12 @@ pub(crate) fn bind_data_texture( /// /// - `Result<(), JsValue>` - `Ok` once the upload was accepted. pub(crate) fn upload_packed( - context: &WebGl2RenderingContext, + upload: &WebGlPackedUpload, target: &WebGlRayTraceTexture, - unit: u32, - packed: &[f32], scratch: &mut Vec, ) -> Result<(), JsValue> { + let context: &WebGl2RenderingContext = upload.get_context(); + let packed: &[f32] = upload.get_packed(); let slots: usize = packed_slot_count(packed.len()); if slots == 0 { return Err(backend_error( @@ -473,10 +472,10 @@ pub(crate) fn upload_packed( for (index, value) in packed.iter().enumerate() { scratch[index] = *value; } - let upload: &[f32] = &scratch[..needed]; - let view: Float32Array = Float32Array::from(upload); + let staged: &[f32] = &scratch[..needed]; + let view: Float32Array = Float32Array::from(staged); require_clear_gl_error(context, ERROR_WEBGL_UPLOAD, WEBGL_ERROR_SOURCE_PENDING)?; - bind_data_texture(context, unit, target.get_texture())?; + bind_data_texture(context, upload.get_unit(), target.get_texture())?; let outcome: Result<(), JsValue> = context .tex_sub_image_2d_with_i32_and_i32_and_u32_and_type_and_opt_array_buffer_view( WEBGL_TEXTURE_TARGET_2D, @@ -495,84 +494,31 @@ pub(crate) fn upload_packed( } } -/// Grows the scene texture in place when its block no longer fits. -/// -/// A block that already fits the current texture reuses it and touches -/// nothing, so a repeated update at a fixed size issues one -/// `texSubImage2D` and creates no GL object. A block that does not fit -/// is allocated once at its own shape: the new texture is bound and -/// fully written before it is adopted, so a failed allocation or a -/// failed write leaves the previous, still valid, texture in place. The -/// texture being replaced is deleted only after the swap succeeds, so a -/// rejected block never leaves the backend without a usable texture. -/// -/// # Arguments -/// -/// - `&WebGl2RenderingContext` - The context to allocate in. -/// - `&mut WebGlRayTraceTexture` - The current texture, replaced only -/// on success. -/// - `&mut Vec` - The scratch buffer the upload is built in. -/// - `&[f32]` - The packed scene block. -/// -/// # Returns -/// -/// - `Result<(), JsValue>` - `Ok` once the block is live on the GPU. -pub(crate) fn ensure_scene_texture( - context: &WebGl2RenderingContext, - current: &mut WebGlRayTraceTexture, - scratch: &mut Vec, - packed: &[f32], -) -> Result<(), JsValue> { - grow_texture(context, current, WEBGL_TEXTURE_UNIT_SCENE, scratch, packed) -} - -/// Grows the frame texture in place when its block no longer fits. -/// -/// The mirror of [`ensure_scene_texture`] for the block every frame -/// uploads, so a steady frame reuses one allocation and a larger block -/// reallocates exactly once. +/// Uploads one packed scene or frame block, growing its texture when needed. /// -/// # Arguments -/// -/// - `&WebGl2RenderingContext` - The context to allocate in. -/// - `&mut WebGlRayTraceTexture` - The current texture, replaced only -/// on success. -/// - `&mut Vec` - The scratch buffer the upload is built in. -/// - `&[f32]` - The packed frame block. -/// -/// # Returns -/// -/// - `Result<(), JsValue>` - `Ok` once the block is live on the GPU. -pub(crate) fn ensure_frame_texture( - context: &WebGl2RenderingContext, - current: &mut WebGlRayTraceTexture, - scratch: &mut Vec, - packed: &[f32], -) -> Result<(), JsValue> { - grow_texture(context, current, WEBGL_TEXTURE_UNIT_FRAME, scratch, packed) -} - -/// Reallocates and re-uploads one data texture when its block outgrows -/// it, mutating it in place only after the new texture is live. +/// Growth allocates and uploads a replacement before adopting it. A failed +/// replacement is deleted while the previous texture remains owned by the +/// backend. Blocks that fit reuse the existing texture and staging buffer. +/// The caller supplies the matching texture and unit in the upload description. /// /// # Arguments /// -/// - `&WebGl2RenderingContext` - The context to allocate in. +/// - `&WebGlPackedUpload` - The context, the unit, and the packed block +/// the write is described by. /// - `&mut WebGlRayTraceTexture` - The texture to grow in place. -/// - `u32` - The texture unit the texture is bound to. /// - `&mut Vec` - The scratch buffer the upload is built in. -/// - `&[f32]` - The packed block. /// /// # Returns /// /// - `Result<(), JsValue>` - `Ok` once the block is live on the GPU. -fn grow_texture( - context: &WebGl2RenderingContext, +pub(crate) fn ensure_packed_texture( + upload: &WebGlPackedUpload, current: &mut WebGlRayTraceTexture, - unit: u32, scratch: &mut Vec, - packed: &[f32], ) -> Result<(), JsValue> { + let context: &WebGl2RenderingContext = upload.get_context(); + let unit: u32 = upload.get_unit(); + let packed: &[f32] = upload.get_packed(); let slots: usize = packed_slot_count(packed.len()); if slots == 0 { return Err(backend_error( @@ -585,7 +531,7 @@ fn grow_texture( let limit: u32 = texture_size_limit(context); let (width, height): (i32, i32) = packed_shape(packed.len(), limit)?; let grown: WebGlRayTraceTexture = create_packed_texture(context, unit, width, height)?; - match upload_packed(context, &grown, unit, packed, scratch) { + match upload_packed(upload, &grown, scratch) { Ok(()) => {} Err(reason) => { context.delete_texture(Some(grown.get_texture())); @@ -598,7 +544,7 @@ fn grow_texture( current.set_height(grown.get_height()); return Ok(()); } - upload_packed(context, current, unit, packed, scratch) + upload_packed(upload, current, scratch) } /// Reads the `MAX_TEXTURE_SIZE` of a context. @@ -766,33 +712,28 @@ pub(crate) fn attach_context( /// Releases every GL object a half-built attach owns. /// +/// Each object is released at most once and only when it was actually +/// created, so this is safe to call at any point in a failed attach: the +/// order is fixed rather than derived from the arguments, and an absent +/// object contributes nothing. +/// /// # Arguments /// /// - `&WebGl2RenderingContext` - The context the objects belong to. -/// - `Option<&WebGlProgram>` - The program, when one was linked. -/// - `Option<&WebGlVertexArrayObject>` - The vertex array, when one was -/// created. -/// - `Option<&WebGlRayTraceTexture>` - The scene texture, when one was -/// allocated. -/// - `Option<&WebGlRayTraceTexture>` - The frame texture, when one was -/// allocated. -pub(crate) fn release_partial( - context: &WebGl2RenderingContext, - program: Option<&WebGlProgram>, - vao: Option<&WebGlVertexArrayObject>, - scene_texture: Option<&WebGlRayTraceTexture>, - frame_texture: Option<&WebGlRayTraceTexture>, -) { - if let Some(texture) = frame_texture { +/// - `&WebGlPartialAttach` - The program, vertex array, scene texture, +/// and frame texture the attach owns so far, each present only once +/// its own step has succeeded. +pub(crate) fn release_partial(context: &WebGl2RenderingContext, owned: &WebGlPartialAttach) { + if let Some(texture) = owned.get_frame_texture() { context.delete_texture(Some(texture.get_texture())); } - if let Some(texture) = scene_texture { + if let Some(texture) = owned.get_scene_texture() { context.delete_texture(Some(texture.get_texture())); } - if let Some(array) = vao { + if let Some(array) = owned.get_vao() { context.delete_vertex_array(Some(array)); } - if let Some(linked) = program { + if let Some(linked) = owned.get_program() { context.delete_program(Some(linked)); } } diff --git a/engine/src/raytracing/backend/webgl/impl.rs b/engine/src/raytracing/backend/webgl/impl.rs index ac8fffea..e2d9cb94 100644 --- a/engine/src/raytracing/backend/webgl/impl.rs +++ b/engine/src/raytracing/backend/webgl/impl.rs @@ -52,12 +52,28 @@ impl WebGlRayTraceBackend { match uniforms { Ok(pair) => pair, Err(reason) => { - release_partial(&context, Some(&program), None, None, None); + release_partial( + &context, + &WebGlPartialAttach { + program: Some(&program), + vao: None, + scene_texture: None, + frame_texture: None, + }, + ); return Err(reason); } }; let Some(vao) = context.create_vertex_array() else { - release_partial(&context, Some(&program), None, None, None); + release_partial( + &context, + &WebGlPartialAttach { + program: Some(&program), + vao: None, + scene_texture: None, + frame_texture: None, + }, + ); return Err(backend_error( ERROR_WEBGL_PROGRAM, ERROR_WEBGL_VAO_REJECTED.to_string(), @@ -74,7 +90,15 @@ impl WebGlRayTraceBackend { ) { Ok(texture) => texture, Err(reason) => { - release_partial(&context, Some(&program), Some(&vao), None, None); + release_partial( + &context, + &WebGlPartialAttach { + program: Some(&program), + vao: Some(&vao), + scene_texture: None, + frame_texture: None, + }, + ); return Err(reason); } }; @@ -88,10 +112,12 @@ impl WebGlRayTraceBackend { Err(reason) => { release_partial( &context, - Some(&program), - Some(&vao), - Some(&scene_texture), - None, + &WebGlPartialAttach { + program: Some(&program), + vao: Some(&vao), + scene_texture: Some(&scene_texture), + frame_texture: None, + }, ); return Err(reason); } @@ -192,8 +218,13 @@ impl WebGlRayTraceBackend { } let context: WebGl2RenderingContext = self.get_context().clone(); let mut scratch: Vec = mem::take(self.get_mut_frame_scratch()); + let upload: WebGlPackedUpload = WebGlPackedUpload { + context: &context, + unit: WEBGL_TEXTURE_UNIT_FRAME, + packed: frame, + }; let outcome: Result<(), JsValue> = - ensure_frame_texture(&context, self.get_mut_frame_texture(), &mut scratch, frame); + ensure_packed_texture(&upload, self.get_mut_frame_texture(), &mut scratch); self.set_frame_scratch(scratch); outcome?; apply_full_viewport(self.get_context())?; @@ -252,8 +283,13 @@ impl WebGlRayTraceBackend { } let context: WebGl2RenderingContext = self.get_context().clone(); let mut scratch: Vec = mem::take(self.get_mut_scene_scratch()); + let upload: WebGlPackedUpload = WebGlPackedUpload { + context: &context, + unit: WEBGL_TEXTURE_UNIT_SCENE, + packed, + }; let outcome: Result<(), JsValue> = - ensure_scene_texture(&context, self.get_mut_scene_texture(), &mut scratch, packed); + ensure_packed_texture(&upload, self.get_mut_scene_texture(), &mut scratch); self.set_scene_scratch(scratch); outcome } diff --git a/engine/src/raytracing/backend/webgl/struct.rs b/engine/src/raytracing/backend/webgl/struct.rs index 4c4932b5..16490eb9 100644 --- a/engine/src/raytracing/backend/webgl/struct.rs +++ b/engine/src/raytracing/backend/webgl/struct.rs @@ -99,3 +99,42 @@ pub(crate) struct WebGlRayTraceBackend { #[get_mut(pub(crate))] pub(crate) dimension_limit_value: u32, } + +/// The read-only description of one packed-block texture write. +/// +/// Groups the context, texture unit, and borrowed data used by both growth +/// and upload. The destination texture and reusable mutable staging buffer +/// remain separate parameters. Callers choose the scene or frame unit at +/// the named-field construction site. +#[derive(Debug, Getter)] +pub(crate) struct WebGlPackedUpload<'a> { + /// The context every call in the write goes through. + #[get(pub(crate), type(copy))] + pub(crate) context: &'a WebGl2RenderingContext, + /// The texture unit the destination texture is bound to. + #[get(pub(crate), type(copy))] + pub(crate) unit: u32, + /// The packed block to write into the destination. + #[get(pub(crate), type(copy))] + pub(crate) packed: &'a [f32], +} + +/// The GL objects a half-built attach owns at one point in its setup. +/// +/// Named optional fields record which allocations succeeded before an +/// initialization error, so cleanup releases only the resources acquired. +#[derive(Debug, Getter)] +pub(crate) struct WebGlPartialAttach<'a> { + /// The linked program, when the link succeeded. + #[get(pub(crate), type(clone))] + pub(crate) program: Option<&'a WebGlProgram>, + /// The vertex array, when it was created. + #[get(pub(crate), type(clone))] + pub(crate) vao: Option<&'a WebGlVertexArrayObject>, + /// The packed scene data texture, when it was allocated. + #[get(pub(crate), type(clone))] + pub(crate) scene_texture: Option<&'a WebGlRayTraceTexture>, + /// The packed frame data texture, when it was allocated. + #[get(pub(crate), type(clone))] + pub(crate) frame_texture: Option<&'a WebGlRayTraceTexture>, +} diff --git a/engine/src/raytracing/backend/webgpu/fn.rs b/engine/src/raytracing/backend/webgpu/fn.rs index 1ea5dd70..9d905d8b 100644 --- a/engine/src/raytracing/backend/webgpu/fn.rs +++ b/engine/src/raytracing/backend/webgpu/fn.rs @@ -173,38 +173,46 @@ pub(crate) fn call2( } } -/// Calls a method with four arguments. +/// Issues a non-indexed draw of the geometry a render pass has bound. /// /// `draw` takes `(vertexCount, instanceCount, firstVertex, -/// firstInstance)`; handed one array instead, it reads `vertexCount` as -/// `undefined` and issues no primitives at all. +/// firstInstance)` as four separate arguments; handed one array instead, +/// it reads `vertexCount` as `undefined` and issues no primitives at +/// all. The four counts therefore stay four separate `Function::call4` +/// arguments here — bundling them into one JS value would silently +/// produce a frame with nothing drawn — while the Rust side carries them +/// as one [`DrawArgs`] so the counts cannot be transposed at the call +/// site. /// /// # Arguments /// -/// - `&JsValue` - The handle to call on. -/// - `&str` - The method name. -/// - `&JsValue` - The first argument. -/// - `&JsValue` - The second argument. -/// - `&JsValue` - The third argument. -/// - `&JsValue` - The fourth argument. +/// - `&JsValue` - The render pass to draw in. +/// - `&DrawArgs` - The vertex count, instance count, and the first +/// vertex and instance the pass starts from. /// /// # Returns /// /// - `Result` - The call's return value. -pub(crate) fn call4( - target: &JsValue, - name: &str, - first: &JsValue, - second: &JsValue, - third: &JsValue, - fourth: &JsValue, -) -> Result { - let function: Function = method(target, name)?; - match function.call4(target, first, second, third, fourth) { +pub(crate) fn draw_vertices(pass: &JsValue, args: &DrawArgs) -> Result { + let function: Function = method(pass, BACKEND_WEBGPU_METHOD_DRAW)?; + let vertex_count: JsValue = JsValue::from_f64(f64::from(args.get_vertex_count())); + let instance_count: JsValue = JsValue::from_f64(f64::from(args.get_instance_count())); + let first_vertex: JsValue = JsValue::from_f64(f64::from(args.get_first_vertex())); + let first_instance: JsValue = JsValue::from_f64(f64::from(args.get_first_instance())); + match function.call4( + pass, + &vertex_count, + &instance_count, + &first_vertex, + &first_instance, + ) { Ok(value) => Ok(value), Err(reason) => Err(backend_error( ERROR_WEBGPU_CALL, - format!("{name} threw: {}", describe_webgpu(&reason)), + format!( + "{BACKEND_WEBGPU_METHOD_DRAW} threw: {}", + describe_webgpu(&reason) + ), )), } } diff --git a/engine/src/raytracing/backend/webgpu/impl.rs b/engine/src/raytracing/backend/webgpu/impl.rs index 71960d28..939018a2 100644 --- a/engine/src/raytracing/backend/webgpu/impl.rs +++ b/engine/src/raytracing/backend/webgpu/impl.rs @@ -653,18 +653,13 @@ impl WebGpuRayTraceBackend { let group: JsValue = self.get_bind_group(); let index: JsValue = JsValue::from_f64(BACKEND_WEBGPU_BIND_GROUP_INDEX as f64); call2(&pass, BACKEND_WEBGPU_METHOD_SET_BIND_GROUP, &index, &group)?; - let vertex_count: JsValue = JsValue::from_f64(BACKEND_WEBGPU_VERTEX_COUNT as f64); - let instances: JsValue = JsValue::from_f64(BACKEND_WEBGPU_INSTANCE_COUNT as f64); - let first_vertex: JsValue = JsValue::from_f64(BACKEND_WEBGPU_FIRST_VERTEX as f64); - let first_instance: JsValue = JsValue::from_f64(BACKEND_WEBGPU_FIRST_INSTANCE as f64); - call4( - &pass, - BACKEND_WEBGPU_METHOD_DRAW, - &vertex_count, - &instances, - &first_vertex, - &first_instance, - )?; + let args: DrawArgs = DrawArgs { + vertex_count: BACKEND_WEBGPU_VERTEX_COUNT, + instance_count: BACKEND_WEBGPU_INSTANCE_COUNT, + first_vertex: BACKEND_WEBGPU_FIRST_VERTEX, + first_instance: BACKEND_WEBGPU_FIRST_INSTANCE, + }; + draw_vertices(&pass, &args)?; call0(&pass, BACKEND_WEBGPU_METHOD_END)?; let command: JsValue = call0(&encoder, BACKEND_WEBGPU_METHOD_FINISH)?; let batch: Array = Array::new(); diff --git a/engine/src/raytracing/pipeline/data/fn.rs b/engine/src/raytracing/pipeline/data/fn.rs index d387fd00..2e443812 100644 --- a/engine/src/raytracing/pipeline/data/fn.rs +++ b/engine/src/raytracing/pipeline/data/fn.rs @@ -220,23 +220,6 @@ pub fn pack_vector_upper( ]) } -/// Appends four `f32` values to `out`, reusing its capacity when it -/// already covers the write. -/// -/// # Arguments -/// -/// - `&mut Vec` - The destination, which keeps its capacity. -/// - `f32` - The x component. -/// - `f32` - The y component. -/// - `f32` - The z component. -/// - `f32` - The w component. -pub fn push_vec4(out: &mut Vec, x: f32, y: f32, z: f32, w: f32) { - out.push(x); - out.push(y); - out.push(z); - out.push(w); -} - /// Converts a `usize` node or primitive index into an exactly /// representable `f32`. /// @@ -461,41 +444,39 @@ pub(crate) fn pack_primitive( RAYTRACE_ERROR_F32_RANGE, )?; let vertices: [Vector3D; 3] = occluder.get_vertices(); - push_vec4( - out, + out.extend_from_slice(&[ center_packed[0], center_packed[1], center_packed[2], packed_shape_code(kind), - ); - push_vec4( - out, + ]); + out.extend_from_slice(&[ extent_packed[0], extent_packed[1], extent_packed[2], packed_material_code(material.get_kind()), - ); + ]); let vertex0: [f32; 3] = pack_vector( vertices[0], RAYTRACE_ERROR_NON_FINITE, RAYTRACE_ERROR_F32_RANGE, )?; - push_vec4(out, vertex0[0], vertex0[1], vertex0[2], bound_index); + out.extend_from_slice(&[vertex0[0], vertex0[1], vertex0[2], bound_index]); let vertex1: [f32; 3] = pack_vector( vertices[1], RAYTRACE_ERROR_NON_FINITE, RAYTRACE_ERROR_F32_RANGE, )?; - push_vec4(out, vertex1[0], vertex1[1], vertex1[2], 0.0); + out.extend_from_slice(&[vertex1[0], vertex1[1], vertex1[2], 0.0]); let vertex2: [f32; 3] = pack_vector( vertices[2], RAYTRACE_ERROR_NON_FINITE, RAYTRACE_ERROR_F32_RANGE, )?; - push_vec4(out, vertex2[0], vertex2[1], vertex2[2], 0.0); - push_vec4(out, albedo[0], albedo[1], albedo[2], specular); - push_vec4(out, emissive[0], emissive[1], emissive[2], shininess); - push_vec4(out, 0.0, 0.0, 0.0, 0.0); + out.extend_from_slice(&[vertex2[0], vertex2[1], vertex2[2], 0.0]); + out.extend_from_slice(&[albedo[0], albedo[1], albedo[2], specular]); + out.extend_from_slice(&[emissive[0], emissive[1], emissive[2], shininess]); + out.extend_from_slice(&[0.0, 0.0, 0.0, 0.0]); Ok(()) } @@ -539,9 +520,9 @@ pub(crate) fn pack_node(out: &mut Vec, node: &RayTraceNode) -> Result<(), S let start: f32 = pack_index(node.get_start())?; let count: f32 = pack_index(node.get_count())?; let escape: f32 = pack_index(node.get_escape())?; - push_vec4(out, lower[0], lower[1], lower[2], start); - push_vec4(out, upper[0], upper[1], upper[2], count); - push_vec4(out, escape, 0.0, 0.0, 0.0); + out.extend_from_slice(&[lower[0], lower[1], lower[2], start]); + out.extend_from_slice(&[upper[0], upper[1], upper[2], count]); + out.extend_from_slice(&[escape, 0.0, 0.0, 0.0]); Ok(()) } @@ -682,31 +663,35 @@ pub(crate) fn frame_basis( /// overflow check, not silently truncated. /// /// The supplied `out` is cleared and then reused, so its capacity is kept -/// and a caller packing every frame performs no allocation. +/// and a caller packing every frame performs no allocation. Every value +/// the packer reads arrives through one [`RayTraceFrame`], the same +/// parameter type the CPU renderer takes, so the two backends cannot be +/// called with mismatched camera, lights, settings, or dimensions. +/// +/// `out` is cleared only after the settings and the camera have been +/// validated, so the rejections a caller is most likely to hit — an +/// out-of-range sample grid, a zero or overflowing dimension, a +/// degenerate camera — leave the previous frame's contents intact. A +/// light whose components cannot be represented is a different case: that +/// check runs inside the write loop, after the clear, so such a frame +/// leaves a partially written buffer behind rather than the old one. /// /// # Arguments /// /// - `&mut Vec` - The destination packed buffer, cleared then reused. -/// - `&Camera3D` - The camera supplying the eye, target, up, and fov. -/// - `&LightingUniforms` - The lights, ambient, and shading eye. -/// - `&RayTraceSettings` - The sampling and bounce settings. -/// - `u32` - The frame width in pixels. -/// - `u32` - The frame height in pixels. +/// - `&RayTraceFrame<'_>` - The camera, lights, settings, and dimensions +/// to pack. /// /// # Returns /// /// - `Result<(), String>` - `Ok` once the block is packed, or the reason /// the frame cannot be represented. -pub fn pack_raytrace_frame( - out: &mut Vec, - camera: &Camera3D, - lights: &LightingUniforms, - settings: &RayTraceSettings, - width: u32, - height: u32, -) -> Result<(), String> { +pub fn pack_raytrace_frame(out: &mut Vec, frame: &RayTraceFrame<'_>) -> Result<(), String> { + let camera: &Camera3D = frame.get_camera(); + let lights: &LightingUniforms = frame.get_lights(); + let settings: &RayTraceSettings = frame.get_settings(); settings.validate()?; - let basis: FrameBasis = frame_basis(camera, width, height)?; + let basis: FrameBasis = frame_basis(camera, frame.get_width(), frame.get_height())?; let light_count: usize = lights.get_lights().len(); let light_slots: usize = light_count .checked_mul(RAYTRACE_PACKED_LIGHT_VEC4S) @@ -756,44 +741,39 @@ pub fn pack_raytrace_frame( RAYTRACE_ERROR_LIGHT_F32_RANGE, )?; out.clear(); - push_vec4(out, eye[0], eye[1], eye[2], tan_half_fov); - push_vec4( - out, + out.extend_from_slice(&[eye[0], eye[1], eye[2], tan_half_fov]); + out.extend_from_slice(&[ forward_packed[0], forward_packed[1], forward_packed[2], basis.get_aspect() as f32, - ); - push_vec4( - out, + ]); + out.extend_from_slice(&[ right_packed[0], right_packed[1], right_packed[2], basis.get_width() as f32, - ); - push_vec4( - out, + ]); + out.extend_from_slice(&[ up_packed[0], up_packed[1], up_packed[2], basis.get_height() as f32, - ); - push_vec4(out, ambient[0], ambient[1], ambient[2], light_count as f32); - push_vec4( - out, + ]); + out.extend_from_slice(&[ambient[0], ambient[1], ambient[2], light_count as f32]); + out.extend_from_slice(&[ lighting_eye[0], lighting_eye[1], lighting_eye[2], settings.get_max_bounces() as f32, - ); - push_vec4( - out, + ]); + out.extend_from_slice(&[ basis.get_near_t() as f32, basis.get_far_t() as f32, settings.get_samples_per_axis() as f32, 0.0, - ); - push_vec4(out, 0.0, 0.0, 0.0, 0.0); + ]); + out.extend_from_slice(&[0.0, 0.0, 0.0, 0.0]); for light in lights.get_lights().iter() { let position: [f32; 3] = pack_vector( light.get_position(), @@ -825,16 +805,15 @@ pub fn pack_raytrace_frame( RAYTRACE_ERROR_LIGHT_NON_FINITE, RAYTRACE_ERROR_LIGHT_F32_RANGE, )?; - push_vec4( - out, + out.extend_from_slice(&[ position[0], position[1], position[2], packed_light_code(light.get_kind()), - ); - push_vec4(out, direction[0], direction[1], direction[2], intensity); - push_vec4(out, color[0], color[1], color[2], falloff); - push_vec4(out, spot_cos, 0.0, 0.0, 0.0); + ]); + out.extend_from_slice(&[direction[0], direction[1], direction[2], intensity]); + out.extend_from_slice(&[color[0], color[1], color[2], falloff]); + out.extend_from_slice(&[spot_cos, 0.0, 0.0, 0.0]); } Ok(()) } diff --git a/engine/src/raytracing/pipeline/data/impl.rs b/engine/src/raytracing/pipeline/data/impl.rs index 04888436..c4ef5cf1 100644 --- a/engine/src/raytracing/pipeline/data/impl.rs +++ b/engine/src/raytracing/pipeline/data/impl.rs @@ -124,13 +124,12 @@ impl RayTraceScene { } let mut out: Vec = Vec::with_capacity(total_floats); let primitive_offset: usize = RAYTRACE_PACKED_SCENE_HEADER_VEC4S + node_slots; - push_vec4( - &mut out, + out.extend_from_slice(&[ pack_index(node_count)?, pack_index(primitive_count)?, pack_index(primitive_offset)?, 0.0, - ); + ]); for node in nodes.iter() { pack_node(&mut out, node)?; } diff --git a/engine/src/raytracing/pipeline/data/mod.rs b/engine/src/raytracing/pipeline/data/mod.rs index 07740211..db8f87d3 100644 --- a/engine/src/raytracing/pipeline/data/mod.rs +++ b/engine/src/raytracing/pipeline/data/mod.rs @@ -3,7 +3,7 @@ mod r#fn; mod r#impl; mod r#struct; -pub use {r#fn::*, r#struct::RayTraceSettings}; +pub use {r#fn::*, r#struct::RayTraceFrame, r#struct::RayTraceSettings}; pub(crate) use {r#const::*, r#struct::FrameBasis}; diff --git a/engine/src/raytracing/pipeline/data/struct.rs b/engine/src/raytracing/pipeline/data/struct.rs index 4714ff15..8f8b9dc5 100644 --- a/engine/src/raytracing/pipeline/data/struct.rs +++ b/engine/src/raytracing/pipeline/data/struct.rs @@ -17,6 +17,30 @@ pub struct RayTraceSettings { pub max_bounces: u32, } +/// Read-only frame inputs shared by the CPU renderer and GPU header packer. +/// +/// Named width and height fields make their roles explicit at call sites. +/// The camera, lighting, and settings are borrowed; dimensions are copied. +/// Constructing a frame neither clones those resources nor allocates memory. +#[derive(Clone, Copy, Debug, Getter)] +pub struct RayTraceFrame<'a> { + /// The camera supplying the eye, target, up, fov, and depth range. + #[get(type(copy))] + pub camera: &'a Camera3D, + /// The lights, ambient, and shading eye. + #[get(type(copy))] + pub lights: &'a LightingUniforms, + /// The sampling grid and bounce limit. + #[get(type(copy))] + pub settings: &'a RayTraceSettings, + /// The frame width in pixels. + #[get(type(copy))] + pub width: u32, + /// The frame height in pixels. + #[get(type(copy))] + pub height: u32, +} + /// A validated, immutable description of one frame's camera. /// /// Building a [`FrameBasis`] performs every check the packed frame header diff --git a/engine/src/raytracing/pipeline/render/fn.rs b/engine/src/raytracing/pipeline/render/fn.rs index b58fc1f5..a27c2502 100644 --- a/engine/src/raytracing/pipeline/render/fn.rs +++ b/engine/src/raytracing/pipeline/render/fn.rs @@ -16,23 +16,17 @@ use super::*; /// # Arguments /// /// - `&FrameBasis` - The validated camera basis. -/// - `f64` - The pixel's x coordinate. -/// - `f64` - The pixel's y coordinate, counted from the top row. -/// - `f64` - The sub-sample's offset within the pixel, in `[0, 1)`. -/// - `f64` - The second sub-sample's offset within the pixel. +/// - `&PixelSample` - The pixel coordinates and the sub-sample's offset +/// within that pixel. /// /// # Returns /// /// - `Vector3D` - The unit world-space ray direction. -pub(crate) fn pixel_direction( - basis: &FrameBasis, - x: f64, - y: f64, - offset_x: f64, - offset_y: f64, -) -> Vector3D { - let ndc_x: f64 = ((x + offset_x) / basis.get_width() as f64) * 2.0 - 1.0; - let ndc_y: f64 = 1.0 - ((y + offset_y) / basis.get_height() as f64) * 2.0; +pub(crate) fn pixel_direction(basis: &FrameBasis, sample: &PixelSample) -> Vector3D { + let ndc_x: f64 = + ((sample.get_x() + sample.get_offset_x()) / basis.get_width() as f64) * 2.0 - 1.0; + let ndc_y: f64 = + 1.0 - ((sample.get_y() + sample.get_offset_y()) / basis.get_height() as f64) * 2.0; let tan_half: f64 = basis.get_tan_half_fov(); (basis.get_forward() + basis @@ -82,15 +76,15 @@ pub fn gamma_byte(channel: f64) -> u8 { /// them, clamps the linear color to `[0, 1]`, applies the `1 / 2.2` gamma /// curve, and writes an opaque alpha. Rows are emitted top-down. /// +/// Frame inputs arrive through the same [`RayTraceFrame`] parameter type +/// accepted by [`pack_raytrace_frame`]. The scene and mutable output remain +/// separate, so the descriptor itself contains only read-only frame state. +/// /// # Arguments /// /// - `&RayTraceScene` - The scene to render. -/// - `&Camera3D` - The camera supplying the eye, target, up, fov, and -/// near/far range. -/// - `&LightingUniforms` - The lights, ambient, and shading eye. -/// - `&RayTraceSettings` - The sampling grid and bounce limit. -/// - `u32` - The frame width in pixels. -/// - `u32` - The frame height in pixels. +/// - `&RayTraceFrame<'_>` - The camera, lights, settings, and dimensions +/// to render. /// - `&mut Vec` - The destination RGBA buffer, cleared and reused. /// /// # Returns @@ -99,24 +93,21 @@ pub fn gamma_byte(channel: f64) -> u8 { /// the frame cannot be rendered. pub fn render_raytrace_rgba( scene: &RayTraceScene, - camera: &Camera3D, - lights: &LightingUniforms, - settings: &RayTraceSettings, - width: u32, - height: u32, + frame: &RayTraceFrame<'_>, out: &mut Vec, ) -> Result<(), String> { - settings.validate()?; - let basis: FrameBasis = frame_basis(camera, width, height)?; + let lights: &LightingUniforms = frame.get_lights(); + frame.get_settings().validate()?; + let basis: FrameBasis = frame_basis(frame.get_camera(), frame.get_width(), frame.get_height())?; let pixel_count: usize = (basis.get_width() as usize) * (basis.get_height() as usize); let byte_count: usize = match pixel_count.checked_mul(4) { Some(count) => count, None => return Err(RAYTRACE_ERROR_DIMENSION_OVERFLOW.to_string()), }; - let samples: u32 = settings.get_samples_per_axis(); + let samples: u32 = frame.get_settings().get_samples_per_axis(); let sample_scale: f64 = 1.0 / samples as f64; let rays: f64 = (samples * samples) as f64; - let bounces: u32 = settings.get_max_bounces(); + let bounces: u32 = frame.get_settings().get_max_bounces(); out.clear(); out.resize(byte_count, 0); for y in 0..basis.get_height() { @@ -124,13 +115,13 @@ pub fn render_raytrace_rgba( let mut accumulator: Vector3D = Vector3D::zero(); for sy in 0..samples { for sx in 0..samples { - let direction: Vector3D = pixel_direction( - &basis, - x as f64, - y as f64, - (sx as f64 + 0.5) * sample_scale, - (sy as f64 + 0.5) * sample_scale, - ); + let sample: PixelSample = PixelSample { + x: x as f64, + y: y as f64, + offset_x: (sx as f64 + 0.5) * sample_scale, + offset_y: (sy as f64 + 0.5) * sample_scale, + }; + let direction: Vector3D = pixel_direction(&basis, &sample); let ray: Ray = Ray::new(basis.get_eye(), direction) .with_range(basis.get_near_t(), basis.get_far_t()); accumulator += scene.trace_with_bounces(ray, lights, bounces); diff --git a/engine/src/raytracing/pipeline/render/mod.rs b/engine/src/raytracing/pipeline/render/mod.rs index 7087a15a..e6563e00 100644 --- a/engine/src/raytracing/pipeline/render/mod.rs +++ b/engine/src/raytracing/pipeline/render/mod.rs @@ -1,5 +1,8 @@ mod r#fn; +mod r#struct; pub use r#fn::*; +pub(crate) use r#struct::*; + use super::*; diff --git a/engine/src/raytracing/pipeline/render/struct.rs b/engine/src/raytracing/pipeline/render/struct.rs new file mode 100644 index 00000000..bc7d3f70 --- /dev/null +++ b/engine/src/raytracing/pipeline/render/struct.rs @@ -0,0 +1,21 @@ +use super::*; + +/// One supersample position inside one pixel of a frame. +/// +/// Names the pixel coordinates and sub-pixel offsets used by `pixel_direction`. +/// All four values are scalars, with no per-sample heap allocation. +#[derive(Clone, Copy, Debug, Getter)] +pub(crate) struct PixelSample { + /// The pixel's x coordinate, counted from the left column. + #[get(pub(crate), type(copy))] + pub(crate) x: f64, + /// The pixel's y coordinate, counted from the top row. + #[get(pub(crate), type(copy))] + pub(crate) y: f64, + /// The sub-sample's x offset within the pixel, in `[0, 1)`. + #[get(pub(crate), type(copy))] + pub(crate) offset_x: f64, + /// The sub-sample's y offset within the pixel, in `[0, 1)`. + #[get(pub(crate), type(copy))] + pub(crate) offset_y: f64, +} diff --git a/engine/src/raytracing/scene/fn.rs b/engine/src/raytracing/scene/fn.rs index b9cd0b64..e09865fe 100644 --- a/engine/src/raytracing/scene/fn.rs +++ b/engine/src/raytracing/scene/fn.rs @@ -93,33 +93,25 @@ fn slab_interval( /// Returns the clipped entry parameter of a ray segment against an AABB. /// /// The result is `None` when the segment provably cannot touch the box. -/// The interval is clipped to `t_min`..=`t_max`, so a traversal that -/// shrinks `t_max` as it discovers nearer hits also prunes the boxes -/// behind those hits for free. Parallel slabs are handled inside +/// The interval is clipped to the ray's own `t_min`..=`t_max`, so a +/// traversal that narrows `t_max` as it discovers nearer hits also prunes +/// the boxes behind those hits for free. Parallel slabs are handled inside /// [`slab_interval`], so an axis-aligned ray running along a face still /// reports the other two axes' constraint instead of dividing by zero. /// /// # Arguments /// -/// - `Vector3D` - The ray origin. -/// - `Vector3D` - The ray direction, not required to be unit length. +/// - `&Ray` - The ray whose origin, direction and parameter range are used. /// - `Vector3D` - The AABB minimum corner. /// - `Vector3D` - The AABB maximum corner. -/// - `f64` - The minimum ray parameter considered a valid hit. -/// - `f64` - The maximum ray parameter considered a valid hit. /// /// # Returns /// -/// - `Option` - The entry parameter clipped into the requested range, -/// or `None` when the segment misses the box. -fn aabb_entry_parameter( - origin: Vector3D, - dir: Vector3D, - lower: Vector3D, - upper: Vector3D, - t_min: f64, - t_max: f64, -) -> Option { +/// - `Option` - The entry parameter clipped into the ray's range, or +/// `None` when the segment misses the box. +fn aabb_entry_parameter(ray: &Ray, lower: Vector3D, upper: Vector3D) -> Option { + let origin: Vector3D = ray.get_origin(); + let dir: Vector3D = ray.get_direction(); if !is_finite_vector(origin) || !is_finite_vector(dir) || !is_finite_vector(lower) @@ -141,43 +133,40 @@ fn aabb_entry_parameter( if t_enter > t_exit { return None; } - let clipped: f64 = t_enter.max(t_min); - if clipped > t_exit.min(t_max) { + let clipped: f64 = t_enter.max(ray.get_t_min()); + if clipped > t_exit.min(ray.get_t_max()) { return None; } Some(clipped) } -/// Intersects a ray with the triangle `v0`, `v1`, `v2` using the -/// Moller-Trumbore algorithm. +/// Intersects a ray with a triangle using the Moller-Trumbore algorithm. /// /// The test is two-sided: a hit on either face counts, and the returned /// normal is flipped when needed so that it always faces against the ray /// direction, making back-face hits usable for shading. A ray parallel to -/// the triangle plane yields `None`. No `t_min`/`t_max` filtering is -/// applied here; use [`Ray::intersect_triangle`] for the range-clamped -/// variant. +/// the triangle plane yields `None`. +/// +/// The ray's own `t_min`/`t_max` are deliberately not consulted: this free +/// function reports the raw distance along the ray however far away the +/// triangle is. [`Ray::intersect_triangle`] is the range-clamped variant and +/// the two must stay distinguishable at the call site. /// /// # Arguments /// -/// - `Vector3D` - The ray origin. -/// - `Vector3D` - The ray direction (expected to be unit length). -/// - `Vector3D` - The first triangle vertex. -/// - `Vector3D` - The second triangle vertex. -/// - `Vector3D` - The third triangle vertex. +/// - `&Ray` - The ray whose origin and direction are tested; its parameter +/// range is ignored. +/// - `[Vector3D; 3]` - The three triangle vertices in winding order. /// /// # Returns /// /// - `Option<(f64, Vector3D)>` - The hit distance along the ray and the /// unit surface normal oriented against the ray direction, or `None` on /// miss. -pub fn intersect_triangle( - origin: Vector3D, - dir: Vector3D, - v0: Vector3D, - v1: Vector3D, - v2: Vector3D, -) -> Option<(f64, Vector3D)> { +pub fn intersect_triangle(ray: &Ray, vertices: [Vector3D; 3]) -> Option<(f64, Vector3D)> { + let [v0, v1, v2]: [Vector3D; 3] = vertices; + let origin: Vector3D = ray.get_origin(); + let dir: Vector3D = ray.get_direction(); let edge1: Vector3D = v1 - v0; let edge2: Vector3D = v2 - v0; let pvec: Vector3D = dir.cross(edge2); @@ -321,33 +310,28 @@ fn axis_component(value: Vector3D, axis: usize) -> f64 { /// inside the sphere. A ray whose direction is zero, a negative radius, or /// any `NaN` input never reports a hit. /// +/// The direction is read from the ray and never normalised, so a non-unit +/// direction reports a true distance in the ray's own parameterisation. +/// /// # Arguments /// -/// - `Vector3D` - The ray origin. -/// - `Vector3D` - The ray direction, not required to be unit length. +/// - `&Ray` - The ray whose origin, direction and parameter range are used. /// - `Vector3D` - The sphere center. /// - `f64` - The sphere radius. -/// - `f64` - The minimum acceptable hit parameter. -/// - `f64` - The maximum acceptable hit parameter. /// /// # Returns /// /// - `Option<(f64, Vector3D)>` - The hit parameter and the outward unit /// normal, or `None` when the range-constrained ray misses. -fn intersect_sphere_in_range( - origin: Vector3D, - dir: Vector3D, - center: Vector3D, - radius: f64, - t_min: f64, - t_max: f64, -) -> Option<(f64, Vector3D)> { +fn intersect_sphere_in_range(ray: &Ray, center: Vector3D, radius: f64) -> Option<(f64, Vector3D)> { // A zero or negative radius describes no surface at all, and the // normalising step below would divide by its length, so reject both // before any arithmetic. if !radius.is_finite() || radius <= 0.0 { return None; } + let origin: Vector3D = ray.get_origin(); + let dir: Vector3D = ray.get_direction(); if !is_finite_vector(origin) || !is_finite_vector(dir) || !is_finite_vector(center) { return None; } @@ -358,6 +342,8 @@ fn intersect_sphere_in_range( if !a.is_finite() || a <= 0.0 { return None; } + let t_min: f64 = ray.get_t_min(); + let t_max: f64 = ray.get_t_max(); let oc: Vector3D = origin - center; let half_b: f64 = oc.dot(dir); let c: f64 = oc.dot(oc) - radius * radius; @@ -395,25 +381,19 @@ fn intersect_sphere_in_range( /// /// # Arguments /// -/// - `Vector3D` - The ray origin. -/// - `Vector3D` - The ray direction, not required to be unit length. +/// - `&Ray` - The ray whose origin, direction and parameter range are used. /// - `Vector3D` - The AABB minimum corner. /// - `Vector3D` - The AABB maximum corner. -/// - `f64` - The minimum acceptable hit parameter. -/// - `f64` - The maximum acceptable hit parameter. /// /// # Returns /// /// - `Option<(f64, Vector3D)>` - The hit parameter and the outward unit /// normal, or `None` when the range-constrained ray misses. -fn intersect_aabb_in_range( - origin: Vector3D, - dir: Vector3D, - lower: Vector3D, - upper: Vector3D, - t_min: f64, - t_max: f64, -) -> Option<(f64, Vector3D)> { +fn intersect_aabb_in_range(ray: &Ray, lower: Vector3D, upper: Vector3D) -> Option<(f64, Vector3D)> { + let origin: Vector3D = ray.get_origin(); + let dir: Vector3D = ray.get_direction(); + let t_min: f64 = ray.get_t_min(); + let t_max: f64 = ray.get_t_max(); if !is_finite_vector(origin) || !is_finite_vector(dir) { return None; } @@ -511,8 +491,8 @@ fn intersect_aabb_in_range( Some((t, normal)) } -/// Runs the exact intersection test for one occluder inside a parameter -/// range. +/// Runs the exact intersection test for one occluder inside a ray's +/// parameter range. /// /// This is the single source of truth for what counts as a hit: sphere, /// AABB and triangle each dispatch to their own range-aware routine, and a @@ -522,69 +502,47 @@ fn intersect_aabb_in_range( /// # Arguments /// /// - `&Occluder` - The occluder to test. -/// - `Vector3D` - The ray origin. -/// - `Vector3D` - The ray direction, not required to be unit length. -/// - `f64` - The minimum acceptable hit parameter. -/// - `f64` - The maximum acceptable hit parameter. +/// - `&Ray` - The ray whose origin, direction and parameter range are used. /// /// # Returns /// /// - `Option<(f64, Vector3D)>` - The hit parameter and the outward unit /// normal, or `None` when the occluder is missed or degenerate. -fn intersect_occluder_in_range( - occluder: &Occluder, - origin: Vector3D, - dir: Vector3D, - t_min: f64, - t_max: f64, -) -> Option<(f64, Vector3D)> { +fn intersect_occluder_in_range(occluder: &Occluder, ray: &Ray) -> Option<(f64, Vector3D)> { + let t_min: f64 = ray.get_t_min(); + let t_max: f64 = ray.get_t_max(); match occluder.get_kind() { - OccluderKind::Sphere => intersect_sphere_in_range( - origin, - dir, - occluder.get_center(), - occluder.get_extent().get_x(), - t_min, - t_max, - ), + OccluderKind::Sphere => { + intersect_sphere_in_range(ray, occluder.get_center(), occluder.get_extent().get_x()) + } OccluderKind::Aabb => { let (lower, upper): (Vector3D, Vector3D) = occluder_aabb_extents(occluder); - intersect_aabb_in_range(origin, dir, lower, upper, t_min, t_max) - } - OccluderKind::Triangle => { - let [v0, v1, v2]: [Vector3D; 3] = occluder.get_vertices(); - match intersect_triangle(origin, dir, v0, v1, v2) { - Some((t, normal)) if t.is_finite() && t >= t_min && t <= t_max => Some((t, normal)), - _ => None, - } + intersect_aabb_in_range(ray, lower, upper) } + OccluderKind::Triangle => match intersect_triangle(ray, occluder.get_vertices()) { + Some((t, normal)) if t.is_finite() && t >= t_min && t <= t_max => Some((t, normal)), + _ => None, + }, } } -/// Returns the tight AABB covering the primitives listed in -/// `indices[start..start + count]`. +/// Returns the tight AABB covering the primitives listed in a range. /// /// # Arguments /// /// - `&[usize]` - The primitive indexes of the range to measure. /// - `&BvhBuild` - The per-primitive bounds and centroids. -/// - `usize` - The first index of the range. -/// - `usize` - The number of indexes in the range. +/// - `BvhRange` - The primitives to measure. /// /// # Returns /// /// - `(Vector3D, Vector3D)` - The `(min, max)` corners covering the range. -fn bounds_of_range( - indices: &[usize], - build: &BvhBuild, - start: usize, - count: usize, -) -> (Vector3D, Vector3D) { +fn bounds_of_range(indices: &[usize], build: &BvhBuild, range: BvhRange) -> (Vector3D, Vector3D) { let mut lower: Vector3D = Vector3D::new(f64::INFINITY, f64::INFINITY, f64::INFINITY); let mut upper: Vector3D = Vector3D::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); - for slot in 0..count { - let primitive: usize = indices[start + slot]; + for slot in 0..range.count { + let primitive: usize = indices[range.start + slot]; lower = vector3d_min(lower, build.lower[primitive]); upper = vector3d_max(upper, build.upper[primitive]); } @@ -601,19 +559,12 @@ fn bounds_of_range( /// /// - `&[usize]` - The primitive indexes of the range. /// - `&BvhBuild` - The per-primitive bounds and centroids. -/// - `usize` - The first index of the range. -/// - `usize` - The number of indexes in the range. +/// - `BvhRange` - The primitives to measure. /// /// # Returns /// -/// - `(usize, f64)` - The `(axis, midpoint)` cutting the widest centroid -/// spread. -fn longest_centroid_axis( - indices: &[usize], - build: &BvhBuild, - start: usize, - count: usize, -) -> (usize, f64) { +/// - `SahSplit` - The axis and midpoint cutting the widest centroid spread. +fn longest_centroid_axis(indices: &[usize], build: &BvhBuild, range: BvhRange) -> SahSplit { let centroids: &[Vector3D] = &build.centroids; let mut best_axis: usize = 0; let mut best_span: f64 = -1.0; @@ -621,8 +572,8 @@ fn longest_centroid_axis( for axis in 0..3 { let mut min: f64 = f64::INFINITY; let mut max: f64 = f64::NEG_INFINITY; - for slot in 0..count { - let value: f64 = axis_component(centroids[indices[start + slot]], axis); + for slot in 0..range.count { + let value: f64 = axis_component(centroids[indices[range.start + slot]], axis); min = min.min(value); max = max.max(value); } @@ -633,11 +584,14 @@ fn longest_centroid_axis( best_mid = (min + max) * 0.5; } } - (best_axis, best_mid) + SahSplit { + axis: best_axis, + boundary: best_mid, + } } -/// Partitions `indices[start..start + count]` around `split_value` on one -/// centroid axis and reports where the left partition ends. +/// Partitions a range around a centroid split and reports where the left +/// partition ends. /// /// The partition is an in-place two-way scan, so one level of the build /// costs linear time instead of sorting every axis at every level. @@ -649,10 +603,8 @@ fn longest_centroid_axis( /// /// - `&mut [usize]` - The primitive index array, partitioned in place. /// - `&BvhBuild` - The per-primitive bounds and centroids. -/// - `usize` - The first index of the range to partition. -/// - `usize` - The number of indexes in the range. -/// - `usize` - `0` for `x`, `1` for `y`, `2` for `z`. -/// - `f64` - The split position on that axis. +/// - `BvhRange` - The range to partition. +/// - `SahSplit` - The centroid axis and split position. /// /// # Returns /// @@ -660,27 +612,28 @@ fn longest_centroid_axis( fn partition_range( indices: &mut [usize], build: &BvhBuild, - start: usize, - count: usize, - axis: usize, - split_value: f64, + range: BvhRange, + split: SahSplit, ) -> usize { let centroids: &[Vector3D] = &build.centroids; let mut left: usize = 0; - let mut right: usize = count; + let mut right: usize = range.count; while left < right { - while left < right && axis_component(centroids[indices[start + left]], axis) < split_value { + while left < right + && axis_component(centroids[indices[range.start + left]], split.axis) < split.boundary + { left += 1; } while right > left - && axis_component(centroids[indices[start + right - 1]], axis) >= split_value + && axis_component(centroids[indices[range.start + right - 1]], split.axis) + >= split.boundary { right -= 1; } if left >= right { break; } - indices.swap(start + left, start + right - 1); + indices.swap(range.start + left, range.start + right - 1); left += 1; right -= 1; } @@ -700,21 +653,20 @@ fn partition_range( /// /// - `&[usize]` - The primitive indexes of the range to split. /// - `&BvhBuild` - The per-primitive bounds and centroids. -/// - `usize` - The first index of the range. -/// - `usize` - The number of indexes in the range. +/// - `BvhRange` - The range to split. /// - `f64` - The surface area of the range's parent bounds. /// /// # Returns /// -/// - `Option<(usize, f64)>` - The chosen `(axis, split position)`, or -/// `None` when no bin boundary improves on a leaf. +/// - `Option` - The chosen axis and split position, or `None` +/// when no bin boundary improves on a leaf. fn choose_sah_split( indices: &[usize], build: &BvhBuild, - start: usize, - count: usize, + range: BvhRange, parent_area: f64, -) -> Option<(usize, f64)> { +) -> Option { + let count: usize = range.count; if count < 2 || !parent_area.is_finite() || parent_area <= 0.0 { return None; } @@ -724,7 +676,7 @@ fn choose_sah_split( let mut centroid_min: f64 = f64::INFINITY; let mut centroid_max: f64 = f64::NEG_INFINITY; for slot in 0..count { - let value: f64 = axis_component(centroids[indices[start + slot]], axis); + let value: f64 = axis_component(centroids[indices[range.start + slot]], axis); centroid_min = centroid_min.min(value); centroid_max = centroid_max.max(value); } @@ -737,7 +689,7 @@ fn choose_sah_split( let mut bin_upper: [Vector3D; RAYTRACE_BVH_BINS] = [Vector3D::zero(); RAYTRACE_BVH_BINS]; let mut bin_count: [usize; RAYTRACE_BVH_BINS] = [0; RAYTRACE_BVH_BINS]; for slot in 0..count { - let primitive: usize = indices[start + slot]; + let primitive: usize = indices[range.start + slot]; let scaled: f64 = (axis_component(centroids[primitive], axis) - centroid_min) * scale; let mut bin: usize = scaled as usize; if bin >= RAYTRACE_BVH_BINS { @@ -795,7 +747,7 @@ fn choose_sah_split( if !cost.is_finite() || cost >= incumbent { return None; } - Some((axis, boundary)) + Some(SahSplit { axis, boundary }) } /// Builds one BVH subtree in preorder, appending its root and descendants. @@ -808,81 +760,81 @@ fn choose_sah_split( /// the finished hierarchy inside the fixed traversal stack, because the /// heuristic can otherwise chain linearly on a geometric spread. /// +/// The node array and the index array it permutes live in `context`, and +/// `subtree` carries only this call's own node, so recursion allocates +/// nothing beyond the frames themselves. +/// /// # Arguments /// -/// - `&mut Vec` - The node array under construction. -/// - `&mut [usize]` - The primitive index array, permuted in place. -/// - `&BvhBuild` - The per-primitive bounds and centroids. -/// - `(Vector3D, Vector3D)` - The bounds of this subtree. -/// - `usize` - The first primitive index of this range. -/// - `usize` - The number of primitive indexes in this range. -/// - `usize` - The nesting level of this subtree, starting at zero. -fn build_bvh_subtree( - nodes: &mut Vec, - indices: &mut [usize], - build: &BvhBuild, - bounds: (Vector3D, Vector3D), - start: usize, - count: usize, - depth: usize, -) { - let root: usize = nodes.len(); - let too_small_to_split: bool = count <= RAYTRACE_BVH_LEAF_SIZE || count < 2; - if too_small_to_split || depth >= RAYTRACE_BVH_MAX_DEPTH { - nodes.push(RayTraceNode { +/// - `&mut BvhBuildContext` - The node array, index array and per-primitive +/// build data shared by every step of this build. +/// - `BvhSubtree` - The bounds, primitive range and nesting level of the +/// node to emit. +fn build_bvh_subtree(context: &mut BvhBuildContext, subtree: BvhSubtree) { + let root: usize = context.nodes.len(); + let bounds: (Vector3D, Vector3D) = subtree.bounds; + let range: BvhRange = subtree.range; + let too_small_to_split: bool = range.count <= RAYTRACE_BVH_LEAF_SIZE || range.count < 2; + if too_small_to_split || subtree.depth >= RAYTRACE_BVH_MAX_DEPTH { + context.nodes.push(RayTraceNode { lower: bounds.0, upper: bounds.1, - start, - count, + start: range.start, + count: range.count, escape: root + 1, }); return; } - let (axis, boundary): (usize, f64) = match choose_sah_split( - indices, + let build: &BvhBuild = context.build; + let split: SahSplit = match choose_sah_split( + context.indices, build, - start, - count, + range, aabb_surface_area(bounds.0, bounds.1), ) { Some(chosen) => chosen, - None => longest_centroid_axis(indices, build, start, count), + None => longest_centroid_axis(context.indices, build, range), }; - let partitioned: usize = partition_range(indices, build, start, count, axis, boundary); - let left_count: usize = if partitioned == 0 || partitioned == count { - count / 2 + let partitioned: usize = partition_range(context.indices, build, range, split); + let left_count: usize = if partitioned == 0 || partitioned == range.count { + range.count / 2 } else { partitioned }; - nodes.push(RayTraceNode { + context.nodes.push(RayTraceNode { lower: bounds.0, upper: bounds.1, - start, + start: range.start, count: 0, escape: root + 1, }); - let left_bounds: (Vector3D, Vector3D) = bounds_of_range(indices, build, start, left_count); + let left_range: BvhRange = BvhRange { + start: range.start, + count: left_count, + }; + let left_bounds: (Vector3D, Vector3D) = bounds_of_range(context.indices, build, left_range); build_bvh_subtree( - nodes, - indices, - build, - left_bounds, - start, - left_count, - depth + 1, + context, + BvhSubtree { + bounds: left_bounds, + range: left_range, + depth: subtree.depth + 1, + }, ); - let right_bounds: (Vector3D, Vector3D) = - bounds_of_range(indices, build, start + left_count, count - left_count); + let right_range: BvhRange = BvhRange { + start: range.start + left_count, + count: range.count - left_count, + }; + let right_bounds: (Vector3D, Vector3D) = bounds_of_range(context.indices, build, right_range); build_bvh_subtree( - nodes, - indices, - build, - right_bounds, - start + left_count, - count - left_count, - depth + 1, + context, + BvhSubtree { + bounds: right_bounds, + range: right_range, + depth: subtree.depth + 1, + }, ); - nodes[root].escape = nodes.len(); + context.nodes[root].escape = context.nodes.len(); } /// Builds the flat BVH for a list of occluders. @@ -928,9 +880,22 @@ pub(crate) fn build_bvh(occluders: &[Occluder]) -> (Vec, Vec Option { /// /// # Arguments /// -/// - `&Ray` - The ray being traced. +/// - `&Ray` - The ray being traced, carrying origin, direction and range. /// - `&[Occluder]` - Every primitive in the scene. -/// - `Vector3D` - The validated ray direction. /// /// # Returns /// @@ -1005,13 +969,12 @@ fn traceable_direction_of(ray: &Ray) -> Option { fn linear_closest_hit( ray: &Ray, occluders: &[Occluder], - dir: Vector3D, ) -> Option<(usize, f64, Vector3D, Vector3D)> { - let t_min: f64 = ray.get_t_min(); + let origin: Vector3D = ray.get_origin(); + let dir: Vector3D = ray.get_direction(); let mut best: Option<(usize, f64, Vector3D, Vector3D)> = None; for (index, occluder) in occluders.iter().enumerate() { - let hit: Option<(f64, Vector3D)> = - intersect_occluder_in_range(occluder, ray.get_origin(), dir, t_min, ray.get_t_max()); + let hit: Option<(f64, Vector3D)> = intersect_occluder_in_range(occluder, ray); let Some((t, normal)) = hit else { continue; }; @@ -1022,7 +985,7 @@ fn linear_closest_hit( } }; if nearer { - best = Some((index, t, ray.get_origin() + dir.scaled(t), normal)); + best = Some((index, t, origin + dir.scaled(t), normal)); } } best @@ -1032,24 +995,16 @@ fn linear_closest_hit( /// /// # Arguments /// -/// - `&Ray` - The ray being tested. +/// - `&Ray` - The ray being tested, carrying origin, direction and range. /// - `&[Occluder]` - Every primitive in the scene. -/// - `Vector3D` - The validated ray direction. /// /// # Returns /// /// - `bool` - Whether any primitive blocks the ray. -fn linear_occluded(ray: &Ray, occluders: &[Occluder], dir: Vector3D) -> bool { - occluders.iter().any(|occluder: &Occluder| { - intersect_occluder_in_range( - occluder, - ray.get_origin(), - dir, - ray.get_t_min(), - ray.get_t_max(), - ) - .is_some() - }) +fn linear_occluded(ray: &Ray, occluders: &[Occluder]) -> bool { + occluders + .iter() + .any(|occluder: &Occluder| intersect_occluder_in_range(occluder, ray).is_some()) } /// Finds the closest intersection between a ray and a scene's BVH without @@ -1076,16 +1031,19 @@ pub(crate) fn closest_hit_indexed( ray: &Ray, scene: &RayTraceScene, ) -> Option<(usize, f64, Vector3D, Vector3D)> { - let dir: Vector3D = traceable_direction_of(ray)?; + // The direction is validated once here rather than threaded through the + // traversal: every node test re-reads it from `ray` unchanged, so no + // rescaled copy can drift away from the parameterisation `Ray::at` + // reconstructs points in. + traceable_direction_of(ray)?; let nodes: &[RayTraceNode] = scene.get_nodes(); if nodes.is_empty() { return None; } let occluders: &[Occluder] = scene.get_occluders(); if occluders.len() <= RAYTRACE_LINEAR_MAX_PRIMITIVES { - return linear_closest_hit(ray, occluders, dir); + return linear_closest_hit(ray, occluders); } - let occluders: &[Occluder] = scene.get_occluders(); let indices: &[usize] = scene.get_indices(); let origin: Vector3D = ray.get_origin(); let t_min: f64 = ray.get_t_min(); @@ -1095,17 +1053,11 @@ pub(crate) fn closest_hit_indexed( let mut found: bool = false; let mut stack: [usize; RAYTRACE_BVH_STACK_SIZE] = [0usize; RAYTRACE_BVH_STACK_SIZE]; let root: &RayTraceNode = &nodes[0]; + let dir: Vector3D = ray.get_direction(); // The root's entry parameter is only a probe: a root the ray misses means // the whole subtree is out of range, so `?` on the discarded value is the // early return clippy asks for. - let _root_entry: f64 = aabb_entry_parameter( - origin, - dir, - root.get_lower(), - root.get_upper(), - t_min, - best_t, - )?; + let _root_entry: f64 = aabb_entry_parameter(ray, root.get_lower(), root.get_upper())?; stack[0] = 0usize; let mut depth: usize = 1; while depth > 0 { @@ -1117,22 +1069,11 @@ pub(crate) fn closest_hit_indexed( let right_index: usize = nodes[left_index].get_escape(); let left: &RayTraceNode = &nodes[left_index]; let right: &RayTraceNode = &nodes[right_index]; - let left_entry: Option = aabb_entry_parameter( - origin, - dir, - left.get_lower(), - left.get_upper(), - t_min, - best_t, - ); - let right_entry: Option = aabb_entry_parameter( - origin, - dir, - right.get_lower(), - right.get_upper(), - t_min, - best_t, - ); + let probe: Ray = ray.with_range(t_min, best_t); + let left_entry: Option = + aabb_entry_parameter(&probe, left.get_lower(), left.get_upper()); + let right_entry: Option = + aabb_entry_parameter(&probe, right.get_lower(), right.get_upper()); match (left_entry, right_entry) { (None, None) => {} (Some(_), None) => { @@ -1162,10 +1103,11 @@ pub(crate) fn closest_hit_indexed( continue; } let start: usize = node.get_start(); + let mut probe: Ray = ray.with_range(t_min, best_t); for slot in 0..node.get_count() { let primitive: usize = indices[start + slot]; let hit: Option<(f64, Vector3D)> = - intersect_occluder_in_range(&occluders[primitive], origin, dir, t_min, best_t); + intersect_occluder_in_range(&occluders[primitive], &probe); if let Some((t, normal)) = hit { let nearer: bool = t < best_t; let stable_tie: bool = t == best_t && (!found || primitive < best_index); @@ -1174,6 +1116,7 @@ pub(crate) fn closest_hit_indexed( best_index = primitive; best_normal = normal; found = true; + probe = ray.with_range(t_min, best_t); } } } @@ -1199,36 +1142,21 @@ pub(crate) fn closest_hit_indexed( /// /// - `bool` - `true` when at least one occluder intersects the range. pub(crate) fn occluded_indexed(ray: &Ray, scene: &RayTraceScene) -> bool { - let untraceable: Option = traceable_direction_of(ray); - let dir: Vector3D = match untraceable { - Some(direction) => direction, - None => return false, - }; + if traceable_direction_of(ray).is_none() { + return false; + } let occluders: &[Occluder] = scene.get_occluders(); if occluders.len() <= RAYTRACE_LINEAR_MAX_PRIMITIVES { - return linear_occluded(ray, occluders, dir); + return linear_occluded(ray, occluders); } let nodes: &[RayTraceNode] = scene.get_nodes(); if nodes.is_empty() { return false; } - let occluders: &[Occluder] = scene.get_occluders(); let indices: &[usize] = scene.get_indices(); - let origin: Vector3D = ray.get_origin(); - let t_min: f64 = ray.get_t_min(); - let t_max: f64 = ray.get_t_max(); let mut stack: [usize; RAYTRACE_BVH_STACK_SIZE] = [0usize; RAYTRACE_BVH_STACK_SIZE]; let root: &RayTraceNode = &nodes[0]; - if aabb_entry_parameter( - origin, - dir, - root.get_lower(), - root.get_upper(), - t_min, - t_max, - ) - .is_none() - { + if aabb_entry_parameter(ray, root.get_lower(), root.get_upper()).is_none() { return false; } stack[0] = 0usize; @@ -1242,14 +1170,8 @@ pub(crate) fn occluded_indexed(ray: &Ray, scene: &RayTraceScene) -> bool { let right_index: usize = nodes[left_index].get_escape(); for child_index in [left_index, right_index] { let child: &RayTraceNode = &nodes[child_index]; - let entry: Option = aabb_entry_parameter( - origin, - dir, - child.get_lower(), - child.get_upper(), - t_min, - t_max, - ); + let entry: Option = + aabb_entry_parameter(ray, child.get_lower(), child.get_upper()); if entry.is_some() { stack[depth] = child_index; depth += 1; @@ -1260,9 +1182,7 @@ pub(crate) fn occluded_indexed(ray: &Ray, scene: &RayTraceScene) -> bool { let start: usize = node.get_start(); for slot in 0..node.get_count() { let primitive: usize = indices[start + slot]; - let blocked: bool = - intersect_occluder_in_range(&occluders[primitive], origin, dir, t_min, t_max) - .is_some(); + let blocked: bool = intersect_occluder_in_range(&occluders[primitive], ray).is_some(); if blocked { return true; } diff --git a/engine/src/raytracing/scene/impl.rs b/engine/src/raytracing/scene/impl.rs index b8ce41ad..6b73d08e 100644 --- a/engine/src/raytracing/scene/impl.rs +++ b/engine/src/raytracing/scene/impl.rs @@ -83,31 +83,26 @@ impl Ray { } } - /// Intersects this ray with the triangle `v0`, `v1`, `v2` using the - /// Moller-Trumbore algorithm, keeping only hits inside this ray's - /// `t_min`..=`t_max` range. + /// Intersects this ray with a triangle using the Moller-Trumbore + /// algorithm, keeping only hits inside this ray's `t_min`..=`t_max` + /// range. /// /// The test is two-sided: a hit from either face counts, and the - /// returned normal always faces against the ray direction. + /// returned normal always faces against the ray direction. This is the + /// range-clamped counterpart of the free + /// [`intersect_triangle`](crate::intersect_triangle), which reports the + /// raw distance however far away the triangle is. /// /// # Arguments /// - /// - `Vector3D` - The first triangle vertex. - /// - `Vector3D` - The second triangle vertex. - /// - `Vector3D` - The third triangle vertex. + /// - `[Vector3D; 3]` - The three triangle vertices in winding order. /// /// # Returns /// /// - `Option<(f64, Vector3D)>` - The hit distance along the ray and the /// unit normal oriented against the ray direction, or `None` on miss. - pub fn intersect_triangle( - &self, - v0: Vector3D, - v1: Vector3D, - v2: Vector3D, - ) -> Option<(f64, Vector3D)> { - let (t, normal): (f64, Vector3D) = - intersect_triangle(self.get_origin(), self.get_direction(), v0, v1, v2)?; + pub fn intersect_triangle(&self, vertices: [Vector3D; 3]) -> Option<(f64, Vector3D)> { + let (t, normal): (f64, Vector3D) = intersect_triangle(self, vertices)?; if t < self.get_t_min() || t > self.get_t_max() { return None; } diff --git a/engine/src/raytracing/scene/mod.rs b/engine/src/raytracing/scene/mod.rs index cf8b81f1..fa44afe8 100644 --- a/engine/src/raytracing/scene/mod.rs +++ b/engine/src/raytracing/scene/mod.rs @@ -10,6 +10,8 @@ pub(crate) use r#const::*; pub(crate) use r#fn::{ build_bvh, closest_hit_indexed, collect_occluder_points, occluded_indexed, trace_bounces, }; -pub(crate) use r#struct::{BvhBuild, RayTraceNode}; +pub(crate) use r#struct::{ + BvhBuild, BvhBuildContext, BvhRange, BvhSubtree, RayTraceNode, SahSplit, +}; use super::*; diff --git a/engine/src/raytracing/scene/struct.rs b/engine/src/raytracing/scene/struct.rs index 804d06bb..1b7134c3 100644 --- a/engine/src/raytracing/scene/struct.rs +++ b/engine/src/raytracing/scene/struct.rs @@ -145,6 +145,65 @@ pub(crate) struct BvhBuild { pub(crate) centroids: Vec, } +/// A half-open slice of the permuted primitive index array that one BVH +/// node owns. +/// +/// The named start and count fields are shared by measurement, partition, +/// and split helpers. +#[derive(Clone, Copy, Debug)] +pub(crate) struct BvhRange { + /// The first entry of the range in the primitive index array. + pub(crate) start: usize, + /// The number of entries in the range. + pub(crate) count: usize, +} + +/// The single node one `build_bvh_subtree` call is responsible for +/// emitting. +/// +/// The bounds, the primitive range and the nesting level describe which +/// subtree is being built and at what depth, which is distinct from the +/// mutable arrays every step shares. +#[derive(Clone, Copy, Debug)] +pub(crate) struct BvhSubtree { + /// The `(min, max)` corners covering this subtree. + pub(crate) bounds: (Vector3D, Vector3D), + /// The primitives this subtree owns. + pub(crate) range: BvhRange, + /// The nesting level of this subtree, counting from zero at the root. + pub(crate) depth: usize, +} + +/// One centroid axis and the position on it where a range is cut. +/// +/// The surface-area search chooses a split and the fallback median split +/// picks one too, so both hand the same pair of values to the partition +/// step instead of it re-reading two loose arguments. +#[derive(Clone, Copy, Debug)] +pub(crate) struct SahSplit { + /// `0` for `x`, `1` for `y`, `2` for `z`. + pub(crate) axis: usize, + /// The split position on that axis. + pub(crate) boundary: f64, +} + +/// The mutable build state that every BVH construction step shares. +/// +/// The node array under construction and the primitive index array it +/// permutes have to be threaded through every helper, including both sides +/// of every recursive call. Holding them behind one borrowed context keeps +/// each helper's remaining parameters to the decisions that helper alone +/// makes. Constructing the context does not allocate additional storage. +#[derive(Debug)] +pub(crate) struct BvhBuildContext<'a> { + /// The preorder node array under construction. + pub(crate) nodes: &'a mut Vec, + /// The primitive index array, partitioned in place. + pub(crate) indices: &'a mut [usize], + /// The per-primitive bounds and centroids the split search reads. + pub(crate) build: &'a BvhBuild, +} + /// An owned ray-tracing scene with a precomputed bounding volume /// hierarchy and shadow data. /// diff --git a/engine/tests/lighting/fn.rs b/engine/tests/lighting/fn.rs index 9004ab5f..3fb532bc 100644 --- a/engine/tests/lighting/fn.rs +++ b/engine/tests/lighting/fn.rs @@ -170,6 +170,62 @@ fn soft_shadow_no_occluder_returns_one() { ); } +#[test] +fn soft_shadow_tests_both_ray_sphere_roots_so_a_segment_starting_inside_an_occluder_is_shadowed() { + let origin: Vector3D = Vector3D::zero(); + let light_pos: Vector3D = Vector3D::new(0.0, 0.0, 10.0); + let radius: f64 = 1.0; + let enclosing: [(Vector3D, f64); 1] = [(Vector3D::new(0.0, 0.0, 0.5), radius)]; + let from_inside: f64 = soft_shadow_factor(origin, light_pos, &enclosing); + assert_eq!( + from_inside, 0.0, + "the origin is inside the sphere: the near root is -0.5 and the far root is 1.5, so only the far root lies inside the light segment" + ); + let clear_of_it: [(Vector3D, f64); 1] = [(Vector3D::new(5.0, 0.0, 5.0), radius)]; + let beside: f64 = soft_shadow_factor(origin, light_pos, &clear_of_it); + assert!( + (beside - 1.0).abs() < EPSILON, + "a segment passing through empty space beside the occluder must stay visible, got {beside}" + ); +} + +#[test] +fn soft_shadow_ignores_a_sphere_the_segment_only_touches_at_an_endpoint() { + let origin: Vector3D = Vector3D::zero(); + let light_pos: Vector3D = Vector3D::new(0.0, 0.0, 10.0); + let radius: f64 = 1.0; + let behind_the_surface: [(Vector3D, f64); 1] = [(Vector3D::new(0.0, 0.0, -radius), radius)]; + let touched_at_the_start: f64 = soft_shadow_factor(origin, light_pos, &behind_the_surface); + assert!( + (touched_at_the_start - 1.0).abs() < EPSILON, + "the segment's open interval starts at EPSILON, so a sphere only tangent at the shaded point is not an occluder, got {touched_at_the_start}" + ); + let beyond_the_light: [(Vector3D, f64); 1] = [(Vector3D::new(0.0, 0.0, 10.0 + radius), radius)]; + let touched_at_the_end: f64 = soft_shadow_factor(origin, light_pos, &beyond_the_light); + assert!( + (touched_at_the_end - 1.0).abs() < EPSILON, + "the open interval ends at distance - EPSILON, so a sphere only tangent at the lamp is not an occluder, got {touched_at_the_end}" + ); + let midway: [(Vector3D, f64); 1] = [(Vector3D::new(0.0, 0.0, 5.0), radius)]; + let blocked: f64 = soft_shadow_factor(origin, light_pos, &midway); + assert_eq!( + blocked, 0.0, + "the same sphere placed strictly inside the segment must block, so the endpoint cases above are excluded by the open interval and not by a discarded sphere" + ); +} + +#[test] +fn soft_shadow_treats_a_coincident_light_as_visible() { + let origin: Vector3D = Vector3D::zero(); + let on_the_lamp: Vector3D = Vector3D::new(0.0, 0.0, 0.0); + let enclosing: [(Vector3D, f64); 1] = [(Vector3D::new(0.0, 0.0, 1.0), 2.0)]; + let degenerate: f64 = soft_shadow_factor(origin, on_the_lamp, &enclosing); + assert!( + (degenerate - 1.0).abs() < EPSILON, + "a light at the shaded point defines no segment, so there is nothing to occlude and the point must read as lit, got {degenerate}" + ); +} + fn lighting_setup() -> LightingUniforms { let mut uniforms: LightingUniforms = LightingUniforms::with_eye(Vector3D::new(0.0, 0.0, 10.0)); uniforms.add_light(Light::new_directional( diff --git a/engine/tests/mod.rs b/engine/tests/mod.rs index 644e0b6f..32c15fcf 100644 --- a/engine/tests/mod.rs +++ b/engine/tests/mod.rs @@ -85,6 +85,7 @@ use std::{ f64::consts::{self, FRAC_PI_2, PI, TAU}, fmt::{self, Debug, Write}, future::{Future, Ready, ready}, + ptr::eq, rc::{Rc, Weak}, sync::OnceLock, }; diff --git a/engine/tests/raypipeline/fn.rs b/engine/tests/raypipeline/fn.rs index 688c6a87..ce6c25f1 100644 --- a/engine/tests/raypipeline/fn.rs +++ b/engine/tests/raypipeline/fn.rs @@ -252,7 +252,16 @@ fn frame_header_layout_and_light_records() { let lights: LightingUniforms = lit_scene(); let settings: RayTraceSettings = RayTraceSettings::with_quality(2, 3); let mut packed: Vec = Vec::new(); - if let Err(reason) = pack_raytrace_frame(&mut packed, &camera, &lights, &settings, 32, 16) { + if let Err(reason) = pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 32, + height: 16, + }, + ) { panic!("frame must pack: {reason}"); } assert_eq!( @@ -325,54 +334,144 @@ fn frame_rejects_invalid_camera_and_dimensions() { let settings: RayTraceSettings = RayTraceSettings::new_default(); let mut packed: Vec = Vec::new(); assert!( - pack_raytrace_frame(&mut packed, &camera(), &lights, &settings, 0, 16).is_err(), + pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: &camera(), + lights: &lights, + settings: &settings, + width: 0, + height: 16, + } + ) + .is_err(), "a zero width must be rejected" ); assert!( - pack_raytrace_frame(&mut packed, &camera(), &lights, &settings, 16, 0).is_err(), + pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: &camera(), + lights: &lights, + settings: &settings, + width: 16, + height: 0, + } + ) + .is_err(), "a zero height must be rejected" ); let mut degenerate: Camera3D = camera(); degenerate.set_target(degenerate.get_position()); assert!( - pack_raytrace_frame(&mut packed, °enerate, &lights, &settings, 16, 16).is_err(), + pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: °enerate, + lights: &lights, + settings: &settings, + width: 16, + height: 16, + } + ) + .is_err(), "a zero forward vector must be rejected" ); let mut parallel: Camera3D = camera(); let forward: Vector3D = parallel.forward(); parallel.set_up(forward); assert!( - pack_raytrace_frame(&mut packed, ¶llel, &lights, &settings, 16, 16).is_err(), + pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: ¶llel, + lights: &lights, + settings: &settings, + width: 16, + height: 16, + } + ) + .is_err(), "an up vector parallel to forward must be rejected" ); let mut wide: Camera3D = camera(); wide.set_fov(4.0); assert!( - pack_raytrace_frame(&mut packed, &wide, &lights, &settings, 16, 16).is_err(), + pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: &wide, + lights: &lights, + settings: &settings, + width: 16, + height: 16, + } + ) + .is_err(), "an fov at or beyond PI must be rejected" ); let mut inverted: Camera3D = camera(); inverted.set_near(inverted.get_far()); assert!( - pack_raytrace_frame(&mut packed, &inverted, &lights, &settings, 16, 16).is_err(), + pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: &inverted, + lights: &lights, + settings: &settings, + width: 16, + height: 16, + } + ) + .is_err(), "a near plane at or beyond the far plane must be rejected" ); let mut negative: Camera3D = camera(); negative.set_near(-1.0); assert!( - pack_raytrace_frame(&mut packed, &negative, &lights, &settings, 16, 16).is_err(), + pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: &negative, + lights: &lights, + settings: &settings, + width: 16, + height: 16, + } + ) + .is_err(), "a negative near plane must be rejected" ); let mut unbounded: Camera3D = camera(); unbounded.set_far(f64::MAX); assert!( - pack_raytrace_frame(&mut packed, &unbounded, &lights, &settings, 16, 16).is_err(), + pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: &unbounded, + lights: &lights, + settings: &settings, + width: 16, + height: 16, + } + ) + .is_err(), "a far plane beyond the f32 range must be rejected, not written as infinity" ); let mut beyond: Camera3D = camera(); beyond.set_position(Vector3D::new(f64::NAN, 0.0, 0.0)); assert!( - pack_raytrace_frame(&mut packed, &beyond, &lights, &settings, 16, 16).is_err(), + pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: &beyond, + lights: &lights, + settings: &settings, + width: 16, + height: 16, + } + ) + .is_err(), "a non-finite eye must be rejected" ); } @@ -382,11 +481,29 @@ fn frame_reuses_its_buffer() { let lights: LightingUniforms = lit_scene(); let settings: RayTraceSettings = RayTraceSettings::new_default(); let mut packed: Vec = Vec::new(); - if let Err(reason) = pack_raytrace_frame(&mut packed, &camera(), &lights, &settings, 8, 8) { + if let Err(reason) = pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: &camera(), + lights: &lights, + settings: &settings, + width: 8, + height: 8, + }, + ) { panic!("frame must pack: {reason}"); } let capacity: usize = packed.capacity(); - if let Err(reason) = pack_raytrace_frame(&mut packed, &camera(), &lights, &settings, 8, 8) { + if let Err(reason) = pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: &camera(), + lights: &lights, + settings: &settings, + width: 8, + height: 8, + }, + ) { panic!("frame must pack: {reason}"); } assert_eq!( @@ -396,6 +513,222 @@ fn frame_reuses_its_buffer() { ); } +#[test] +fn frame_struct_carries_its_own_inputs() { + let camera: Camera3D = camera(); + let lights: LightingUniforms = lit_scene(); + let settings: RayTraceSettings = RayTraceSettings::with_quality(2, 3); + let frame: RayTraceFrame<'_> = RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 40, + height: 10, + }; + assert!( + eq(frame.get_camera(), &camera), + "the frame must hand back the very camera it was built from" + ); + assert!( + eq(frame.get_lights(), &lights), + "the frame must hand back the very lights it was built from" + ); + assert!( + eq(frame.get_settings(), &settings), + "the frame must hand back the very settings it was built from" + ); + assert_eq!(frame.get_width(), 40, "width must survive the struct"); + assert_eq!(frame.get_height(), 10, "height must survive the struct"); +} + +#[test] +fn frame_packs_a_non_square_dimensions_pair_into_its_own_slots() { + let camera: Camera3D = camera(); + let lights: LightingUniforms = lit_scene(); + let settings: RayTraceSettings = RayTraceSettings::with_quality(3, 5); + let mut wide: Vec = Vec::new(); + if let Err(reason) = pack_raytrace_frame( + &mut wide, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 40, + height: 10, + }, + ) { + panic!("frame must pack: {reason}"); + } + assert_eq!(wide[11], 40.0, "slot 2.w must carry width, not the height"); + assert_eq!(wide[15], 10.0, "slot 3.w must carry height, not the width"); + assert!( + (wide[7] as f64 - 4.0).abs() < 1e-6, + "slot 1.w must carry the 40 / 10 aspect, got {}", + wide[7] + ); + assert_eq!(wide[23], 5.0, "slot 5.w must carry max_bounces"); + assert_eq!(wide[26], 3.0, "slot 6.z must carry samples_per_axis"); + let mut tall: Vec = Vec::new(); + if let Err(reason) = pack_raytrace_frame( + &mut tall, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 10, + height: 40, + }, + ) { + panic!("frame must pack: {reason}"); + } + assert_eq!(tall[11], 10.0, "the transposed width lands in slot 2.w"); + assert_eq!(tall[15], 40.0, "the transposed height lands in slot 3.w"); + assert!( + (tall[7] as f64 - 0.25).abs() < 1e-6, + "the transposed aspect must be the reciprocal, got {}", + tall[7] + ); + assert_ne!( + wide, tall, + "swapping width and height must not produce the same header" + ); +} + +#[test] +fn frame_packs_the_camera_lights_and_settings_it_was_given() { + let lights: LightingUniforms = lit_scene(); + let quiet: RayTraceSettings = RayTraceSettings::with_quality(1, 0); + let bright: RayTraceSettings = RayTraceSettings::with_quality(2, 2); + let low: Camera3D = + Camera3D::create(Vector3D::new(0.0, 0.0, 2.0), Vector3D::zero(), 64.0, 64.0); + let far: Camera3D = + Camera3D::create(Vector3D::new(0.0, 0.0, 9.0), Vector3D::zero(), 64.0, 64.0); + let mut near_frame: Vec = Vec::new(); + if let Err(reason) = pack_raytrace_frame( + &mut near_frame, + &RayTraceFrame { + camera: &low, + lights: &lights, + settings: &quiet, + width: 8, + height: 8, + }, + ) { + panic!("frame must pack: {reason}"); + } + let mut far_frame: Vec = Vec::new(); + if let Err(reason) = pack_raytrace_frame( + &mut far_frame, + &RayTraceFrame { + camera: &far, + lights: &lights, + settings: &bright, + width: 8, + height: 8, + }, + ) { + panic!("frame must pack: {reason}"); + } + assert!( + (near_frame[2] as f64 - 2.0).abs() < 1e-6, + "slot 0.z must carry the first camera's eye depth, got {}", + near_frame[2] + ); + assert!( + (far_frame[2] as f64 - 9.0).abs() < 1e-6, + "slot 0.z must carry the second camera's eye depth, got {}", + far_frame[2] + ); + assert_eq!(near_frame[23], 0.0, "the quiet frame carries zero bounces"); + assert_eq!(far_frame[23], 2.0, "the bright frame carries its bounces"); + assert_eq!( + near_frame[26], 1.0, + "the quiet frame carries one sample axis" + ); + assert_eq!( + far_frame[26], 2.0, + "the bright frame carries its sample axis" + ); + let light_count: usize = near_frame[19] as usize; + assert_eq!( + near_frame[19], far_frame[19], + "both frames read the same lights, so light_count must match" + ); + assert!( + light_count > 0, + "the fixture must declare at least one light" + ); + let lit_ambient: [f32; 3] = [near_frame[16], near_frame[17], near_frame[18]]; + assert!( + lit_ambient.iter().all(|value: &f32| *value > 0.0), + "slot 4.xyz must carry the frame's ambient, got {lit_ambient:?}" + ); +} + +#[test] +fn frame_rejects_invalid_dimensions_without_touching_the_buffer() { + let camera: Camera3D = camera(); + let lights: LightingUniforms = lit_scene(); + let settings: RayTraceSettings = RayTraceSettings::new_default(); + let good: RayTraceFrame<'_> = RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 8, + height: 8, + }; + let mut packed: Vec = Vec::new(); + if let Err(reason) = pack_raytrace_frame(&mut packed, &good) { + panic!("frame must pack: {reason}"); + } + let kept: Vec = packed.clone(); + let zero_width: RayTraceFrame<'_> = RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 0, + height: 8, + }; + let zero_height: RayTraceFrame<'_> = RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 8, + height: 0, + }; + let bad_settings: RayTraceFrame<'_> = RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &RayTraceSettings::with_quality(9, 1), + width: 8, + height: 8, + }; + assert!( + pack_raytrace_frame(&mut packed, &zero_width).is_err(), + "a zero width must be rejected" + ); + assert_eq!( + packed, kept, + "a rejected width must leave the buffer as it was" + ); + assert!( + pack_raytrace_frame(&mut packed, &zero_height).is_err(), + "a zero height must be rejected" + ); + assert_eq!( + packed, kept, + "a rejected height must leave the buffer as it was" + ); + assert!( + pack_raytrace_frame(&mut packed, &bad_settings).is_err(), + "an invalid sample grid must be rejected" + ); + assert_eq!( + packed, kept, + "a rejected settings block must leave the buffer as it was" + ); +} + #[test] fn render_rejects_invalid_dimensions_and_settings() { let scene: RayTraceScene = mixed_scene(); @@ -404,11 +737,13 @@ fn render_rejects_invalid_dimensions_and_settings() { assert!( render_raytrace_rgba( &scene, - &camera(), - &lights, - &RayTraceSettings::new_default(), - 0, - 8, + &RayTraceFrame { + camera: &camera(), + lights: &lights, + settings: &RayTraceSettings::new_default(), + width: 0, + height: 8, + }, &mut out ) .is_err(), @@ -417,11 +752,13 @@ fn render_rejects_invalid_dimensions_and_settings() { assert!( render_raytrace_rgba( &scene, - &camera(), - &lights, - &RayTraceSettings::with_quality(9, 1), - 8, - 8, + &RayTraceFrame { + camera: &camera(), + lights: &lights, + settings: &RayTraceSettings::with_quality(9, 1), + width: 8, + height: 8, + }, &mut out ) .is_err(), @@ -438,11 +775,13 @@ fn render_empty_scene_is_ambient_everywhere() { let mut out: Vec = Vec::new(); if let Err(reason) = render_raytrace_rgba( &scene, - &camera, - &lights, - &RayTraceSettings::new_default(), - 4, - 3, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &RayTraceSettings::new_default(), + width: 4, + height: 3, + }, &mut out, ) { panic!("empty scene must render: {reason}"); @@ -465,15 +804,33 @@ fn render_reuses_its_output_buffer() { let settings: RayTraceSettings = RayTraceSettings::new_default(); let mut out: Vec = Vec::new(); for _ in 0..2 { - if let Err(reason) = - render_raytrace_rgba(&scene, &camera, &lights, &settings, 6, 5, &mut out) - { + if let Err(reason) = render_raytrace_rgba( + &scene, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 6, + height: 5, + }, + &mut out, + ) { panic!("frame must render: {reason}"); } } assert_eq!(out.len(), 6 * 5 * 4); let capacity: usize = out.capacity(); - if let Err(reason) = render_raytrace_rgba(&scene, &camera, &lights, &settings, 6, 5, &mut out) { + if let Err(reason) = render_raytrace_rgba( + &scene, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 6, + height: 5, + }, + &mut out, + ) { panic!("frame must render: {reason}"); } assert_eq!( @@ -483,6 +840,78 @@ fn render_reuses_its_output_buffer() { ); } +#[test] +fn render_reuses_its_output_buffer_across_frame_shapes() { + let scene: RayTraceScene = mixed_scene(); + let lights: LightingUniforms = lit_scene(); + let camera: Camera3D = camera(); + let settings: RayTraceSettings = RayTraceSettings::new_default(); + let mut out: Vec = Vec::new(); + if let Err(reason) = render_raytrace_rgba( + &scene, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 9, + height: 4, + }, + &mut out, + ) { + panic!("wide frame must render: {reason}"); + } + assert_eq!( + out.len(), + 9 * 4 * 4, + "a wide frame writes one row per scanline" + ); + let kept: Vec = out.clone(); + if let Err(reason) = render_raytrace_rgba( + &scene, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 4, + height: 9, + }, + &mut out, + ) { + panic!("tall frame must render: {reason}"); + } + assert_eq!( + out.len(), + 4 * 9 * 4, + "resizing the frame must resize the reused buffer, not append to it" + ); + assert_ne!( + out, kept, + "a transposed frame shape must not reuse the old image" + ); + let tall: Vec = out.clone(); + assert!( + render_raytrace_rgba( + &scene, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 4, + height: 0, + }, + &mut out, + ) + .is_err(), + "a zero height must be rejected" + ); + assert_eq!( + out.len(), + 4 * 9 * 4, + "a rejected frame must leave the previous image in the buffer" + ); + assert_eq!(out, tall, "a rejected frame must preserve every image byte"); +} + #[test] fn render_is_deterministic_across_repeated_frames() { let scene: RayTraceScene = mixed_scene(); @@ -491,13 +920,30 @@ fn render_is_deterministic_across_repeated_frames() { let settings: RayTraceSettings = RayTraceSettings::with_quality(2, 2); let mut first: Vec = Vec::new(); let mut second: Vec = Vec::new(); - if let Err(reason) = render_raytrace_rgba(&scene, &camera, &lights, &settings, 8, 6, &mut first) - { + if let Err(reason) = render_raytrace_rgba( + &scene, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 8, + height: 6, + }, + &mut first, + ) { panic!("frame must render: {reason}"); } - if let Err(reason) = - render_raytrace_rgba(&scene, &camera, &lights, &settings, 8, 6, &mut second) - { + if let Err(reason) = render_raytrace_rgba( + &scene, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width: 8, + height: 6, + }, + &mut second, + ) { panic!("frame must render: {reason}"); } assert_eq!(first, second, "the same inputs must produce the same frame"); @@ -510,11 +956,13 @@ fn render_produces_distinct_pixels_for_a_mixed_scene() { let mut out: Vec = Vec::new(); if let Err(reason) = render_raytrace_rgba( &scene, - &camera(), - &lights, - &RayTraceSettings::new_default(), - 24, - 18, + &RayTraceFrame { + camera: &camera(), + lights: &lights, + settings: &RayTraceSettings::new_default(), + width: 24, + height: 18, + }, &mut out, ) { panic!("frame must render: {reason}"); @@ -551,15 +999,30 @@ fn render_matches_per_pixel_scene_trace() { let width: u32 = 16; let height: u32 = 12; let mut out: Vec = Vec::new(); - if let Err(reason) = - render_raytrace_rgba(&scene, &camera, &lights, &settings, width, height, &mut out) - { + if let Err(reason) = render_raytrace_rgba( + &scene, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width, + height, + }, + &mut out, + ) { panic!("frame must render: {reason}"); } let mut packed: Vec = Vec::new(); - if let Err(reason) = - pack_raytrace_frame(&mut packed, &camera, &lights, &settings, width, height) - { + if let Err(reason) = pack_raytrace_frame( + &mut packed, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &settings, + width, + height, + }, + ) { panic!("frame must pack: {reason}"); } let eye: &[f32] = &packed[0..4]; @@ -613,22 +1076,26 @@ fn supersampling_refines_the_image() { let mut fine: Vec = Vec::new(); if let Err(reason) = render_raytrace_rgba( &scene, - &camera, - &lights, - &RayTraceSettings::with_quality(1, 2), - 20, - 16, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &RayTraceSettings::with_quality(1, 2), + width: 20, + height: 16, + }, &mut coarse, ) { panic!("frame must render: {reason}"); } if let Err(reason) = render_raytrace_rgba( &scene, - &camera, - &lights, - &RayTraceSettings::with_quality(4, 2), - 20, - 16, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &RayTraceSettings::with_quality(4, 2), + width: 20, + height: 16, + }, &mut fine, ) { panic!("frame must render: {reason}"); @@ -659,11 +1126,13 @@ fn zero_bounces_still_shades_the_primary_hit() { let mut out: Vec = Vec::new(); if let Err(reason) = render_raytrace_rgba( &emissive, - &camera, - &lights, - &RayTraceSettings::with_quality(1, 0), - 16, - 16, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &RayTraceSettings::with_quality(1, 0), + width: 16, + height: 16, + }, &mut out, ) { panic!("frame must render: {reason}"); @@ -677,11 +1146,13 @@ fn zero_bounces_still_shades_the_primary_hit() { let mut lit_scene_frame: Vec = Vec::new(); if let Err(reason) = render_raytrace_rgba( &scene, - &camera, - &lights, - &RayTraceSettings::with_quality(1, 0), - 16, - 16, + &RayTraceFrame { + camera: &camera, + lights: &lights, + settings: &RayTraceSettings::with_quality(1, 0), + width: 16, + height: 16, + }, &mut lit_scene_frame, ) { panic!("frame must render: {reason}"); diff --git a/engine/tests/raytracing/bvh.rs b/engine/tests/raytracing/bvh.rs index 91228ccd..f1e80449 100644 --- a/engine/tests/raytracing/bvh.rs +++ b/engine/tests/raytracing/bvh.rs @@ -73,22 +73,18 @@ fn axis_value(value: Vector3D, axis: usize) -> f64 { parts[axis] } -fn brute_sphere( - origin: Vector3D, - dir: Vector3D, - center: Vector3D, - radius: f64, - t_min: f64, - t_max: f64, -) -> Option { +fn brute_sphere(ray: &Ray, center: Vector3D, radius: f64) -> Option { if !radius.is_finite() || radius < 0.0 { return None; } + let dir: Vector3D = ray.get_direction(); + let t_min: f64 = ray.get_t_min(); + let t_max: f64 = ray.get_t_max(); + let oc: Vector3D = ray.get_origin() - center; let a: f64 = dir.dot(dir); if !a.is_finite() || a <= 0.0 { return None; } - let oc: Vector3D = origin - center; let half_b: f64 = oc.dot(dir); let c: f64 = oc.dot(oc) - radius * radius; let discriminant: f64 = half_b * half_b - a * c; @@ -107,14 +103,11 @@ fn brute_sphere( None } -fn brute_aabb( - origin: Vector3D, - dir: Vector3D, - lower: Vector3D, - upper: Vector3D, - t_min: f64, - t_max: f64, -) -> Option { +fn brute_aabb(ray: &Ray, lower: Vector3D, upper: Vector3D) -> Option { + let origin: Vector3D = ray.get_origin(); + let dir: Vector3D = ray.get_direction(); + let t_min: f64 = ray.get_t_min(); + let t_max: f64 = ray.get_t_max(); let lo: Vector3D = Vector3D::new( lower.get_x().min(upper.get_x()), lower.get_y().min(upper.get_y()), @@ -156,13 +149,12 @@ fn brute_aabb( } fn brute_hit(ray: &Ray, occluders: &[Occluder]) -> Option<(usize, f64)> { - let origin: Vector3D = ray.get_origin(); - let dir: Vector3D = ray.get_direction(); let t_min: f64 = ray.get_t_min(); let t_max: f64 = ray.get_t_max(); if !t_min.is_finite() || !t_max.is_finite() || t_min > t_max { return None; } + let dir: Vector3D = ray.get_direction(); let squared_length: f64 = dir.dot(dir); if !squared_length.is_finite() || squared_length <= 0.0 { return None; @@ -170,25 +162,17 @@ fn brute_hit(ray: &Ray, occluders: &[Occluder]) -> Option<(usize, f64)> { let mut best: Option<(usize, f64)> = None; for (index, occluder) in occluders.iter().enumerate() { let candidate: Option = match occluder.get_kind() { - OccluderKind::Sphere => brute_sphere( - origin, - dir, - occluder.get_center(), - occluder.get_extent().get_x(), - t_min, - t_max, - ), + OccluderKind::Sphere => { + brute_sphere(ray, occluder.get_center(), occluder.get_extent().get_x()) + } OccluderKind::Aabb => { let (lower, upper): (Vector3D, Vector3D) = occluder_bounds(occluder); - brute_aabb(origin, dir, lower, upper, t_min, t_max) - } - OccluderKind::Triangle => { - let vertices: [Vector3D; 3] = occluder.get_vertices(); - match intersect_triangle(origin, dir, vertices[0], vertices[1], vertices[2]) { - Some((t, _)) if t >= t_min && t <= t_max => Some(t), - _ => None, - } + brute_aabb(ray, lower, upper) } + OccluderKind::Triangle => match intersect_triangle(ray, occluder.get_vertices()) { + Some((t, _)) if t >= t_min && t <= t_max => Some(t), + _ => None, + }, }; if let Some(t) = candidate { let wins: bool = match best { diff --git a/engine/tests/raytracing/fn.rs b/engine/tests/raytracing/fn.rs index 3c33a247..4ca30e5e 100644 --- a/engine/tests/raytracing/fn.rs +++ b/engine/tests/raytracing/fn.rs @@ -167,7 +167,8 @@ fn intersect_triangle_hits_from_above_at_unit_distance_with_upward_normal() { let v2: Vector3D = Vector3D::new(0.0, 0.0, 1.0); let origin: Vector3D = Vector3D::new(0.25, 1.0, 0.25); let dir: Vector3D = Vector3D::new(0.0, -1.0, 0.0); - let hit: Option<(f64, Vector3D)> = intersect_triangle(origin, dir, v0, v1, v2); + let ray: Ray = Ray::new(origin, dir); + let hit: Option<(f64, Vector3D)> = intersect_triangle(&ray, [v0, v1, v2]); assert!( hit.is_some(), "ray (0.25,1,0.25) -> (0,-1,0) passes through the interior of the y=0 triangle", @@ -201,7 +202,8 @@ fn intersect_triangle_hits_from_below_at_unit_distance_with_downward_normal() { let v2: Vector3D = Vector3D::new(0.0, 0.0, 1.0); let origin: Vector3D = Vector3D::new(0.25, -1.0, 0.25); let dir: Vector3D = Vector3D::new(0.0, 1.0, 0.0); - let hit: Option<(f64, Vector3D)> = intersect_triangle(origin, dir, v0, v1, v2); + let ray: Ray = Ray::new(origin, dir); + let hit: Option<(f64, Vector3D)> = intersect_triangle(&ray, [v0, v1, v2]); assert!( hit.is_some(), "the test is two-sided, so the back face of the y=0 triangle must also register a hit", @@ -230,13 +232,8 @@ fn intersect_triangle_misses_beyond_the_hypotenuse() { let v0: Vector3D = Vector3D::new(0.0, 0.0, 0.0); let v1: Vector3D = Vector3D::new(1.0, 0.0, 0.0); let v2: Vector3D = Vector3D::new(0.0, 0.0, 1.0); - let hit: Option<(f64, Vector3D)> = intersect_triangle( - Vector3D::new(2.0, 1.0, 2.0), - Vector3D::new(0.0, -1.0, 0.0), - v0, - v1, - v2, - ); + let beyond: Ray = Ray::new(Vector3D::new(2.0, 1.0, 2.0), Vector3D::new(0.0, -1.0, 0.0)); + let hit: Option<(f64, Vector3D)> = intersect_triangle(&beyond, [v0, v1, v2]); assert!( hit.is_none(), "the landing point (2, 0, 2) has x + z = 4 > 1, which is outside the triangle", @@ -248,24 +245,14 @@ fn intersect_triangle_returns_none_for_ray_parallel_to_the_plane() { let v0: Vector3D = Vector3D::new(0.0, 0.0, 0.0); let v1: Vector3D = Vector3D::new(1.0, 0.0, 0.0); let v2: Vector3D = Vector3D::new(0.0, 0.0, 1.0); - let parallel_in_plane: Option<(f64, Vector3D)> = intersect_triangle( - Vector3D::new(0.25, 1.0, 0.0), - Vector3D::new(1.0, 0.0, 0.0), - v0, - v1, - v2, - ); + let in_plane: Ray = Ray::new(Vector3D::new(0.25, 1.0, 0.0), Vector3D::new(1.0, 0.0, 0.0)); + let parallel_in_plane: Option<(f64, Vector3D)> = intersect_triangle(&in_plane, [v0, v1, v2]); assert!( parallel_in_plane.is_none(), "a direction of (1,0,0) is parallel to the y=0 triangle plane, so the determinant is zero and there is no intersection", ); - let parallel_above: Option<(f64, Vector3D)> = intersect_triangle( - Vector3D::new(0.25, 1.0, 0.25), - Vector3D::new(1.0, 0.0, 0.0), - v0, - v1, - v2, - ); + let above: Ray = Ray::new(Vector3D::new(0.25, 1.0, 0.25), Vector3D::new(1.0, 0.0, 0.0)); + let parallel_above: Option<(f64, Vector3D)> = intersect_triangle(&above, [v0, v1, v2]); assert!( parallel_above.is_none(), "even a ray skimming above the plane stays parallel, so it must miss too", @@ -282,16 +269,10 @@ fn ray_intersect_triangle_rejects_hits_beyond_the_ray_range() { Vector3D::new(0.0, -1.0, 0.0), ); assert!( - far.intersect_triangle(v0, v1, v2).is_none(), + far.intersect_triangle([v0, v1, v2]).is_none(), "t = 2000 exceeds the default t_max of 1000, so the ray method must reject the hit", ); - let unbounded: Option<(f64, Vector3D)> = intersect_triangle( - Vector3D::new(0.25, 2000.0, 0.25), - Vector3D::new(0.0, -1.0, 0.0), - v0, - v1, - v2, - ); + let unbounded: Option<(f64, Vector3D)> = intersect_triangle(&far, [v0, v1, v2]); assert!( close_to( 2000.0, @@ -304,7 +285,7 @@ fn ray_intersect_triangle_rejects_hits_beyond_the_ray_range() { Vector3D::new(0.0, -1.0, 0.0), ); assert!( - grazing.intersect_triangle(v0, v1, v2).is_none(), + grazing.intersect_triangle([v0, v1, v2]).is_none(), "t = 0 is below the default t_min of 0.001, so the ray method must reject the self-hit", ); } @@ -503,14 +484,11 @@ fn trace_bounces_shades_a_triangle_occluder() { ); } -fn oracle_sphere_t( - origin: Vector3D, - dir: Vector3D, - center: Vector3D, - radius: f64, - t_min: f64, - t_max: f64, -) -> Option { +fn oracle_sphere_t(ray: &Ray, center: Vector3D, radius: f64) -> Option { + let origin: Vector3D = ray.get_origin(); + let dir: Vector3D = ray.get_direction(); + let t_min: f64 = ray.get_t_min(); + let t_max: f64 = ray.get_t_max(); let oc: Vector3D = origin - center; let a: f64 = dir.dot(dir); if !a.is_finite() || a <= 0.0 { @@ -534,14 +512,11 @@ fn oracle_sphere_t( None } -fn oracle_aabb_t( - origin: Vector3D, - dir: Vector3D, - lower: Vector3D, - upper: Vector3D, - t_min: f64, - t_max: f64, -) -> Option { +fn oracle_aabb_t(ray: &Ray, lower: Vector3D, upper: Vector3D) -> Option { + let origin: Vector3D = ray.get_origin(); + let dir: Vector3D = ray.get_direction(); + let t_min: f64 = ray.get_t_min(); + let t_max: f64 = ray.get_t_max(); let lo: Vector3D = Vector3D::new( lower.get_x().min(upper.get_x()), lower.get_y().min(upper.get_y()), @@ -596,13 +571,12 @@ fn oracle_axis(value: Vector3D, axis: usize) -> f64 { } fn unbounded_closest_hit(ray: &Ray, occluders: &[Occluder]) -> Option { - let origin: Vector3D = ray.get_origin(); - let dir: Vector3D = ray.get_direction(); let t_min: f64 = ray.get_t_min(); let t_max: f64 = ray.get_t_max(); if !t_min.is_finite() || !t_max.is_finite() || t_min > t_max { return None; } + let dir: Vector3D = ray.get_direction(); let squared_length: f64 = dir.dot(dir); if !squared_length.is_finite() || squared_length <= 0.0 { return None; @@ -610,29 +584,14 @@ fn unbounded_closest_hit(ray: &Ray, occluders: &[Occluder]) -> Option { let mut best: Option = None; for occ in occluders.iter() { let candidate: Option = match occ.get_kind() { - OccluderKind::Sphere => oracle_sphere_t( - origin, - dir, - occ.get_center(), - occ.get_extent().get_x(), - t_min, - t_max, - ), - OccluderKind::Aabb => oracle_aabb_t( - origin, - dir, - occ.get_center(), - occ.get_extent(), - t_min, - t_max, - ), - OccluderKind::Triangle => { - let vertices: [Vector3D; 3] = occ.get_vertices(); - match intersect_triangle(origin, dir, vertices[0], vertices[1], vertices[2]) { - Some((t, _)) if t >= t_min && t <= t_max => Some(t), - _ => None, - } + OccluderKind::Sphere => { + oracle_sphere_t(ray, occ.get_center(), occ.get_extent().get_x()) } + OccluderKind::Aabb => oracle_aabb_t(ray, occ.get_center(), occ.get_extent()), + OccluderKind::Triangle => match intersect_triangle(ray, occ.get_vertices()) { + Some((t, _)) if t >= t_min && t <= t_max => Some(t), + _ => None, + }, }; if let Some(t) = candidate { let keep_earlier: bool = matches!(best, Some(best_t) if best_t <= t); diff --git a/example/src/page/raytrace/hook/fn.rs b/example/src/page/raytrace/hook/fn.rs index eb7d847e..a1d29109 100644 --- a/example/src/page/raytrace/hook/fn.rs +++ b/example/src/page/raytrace/hook/fn.rs @@ -396,46 +396,39 @@ pub(crate) fn raytrace_error_detail(error: &JsValue) -> String { /// actually rendered, and it is cleared again the moment a frame starts /// failing, so a dead renderer can never keep reporting itself as live. /// +/// [`RayTraceErrorState`] groups the destination signals, while +/// [`RayTraceErrorReport`] names the rejected call and its error value. +/// /// # Arguments /// -/// - `Signal` - The tab's `active` flag. -/// - `Signal` - The tab's error detail signal. -/// - `Signal<&'static str>` - The tab's stable error code signal. -/// - `&'static str` - The stable error code for this call site. -/// - `&str` - A short label naming the call that failed. -/// - `&JsValue` - The rejected error value. -pub(crate) fn raytrace_record_error( - active: Signal, - error_detail: Signal, - init_error_code: Signal<&'static str>, - code: &'static str, - call: &str, - error: &JsValue, -) { - let detail: String = raytrace_error_detail(error); +/// - `RayTraceErrorState` - The tab's `active` flag, error detail +/// signal, and stable error code signal. +/// - `RayTraceErrorReport` - The stable code, the failing call label, +/// and the rejected error value. +pub(crate) fn raytrace_record_error(state: RayTraceErrorState, report: RayTraceErrorReport) { + let detail: String = raytrace_error_detail(report.get_error()); Console::error(format!( - "{RAYTRACE_LOG_PREFIX} {call} failed [{code}]: {detail}" + "{} {} failed [{}]: {}", + RAYTRACE_LOG_PREFIX, + report.get_call(), + report.get_code(), + detail )); - error_detail.set(detail); - init_error_code.set(code); - active.set(false); + state.get_error_detail().set(detail); + state.get_init_error_code().set(report.get_code()); + state.get_active().set(false); } /// Clears the tab's error state after a successful frame. /// /// # Arguments /// -/// - `Signal` - The tab's `active` flag. -/// - `Signal` - The tab's error detail signal. -/// - `Signal<&'static str>` - The tab's stable error code signal. -pub(crate) fn raytrace_clear_error( - active: Signal, - error_detail: Signal, - init_error_code: Signal<&'static str>, -) { - error_detail.set(String::new()); - init_error_code.set(""); - active.set(true); +/// - `RayTraceErrorState` - The tab's `active` flag, error detail +/// signal, and stable error code signal. +pub(crate) fn raytrace_clear_error(state: RayTraceErrorState) { + state.get_error_detail().set(String::new()); + state.get_init_error_code().set(""); + state.get_active().set(true); } /// Schedules a one-shot `setTimeout` whose Rust closure is owned rather @@ -640,22 +633,18 @@ pub(crate) fn read_raytrace_dpr() -> f64 { /// /// # Arguments /// -/// - `&mut RayTraceRenderer` - The engine backend. -/// - `&str` - The CSS selector of the tab's canvas. -/// - `Signal` - The tab's `active` flag. -/// - `Signal` - The tab's error detail signal. -/// - `Signal<&'static str>` - The tab's stable error code signal. +/// - `&mut RayTraceRenderer` - The engine backend to resize. +/// - `RayTraceResizeTarget` - The canvas CSS selector, plus the tab's +/// `active` flag, error detail signal, and stable error code signal. /// /// # Returns /// /// - `bool` - Whether the backend now matches the canvas. pub(crate) fn raytrace_resize_backend_to_canvas( renderer: &mut RayTraceRenderer, - canvas_selector: &str, - active: Signal, - error_detail: Signal, - init_error_code: Signal<&'static str>, + target: RayTraceResizeTarget, ) -> bool { + let canvas_selector: &str = target.get_canvas_selector(); let Some(canvas): Option = query_raytrace_canvas(canvas_selector) else { return false; }; @@ -677,17 +666,17 @@ pub(crate) fn raytrace_resize_backend_to_canvas( } match renderer.resize(target_width, target_height) { Ok(()) => { - raytrace_clear_error(active, error_detail, init_error_code); + raytrace_clear_error(target.get_state()); true } Err(error) => { raytrace_record_error( - active, - error_detail, - init_error_code, - RAYTRACE_ERROR_CODE_RESIZE, - "resize", - &error, + target.get_state(), + RayTraceErrorReport { + code: RAYTRACE_ERROR_CODE_RESIZE, + call: "resize", + error: &error, + }, ); false } @@ -715,30 +704,22 @@ pub(crate) fn raytrace_resize_backend_to_canvas( /// /// # Arguments /// -/// - `Rc>` - The cancellation flag the loop body checks. -/// - `Rc>>` - The pending animation-frame handle. -/// - `RafClosureCell` - The cell owning the animation-frame closure. -/// - `RayTracePendingTimers` - The owner list of -/// one-shot timers this loop armed. -/// - `Rc>>` - The cell owning the engine -/// backend. -fn raytrace_release_loop( - cancelled: Rc>, - raf_id: Rc>>, - closure_cell: RafClosureCell, - pending_timers: RayTracePendingTimers, - renderer_rc: Rc>>, -) { - cancelled.set(true); - if let Some(cancel_id) = raf_id.get() +/// - `RayTraceLoopCleanup` - The cancellation flag, the pending +/// animation-frame handle, the cell owning the animation-frame +/// closure, the owner list of one-shot timers this loop armed, and +/// the cell owning the engine backend. +fn raytrace_release_loop(cleanup: RayTraceLoopCleanup) { + cleanup.get_cancelled().set(true); + if let Some(cancel_id) = cleanup.get_raf_id().get() && let Some(window_value) = window() { let _: Result<(), JsValue> = window_value.cancel_animation_frame(cancel_id); } - raf_id.set(None); - raytrace_release_pending_timers(pending_timers); - let _: Option> = closure_cell.try_take(); - if let Some(mut renderer) = renderer_rc + cleanup.get_raf_id().set(None); + raytrace_release_pending_timers(cleanup.get_pending_timers()); + let _: Option> = cleanup.get_closure_cell().try_take(); + if let Some(mut renderer) = cleanup + .get_renderer_cell() .try_borrow_mut() .ok() .and_then(|mut slot: RefMut<'_, Option>| slot.take()) @@ -778,6 +759,11 @@ fn raytrace_release_loop( pub(crate) fn start_raytrace_loop(state: UseRayTrace, angles: RayTraceCameraAngles) { let init_state: UseRayTrace = state; let loop_state: UseRayTrace = state; + let error_state: RayTraceErrorState = RayTraceErrorState { + active: state.get_active(), + error_detail: state.get_error_detail(), + init_error_code: state.get_init_error_code(), + }; let renderer_rc: Rc>> = Rc::new(RefCell::new(None)); let cancelled: Rc> = Rc::new(Cell::new(false)); let raf_id: Rc>> = Rc::new(Cell::new(None)); @@ -794,13 +780,13 @@ pub(crate) fn start_raytrace_loop(state: UseRayTrace, angles: RayTraceCameraAngl // backend. Taking the value out of the cell releases the last Rust // reference as well, which is what lets the renderer run its `Drop`. App::use_cleanup(move || { - raytrace_release_loop( - cancelled_for_cleanup, - raf_for_cleanup, - cell_for_cleanup, - timers_for_cleanup, - renderer_for_cleanup, - ); + raytrace_release_loop(RayTraceLoopCleanup { + cancelled: cancelled_for_cleanup, + raf_id: raf_for_cleanup, + closure_cell: cell_for_cleanup, + pending_timers: timers_for_cleanup, + renderer_cell: renderer_for_cleanup, + }); }); let cancelled_for_init: Rc> = cancelled.clone(); let pending_timers_for_init: RayTracePendingTimers = pending_timers.clone(); @@ -832,12 +818,12 @@ pub(crate) fn start_raytrace_loop(state: UseRayTrace, angles: RayTraceCameraAngl Ok(value) => value, Err(error) => { raytrace_record_error( - init_state.get_active(), - init_state.get_error_detail(), - init_state.get_init_error_code(), - RAYTRACE_ERROR_CODE_INIT, - "canvas2d init", - &error, + error_state, + RayTraceErrorReport { + code: RAYTRACE_ERROR_CODE_INIT, + call: "canvas2d init", + error: &error, + }, ); init_state.get_loaded().set(true); return; @@ -897,13 +883,13 @@ pub(crate) fn start_raytrace_loop(state: UseRayTrace, angles: RayTraceCameraAngl // this call returns, and the release itself defers the // last drop to a microtask, so the closure being taken // from underneath its own frame is safe. - raytrace_release_loop( - cancelled_for_loop.clone(), - raf_for_detach.clone(), - cell_for_detach.clone(), - timers_for_detach.clone(), - renderer_for_detach.clone(), - ); + raytrace_release_loop(RayTraceLoopCleanup { + cancelled: cancelled_for_loop.clone(), + raf_id: raf_for_detach.clone(), + closure_cell: cell_for_detach.clone(), + pending_timers: timers_for_detach.clone(), + renderer_cell: renderer_for_detach.clone(), + }); return; } let Some(window_value): Option = window() else { @@ -945,11 +931,7 @@ pub(crate) fn start_raytrace_loop(state: UseRayTrace, angles: RayTraceCameraAngl let render_millis: f64 = performance.now() - render_start; match render_result { Ok(()) => { - raytrace_clear_error( - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), - ); + raytrace_clear_error(error_state); if !loop_state.get_loaded().get() { raytrace_set_loaded_delayed( timers_for_loop.clone(), @@ -960,12 +942,12 @@ pub(crate) fn start_raytrace_loop(state: UseRayTrace, angles: RayTraceCameraAngl } Err(error) => { raytrace_record_error( - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), - RAYTRACE_ERROR_CODE_RENDER, - "canvas2d render", - &error, + error_state, + RayTraceErrorReport { + code: RAYTRACE_ERROR_CODE_RENDER, + call: "canvas2d render", + error: &error, + }, ); } } @@ -1040,12 +1022,12 @@ pub(crate) fn start_raytrace_loop(state: UseRayTrace, angles: RayTraceCameraAngl .set(RAYTRACE_RENDER_SCALES[next]); } else { raytrace_record_error( - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), - RAYTRACE_ERROR_CODE_RESIZE, - "render scale", - &JsValue::NULL, + error_state, + RayTraceErrorReport { + code: RAYTRACE_ERROR_CODE_RESIZE, + call: "render scale", + error: &JsValue::NULL, + }, ); // Reset the streak so the controller does not // re-attempt the same rejected rung on the very @@ -1124,6 +1106,11 @@ pub(crate) fn start_raytrace_loop(state: UseRayTrace, angles: RayTraceCameraAngl pub(crate) fn start_raytrace_webgl_loop(state: UseRayTraceWebGl, angles: RayTraceCameraAngles) { let init_state: UseRayTraceWebGl = state; let loop_state: UseRayTraceWebGl = state; + let error_state: RayTraceErrorState = RayTraceErrorState { + active: state.get_active(), + error_detail: state.get_error_detail(), + init_error_code: state.get_init_error_code(), + }; let renderer_rc: Rc>> = Rc::new(RefCell::new(None)); let cancelled: Rc> = Rc::new(Cell::new(false)); let raf_id: Rc>> = Rc::new(Cell::new(None)); @@ -1151,13 +1138,13 @@ pub(crate) fn start_raytrace_webgl_loop(state: UseRayTraceWebGl, angles: RayTrac let timers_for_cleanup: RayTracePendingTimers = pending_timers.clone(); let debounce_for_cleanup: RayTraceDebounceOwner = debounce_owner; App::use_cleanup(move || { - raytrace_release_loop( - cancelled_for_cleanup, - raf_for_cleanup, - cell_for_cleanup, - timers_for_cleanup, - renderer_for_cleanup, - ); + raytrace_release_loop(RayTraceLoopCleanup { + cancelled: cancelled_for_cleanup, + raf_id: raf_for_cleanup, + closure_cell: cell_for_cleanup, + pending_timers: timers_for_cleanup, + renderer_cell: renderer_for_cleanup, + }); // Every step below is independent: a missing `window()` must // never skip the observer teardown, so no early returns here. if let Some(timer_id) = resize_timer_for_cleanup.get() @@ -1226,12 +1213,12 @@ pub(crate) fn start_raytrace_webgl_loop(state: UseRayTraceWebGl, angles: RayTrac Ok(value) => value, Err(error) => { raytrace_record_error( - init_state.get_active(), - init_state.get_error_detail(), - init_state.get_init_error_code(), - RAYTRACE_ERROR_CODE_INIT, - "webgl2 init", - &error, + error_state, + RayTraceErrorReport { + code: RAYTRACE_ERROR_CODE_INIT, + call: "webgl2 init", + error: &error, + }, ); init_state.get_loaded().set(true); return; @@ -1266,10 +1253,10 @@ pub(crate) fn start_raytrace_webgl_loop(state: UseRayTraceWebGl, angles: RayTrac Ok(mut renderer_slot) => match renderer_slot.as_mut() { Some(renderer) => raytrace_resize_backend_to_canvas( renderer, - RAYTRACE_WEBGL_CANVAS_SELECTOR, - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), + RayTraceResizeTarget { + canvas_selector: RAYTRACE_WEBGL_CANVAS_SELECTOR, + state: error_state, + }, ), // The renderer is gone: a tab switch already // disposed it, so there is no surface left to @@ -1354,13 +1341,13 @@ pub(crate) fn start_raytrace_webgl_loop(state: UseRayTraceWebGl, angles: RayTrac // a live observer pointing at a disposed backend. It is // disconnected by the hook cleanup, and until then its // resize call is a no-op because the renderer is gone. - raytrace_release_loop( - cancelled_for_loop.clone(), - raf_for_detach.clone(), - cell_for_detach.clone(), - timers_for_detach.clone(), - renderer_for_detach.clone(), - ); + raytrace_release_loop(RayTraceLoopCleanup { + cancelled: cancelled_for_loop.clone(), + raf_id: raf_for_detach.clone(), + closure_cell: cell_for_detach.clone(), + pending_timers: timers_for_detach.clone(), + renderer_cell: renderer_for_detach.clone(), + }); return; } let Some(window_value): Option = window() else { @@ -1401,10 +1388,10 @@ pub(crate) fn start_raytrace_webgl_loop(state: UseRayTraceWebGl, angles: RayTrac // a size the backend does not have. if !raytrace_resize_backend_to_canvas( renderer, - RAYTRACE_WEBGL_CANVAS_SELECTOR, - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), + RayTraceResizeTarget { + canvas_selector: RAYTRACE_WEBGL_CANVAS_SELECTOR, + state: error_state, + }, ) { return None; } @@ -1430,11 +1417,7 @@ pub(crate) fn start_raytrace_webgl_loop(state: UseRayTraceWebGl, angles: RayTrac if running { match render_result { Some(Ok(())) => { - raytrace_clear_error( - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), - ); + raytrace_clear_error(error_state); if !loop_state.get_loaded().get() { raytrace_set_loaded_delayed( timers_for_loop.clone(), @@ -1445,12 +1428,12 @@ pub(crate) fn start_raytrace_webgl_loop(state: UseRayTraceWebGl, angles: RayTrac } Some(Err(error)) => { raytrace_record_error( - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), - RAYTRACE_ERROR_CODE_RENDER, - "webgl2 render", - &error, + error_state, + RayTraceErrorReport { + code: RAYTRACE_ERROR_CODE_RENDER, + call: "webgl2 render", + error: &error, + }, ); } // A rejected resize and a missing renderer both land @@ -1463,12 +1446,12 @@ pub(crate) fn start_raytrace_webgl_loop(state: UseRayTraceWebGl, angles: RayTrac None => { if loop_state.get_error_detail().get().is_empty() { raytrace_record_error( - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), - RAYTRACE_ERROR_CODE_RENDER, - "webgl2 render", - &JsValue::NULL, + error_state, + RayTraceErrorReport { + code: RAYTRACE_ERROR_CODE_RENDER, + call: "webgl2 render", + error: &JsValue::NULL, + }, ); } } @@ -1530,6 +1513,11 @@ pub(crate) fn start_raytrace_webgl_loop(state: UseRayTraceWebGl, angles: RayTrac pub(crate) fn start_raytrace_webgpu_loop(state: UseRayTraceWebGpu, angles: RayTraceCameraAngles) { let init_state: UseRayTraceWebGpu = state; let loop_state: UseRayTraceWebGpu = state; + let error_state: RayTraceErrorState = RayTraceErrorState { + active: state.get_active(), + error_detail: state.get_error_detail(), + init_error_code: state.get_init_error_code(), + }; let renderer_rc: Rc>> = Rc::new(RefCell::new(None)); let cancelled: Rc> = Rc::new(Cell::new(false)); let raf_id: Rc>> = Rc::new(Cell::new(None)); @@ -1560,13 +1548,13 @@ pub(crate) fn start_raytrace_webgpu_loop(state: UseRayTraceWebGpu, angles: RayTr // `dispose` frees the owned buffers, textures, and device eagerly // instead of leaving them to GC, which is what lets a fresh // `init` acquire a live device rather than reusing a dead one. - raytrace_release_loop( - cancelled_for_cleanup, - raf_for_cleanup, - cell_for_cleanup, - timers_for_cleanup, - renderer_for_cleanup, - ); + raytrace_release_loop(RayTraceLoopCleanup { + cancelled: cancelled_for_cleanup, + raf_id: raf_for_cleanup, + closure_cell: cell_for_cleanup, + pending_timers: timers_for_cleanup, + renderer_cell: renderer_for_cleanup, + }); // Every step below is independent: a missing `window()` must // never skip the observer teardown, so no early returns here. if let Some(timer_id) = resize_timer_for_cleanup.get() @@ -1629,12 +1617,12 @@ pub(crate) fn start_raytrace_webgpu_loop(state: UseRayTraceWebGpu, angles: RayTr Ok(value) => value, Err(error) => { raytrace_record_error( - init_state.get_active(), - init_state.get_error_detail(), - init_state.get_init_error_code(), - RAYTRACE_ERROR_CODE_INIT, - "webgpu init", - &error, + error_state, + RayTraceErrorReport { + code: RAYTRACE_ERROR_CODE_INIT, + call: "webgpu init", + error: &error, + }, ); init_state.get_loaded().set(true); return; @@ -1669,10 +1657,10 @@ pub(crate) fn start_raytrace_webgpu_loop(state: UseRayTraceWebGpu, angles: RayTr Ok(mut renderer_slot) => match renderer_slot.as_mut() { Some(renderer) => raytrace_resize_backend_to_canvas( renderer, - RAYTRACE_WEBGPU_CANVAS_SELECTOR, - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), + RayTraceResizeTarget { + canvas_selector: RAYTRACE_WEBGPU_CANVAS_SELECTOR, + state: error_state, + }, ), // The renderer is gone: a tab switch already // disposed it, so there is no surface left to @@ -1750,13 +1738,13 @@ pub(crate) fn start_raytrace_webgpu_loop(state: UseRayTraceWebGpu, angles: RayTr // a live observer pointing at a disposed backend. It is // disconnected by the hook cleanup, and until then its // resize call is a no-op because the renderer is gone. - raytrace_release_loop( - cancelled_for_loop.clone(), - raf_for_detach.clone(), - cell_for_detach.clone(), - timers_for_detach.clone(), - renderer_for_detach.clone(), - ); + raytrace_release_loop(RayTraceLoopCleanup { + cancelled: cancelled_for_loop.clone(), + raf_id: raf_for_detach.clone(), + closure_cell: cell_for_detach.clone(), + pending_timers: timers_for_detach.clone(), + renderer_cell: renderer_for_detach.clone(), + }); return; } let Some(window_value): Option = window() else { @@ -1795,10 +1783,10 @@ pub(crate) fn start_raytrace_webgpu_loop(state: UseRayTraceWebGpu, angles: RayTr // a size the backend does not have. if !raytrace_resize_backend_to_canvas( renderer, - RAYTRACE_WEBGPU_CANVAS_SELECTOR, - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), + RayTraceResizeTarget { + canvas_selector: RAYTRACE_WEBGPU_CANVAS_SELECTOR, + state: error_state, + }, ) { return None; } @@ -1824,11 +1812,7 @@ pub(crate) fn start_raytrace_webgpu_loop(state: UseRayTraceWebGpu, angles: RayTr if running { match render_result { Some(Ok(())) => { - raytrace_clear_error( - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), - ); + raytrace_clear_error(error_state); if !loop_state.get_loaded().get() { raytrace_set_loaded_delayed( timers_for_loop.clone(), @@ -1839,12 +1823,12 @@ pub(crate) fn start_raytrace_webgpu_loop(state: UseRayTraceWebGpu, angles: RayTr } Some(Err(error)) => { raytrace_record_error( - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), - RAYTRACE_ERROR_CODE_RENDER, - "webgpu render", - &error, + error_state, + RayTraceErrorReport { + code: RAYTRACE_ERROR_CODE_RENDER, + call: "webgpu render", + error: &error, + }, ); } // A rejected resize and a missing renderer both land @@ -1857,12 +1841,12 @@ pub(crate) fn start_raytrace_webgpu_loop(state: UseRayTraceWebGpu, angles: RayTr None => { if loop_state.get_error_detail().get().is_empty() { raytrace_record_error( - loop_state.get_active(), - loop_state.get_error_detail(), - loop_state.get_init_error_code(), - RAYTRACE_ERROR_CODE_RENDER, - "webgpu render", - &JsValue::NULL, + error_state, + RayTraceErrorReport { + code: RAYTRACE_ERROR_CODE_RENDER, + call: "webgpu render", + error: &JsValue::NULL, + }, ); } } diff --git a/example/src/page/raytrace/hook/struct.rs b/example/src/page/raytrace/hook/struct.rs index 0efb78ac..acba38a0 100644 --- a/example/src/page/raytrace/hook/struct.rs +++ b/example/src/page/raytrace/hook/struct.rs @@ -206,6 +206,89 @@ pub(crate) struct UseRayTraceFullscreen { pub(crate) web_gpu: Signal, } +/// The reactive status signals used to record and clear one tab's errors. +/// +/// Signal handles are copied, so the same group can be reused by the tab's +/// initialization, render, and resize paths. +#[derive(Clone, Copy, Debug, Getter)] +pub(crate) struct RayTraceErrorState { + /// Whether the backend rendered a frame successfully and most + /// recently still is. + /// + /// Cleared by every rejected call, so a dead renderer can never keep + /// reporting itself as live. + #[get(pub(crate), type(copy))] + pub(crate) active: Signal, + /// The most recent engine rejection detail as a human-readable + /// string, or empty while the backend is healthy. + #[get(pub(crate), type(copy))] + pub(crate) error_detail: Signal, + /// The most recent rejected call site as a stable code, or empty + /// while the backend is healthy. + /// + /// Stored as a code rather than an error enum so the signal stays + /// `Copy` and the banner can tell "this browser cannot host this + /// backend" apart from "the backend died mid-run". + #[get(pub(crate), type(copy))] + pub(crate) init_error_code: Signal<&'static str>, +} + +/// A rejected engine call's stable code, label, and borrowed error value. +/// +/// Constructing the report allocates nothing. Recording it still formats +/// the error detail and console message. +#[derive(Clone, Copy, Debug, Getter)] +pub(crate) struct RayTraceErrorReport<'a> { + /// The stable error code recorded for this call site. + #[get(pub(crate), type(copy))] + pub(crate) code: &'static str, + /// A short label naming the call that failed, e.g. `webgl2 render`. + #[get(pub(crate), type(copy))] + pub(crate) call: &'a str, + /// The rejected error value the engine handed back. + #[get(pub(crate), type(copy))] + pub(crate) error: &'a JsValue, +} + +/// The canvas selector and status signals for one backend resize. +/// +/// The mutable renderer remains a separate argument; selectors come from +/// the tab's module-level constants. +#[derive(Clone, Copy, Debug, Getter)] +pub(crate) struct RayTraceResizeTarget { + /// The CSS selector of the canvas whose backing store the backend + /// must match. + #[get(pub(crate), type(copy))] + pub(crate) canvas_selector: &'static str, + /// The tab's `active` flag, error detail signal, and stable error + /// code signal, which a rejected resize reports against. + #[get(pub(crate), type(copy))] + pub(crate) state: RayTraceErrorState, +} + +/// The handles released when a ray-tracing loop is cancelled or detached. +/// +/// All fields are reference-counted handles. Cloning the set shares its +/// resources rather than cloning the renderer or animation-frame closure. +#[derive(Clone, Getter)] +pub(crate) struct RayTraceLoopCleanup { + /// The cancellation flag the loop body checks before each frame. + #[get(pub(crate), type(clone))] + pub(crate) cancelled: Rc>, + /// The pending animation-frame handle, cancelled and then cleared. + #[get(pub(crate), type(clone))] + pub(crate) raf_id: Rc>>, + /// The cell owning the animation-frame closure. + #[get(pub(crate), type(clone))] + pub(crate) closure_cell: RafClosureCell, + /// The owner list of one-shot timers this loop armed. + #[get(pub(crate), type(clone))] + pub(crate) pending_timers: RayTracePendingTimers, + /// The cell owning the engine backend, taken out and disposed last. + #[get(pub(crate), type(clone))] + pub(crate) renderer_cell: Rc>>, +} + /// Non-reactive camera orbit angles persisted via a `Signal` wrapper. /// /// The `Signal` is read once to obtain the `Rc` handles; all subsequent