## Based on the work of https://github.com/edin/raytracer
## MIT License
## Copyright (c) 2021 Edin Omeragic

import benchy, chroma, math, glm
from pixie import Image, newImage, writeFile, dataIndex

type Vec3 = glm.Vec3[float32]

{.push inline, noinit, checks: off.}

type
  SurfaceType = enum
    ShinySurface, CheckerBoardSurface

  ObjectType = enum
    Sphere, Plane

  Camera = object
    forward, right, up, pos: Vec3

  Ray = object
    start, dir: Vec3

  Thing = ref object
    surfaceType: SurfaceType
    case objectType: ObjectType
    of Sphere:
      center: Vec3
      radius2: float32
    of Plane:
      normal: Vec3
      offset: float32

  Intersection = object
    thing: Thing
    ray: Ray
    dist: float32

  Light = object
    pos: Vec3
    color: Color

  Scene = ref object
    maxDepth: int
    things: seq[Thing]
    lights: seq[Light]
    camera: Camera

  SurfaceProperties = object
    diffuse, specular: Color
    reflect, roughness: float32

const
  farAway: float32 = 1000000.0
  white = color(1.0, 1.0, 1.0)
  grey = color(0.5, 0.5, 0.5)
  black = color(0.0, 0.0, 0.0)
  background = color(0.0, 0.0, 0.0)
  defaultColor = color(0.0, 0.0, 0.0)

proc `*`(c: Color, k: float32): Color = color(k * c.r, k * c.g, k * c.b)
proc `*`(a: Color, b: Color): Color = color(a.r * b.r, a.g * b.g, a.b * b.b)
proc `+`(a: Color, b: Color): Color = color(a.r + b.r, a.g + b.g, a.b + b.b)

proc newCamera(pos: Vec3, lookAt: Vec3): Camera =
  var
    down = vec3(0.0f, -1.0f, 0.0f)
    forward = lookAt - pos
  result.pos = pos
  result.forward = forward.normalize()
  result.right = result.forward.cross(down)
  result.up = result.forward.cross(result.right)
  let
    rightNorm = result.right.normalize()
    upNorm = result.up.normalize()
  result.right = rightNorm * 1.5
  result.up = upNorm * 1.5

proc getNormal(obj: Thing, pos: Vec3): Vec3 =
  case obj.objectType:
    of Sphere:
      return (pos - obj.center).normalize()
    of Plane:
      return obj.normal

proc objectIntersect(obj: Thing, ray: Ray): Intersection =
  case obj.objectType:
    of Sphere:
      let
        eo = obj.center - ray.start
        v = eo.dot(ray.dir)
      if v >= 0:
        var dist = 0.0
        let disc = obj.radius2 - (eo.dot(eo) - (v * v))
        if disc >= 0:
          dist = v - sqrt(disc)
        if dist != 0.0:
          result.thing = obj
          result.ray = ray
          result.dist = dist
    of Plane:
      let denom = obj.normal.dot(ray.dir)
      if denom <= 0:
        result.dist = (obj.normal.dot(ray.start) + obj.offset) / (-denom)
        result.thing = obj
        result.ray = ray

proc newSphere(center: Vec3, radius: float32, surfaceType: SurfaceType): Thing =
  Thing(surfaceType: surfaceType, objectType: Sphere, center: center,
    radius2: radius * radius)

proc newPlane(normal: Vec3, offset: float32, surfaceType: SurfaceType): Thing =
  Thing(surfaceType: surfaceType, objectType: Plane, normal: normal,
    offset: offset)

proc getSurfaceProperties(obj: Thing, pos: Vec3): SurfaceProperties =
  case obj.surfaceType:
    of ShinySurface:
      result.diffuse = white
      result.specular = grey
      result.reflect = 0.7
      result.roughness = 250.0
    of CheckerBoardSurface:
      let val = int(floor(pos.z) + floor(pos.x))
      if val mod 2 != 0:
        result.reflect = 0.1
        result.diffuse = white
      else:
        result.reflect = 0.7
        result.diffuse = black
      result.specular = white
      result.roughness = 150.0

proc newScene(): Scene =
  result = Scene()
  result.maxDepth = 5
  result.things = @[
    newPlane(vec3(0.0f, 1.0f, 0.0f), 0.0, CheckerBoardSurface),
    newSphere(vec3(0.0f, 1.0f, -0.25f), 1.0, ShinySurface),
    newSphere(vec3(-1.0f, 0.5f, 1.5f), 0.5, ShinySurface)
  ]
  result.lights = @[
    Light(pos: vec3(-2.0f, 2.5f, 0.0f), color: color(0.49, 0.07, 0.07)),
    Light(pos: vec3(1.5f, 2.5f, 1.5f), color: color(0.07, 0.07, 0.49)),
    Light(pos: vec3(1.5f, 2.5f, -1.5f), color: color(0.07, 0.49, 0.071)),
    Light(pos: vec3(0.0f, 3.5f, 0.0f), color: color(0.21, 0.21, 0.35))
  ]
  result.camera = newCamera(vec3(3.0f, 2.0f, 4.0f), vec3(-1.0f, 0.5f, 0.0f))

