diff --git a/Cargo.lock b/Cargo.lock
index 3d4675a..fc0899a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -107,6 +107,7 @@ dependencies = [
  "enum_dispatch",
  "fasthash",
  "glam",
+ "range2d",
 ]
 
 [[package]]
@@ -257,6 +258,12 @@ version = "0.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
 
+[[package]]
+name = "range2d"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7169c4e8549f72214a10fe6a64e24bf5e2d75ca9378d0ac7934afb9acbdfc313"
+
 [[package]]
 name = "rdrand"
 version = "0.4.0"
diff --git a/core/Cargo.toml b/core/Cargo.toml
index fd419bc..b7d2e00 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -11,6 +11,7 @@ bytemuck = { version = "1.22.0", optional = true }
 enum_dispatch = "0.3.13"
 fasthash = "0.4.0"
 glam = "0.30.2"
+range2d = "0.2.0"
 
 [features]
 fast-math = ["glam/fast-math"]
diff --git a/core/src/camera.rs b/core/src/camera.rs
index b98d330..548e610 100644
--- a/core/src/camera.rs
+++ b/core/src/camera.rs
@@ -1,4 +1,8 @@
-use crate::geometry::{Point, Ray, Transform, Vector};
+use crate::{
+    color::Rgba,
+    geometry::{Point, Point2, Ray, Transform, Transform2, Vector, point2, ray, vec3},
+    sampling::PixelSample,
+};
 
 #[derive(Debug, Clone)]
 pub struct Camera {
@@ -16,9 +20,52 @@ impl Camera {
         Self { transform }
     }
 
-    pub fn ray(&self, x: f32, y: f32) -> Ray {
-        let origin = self.transform * Point::ORIGIN;
-        let direction = self.transform * Vector::new(x, y, 1.0);
-        Ray::new(origin, direction)
+    pub fn ray(&self, p_film: Point2) -> Ray {
+        ray(
+            self.transform * Point::ORIGIN,
+            self.transform * vec3(p_film.x, p_film.y, 1.0),
+        )
+    }
+}
+
+pub struct Film {
+    width: u32,
+    height: u32,
+    transform: Transform2,
+    data: Box<[Rgba]>,
+}
+
+impl Film {
+    pub fn new(width: u32, height: u32) -> Self {
+        let size = width as usize * height as usize;
+
+        let data = vec![Rgba::BLACK; size].into_boxed_slice();
+
+        let w = width as f32;
+        let h = height as f32;
+        let ar = h / w;
+
+        let transform = Transform2::translate(-1.0, ar) * Transform2::scale(2.0 / w, -2.0 / w);
+
+        Self {
+            width,
+            height,
+            transform,
+            data,
+        }
+    }
+
+    pub fn add_sample(&mut self, x: u32, y: u32, color: Rgba, weight: f32) {
+        let i = y as usize * self.width as usize + x as usize;
+        self.data[i] += color * weight;
+    }
+
+    pub fn get_camera_sample(&self, x: u32, y: u32, sampler: &mut PixelSample) -> Point2 {
+        let p = point2(x as f32, y as f32) + sampler.get_2d().into();
+        self.transform * p
+    }
+
+    pub fn into_data(self) -> Box<[Rgba]> {
+        self.data
     }
 }
diff --git a/core/src/color.rs b/core/src/color.rs
index 9498ddc..44a62ef 100644
--- a/core/src/color.rs
+++ b/core/src/color.rs
@@ -1,4 +1,7 @@
-use std::fmt;
+use std::{
+    fmt,
+    ops::{Add, AddAssign, Mul},
+};
 
 #[derive(Clone, Copy)]
 #[repr(C)]
@@ -9,12 +12,12 @@ pub struct Rgba {
     a: f32,
 }
 
-impl Rgba {
-    pub const BLACK: Self = Self::new(0.0, 0.0, 0.0, 1.0);
+pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Rgba {
+    Rgba { r, g, b, a }
+}
 
-    pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
-        Self { r, g, b, a }
-    }
+impl Rgba {
+    pub const BLACK: Self = rgba(0.0, 0.0, 0.0, 1.0);
 }
 
 impl fmt::Debug for Rgba {
@@ -27,3 +30,46 @@ impl fmt::Debug for Rgba {
             .finish()
     }
 }
+
+impl Add for Rgba {
+    type Output = Self;
+
+    fn add(self, Self { r, g, b, a }: Self) -> Self::Output {
+        Self {
+            r: self.r + r,
+            g: self.g + g,
+            b: self.b + b,
+            a: self.a + a,
+        }
+    }
+}
+
+impl AddAssign for Rgba {
+    fn add_assign(&mut self, Self { r, g, b, a }: Self) {
+        self.r += r;
+        self.g += g;
+        self.b += b;
+        self.a += a;
+    }
+}
+
+impl Mul<f32> for Rgba {
+    type Output = Self;
+
+    fn mul(self, rhs: f32) -> Self::Output {
+        Self {
+            r: self.r * rhs,
+            g: self.g * rhs,
+            b: self.b * rhs,
+            a: self.a * rhs,
+        }
+    }
+}
+
+impl Mul<Rgba> for f32 {
+    type Output = Rgba;
+
+    fn mul(self, rhs: Rgba) -> Self::Output {
+        rhs * self
+    }
+}
diff --git a/core/src/geometry.rs b/core/src/geometry.rs
index 5946273..0c719fc 100644
--- a/core/src/geometry.rs
+++ b/core/src/geometry.rs
@@ -8,7 +8,7 @@ mod shapes;
 mod transform;
 
 pub use _2d::*;
-pub use shapes::Shape;
+pub use shapes::{Hit, Hittable, Shape};
 pub use transform::Transform;
 
 #[repr(transparent)]
diff --git a/core/src/geometry/_2d.rs b/core/src/geometry/_2d.rs
index 492e844..bd37b70 100644
--- a/core/src/geometry/_2d.rs
+++ b/core/src/geometry/_2d.rs
@@ -1,6 +1,6 @@
 use std::{
     fmt,
-    ops::{Add, AddAssign, Div, DivAssign, Mul, Sub},
+    ops::{Add, AddAssign, Deref, Div, DivAssign, Mul, Sub},
 };
 
 #[repr(transparent)]
@@ -88,6 +88,12 @@ pub const fn point2(x: f32, y: f32) -> Point2 {
     Point2(glam::vec2(x, y))
 }
 
+impl From<(f32, f32)> for Point2 {
+    fn from((x, y): (f32, f32)) -> Self {
+        point2(x, y)
+    }
+}
+
 impl fmt::Debug for Point2 {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         f.debug_tuple(stringify!(Point))
@@ -97,9 +103,12 @@ impl fmt::Debug for Point2 {
     }
 }
 
-impl From<(f32, f32)> for Point2 {
-    fn from((x, y): (f32, f32)) -> Self {
-        point2(x, y)
+impl Deref for Point2 {
+    type Target = glam::Vec2;
+
+    #[inline]
+    fn deref(&self) -> &Self::Target {
+        &self.0
     }
 }
 
@@ -126,3 +135,54 @@ impl Sub for Point2 {
         Vector2(self.0 - rhs.0)
     }
 }
+
+#[derive(Copy, Clone)]
+#[repr(transparent)]
+pub struct Transform2(glam::Affine2);
+
+impl fmt::Debug for Transform2 {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct(stringify!(Transform))
+            .field(stringify!(matrix2), &self.0.matrix2)
+            .field(stringify!(translation), &self.0.translation)
+            .finish()
+    }
+}
+
+impl Transform2 {
+    pub fn scale(x: f32, y: f32) -> Self {
+        Self(glam::Affine2::from_scale(glam::vec2(x, y)))
+    }
+
+    pub fn inverse(self) -> Self {
+        Self(self.0.inverse())
+    }
+
+    pub fn translate(x: f32, y: f32) -> Self {
+        Self(glam::Affine2::from_translation(glam::vec2(x, y)))
+    }
+}
+
+impl Mul for Transform2 {
+    type Output = Transform2;
+
+    fn mul(self, rhs: Self) -> Self::Output {
+        Self(self.0 * rhs.0)
+    }
+}
+
+impl Mul<Vector2> for Transform2 {
+    type Output = Vector2;
+
+    fn mul(self, rhs: Vector2) -> Self::Output {
+        Vector2(self.0.transform_vector2(rhs.0))
+    }
+}
+
+impl Mul<Point2> for Transform2 {
+    type Output = Point2;
+
+    fn mul(self, rhs: Point2) -> Self::Output {
+        Point2(self.0.transform_point2(rhs.0))
+    }
+}
diff --git a/core/src/geometry/shapes.rs b/core/src/geometry/shapes.rs
index 68be55d..2af3b6e 100644
--- a/core/src/geometry/shapes.rs
+++ b/core/src/geometry/shapes.rs
@@ -5,14 +5,14 @@ use enum_dispatch::enum_dispatch;
 use super::{Point, Ray, Vector};
 
 #[derive(Debug)]
-pub(crate) struct Hit {
+pub struct Hit {
     pub point: Point,
     pub normal: Vector,
     pub t: f32,
 }
 
 #[enum_dispatch]
-pub(crate) trait Hittable {
+pub trait Hittable {
     fn intersect(&self, ray: &Ray, t_max: f32) -> Option<Hit>;
 }
 
diff --git a/core/src/lib.rs b/core/src/lib.rs
index bce4906..84c9bbe 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -1,3 +1,8 @@
+use core::f32;
+
+use geometry::{Hit, Hittable, Ray, point2, vec3};
+use range2d::Range2D;
+
 #[cfg(feature = "bytemuck")]
 pub mod impl_bytemuck;
 
@@ -7,70 +12,57 @@ mod camera;
 mod geometry;
 mod sampling;
 
-use core::f32;
-
 pub use camera::Camera;
-pub use geometry::{Point, Transform, Vector, shapes::Shape};
+pub use geometry::{Point, Shape, Transform, Vector};
 
-use color::Rgba;
-use geometry::{
-    Ray,
-    shapes::{Hit, Hittable},
-};
+use camera::Film;
+use color::{Rgba, rgba};
 use sampling::Sampler;
 
 pub struct Scene {
     camera: Camera,
-    contents: Vec<Shape>,
+    objects: Vec<Shape>,
 }
 
 impl Scene {
-    pub fn new(camera: Camera, contents: Vec<Shape>) -> Self {
-        Self { camera, contents }
+    pub fn new(camera: Camera, objects: Vec<Shape>) -> Self {
+        Self { camera, objects }
     }
 
-    pub fn render(&self, width: u32, height: u32, samples: u32) -> Box<[Rgba]> {
-        let mut sampler = Sampler::new(width, height, samples, 0);
-        let mut data = Box::new_uninit_slice(width as usize * height as usize);
-        let w = width as f32;
-        let h = height as f32;
-        let aspect_ratio = h / w;
-
-        for (i, pixel) in data.iter_mut().enumerate() {
-            let bx = i as u32 % width;
-            let by = i as u32 / width;
+    pub fn render(&self, width: u32, height: u32, samples: u32, seed: u32) -> Box<[Rgba]> {
+        let mut sampler = Sampler::new(width, height, samples, seed);
+        let mut film = Film::new(width, height);
 
+        for (y, x) in Range2D::new(0..height, 0..width) {
             let mut rgb = Vector::ZERO;
 
-            for mut pixel_samples in sampler.pixel(bx, by) {
-                let (x_offset, y_offset) = pixel_samples.get_2d();
-                let x = bx as f32 + x_offset;
-                let y = by as f32 + y_offset;
+            for mut pixel_sample in sampler.pixel(x, y) {
+                let p_film = film.get_camera_sample(x, y, &mut pixel_sample);
+                let ray = self.camera.ray(p_film);
 
-                let cx = 2.0 * x / w - 1.0;
-                let cy = (1.0 - 2.0 * y / h) * aspect_ratio;
-
-                if let Some(h) = self.intersect(self.camera.ray(cx, cy)) {
-                    rgb += h.normal.normalize() * 0.5 + Vector::new(0.5, 0.5, 0.5);
+                if let Some(h) = self.intersect(ray) {
+                    rgb += h.normal.normalize() * 0.5 + vec3(0.5, 0.5, 0.5);
                 }
             }
 
             rgb /= samples as f32;
 
-            pixel.write(Rgba::new(rgb.x, rgb.y, rgb.z, 1.0));
+            film.add_sample(x, y, rgba(rgb.x, rgb.y, rgb.z, 0.0), 1.0);
         }
 
-        unsafe { data.assume_init() }
+        film.into_data()
     }
 
-    fn intersect(&self, ray: Ray) -> Option<Hit> {
+    fn intersect(&self, r: Ray) -> Option<Hit> {
         let mut hit: Option<Hit> = None;
-        for shape in &self.contents {
+
+        for obj in &self.objects {
             let t_max = hit.as_ref().map(|h| h.t).unwrap_or(f32::INFINITY);
-            if let Some(h) = shape.intersect(&ray, t_max) {
-                hit = Some(h);
+            if let Some(h) = obj.intersect(&r, t_max) {
+                hit = Some(h)
             }
         }
+
         hit
     }
 }
diff --git a/py/src/lib.rs b/py/src/lib.rs
index 222533a..aad00b3 100644
--- a/py/src/lib.rs
+++ b/py/src/lib.rs
@@ -55,8 +55,9 @@ impl PyScene {
         Self(Scene::new(camera.0.clone(), contents))
     }
 
-    pub fn render(&self, width: u32, height: u32, samples: u32) -> Vec<f32> {
-        let result = self.0.render(width, height, samples);
+    #[pyo3(signature = (width, height, samples, seed = 0))]
+    pub fn render(&self, width: u32, height: u32, samples: u32, seed: u32) -> Vec<f32> {
+        let result = self.0.render(width, height, samples, seed);
 
         cast_slice_box::<Rgba, f32>(result).into_vec()
     }