diff --git a/core/src/camera.rs b/core/src/camera.rs
new file mode 100644
index 0000000..b98d330
--- /dev/null
+++ b/core/src/camera.rs
@@ -0,0 +1,24 @@
+use crate::geometry::{Point, Ray, Transform, Vector};
+
+#[derive(Debug, Clone)]
+pub struct Camera {
+    /// camera to world transform
+    transform: Transform,
+}
+
+impl Camera {
+    pub fn new(eye: Point, center: Point, up: Vector, fov: f32) -> Self {
+        let f = (center - eye).length();
+        let tan = (fov / 2.0).to_radians().tan();
+        let transform =
+            Transform::look_at(eye, center, up).inverse() * Transform::scale(tan * f, tan * f, f);
+
+        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)
+    }
+}
diff --git a/core/src/geometry.rs b/core/src/geometry.rs
index b319ac9..599474b 100644
--- a/core/src/geometry.rs
+++ b/core/src/geometry.rs
@@ -1,6 +1,6 @@
 use std::{
     fmt,
-    ops::{Add, Div, Mul, Sub},
+    ops::{Add, Deref, Div, Mul, Sub},
 };
 
 pub mod shapes;
@@ -31,6 +31,14 @@ impl From<(f32, f32, f32)> for Vector {
     }
 }
 
