Last active
December 29, 2015 22:29
-
-
Save benhardy/7736511 to your computer and use it in GitHub Desktop.
little functional raytracer. it may be longer that the "35 lines of javascript" one but is actually readable. (albeit in need of cleanup, optimizing, etc)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* See bottom for file for example spheres scene | |
*/ | |
import java.awt.Color | |
import java.awt.image.BufferedImage | |
import java.io.File | |
import javax.imageio.ImageIO | |
import math.sqrt | |
import math.min | |
import math.max | |
case class Vec(x:Double, y:Double, z:Double) { | |
def + (other:Vec) = Vec(x + other.x, y + other.y, z + other.z) | |
def unary_- = Vec(-x, -y, -z) | |
def - (other:Vec) = this + ( -other ) | |
def * (k:Double) = Vec(k * x, k * y, k * z) | |
def / (k:Double) = this * (1.0 / k) | |
def dot(other:Vec) = x * other.x + y * other.y + z * other.z | |
def cross(other:Vec) = Vec(y * other.z + z * other.y, z * other.x - x * other.z, x * other.y - y * other.x) | |
lazy val length = sqrt(x * x + y * y + z * z) | |
lazy val unit = { | |
if (length > 0) { | |
this / length | |
} | |
else { | |
this | |
} | |
} | |
} | |
object Vec { | |
val X = Vec(1,0,0) | |
val Y = Vec(0,1,0) | |
val Z = Vec(0,0,1) | |
implicit def fromTuple(tuple:(Double,Double,Double)) = Vec(tuple._1, tuple._2, tuple._3) | |
} | |
case class Ray(from:Vec, direction:Vec) { | |
def along(k: Double) = from + direction * k | |
} | |
case class Finish(diffuse:Double, reflection:Double, ambient:Double, phong:Double = 0.0) | |
object Finish { | |
val Diffuse = Finish(diffuse = 1, reflection = 0, ambient = 0) | |
} | |
object Pigment { | |
val Black = Vec(0,0,0) | |
val Red = Vec(1,0,0) | |
val Green = Vec(0,1,0) | |
val Blue = Vec(0,0,1) | |
val White = Red + Green + Blue | |
val Yellow = Red + Green | |
val Magenta = Red + Blue | |
val Cyan = Blue + Green | |
} | |
import Pigment._ | |
import Finish._ | |
import Vec._ | |
trait Shape { | |
def intersect(ray:Ray): Option[Double] | |
def normalFor(at:Vec): Vec | |
def pigment: Vec | |
def finish: Finish | |
} | |
case class Sphere(center:Vec, radius:Double, | |
override val pigment:Vec = White, | |
override val finish:Finish = Diffuse) extends Shape { | |
def intersect(ray: Ray): Option[Double] = { | |
val a = 2 * (ray.direction dot ray.direction) | |
val b = 2 * ((center dot ray.direction) - (ray.from dot ray.direction)) | |
val c = (ray.from dot ray.from) + (center dot center) - radius * radius - 2 * (ray.from dot center) | |
val disc2 = b * b - 2 * a * c | |
if (disc2 < 0) | |
None | |
else { | |
val howFar = (b - sqrt(disc2)) / a | |
if (howFar > 0) Some(howFar) else None | |
} | |
} | |
def normalFor(at:Vec) = (at - center) | |
} | |
case class Plane(normal:Vec, distanceToOrigin:Double, | |
override val pigment:Vec = White, | |
override val finish:Finish = Diffuse) extends Shape { | |
def intersect(ray:Ray): Option[Double] = { | |
val dot = normal dot ray.direction | |
if (dot == 0.0) { | |
None | |
} else { | |
val howFar = distanceToOrigin * (normal dot ray.from) / dot | |
if (howFar > 0) Some(howFar) else None | |
} | |
} | |
def normalFor(any:Vec) = normal | |
} | |
case class Canvas(width: Int, height:Int) { | |
val image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) | |
def paint(x:Int, y:Int, color:Vec) { | |
val rgb = new Color(color.x.toFloat, color.y.toFloat, color.z.toFloat) | |
image.setRGB(x, y, rgb.getRGB) | |
} | |
} | |
case class Camera(location: Vec = -Z, lookAt: Vec = Z, up: Vec = Y, | |
canvas: Canvas) { | |
val direction = (lookAt - location).unit | |
val right = (up cross direction).unit | |
val actualUp = (direction cross right).unit | |
val pixelSize = 1.0 / canvas.width | |
val aspectRatio = canvas.height.toDouble / canvas.width | |
val filmCenter = location + direction | |
def rayForPixel(x: Int, y:Int) = { | |
val xPixelDelta = right * pixelSize * (x - canvas.width /2) | |
val yPixelDelta = actualUp * pixelSize * -(y - canvas.width /2) | |
val rayDirection = (direction + xPixelDelta + yPixelDelta)//.unit | |
Ray(location, rayDirection) | |
} | |
} | |
object Sky { | |
def Blue(direction:Vec): Vec = { | |
val y = direction.unit.y | |
if (y < 0) { | |
Vec(0,y+1,1) | |
} | |
else { | |
Vec(y,1,1) | |
} | |
} | |
def Black(direction:Vec) = Pigment.Black | |
} | |
case class Light(position: Vec, brightness: Double) | |
case class Scene( | |
contents: Seq[Shape], | |
camera: Camera, | |
lights: Seq[Light], | |
sky: Vec => Vec = Sky.Black) { | |
val canvas = camera.canvas | |
def time[T](itemCount:Int, message:String)(work: =>T): T = { | |
val start = System.currentTimeMillis() | |
val result:T = work | |
val elapsed = System.currentTimeMillis() - start | |
val rate = itemCount.toFloat * 1000 / elapsed | |
System.err.print("%s in %.3fsec at %.3fpps\r".format(message, elapsed/1000.0, rate)) | |
result | |
} | |
def render(filename:String): Unit = { | |
time(canvas.width * canvas.height, "Done") { | |
for (y <- 0 until canvas.height) { | |
val lineMessage = "Rendered line %d/%d".format(y + 1, canvas.height) | |
time(canvas.width, lineMessage) { | |
for (x <- 0 until canvas.width) { | |
val ray = camera.rayForPixel(x, y) | |
val color = trace(ray) | |
canvas.paint(x, y, color) | |
} | |
} | |
} | |
} | |
ImageIO.write(canvas.image, "png", new File(filename)) | |
} | |
def trace(ray:Ray, maxDepth: Int = 3): Vec = { | |
closestIntersection(ray). | |
map { case (closest, howFar) => coloring(closest, ray, howFar, maxDepth) }. | |
getOrElse{ sky(ray.direction) } | |
} | |
def closestIntersection(ray: Ray): Option[(Shape,Double)] = { | |
val distances = for { | |
item <- contents | |
distance <- item.intersect(ray) | |
} yield (item, distance) | |
if (distances.isEmpty) { | |
None | |
} else { | |
val closest = distances.minBy(_._2) | |
Some(closest) | |
} | |
} | |
def coloring(shape:Shape, ray:Ray, howFar:Double, maxDepth:Int): Vec = { | |
val hit = ray along howFar | |
val normal = shape normalFor hit | |
val ambient = shape.finish.ambient | |
val n = normal dot normal | |
val effectiveLights = for { | |
light <- lights | |
hitToLight = light.position - hit | |
facing = normal.unit dot hitToLight.unit if !isBlocked(hit, hitToLight, maxDepth) | |
lit = facing if facing > 0 | |
} yield lit | |
val illumination = effectiveLights.sum + ambient | |
// TODO phong shading | |
val reflected = if (shape.finish.reflection > 0 && maxDepth >0) { | |
val reflected = ray.direction - (normal * (2*(normal dot ray.direction)/n)) | |
trace(Ray(hit, reflected), maxDepth -1) * shape.finish.reflection | |
} else { | |
Pigment.Black | |
} | |
val color = shape.pigment * illumination + reflected | |
colorNormalized(color) | |
} | |
def colorNormalized(color: Vec): Vec = { | |
val biggest = max(max(color.x, color.y), color.z) | |
if (biggest > 1) | |
color / biggest | |
else | |
color | |
} | |
def isBlocked(hit: Vec, hitToLight: Vec, maxDepth: Int): Boolean = { | |
closestIntersection(Ray(hit, hitToLight)).isDefined | |
} | |
} | |
object Trace { | |
def main(args:Array[String]) { | |
val camera = Camera( | |
location = (4.8, 5.5, -2.2), | |
lookAt = (2.2, 1.8, 1.5), | |
canvas = Canvas(400, 300) | |
) | |
val littleAmbience = Finish( | |
diffuse = 0.5, ambient = 0.2, reflection = 0.3, phong = 1 | |
) | |
val cubeSize = 6 | |
val objects = for { | |
x <- 0 to cubeSize; y <- 0 to cubeSize; z <- 0 to cubeSize | |
} yield Sphere( | |
center = Vec(x,y,z) * 0.6, | |
radius = 0.25, | |
pigment = Vec(x,y,z) / cubeSize, | |
finish = littleAmbience | |
) | |
val lights = List( | |
Light(position = ( -5.0, 100.0, -5.0), brightness = 0.8), | |
Light(position = (-100.0, 0.0, -30.0), brightness = 0.5) | |
) | |
val scene = Scene(objects, camera, lights) | |
scene.render("spheres.png") | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment