# Ported from Nimrod: https://gist.github.com/AdrianV/5774141

require "./sdl/sdl"

WIDTH     = 1280
HEIGHT    =  720
FOV       = 45.0
MAX_DEPTH =    6

struct Vec3
  getter :x
  getter :y
  getter :z

  def initialize
    @x, @y, @z = 0.0, 0.0, 0.0
  end

  def initialize(value)
    @x, @y, @z = value, value, value
  end

  def initialize(@x, @y, @z)
  end

  {% for op in %w(+ - * /) %}
    def {{op.id}}(other : Vec3)
      Vec3.new(@x {{op.id}} other.x, @y {{op.id}} other.y, @z {{op.id}} other.z)
    end

    def {{op.id}}(other : Float)
      Vec3.new(@x {{op.id}} other, @y {{op.id}} other, @z {{op.id}} other)
    end
  {% end %}

  def -
    Vec3.new(-@x, -@y, -@z)
  end

  def dot(other)
    @x * other.x + @y * other.y + @z * other.z
  end

  def magnitude
    Math.sqrt(dot(self))
  end

  def normalize
    m = magnitude
    Vec3.new(@x / m, @y / m, @z / m)
  end
end

record Ray, start : Vec3, dir : Vec3

class Sphere
  getter :color
  getter :reflection
  getter :transparency

  def initialize(@center : Vec3, @radius : Float64, @color : Vec3, @reflection = 0.0, @transparency = 0.0)
  end

  def intersects?(ray)
    vl = @center - ray.start
    a = vl.dot(ray.dir)
    return false if a < 0

    b2 = vl.dot(vl) - a * a
    r2 = @radius * @radius
    return false if b2 > r2

    true
  end

  def intersect(ray, distance)
    vl = @center - ray.start
    a = vl.dot(ray.dir)
    return nil if a < 0

    b2 = vl.dot(vl) - a * a
    r2 = @radius * @radius
    return nil if b2 > r2

    c = Math.sqrt(r2 - b2)
    near = a - c
    far = a + c
    near < 0 ? far : near
  end

  def normalize(v)
    (v - @center).normalize
  end
end

record Light, position : Vec3, color : Vec3
record Scene, objects : Array(Sphere), lights : Array(Light)

def trace(ray, scene, depth)
  nearest = 1e9
  obj = nil
  result = Vec3.new

  scene.objects.each do |o|
    distance = 1e9
    if (distance = o.intersect(ray, distance)) && distance < nearest
      nearest = distance
      obj = o
    end
  end

  if obj
    point_of_hit = ray.dir * nearest
    point_of_hit += ray.start
    normal = obj.normalize(point_of_hit)
    inside = false
    dot_normal_ray = normal.dot(ray.dir)
    if dot_normal_ray > 0
      inside = true
      normal = -normal
      dot_normal_ray = -dot_normal_ray
    end

    reflection_ratio = obj.reflection
    norm_e5 = normal * 1.0e-5

    scene.lights.each do |lgt|
      light_direction = (lgt.position - point_of_hit).normalize
      r = Ray.new(point_of_hit + norm_e5, light_direction)

      # go through the scene check whether we're blocked from the lights
      blocked = scene.objects.any? &.intersects? r

      unless blocked
        temp = lgt.color
        temp *= Math.max(0.0, normal.dot(light_direction))
        temp *= obj.color
        temp *= (1.0 - reflection_ratio)
        result += temp
      end
    end

    facing = Math.max(0.0, -dot_normal_ray)
    fresneleffect = reflection_ratio + (1.0 - reflection_ratio) * ((1.0 - facing) ** 5.0)

    # compute reflection
    if depth < MAX_DEPTH && reflection_ratio > 0
      reflection_direction = ray.dir - normal * 2.0 * dot_normal_ray
      reflection = trace(Ray.new(point_of_hit + norm_e5, reflection_direction), scene, depth + 1)
      result += reflection * fresneleffect
    end

    # compute refraction
    if depth < MAX_DEPTH && (obj.transparency > 0.0)
      ior = 1.5
      ce = ray.dir.dot(normal) * -1.0
      ior = inside ? 1.0 / ior : ior
      eta = 1.0 / ior
      gf = (ray.dir + normal * ce) * eta
      sin_t1_2 = 1.0 - ce * ce
      sin_t2_2 = sin_t1_2 * (eta * eta)
      if sin_t2_2 < 1.0
        gc = normal * Math.sqrt(1 - sin_t2_2)
        refraction_direction = gf - gc
        refraction = trace(Ray.new(point_of_hit - normal * 1.0e-4, refraction_direction),
          scene, depth + 1)
        result += refraction * (1.0 - fresneleffect) * obj.transparency
      end
    end
  end

  result
end

def render(scene, surface)
  surface.lock

  eye = Vec3.new
  h = Math.tan(FOV / 360.0 * 2.0 * Math::PI / 2.0) * 2.0
  ww = surface.width.to_f64
  hh = surface.height.to_f64
  w = h * ww / hh

  i = 0
  HEIGHT.times do |y|
    yy = y.to_f64
    WIDTH.times do |x|
      xx = x.to_f64
      dir = Vec3.new((xx - ww / 2.0) / ww * w,
        (hh / 2.0 - yy) / hh * h,
        -1.0).normalize
      pixel = trace(Ray.new(eye, dir), scene, 0.0)
      r = Math.min(255, (pixel.x * 255.0).round.to_i)
      g = Math.min(255, (pixel.y * 255.0).round.to_i)
      b = Math.min(255, (pixel.z * 255.0).round.to_i)
      surface[i] = (b << 24) + (g << 16) + (r << 8)
      i += 1
    end
  end

  surface.unlock
  surface.update_rect 0, 0, 0, 0
end

Signal::INT.trap { exit }

scene = Scene.new(
  [
    Sphere.new(Vec3.new(0.0, -10002.0, -20.0), 10000.0, Vec3.new(0.8, 0.8, 0.8)),
    Sphere.new(Vec3.new(0.0, 2.0, -20.0), 4.0, Vec3.new(0.8, 0.5, 0.5), 0.5),
    Sphere.new(Vec3.new(5.0, 0.0, -15.0), 2.0, Vec3.new(0.3, 0.8, 0.8), 0.2),
    Sphere.new(Vec3.new(-5.0, 0.0, -15.0), 2.0, Vec3.new(0.3, 0.5, 0.8), 0.2),
    Sphere.new(Vec3.new(-2.0, -1.0, -10.0), 1.0, Vec3.new(0.1, 0.1, 0.1), 0.1, 0.8),
  ],
  [
    Light.new(Vec3.new(-10.0, 20.0, 30.0), Vec3.new(2.0, 2.0, 2.0)),
  ]
)

SDL.init
SDL.hide_cursor
surface = SDL.set_video_mode WIDTH, HEIGHT, 32, LibSDL::DOUBLEBUF | LibSDL::HWSURFACE | LibSDL::ASYNCBLIT

first = true

begin
  while true
    SDL.poll_events do |event|
      if event.type.in?(LibSDL::QUIT, LibSDL::KEYDOWN)
        SDL.quit
        exit
      end
    end

    if first
      start = SDL.ticks
      render scene, surface
      ms = SDL.ticks - start
      puts "Rendered in #{ms} ms"
      first = false
    end
  end
ensure
  SDL.quit
end