+impl Deref for Vector {
+    type Target = glam::Vec3A;
+    #[inline]
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
 impl Vector {
     pub const ZERO: Self = Self(glam::Vec3A::ZERO);
     pub const I: Self = Self(glam::Vec3A::X);
@@ -49,6 +57,10 @@ impl Vector {
         self.0.length_squared()
     }
 
+    pub fn normalize(self) -> Vector {
+        Self(self.0.normalize())
+    }
+
     pub fn dot(self, other: Vector) -> f32 {
         self.0.dot(other.0)
     }
@@ -199,6 +211,7 @@ impl Mul<Point> for Transform {
     }
 }
 
+#[derive(Debug)]
 pub struct Ray {
     origin: Point,
     direction: Vector,
diff --git a/core/src/geometry/shapes.rs b/core/src/geometry/shapes.rs
index 0a237b2..03258f7 100644
--- a/core/src/geometry/shapes.rs
+++ b/core/src/geometry/shapes.rs
@@ -6,24 +6,24 @@ use super::{Point, Ray, Vector};
 
 #[derive(Debug)]
 pub(crate) struct Hit {
-    point: Point,
-    normal: Vector,
-    t: f32,
+    pub point: Point,
+    pub normal: Vector,
+    pub t: f32,
 }
 
 #[enum_dispatch]
 pub(crate) trait Hittable {
-    fn intersect(&self, ray: Ray, t_max: f32) -> Option<Hit>;
+    fn intersect(&self, ray: &Ray, t_max: f32) -> Option<Hit>;
 }
 
-#[derive(Debug)]
+#[derive(Debug, Clone)]
 pub struct Sphere {
     center: Point,
     radius: f32,
 }
 
 impl Hittable for Sphere {
-    fn intersect(&self, ray: Ray, t_max: f32) -> Option<Hit> {
+    fn intersect(&self, ray: &Ray, t_max: f32) -> Option<Hit> {
         let oc = self.center - ray.origin;
         let a = ray.direction.length_squared();
         let h = ray.direction.dot(oc);
@@ -41,19 +41,19 @@ impl Hittable for Sphere {
     }
 }
 
-#[derive(Debug)]
+#[derive(Debug, Clone)]
 pub struct Plane {
     normal: Vector,
     d: f32,
 }
 
 impl Hittable for Plane {
-    fn intersect(&self, ray: Ray, t_max: f32) -> Option<Hit> {
+    fn intersect(&self, ray: &Ray, t_max: f32) -> Option<Hit> {
         let n_dot_r = self.normal.dot(ray.direction);
         (n_dot_r.abs() > f32::EPSILON)
             .then(|| {
                 let n_dot_o = self.normal.dot(ray.origin.to_vector());
-                -(n_dot_o + self.d) / n_dot_r
+                -(n_dot_o - self.d) / n_dot_r
             })
             .filter(|t| *t < t_max && *t > 0.0)
             .map(|t| Hit {
@@ -65,6 +65,7 @@ impl Hittable for Plane {
 }
 
 #[enum_dispatch(Hittable)]
+#[derive(Clone)]
 pub enum Shape {
     Sphere,
     Plane,
diff --git a/core/src/lib.rs b/core/src/lib.rs
index 9c189dd..d4bec95 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -3,40 +3,62 @@ pub mod impl_bytemuck;
 
 pub mod color;
 
+mod camera;
 mod geometry;
 
+use core::f32;
+
+pub use camera::Camera;
 pub use geometry::{Point, Transform, Vector, shapes::Shape};
 
 use color::Rgba;
+use geometry::{
+    Ray,
+    shapes::{Hit, Hittable},
+};
 
-pub struct Scene;
+pub struct Scene {
+    camera: Camera,
+    contents: Vec<Shape>,
+}
 
 impl Scene {
-    pub fn new() -> Self {
-        Self
+    pub fn new(camera: Camera, contents: Vec<Shape>) -> Self {
+        Self { camera, contents }
     }
 
     pub fn render(&self, width: usize, height: usize) -> Box<[Rgba]> {
+        let ray = self.camera.ray(0.0, 0.0);
+        dbg!(&ray);
+        dbg!(self.intersect(ray));
         let mut data = Box::new_uninit_slice(width * height);
+        let w = width as f32;
+        let h = height as f32;
+        let aspect_ratio = h / w;
 
         for (i, pixel) in data.iter_mut().enumerate() {
-            let x = (i % width) as f32;
-            let y = (i / width) as f32;
+            let x = ((i % width) as f32 * 2.0 / w) - 1.0;
+            let y = (1.0 - (i / width) as f32 * 2.0 / h) * aspect_ratio;
 
-            pixel.write(Rgba::new(
-                x / (width - 1) as f32,
-                y / (height - 1) as f32,
-                0.5,
-                1.0,
-            ));
+            let rgb = self
+                .intersect(self.camera.ray(x, y))
+                .map(|h| h.normal.normalize() * 0.5 + Vector::new(0.5, 0.5, 0.5))
+                .unwrap_or(Vector::ZERO);
+
+            pixel.write(Rgba::new(rgb.x, rgb.y, rgb.z, 1.0));
         }
 
         unsafe { data.assume_init() }
     }
-}
 
-impl Default for Scene {
-    fn default() -> Self {
-        Self::new()
+    fn intersect(&self, ray: Ray) -> Option<Hit> {
+        let mut hit: Option<Hit> = None;
+        for shape in &self.contents {
+            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);
+            }
+        }
+        hit
     }
 }
diff --git a/examples/basic.py b/examples/basic.py
index 46dab78..b7444ee 100755
--- a/examples/basic.py
+++ b/examples/basic.py
@@ -1,10 +1,20 @@
 #!python
+from PIL import Image
+from mechthild import Scene, Shape, Camera
 
-from mechthild import Shape
-
-s1 = Shape.sphere((0.0, 0.5, 0.0), 0.5)
-s2 = Shape.sphere((1.5, 2.0, -1.0), 2.0)
+WIDTH = 800
+HEIGHT = 600
 
+s1 = Shape.sphere((0.75, 1.0, 1.0), 1.0)
+s2 = Shape.sphere((-1.5, 2.0, -2.0), 2.0)
 ground = Shape.plane((0.0, 1.0, 0.0), 0.0)
 
-print(f"{s1}\n{s2}\n{ground}")
+camera = Camera((0.0, 1.5, 5.0), (0.0, 1.0, 0.0), (0.0, 1.0, 0.0), 80.0)
+
+scene = Scene(camera, [s1, s2, ground])
+
+render = scene.render(WIDTH, HEIGHT)
+rgba = bytes([int(v * 255) for v in render])
+
+image = Image.frombuffer('RGBA', (WIDTH, HEIGHT), rgba)
+image.save("test.png")
diff --git a/py/src/lib.rs b/py/src/lib.rs
index a428282..1b084e4 100644
--- a/py/src/lib.rs
+++ b/py/src/lib.rs
@@ -1,8 +1,30 @@
 use bytemuck::allocation::cast_slice_box;
-use mechthild_core::{Scene, Shape, color::Rgba};
+use mechthild_core::{Camera, Scene, Shape, color::Rgba};
 use pyo3::prelude::*;
 
+#[pyclass(name = "Camera")]
+#[derive(Clone)]
+struct PyCamera(Camera);
+
+#[pymethods]
+impl PyCamera {
+    #[new]
+    pub fn new(
+        eye: (f32, f32, f32),
+        center: (f32, f32, f32),
+        up: (f32, f32, f32),
+        fov: f32,
+    ) -> Self {
+        Self(Camera::new(eye.into(), center.into(), up.into(), fov))
+    }
+
+    pub fn __str__(&self) -> PyResult<String> {
+        Ok(format!("{:#?}", self.0))
+    }
+}
+
 #[pyclass(name = "Shape")]
+#[derive(Clone)]
 struct PyShape(Shape);
 
 #[pymethods]
@@ -28,8 +50,9 @@ struct PyScene(Scene);
 #[pymethods]
 impl PyScene {
     #[new]
-    pub fn new() -> Self {
-        Self(Scene::new())
+    pub fn new(camera: &PyCamera, contents: Vec<PyShape>) -> Self {
+        let contents = contents.into_iter().map(|s| s.0).collect();
+        Self(Scene::new(camera.0.clone(), contents))
     }
 
     pub fn render(&self, width: usize, height: usize) -> Vec<f32> {
@@ -42,6 +65,7 @@ impl PyScene {
 /// A Python module implemented in Rust.
 #[pymodule]
 fn mechthild(m: &Bound<'_, PyModule>) -> PyResult<()> {
+    m.add_class::<PyCamera>()?;
     m.add_class::<PyShape>()?;
     m.add_class::<PyScene>()?;
     Ok(())