Skip to content

Instantly share code, notes, and snippets.

@coderobe
Created January 26, 2024 16:32
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save coderobe/e1a4e4816d0637faf027ece43cf3c868 to your computer and use it in GitHub Desktop.
Save coderobe/e1a4e4816d0637faf027ece43cf3c868 to your computer and use it in GitHub Desktop.
basic ray tracing in crystal, renders to terminal. (2020)
#!/usr/bin/env crystal
TERM = `stty size`.split(" ").map { |x| x.to_i }
WIDTH = TERM.last
HEIGHT = TERM.first - 3
FOV = 45.0
MAX_DEPTH = 6
struct Vec3
property x
property y
property 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
getter :center
setter :center
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
class Light
property position
property color
def initialize(@position : Vec3, @color : Vec3)
end
end
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
normE5 = normal * 1.0e-5
scene.lights.each do |lgt|
light_direction = (lgt.position - point_of_hit).normalize
r = Ray.new(point_of_hit + normE5, 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
else
result = Vec3.new(0.01, 0.01, 0.01)
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 + normE5, 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 write(msg : String)
STDOUT.write msg.to_slice
end
def genb(t, x, y)
((t + x).ceil + (t + y).ceil).to_i % 2 == 0
end
def gend(t, x, y)
(t*2 + x + y).ceil.to_i % 2 == 0
end
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, -25.0), 4.0, Vec3.new(0.9, 0.5, 0.5), 0.5),
Sphere.new(Vec3.new(5.0, 0.0, -10.0), 2.0, Vec3.new(0.5, 0.9, 0.5), 0.2),
Sphere.new(Vec3.new(-5.0, 0.0, -10.0), 2.0, Vec3.new(0.5, 0.5, 0.9), 0.2),
Sphere.new(Vec3.new(-2.0, -1.0, -5.0), 1.0, Vec3.new(0.4, 0.4, 0.4), 0.1, 0.8),
],
[
Light.new(Vec3.new(-10.0, 20.0, 30.0), Vec3.new(2.0, 2.0, 2.0)),
]
)
def frag(t, x, y)
rgb = Vec3.new(0.0, 0.0, 0.0)
s = 0.25 * t
zoom = 1.2
stx = x*zoom/WIDTH
sty = y*zoom/HEIGHT
eye = Vec3.new
h = Math.tan(FOV / 360.0 * 2.0 * Math::PI / 2.0) * 2.0
ww = WIDTH.to_f64
hh = HEIGHT.to_f64
w = h * ww / hh
yy = y.to_f64
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)
rgb = [pixel.x, pixel.y, pixel.z]
return rgb if rgb[0] + rgb[1] + rgb[2] != 0
return [1.0, 0.0, 0.0] if genb(s, stx*2, sty*2)
return [0.0, 1.0, 0.0] if gend(s, -stx*5, -sty*5)
return [0.5, 0.5, 0.5] if genb(s, Math.sin(t) + stx*2, Math.cos(t) + sty*2)
rgb
end
write "\ec"
starttime = Time.monotonic
loop do
frametime = Time.monotonic
SCENE.objects[1].center = Vec3.new(Math.tan((frametime - starttime).total_seconds - (WIDTH/5)), 2.0, -20.0)
SCENE.objects.last.center = Vec3.new(Math.cos((frametime - starttime).total_seconds - (WIDTH/2.5)), -1.0, -5.5)
SCENE.lights.first.position = Vec3.new((Math.sin((frametime - starttime).total_seconds) + 1)*10, (Math.cos((frametime - starttime).total_seconds) + 1)*10 + 10, 30.0)
write "time: #{(Time.monotonic).total_milliseconds.to_i}ms\n"
write "\e[1H"
HEIGHT.times do |y|
WIDTH.times do |x|
r, g, b = frag(frametime.total_milliseconds/800, x, y)
write "\e[48;2;#{(r*200).to_i};#{(g*200).to_i};#{(b*200).to_i}m "
end
write "\e[0m\e[?25l\n"
end
frametime = (Time.monotonic - frametime)
write "frame took #{frametime.total_milliseconds.to_i}ms \n"
sleep Time::Span.new(nanoseconds: (((1 / 60 * 1000) - frametime.total_milliseconds) * 1000000).to_i)
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment