Created
January 26, 2024 16:32
-
-
Save coderobe/e1a4e4816d0637faf027ece43cf3c868 to your computer and use it in GitHub Desktop.
basic ray tracing in crystal, renders to terminal. (2020)
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
#!/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