Spring cleaning

This commit is contained in:
Wito Wiala 2026-06-05 15:51:06 +01:00
parent 3fda37fcd1
commit 4188adbc33
13 changed files with 99 additions and 101 deletions

3
.gitignore vendored
View file

@ -15,3 +15,6 @@ tests/
scenes/
compile.sh
output/
*.md
!README.md
!INSTALL.md

View file

@ -5,6 +5,7 @@ use crate::core::geometry::{max, min};
use crate::utils::gpu_array_from_fn;
use crate::utils::interval::Interval;
use crate::utils::math::lerp;
use crate::{gamma, gamma_t};
use core::mem;
use core::ops::{Add, Div, DivAssign, Mul, Sub};
use num_traits::{Bounded, Num};
@ -220,7 +221,7 @@ where
(center, radius)
}
pub fn insersect(&self, o: Point3<T>, d: Vector3<T>, t_max: T) -> Option<(T, T)> {
pub fn intersect(&self, o: Point3<T>, d: Vector3<T>, t_max: T) -> Option<(T, T)> {
let mut t0 = T::zero();
let mut t1 = t_max;
@ -231,6 +232,8 @@ where
if t_near > t_far {
mem::swap(&mut t_near, &mut t_far);
}
t_far = t_far * (T::one() + (T::one() + T::one()) * gamma_t::<T>(3));
t0 = if t_near > t0 { t_near } else { t0 };
t1 = if t_far < t1 { t_far } else { t1 };
if t0 > t1 {
@ -274,7 +277,10 @@ impl Bounds3f {
// Check Y
let ty_min = (bounds[dir_is_neg[1]].y() - o.y()) * inv_dir.y();
let ty_max = (bounds[1 - dir_is_neg[1]].y() - o.y()) * inv_dir.y();
let mut ty_max = (bounds[1 - dir_is_neg[1]].y() - o.y()) * inv_dir.y();
t_max = t_max * (1. + 2. * gamma(3));
ty_max = ty_max * (1. + 2. * gamma(3));
if t_min > ty_max || ty_min > t_max {
return None;
@ -321,7 +327,11 @@ impl Bounds3f {
let mut t_min = (bounds[dir_is_neg[0]].x() - o.x()) * inv_dir.x();
let mut t_max = (bounds[1 - dir_is_neg[0]].x() - o.x()) * inv_dir.x();
let ty_min = (bounds[dir_is_neg[1]].y() - o.y()) * inv_dir.y();
let ty_max = (bounds[1 - dir_is_neg[1]].y() - o.y()) * inv_dir.y();
let mut ty_max = (bounds[1 - dir_is_neg[1]].y() - o.y()) * inv_dir.y();
t_max = t_max * (1. + 2. * gamma(3));
ty_max = ty_max * (1. + 2. * gamma(3));
if t_min > ty_max || ty_min > t_max {
return false;

View file

@ -52,13 +52,13 @@ impl DirectionCone {
* Vector3f::new(
w.x()
* (wp.y() * w.y() + wp.z() * w.z()
- wp.x() * (square(w.y() + square(w.z())))),
- wp.x() * (square(w.y()) + square(w.z()))),
w.y()
* (wp.x() * w.x() + wp.z() * w.z()
- wp.y() * (square(w.x() + square(w.z())))),
- wp.y() * (square(w.x()) + square(w.z()))),
w.z()
* (wp.x() * w.x() + wp.y() * w.y()
- wp.z() * (square(w.x() + square(w.y())))),
- wp.z() * (square(w.x()) + square(w.y()))),
)
}
@ -91,10 +91,10 @@ impl DirectionCone {
let theta_b = safe_acos(b.cos_theta);
let theta_d = a.w.angle_between(b.w);
if (theta_d + theta_b).min(PI) <= theta_b {
if (theta_d + theta_b).min(PI) <= theta_a {
return a.clone();
}
if (theta_d + theta_a).min(PI) <= theta_a {
if (theta_d + theta_a).min(PI) <= theta_b {
return b.clone();
}
@ -107,7 +107,7 @@ impl DirectionCone {
// Find the merged cone's axis and return cone union
let theta_r = theta_o - theta_a;
let wr = a.w.cross(b.w);
if wr.norm_squared() >= 0. {
if wr.norm_squared() == 0. {
return DirectionCone::entire_sphere();
}

View file

@ -43,25 +43,21 @@ impl Ray {
self.o + self.d * t
}
pub fn offset_origin(p: &Point3fi, n: &Normal3f, w: &Vector3f) -> Point3f {
let d: Float = Vector3f::from(n.abs()).dot(p.error());
let normal: Vector3f = Vector3f::from(*n);
let mut offset = p.midpoint();
if w.dot(normal) < 0.0 {
offset -= normal * d;
} else {
offset += normal * d;
pub fn offset_origin(pi: &Point3fi, n: &Normal3f, w: &Vector3f) -> Point3f {
let d: Float = Vector3f::from(n.abs()).dot(pi.error());
let mut disp: Vector3f = Vector3f::from(*n) * d;
if w.dot(Vector3f::from(*n)) < 0.0 {
disp = -disp;
}
let mut po = pi.midpoint() + disp;
for i in 0..3 {
if n[i] > 0.0 {
offset[i] = next_float_up(offset[i]);
} else if n[i] < 0.0 {
offset[i] = next_float_down(offset[i]);
if disp[i] > 0.0 {
po[i] = next_float_up(po[i]);
} else if disp[i] < 0.0 {
po[i] = next_float_down(po[i]);
}
}
offset
po
}
pub fn spawn(pi: &Point3fi, n: &Normal3f, time: Float, d: Vector3f) -> Ray {

View file

@ -561,6 +561,8 @@ impl SurfaceInteraction {
self.shading.n = ns;
if orientation {
self.common.n = self.n().face_forward(self.shading.n);
} else {
self.shading.n = self.shading.n.face_forward(self.common.n);
}
self.shading.dpdu = dpdus;
self.shading.dpdv = dpdvs;

View file

@ -1,6 +1,6 @@
use crate::core::geometry::Lerp;
use core::ops::{Add, Mul};
use num_traits::{Num, PrimInt};
use num_traits::{Float as NumFloat, Num, NumCast, PrimInt};
use crate::core::light::LightTrait;
use crate::core::shape::Shape;
@ -104,9 +104,16 @@ pub const PI_OVER_2: Float = core::f32::consts::FRAC_PI_2;
pub const PI_OVER_4: Float = core::f32::consts::FRAC_PI_4;
pub const SQRT_2: Float = core::f32::consts::SQRT_2;
#[inline]
pub fn gamma_t<T: NumFloat + NumCast>(n: i32) -> T {
let n = T::from(n).unwrap();
let eps = T::epsilon() / (T::one() + T::one());
n * eps / (T::one() - n * eps)
}
#[inline]
pub fn gamma(n: i32) -> Float {
n as Float * MACHINE_EPSILON / (1. - n as Float * MACHINE_EPSILON)
gamma_t::<Float>(n)
}
#[cfg(feature = "cpu_debug")]

View file

@ -48,8 +48,8 @@ impl DiskShape {
}
fn basic_intersect(&self, r: &Ray, t_max: Float) -> Option<QuadricIntersection> {
let oi = self.object_from_render.apply_to_point(r.o);
let di = self.object_from_render.apply_to_vector(r.d);
let oi = self.object_from_render.apply_to_interval(Point3fi::new_from_point(r.o));
let di = self.object_from_render.apply_to_vector_interval(Vector3fi::new_from_vector(r.d));
// Reject disk intersections for rays parallel to the disks plane
if di.z() == 0. {
return None;
@ -61,7 +61,7 @@ impl DiskShape {
}
// See if hit point is inside disk radii and phi_max
let p_hit: Point3f = oi + t_shape_hit * di;
let p_hit: Point3fi = oi + t_shape_hit * di;
let dist2 = square(p_hit.x()) + square(p_hit.y());
if dist2 > square(self.radius) || dist2 < square(self.inner_radius) {
return None;

View file

@ -86,7 +86,7 @@ impl SphereShape {
let c: Interval =
square(oi.x()) + square(oi.y()) + square(oi.z()) - square(Interval::new(self.radius));
let v: Vector3fi = (oi - b / Vector3fi::from((2. * a) * di)).into();
let v: Vector3fi = (oi - b / 2. * a * di).into();
let length: Interval = v.norm();
let discrim =
4. * a * (Interval::new(self.radius) + length) * (Interval::new(self.radius) - length);
@ -120,6 +120,7 @@ impl SphereShape {
}
let mut p_hit = Point3f::from(oi) + Float::from(t_shape_hit) * Vector3f::from(di);
p_hit *= self.radius / p_hit.distance(Point3f::new(0., 0., 0.));
if p_hit.x() == 0. && p_hit.y() == 0. {
p_hit[0] = 1e-5 * self.radius;
}

View file

@ -129,8 +129,8 @@ impl TransformGeneric<Float> {
let y = p.y();
let z = p.z();
let xn = self.m_inv[0][0] * x + self.m_inv[1][1] * y + self.m_inv[2][0] * z;
let yn = self.m_inv[0][1] * x + self.m_inv[1][1] * y + self.m_inv[2][1] * z;
let zn = self.m_inv[0][2] * x + self.m_inv[1][2] * y + self.m_inv[2][2] * z;
let yn = self.m_inv[1][0] * x + self.m_inv[1][1] * y + self.m_inv[2][1] * z;
let zn = self.m_inv[2][0] * x + self.m_inv[1][2] * y + self.m_inv[2][2] * z;
Normal3f::new(xn, yn, zn)
}

View file

@ -1,4 +1,5 @@
use crate::core::bxdf::BxDFFlags;
use crate::core::film::VisibleSurface;
use crate::core::geometry::{
Normal3f, Point2f, Point2i, Point3f, Point3fi, Ray, RayDifferential, Vector3f,
};
@ -15,22 +16,13 @@ use crate::{Float, Ptr};
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PixelSampleState {
pub filter_weight: SoABuffer<Float>,
pub p_film: SoABuffer<Point2f>,
pub pixel: SoABuffer<Point2i>,
pub l: SoABuffer<SampledSpectrum>,
pub lambda: SoABuffer<SampledWavelengths>,
pub r_u: SoABuffer<SampledSpectrum>,
pub r_l: SoABuffer<SampledSpectrum>,
pub prev_intr_ctx: SoABuffer<LightSampleContext>,
pub beta: SoABuffer<SampledSpectrum>,
pub depth: SoABuffer<u32>,
pub specular_bounce: SoABuffer<u8>,
pub any_non_specular_bounces: SoABuffer<u8>,
pub eta_scale: SoABuffer<Float>,
pub filter_weight: SoABuffer<Float>,
pub camera_ray_weight: SoABuffer<SampledSpectrum>,
pub visible_surface_idx: SoABuffer<u32>,
pub visible_surface: SoABuffer<VisibleSurface>,
pub samples: SoABuffer<RaySamples>,
pub p_pixel: SoABuffer<Point2i>,
}
impl SoA for PixelSampleState {
@ -38,22 +30,13 @@ impl SoA for PixelSampleState {
fn allocate(n: u32, alloc: &dyn SoAAllocator) -> Self {
Self {
filter_weight: alloc_soa_buffer(n, alloc),
p_film: alloc_soa_buffer(n, alloc),
pixel: alloc_soa_buffer(n, alloc),
l: alloc_soa_buffer(n, alloc),
lambda: alloc_soa_buffer(n, alloc),
r_u: alloc_soa_buffer(n, alloc),
r_l: alloc_soa_buffer(n, alloc),
prev_intr_ctx: alloc_soa_buffer(n, alloc),
beta: alloc_soa_buffer(n, alloc),
depth: alloc_soa_buffer(n, alloc),
specular_bounce: alloc_soa_buffer(n, alloc),
any_non_specular_bounces: alloc_soa_buffer(n, alloc),
eta_scale: alloc_soa_buffer(n, alloc),
filter_weight: alloc_soa_buffer(n, alloc),
camera_ray_weight: alloc_soa_buffer(n, alloc),
visible_surface_idx: alloc_soa_buffer(n, alloc),
visible_surface: alloc_soa_buffer(n, alloc),
samples: alloc_soa_buffer(n, alloc),
p_pixel: alloc_soa_buffer(n, alloc),
}
}
@ -73,8 +56,8 @@ pub struct RayWorkItem {
pub r_l: SampledSpectrum,
pub prev_intr_ctx: LightSampleContext,
pub eta_scale: Float,
pub specular_bounce: u8,
pub any_non_specular_bounces: u8,
pub specular_bounce: bool,
pub any_non_specular_bounces: bool,
}
#[repr(C)]
@ -89,8 +72,8 @@ pub struct RayWorkItemSoA {
pub r_l: SoABuffer<SampledSpectrum>,
pub prev_intr_ctx: SoABuffer<LightSampleContext>,
pub eta_scale: SoABuffer<Float>,
pub specular_bounce: SoABuffer<u8>,
pub any_non_specular_bounces: SoABuffer<u8>,
pub specular_bounce: SoABuffer<bool>,
pub any_non_specular_bounces: SoABuffer<bool>,
}
impl SoA for RayWorkItemSoA {
@ -332,14 +315,13 @@ pub struct MaterialEvalWorkItem {
pub lambda: SampledWavelengths,
pub beta: SampledSpectrum,
pub r_u: SampledSpectrum,
// pub r_l: SampledSpectrum,
pub any_non_specular_bounces: bool,
pub depth: u32,
pub eta_scale: Float,
pub dpdus: Vector3f ,
pub dpdus: Vector3f,
pub dpdvs: Vector3f,
pub dndus: Normal3f,
pub dndvs: Normal3f
pub dndvs: Normal3f,
}
#[repr(C)]
@ -361,15 +343,13 @@ pub struct MaterialEvalWorkItemSoA {
pub lambda: SoABuffer<SampledWavelengths>,
pub beta: SoABuffer<SampledSpectrum>,
pub r_u: SoABuffer<SampledSpectrum>,
// pub r_l: SoABuffer<SampledSpectrum>,
pub any_non_specular_bounces: SoABuffer<u8>,
pub depth: SoABuffer<u32>,
pub eta_scale: SoABuffer<Float>,
pub dpdus: SoABuffer<Vector3f>,
pub dpdvs: SoABuffer<Vector3f>,
pub dndus: SoABuffer<Normal3f>,
pub dndvs: SoABuffer<Normal3f>
pub dndvs: SoABuffer<Normal3f>,
}
impl SoA for MaterialEvalWorkItemSoA {
@ -393,7 +373,6 @@ impl SoA for MaterialEvalWorkItemSoA {
lambda: alloc_soa_buffer(n, alloc),
beta: alloc_soa_buffer(n, alloc),
r_u: alloc_soa_buffer(n, alloc),
// r_l: alloc_soa_buffer(n, alloc),
any_non_specular_bounces: alloc_soa_buffer(n, alloc),
depth: alloc_soa_buffer(n, alloc),
eta_scale: alloc_soa_buffer(n, alloc),
@ -422,7 +401,6 @@ impl SoA for MaterialEvalWorkItemSoA {
lambda: self.lambda.get(i),
beta: self.beta.get(i),
r_u: self.r_u.get(i),
// r_l: self.r_l.get(i),
any_non_specular_bounces: self.any_non_specular_bounces.get(i) != 0,
depth: self.depth.get(i),
eta_scale: self.eta_scale.get(i),
@ -430,7 +408,6 @@ impl SoA for MaterialEvalWorkItemSoA {
dpdvs: self.dpdvs.get(i),
dndus: self.dndus.get(i),
dndvs: self.dndvs.get(i),
}
}
@ -451,7 +428,6 @@ impl SoA for MaterialEvalWorkItemSoA {
self.lambda.set(i, v.lambda);
self.beta.set(i, v.beta);
self.r_u.set(i, v.r_u);
// self.r_l.set(i, v.r_l);
self.any_non_specular_bounces
.set(i, v.any_non_specular_bounces as u8);
self.depth.set(i, v.depth);

View file

@ -15,7 +15,7 @@ use shared::spectra::{
cie::SWATCHES_RAW, DenselySampledSpectrum, PiecewiseLinearSpectrum, RGBColorSpace,
};
use shared::utils::math::{linear_least_squares, SquareMatrix};
use shared::{Float, Ptr, leak};
use shared::{leak, Float, Ptr};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, LazyLock};
@ -100,7 +100,7 @@ impl CreatePixelSensor for PixelSensor {
output_colorspace.as_ref(),
sensor_illum.as_deref(),
imaging_ratio,
arena
arena,
))
} else {
let r_opt = get_named_spectrum(&format!("{}_r", sensor_name));
@ -129,7 +129,7 @@ impl CreatePixelSensor for PixelSensor {
.expect("Sensor must have illuminant"),
),
imaging_ratio,
arena
arena,
))
}
}
@ -205,7 +205,7 @@ impl CreatePixelSensor for PixelSensor {
output_colorspace: &RGBColorSpace,
sensor_illum: Option<&Spectrum>,
imaging_ratio: Float,
arena: &Arena
arena: &Arena,
) -> Self {
let spectra = get_spectra_context();
let r_bar = CIE_X_DATA.clone();
@ -229,7 +229,6 @@ impl CreatePixelSensor for PixelSensor {
imaging_ratio,
}
}
}
pub trait CreateFilmBase {
@ -336,6 +335,9 @@ pub trait FilmTrait: Sync {
for x in pixel_bounds.p_min.x()..pixel_bounds.p_max.x() {
let p = Point2i::new(x, y);
let mut rgb = self.get_pixel_rgb(p, Some(splat_scale));
if rgb.r > 1.0 || rgb.g > 1.0 || rgb.b > 1.0 {
eprintln!("p={:?} get_pixel_rgb=({}, {}, {})", p, rgb.r, rgb.g, rgb.b);
}
let mut was_clamped = false;
if write_fp16 {
if rgb.r > 65504.0 {

View file

@ -1,4 +1,5 @@
use crate::globals::get_options;
use log::debug;
use rayon::prelude::*;
use shared::core::geometry::{Bounds3f, Ray, VectorLike};
use shared::core::interaction::{InteractionTrait, SurfaceInteraction};
@ -60,7 +61,7 @@ impl WavefrontAggregate for CpuAggregate {
r_u: r.r_u,
r_l: r.r_l,
depth: r.depth,
specular_bounce: r.specular_bounce != 0,
specular_bounce: r.specular_bounce,
prev_intr_ctx: r.prev_intr_ctx,
});
return;
@ -83,14 +84,14 @@ impl WavefrontAggregate for CpuAggregate {
p: intr.p(),
n: intr.n(),
uv: intr.common.uv,
wo: -r.ray.d,
wo: intr.wo(),
lambda: r.lambda,
pixel_index: r.pixel_index,
beta: r.beta,
r_u: r.r_u,
r_l: r.r_l,
depth: r.depth,
specular_bounce: r.specular_bounce != 0,
specular_bounce: r.specular_bounce,
prev_intr_ctx: r.prev_intr_ctx,
});
}
@ -103,12 +104,20 @@ impl WavefrontAggregate for CpuAggregate {
universal_eval_mtl_q
};
if material.is_conductor() {
debug!("shading frame: {:?}", intr.shading.dpdu);
debug!(
"dot product: {:?}",
intr.shading.dpdu.normalize().dot(intr.n().into())
);
}
eval_q.push(MaterialEvalWorkItem {
p: intr.pi(),
n: intr.n(),
ns: intr.shading.n,
dpdu: intr.shading.dpdu,
dpdv: intr.shading.dpdv,
dpdu: intr.dpdu,
dpdv: intr.dpdv,
uv: intr.common.uv,
wo: intr.wo(),
time: r.ray.time,
@ -120,7 +129,7 @@ impl WavefrontAggregate for CpuAggregate {
lambda: r.lambda,
beta: r.beta,
r_u: r.r_u,
any_non_specular_bounces: r.any_non_specular_bounces != 0,
any_non_specular_bounces: r.any_non_specular_bounces,
depth: r.depth,
eta_scale: r.eta_scale,
dpdus: intr.shading.dpdu,

View file

@ -75,8 +75,6 @@ where
let scanlines_per_pass = (max_samples / res_x).max(1);
let max_queue_size = res_x * scanlines_per_pass;
eprintln!("wavefront got {} lights", lights.len());
let mut infinite_lights = gvec();
for light in &lights {
if light.light_type().is_infinite() {
@ -84,10 +82,6 @@ where
}
}
eprintln!("infinite_lights len = {}", infinite_lights.len());
// for light in
let cpu_aggregate = CpuAggregate::new(*aggregate);
CpuWavefrontRenderer(WavefrontPathIntegrator {
@ -241,7 +235,7 @@ impl CpuWavefrontRenderer {
pixel_bounds.p_min.x() + (pixel_index as i32 % x_resolution),
y0 + (pixel_index as i32 / x_resolution),
);
pixel_sample_state.p_pixel.set(pixel_index, p_pixel);
pixel_sample_state.pixel.set(pixel_index, p_pixel);
// Skipped pixels contribute nothing; their slots are simply never
// populated, and update_film filters them by the same bounds test.
@ -263,9 +257,6 @@ impl CpuWavefrontRenderer {
pixel_sample_state
.filter_weight
.set(pixel_index, camera_sample.filter_weight);
pixel_sample_state
.p_film
.set(pixel_index, camera_sample.p_film);
let Some(camera_ray) = camera.generate_ray(camera_sample, &lambda) else {
pixel_sample_state
@ -288,8 +279,8 @@ impl CpuWavefrontRenderer {
r_l: SampledSpectrum::new(1.0),
prev_intr_ctx: LightSampleContext::default(),
eta_scale: 1.0,
specular_bounce: 0,
any_non_specular_bounces: 0,
specular_bounce: false,
any_non_specular_bounces: false,
});
});
}
@ -318,7 +309,7 @@ impl CpuWavefrontRenderer {
if w.depth == 0 || w.specular_bounce {
l_contrib += w.beta * le / w.r_u.average();
} else {
// MIS: combine BSDF and light sampling weights via ratio tracking
// Compute MIS-weighted radiance contribution from infinite light
let ctx = w.prev_intr_ctx;
let light_choice_pdf = light_sampler.pmf_with_context(&ctx, light);
let r_l = w.r_l * light_choice_pdf * light.pdf_li(&ctx, w.ray_d, true);
@ -353,13 +344,14 @@ impl CpuWavefrontRenderer {
let l_contrib = if w.depth == 0 || w.specular_bounce {
w.beta * le / w.r_u.average()
} else {
let wi = -w.wo;
let ctx = w.prev_intr_ctx;
let light_choice_pdf = light_sampler.pmf_with_context(&ctx, light);
// wi from previous interaction to this light hit
let wi = (w.p - Point3f::from(ctx.pi)).normalize();
let light_pdf = light_choice_pdf * light.pdf_li(&ctx, wi, true);
let r_u = w.r_u;
let r_l = w.r_l * light_pdf;
w.beta * le / (w.r_u + r_l).average()
w.beta * le / (r_u + r_l).average()
};
if !l_contrib.is_black() {
@ -489,8 +481,8 @@ impl CpuWavefrontRenderer {
r_l,
prev_intr_ctx: ctx,
eta_scale,
specular_bounce: bs.is_specular() as u8,
any_non_specular_bounces: any_non_specular as u8,
specular_bounce: bs.is_specular(),
any_non_specular_bounces: any_non_specular,
});
}
}
@ -505,7 +497,7 @@ impl CpuWavefrontRenderer {
};
if flags.is_reflective() && !flags.is_transmissive() {
light_ctx.pi = Point3fi::new_from_point(Ray::offset_origin(&w.p, &w.n, &(-wo)));
light_ctx.pi = Point3fi::new_from_point(Ray::offset_origin(&w.p, &w.n, &wo));
} else if flags.is_transmissive() && flags.is_reflective() {
light_ctx.pi = Point3fi::new_from_point(Ray::offset_origin(&w.p, &w.n, &(-wo)));
}
@ -573,7 +565,7 @@ impl CpuWavefrontRenderer {
(0..self.max_queue_size as usize)
.into_par_iter()
.for_each(|pixel_index| {
let p_pixel = self.pixel_sample_state.p_pixel.get(pixel_index);
let p_pixel = self.pixel_sample_state.pixel.get(pixel_index);
if !pixel_bounds.contains_exclusive(p_pixel) {
return;
}
@ -600,7 +592,7 @@ impl CpuWavefrontRenderer {
let w = unsafe { ray_queue.storage.get(i) };
let dimension = 6 + 7 * w.depth;
let pi = w.pixel_index as usize;
let p_pixel = pixel_sample_state.p_pixel.get(pi);
let p_pixel = pixel_sample_state.pixel.get(pi);
let mut sampler = sampler_proto.clone();
sampler.start_pixel_sample(p_pixel, sample_index as i32, Some(dimension));