proc intersections(scene: Scene, ray: Ray): Intersection =
  var closest: float32 = farAway
  result.thing = nil
  for thing in scene.things:
    let intersect = objectIntersect(thing, ray)
    if (not isNil(intersect.thing)) and (intersect.dist < closest):
      result = intersect
      closest = intersect.dist

proc testRay(scene: Scene, ray: Ray): float32 =
  let intersection = scene.intersections(ray)
  if not isNil(intersection.thing):
    return intersection.dist
  return NaN

proc shade(scene: Scene, intersection: Intersection, depth: int): Color

proc traceRay(scene: Scene, ray: Ray, depth: int): Color =
  let intersection = intersections(scene, ray)
  if not isNil(intersection.thing):
    return scene.shade(intersection, depth)
  return background

proc getReflectionColor(
  scene: Scene, thing: Thing, pos: Vec3, normal: Vec3, reflectDir: Vec3,
  depth: int
): Color =
  var
    ray: Ray = Ray(start: pos, dir: reflectDir)
    color = scene.traceRay(ray, depth + 1)
    properties = getSurfaceProperties(thing, pos)
  return color * properties.reflect

proc getNaturalColor(scene: Scene, thing: Thing, pos, norm,
  reflectDir: Vec3
): Color =
  result = black
  var
    reflectDirNorm = reflectDir.normalize()
    sp = getSurfaceProperties(thing, pos)
  for light in scene.lights:
    let
      lightDist = light.pos - pos
      lightVec = lightDist.normalize()
      lightDistLen = lightDist.length()
      ray = Ray(start: pos, dir: lightVec)
      neatIntersection = scene.testRay(ray)
      isInShadow = neatIntersection.classify != fcNan and
        neatIntersection <= lightDistLen

    if not isInShadow:
      let
        illumination = lightVec.dot(norm)
        specular = lightVec.dot(reflectDirNorm)
      var
        lightColor =
          if illumination > 0: light.color * illumination
          else: defaultColor
        specularColor =
          if specular > 0: light.color * pow(specular, sp.roughness)
          else: defaultColor
      lightColor = lightColor * sp.diffuse
      specularColor = specularColor * sp.specular
      result = result + lightColor + specularColor

proc shade(scene: Scene, intersection: Intersection, depth: int): Color =
  var
    dir = intersection.ray.dir
    scaled = dir * intersection.dist
    pos = scaled + intersection.ray.start
    normal = intersection.thing.getNormal(pos)
    reflectDir = dir - (normal * normal.dot(dir) * 2)
    naturalColor = background + getNaturalColor(scene, intersection.thing,
      pos, normal, reflectDir)
    reflectedColor: Color
  if depth >= scene.maxDepth:
    reflectedColor = grey
  else:
    reflectedColor = getReflectionColor(scene, intersection.thing, pos, normal,
      reflectDir, depth)
  return naturalColor + reflectedColor

proc getPoint(x, y: int, camera: Camera, screenWidth, screenHeight: int): Vec3 =
  var
    sw = float32(screenWidth)
    sh = float32(screenHeight)
    xf = float32(x)
    yf = float32(y)
    recenterX = (xf - (sw / 2.0)) / 2.0 / sw
    recenterY = -(yf - (sh / 2.0)) / 2.0 / sh
    vx = camera.right * recenterX
    vy = camera.up * recenterY
    v = vx + vy
    z = camera.forward + v
  return z.normalize()

proc renderScene(scene: Scene, sceneImage: Image) =
  var ray: Ray
  let
    h = sceneImage.height
    w = sceneImage.width
  ray.start = scene.camera.pos
  for y in 0 ..< h:
    var pos = y * w
    for x in 0 ..< w:
      ray.dir = getPoint(x, y, scene.camera, h, w)
      sceneImage.data[sceneImage.dataIndex(x, y)] = scene.traceRay(ray, 0).asRgbx()
      pos = pos + 1

proc render(): Image =
  var
    scene = newScene()
  result = newImage(500, 500)
  renderScene(scene, result)

render().writeFile("tests/raytracer.png")

timeIt "raytracer", 100:
  discard render()
