ferravex_graphics/
raster.rs

1use crate::{Camera, ClipDepth, Color, MeshCache, RenderItem, Vec3, Vec4, vec2};
2
3/// Resource bounds prevent accidental allocations or unbounded raster loops.
4pub const MAX_DIMENSION: u32 = 8192;
5pub const MAX_PIXELS: usize = 16_777_216;
6const CLIP_VERTEX_CAPACITY: usize = 12;
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum RenderError {
9    InvalidDimensions,
10    InvalidCamera,
11    InvalidSettings,
12}
13impl std::fmt::Display for RenderError {
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        write!(f, "software renderer: {self:?}")
16    }
17}
18impl std::error::Error for RenderError {}
19
20pub struct Framebuffer {
21    pub(crate) width: u32,
22    pub(crate) height: u32,
23    pixels: Vec<[u8; 4]>,
24    depth: Vec<f32>,
25}
26impl Framebuffer {
27    pub fn new(width: u32, height: u32) -> Result<Self, RenderError> {
28        let count = (width as usize)
29            .checked_mul(height as usize)
30            .ok_or(RenderError::InvalidDimensions)?;
31        if width == 0
32            || height == 0
33            || width > MAX_DIMENSION
34            || height > MAX_DIMENSION
35            || count > MAX_PIXELS
36        {
37            return Err(RenderError::InvalidDimensions);
38        }
39        Ok(Self {
40            width,
41            height,
42            pixels: vec![[0, 0, 0, 255]; count],
43            depth: vec![f32::INFINITY; count],
44        })
45    }
46    pub const fn width(&self) -> u32 {
47        self.width
48    }
49    pub const fn height(&self) -> u32 {
50        self.height
51    }
52    /// Mutable color storage for 2D rasterizers; dimensions and depth storage remain fixed.
53    pub fn pixels_mut(&mut self) -> &mut [[u8; 4]] {
54        &mut self.pixels
55    }
56    pub fn pixels(&self) -> &[[u8; 4]] {
57        &self.pixels
58    }
59    pub fn depths(&self) -> &[f32] {
60        &self.depth
61    }
62    pub fn pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> {
63        if x >= self.width || y >= self.height {
64            None
65        } else {
66            Some(self.pixels[(y * self.width + x) as usize])
67        }
68    }
69    pub fn clear(&mut self, color: Color) {
70        self.pixels.fill(color.to_rgba());
71        self.depth.fill(f32::INFINITY);
72    }
73}
74#[derive(Clone, Copy, Debug)]
75pub struct Fog {
76    pub color: Color,
77    pub start: f32,
78    pub end: f32,
79}
80#[derive(Clone, Copy, Debug)]
81pub struct RenderSettings {
82    pub clear_color: Color,
83    /// Direction from the surface toward the light; normalized once each frame.
84    pub light_direction: Vec3,
85    pub ambient: f32,
86    pub diffuse: f32,
87    pub fog: Option<Fog>,
88    pub cull_backfaces: bool,
89}
90impl Default for RenderSettings {
91    fn default() -> Self {
92        Self {
93            clear_color: Color::from_hex(0x293945),
94            light_direction: Vec3::new(-0.4, 0.8, 0.3),
95            ambient: 0.35,
96            diffuse: 0.65,
97            fog: None,
98            cull_backfaces: true,
99        }
100    }
101}
102impl RenderSettings {
103    fn valid(self) -> bool {
104        self.clear_color.to_vec().is_finite()
105            && self.light_direction.is_finite()
106            && self.ambient.is_finite()
107            && self.diffuse.is_finite()
108            && self.ambient >= 0.0
109            && self.diffuse >= 0.0
110            && self.fog.is_none_or(|f| {
111                f.color.to_vec().is_finite()
112                    && f.start.is_finite()
113                    && f.end.is_finite()
114                    && f.start >= 0.0
115                    && f.end > f.start
116            })
117    }
118}
119#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
120pub struct RenderStats {
121    pub submitted_instances: u64,
122    pub visible_instances: u64,
123    pub culled_instances: u64,
124    pub invalid_instances: u64,
125    pub submitted_triangles: u64,
126    pub clipped_triangles: u64,
127    pub rasterized_triangles: u64,
128    pub shaded_pixels: u64,
129}
130#[derive(Clone, Copy, Default)]
131struct ClipVertex {
132    clip: Vec4,
133    world: Vec3,
134    color: Vec4,
135}
136impl ClipVertex {
137    fn lerp(self, other: Self, t: f32) -> Self {
138        Self {
139            clip: self.clip.lerp(other.clip, t),
140            world: self.world.lerp(other.world, t),
141            color: self.color.lerp(other.color, t),
142        }
143    }
144}
145
146pub struct SoftwareRenderer {
147    pub framebuffer: Framebuffer,
148    pub settings: RenderSettings,
149    scratch: Vec<ClipVertex>,
150}
151impl SoftwareRenderer {
152    pub fn new(width: u32, height: u32) -> Result<Self, RenderError> {
153        Ok(Self {
154            framebuffer: Framebuffer::new(width, height)?,
155            settings: RenderSettings::default(),
156            scratch: Vec::new(),
157        })
158    }
159    /// Clears the target and renders the complete opaque scene.
160    /// Stale mesh handles, singular/non-affine transforms and degenerate triangles are skipped.
161    pub fn render(
162        &mut self,
163        cache: &MeshCache,
164        items: &[RenderItem],
165        camera: Camera,
166    ) -> Result<RenderStats, RenderError> {
167        if !self.settings.valid() {
168            return Err(RenderError::InvalidSettings);
169        }
170        if !camera.position.is_finite() {
171            return Err(RenderError::InvalidCamera);
172        }
173        let frustum = camera.frustum().ok_or(RenderError::InvalidCamera)?;
174        self.framebuffer.clear(self.settings.clear_color);
175        let mut stats = RenderStats::default();
176        let light = self.settings.light_direction.normalize_or_zero();
177        for item in items {
178            stats.submitted_instances += 1;
179            let Some(mesh) = cache.get(item.mesh) else {
180                stats.invalid_instances += 1;
181                continue;
182            };
183            if !item.transform.is_finite() || !item.transform.row(3).abs_diff_eq(Vec4::W, 1.0e-5) {
184                stats.invalid_instances += 1;
185                continue;
186            }
187            let Some(inverse) = item.transform.try_inverse() else {
188                stats.invalid_instances += 1;
189                continue;
190            };
191            let bounds = cache.bounds(item.mesh).expect("same validated mesh handle");
192            if !frustum.intersects_bounds(bounds, item.transform) {
193                stats.culled_instances += 1;
194                continue;
195            }
196            stats.visible_instances += 1;
197            let normal_matrix = inverse.transpose();
198            self.scratch.clear();
199            self.scratch.reserve(mesh.vertices.len());
200            for vertex in &mesh.vertices {
201                let world = item.transform.transform_point3(vertex.position);
202                let normal = normal_matrix
203                    .transform_vector3(vertex.normal)
204                    .normalize_or_zero();
205                let lighting =
206                    self.settings.ambient + self.settings.diffuse * normal.dot(light).max(0.0);
207                let color = Vec4::new(
208                    vertex.color.r * lighting,
209                    vertex.color.g * lighting,
210                    vertex.color.b * lighting,
211                    vertex.color.a,
212                );
213                self.scratch.push(ClipVertex {
214                    clip: camera.view_projection * world.extend(1.0),
215                    world,
216                    color,
217                });
218            }
219            let mirrored = item.transform.determinant() < 0.0;
220            for indices in mesh.indices.as_chunks::<3>().0 {
221                stats.submitted_triangles += 1;
222                let triangle = [
223                    self.scratch[indices[0] as usize],
224                    self.scratch[indices[1] as usize],
225                    self.scratch[indices[2] as usize],
226                ];
227                let (mut polygon, count) = clip_triangle(triangle, camera.clip_depth);
228                if count < 3 {
229                    continue;
230                }
231                if mirrored {
232                    polygon[..count].reverse();
233                }
234                stats.clipped_triangles += (count - 2) as u64;
235                for i in 1..count - 1 {
236                    raster_triangle(
237                        &mut self.framebuffer,
238                        [polygon[0], polygon[i], polygon[i + 1]],
239                        self.settings,
240                        camera,
241                        &mut stats,
242                    );
243                }
244            }
245        }
246        Ok(stats)
247    }
248}
249fn clip_distance(vertex: ClipVertex, plane: usize, depth: ClipDepth) -> f32 {
250    let p = vertex.clip;
251    match plane {
252        0 => p.w + p.x,
253        1 => p.w - p.x,
254        2 => p.w + p.y,
255        3 => p.w - p.y,
256        4 => {
257            if depth == ClipDepth::ZeroToOne {
258                p.z
259            } else {
260                p.w + p.z
261            }
262        }
263        5 => p.w - p.z,
264        _ => p.w - 1.0e-6,
265    }
266}
267/// Seven halfspaces: six clip planes plus w >= epsilon. A clipped triangle needs <= 10 vertices.
268fn clip_triangle(
269    triangle: [ClipVertex; 3],
270    depth: ClipDepth,
271) -> ([ClipVertex; CLIP_VERTEX_CAPACITY], usize) {
272    let mut a = [ClipVertex::default(); CLIP_VERTEX_CAPACITY];
273    let mut b = a;
274    a[..3].copy_from_slice(&triangle);
275    let mut count = 3;
276    if triangle
277        .iter()
278        .any(|v| !v.clip.is_finite() || !v.world.is_finite() || !v.color.is_finite())
279    {
280        return (a, 0);
281    }
282    for plane in 0..7 {
283        let mut next = 0;
284        for i in 0..count {
285            let current = a[i];
286            let previous = a[(i + count - 1) % count];
287            let dc = clip_distance(current, plane, depth);
288            let dp = clip_distance(previous, plane, depth);
289            if (dc >= 0.0) != (dp >= 0.0) {
290                let ratio = (f64::from(dp) / (f64::from(dp) - f64::from(dc))) as f32;
291                if next >= CLIP_VERTEX_CAPACITY {
292                    return (a, 0);
293                }
294                b[next] = previous.lerp(current, ratio.clamp(0.0, 1.0));
295                next += 1;
296            }
297            if dc >= 0.0 {
298                if next >= CLIP_VERTEX_CAPACITY {
299                    return (a, 0);
300                }
301                b[next] = current;
302                next += 1;
303            }
304        }
305        if next < 3 {
306            return (a, 0);
307        }
308        std::mem::swap(&mut a, &mut b);
309        count = next;
310    }
311    (a, count)
312}
313fn raster_triangle(
314    frame: &mut Framebuffer,
315    triangle: [ClipVertex; 3],
316    settings: RenderSettings,
317    camera: Camera,
318    stats: &mut RenderStats,
319) {
320    let inverse_w = triangle.map(|v| v.clip.w.recip());
321    let ndc = std::array::from_fn::<_, 3, _>(|i| triangle[i].clip.truncate() * inverse_w[i]);
322    let screen = ndc.map(|p| {
323        vec2(
324            (p.x * 0.5 + 0.5) * frame.width as f32,
325            (0.5 - p.y * 0.5) * frame.height as f32,
326        )
327    });
328    let area = (screen[1] - screen[0]).perp_dot(screen[2] - screen[0]);
329    if !area.is_finite() || area.abs() < 1.0e-6 || (settings.cull_backfaces && area >= 0.0) {
330        return;
331    }
332    let min = screen[0].min(screen[1]).min(screen[2]).floor();
333    let max = screen[0].max(screen[1]).max(screen[2]).ceil();
334    let min_x = (min.x as i64).clamp(0, i64::from(frame.width) - 1) as u32;
335    let max_x = (max.x as i64).clamp(0, i64::from(frame.width) - 1) as u32;
336    let min_y = (min.y as i64).clamp(0, i64::from(frame.height) - 1) as u32;
337    let max_y = (max.y as i64).clamp(0, i64::from(frame.height) - 1) as u32;
338    stats.rasterized_triangles += 1;
339    for y in min_y..=max_y {
340        for x in min_x..=max_x {
341            let sample = vec2(x as f32 + 0.5, y as f32 + 0.5);
342            let bary = [
343                (screen[2] - screen[1]).perp_dot(sample - screen[1]) / area,
344                (screen[0] - screen[2]).perp_dot(sample - screen[2]) / area,
345                (screen[1] - screen[0]).perp_dot(sample - screen[0]) / area,
346            ];
347            if bary.iter().any(|&w| w < 0.0) {
348                continue;
349            }
350            let depth = bary[0] * ndc[0].z + bary[1] * ndc[1].z + bary[2] * ndc[2].z;
351            let depth = if camera.clip_depth == ClipDepth::NegativeOneToOne {
352                depth * 0.5 + 0.5
353            } else {
354                depth
355            };
356            let index = (y * frame.width + x) as usize;
357            if !depth.is_finite()
358                || !(0.0..=1.00001).contains(&depth)
359                || depth >= frame.depth[index]
360            {
361                continue;
362            }
363            let weights = std::array::from_fn::<_, 3, _>(|i| bary[i] * inverse_w[i]);
364            let sum = weights.iter().sum::<f32>();
365            if !sum.is_finite() || sum <= 0.0 {
366                continue;
367            }
368            let mut color = Vec4::ZERO;
369            let mut position = Vec3::ZERO;
370            for i in 0..3 {
371                let weight = weights[i] / sum;
372                color += triangle[i].color * weight;
373                position += triangle[i].world * weight;
374            }
375            if color.w <= 0.0 {
376                continue;
377            }
378            if let Some(fog) = settings.fog {
379                let amount = ((position.distance(camera.position) - fog.start)
380                    / (fog.end - fog.start))
381                    .clamp(0.0, 1.0);
382                color = color.lerp(fog.color.to_vec(), amount);
383            }
384            color.w = 1.0;
385            frame.pixels[index] = Color::from(color).to_rgba();
386            frame.depth[index] = depth.clamp(0.0, 1.0);
387            stats.shaded_pixels += 1;
388        }
389    }
390}