├── .ruby-gemset ├── .ruby-version ├── assets ├── output-16spp.png ├── output-64spp.png ├── output-1024spp.png └── intro-to-ray-tracing.pdf ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md └── tiny-path.rb /.ruby-gemset: -------------------------------------------------------------------------------- 1 | tiny-path -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-2.1.0 -------------------------------------------------------------------------------- /assets/output-16spp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JVillella/tiny-path/HEAD/assets/output-16spp.png -------------------------------------------------------------------------------- /assets/output-64spp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JVillella/tiny-path/HEAD/assets/output-64spp.png -------------------------------------------------------------------------------- /assets/output-1024spp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JVillella/tiny-path/HEAD/assets/output-1024spp.png -------------------------------------------------------------------------------- /assets/intro-to-ray-tracing.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JVillella/tiny-path/HEAD/assets/intro-to-ray-tracing.pdf -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # A sample Gemfile 2 | source "https://rubygems.org" 3 | 4 | gem "chunky_png", "~> 1.3.5" 5 | gem "byebug", "~> 8.2.2" 6 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | byebug (8.2.2) 5 | chunky_png (1.3.5) 6 | 7 | PLATFORMS 8 | ruby 9 | 10 | DEPENDENCIES 11 | byebug (~> 8.2.2) 12 | chunky_png (~> 1.3.5) 13 | 14 | BUNDLED WITH 15 | 1.11.2 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Julian Villella 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tiny Path 2 | A tiny, one-file, Monte Carlo path tracer written in a few hundred lines of Ruby. The source accompanied a short talk I gave as an introduction to Ray Tracing (presentation slides available [here](https://github.com/JVillella/tiny-path/blob/master/assets/intro-to-ray-tracing.pdf)). The hope is that it provides a simple to understand example. The image below is the rendered output at 1024spp. 3 | 4 | ![](https://raw.githubusercontent.com/JVillella/tiny-path/master/assets/output-1024spp.png) 5 | 6 | ## Features 7 | * Monte Carlo method 8 | * Global illumination 9 | * Diffuse, and specular BRDFs 10 | * Ray-sphere intersection 11 | * Soft shadows 12 | * Anti-aliasing 13 | * Modified Cornell box 14 | * PNG image format output 15 | * Progressive saving 16 | 17 | ## Usage 18 | ``` 19 | $ ruby tiny-path.rb --help 20 | 21 | Usage: tiny-path.rb [options] 22 | -w, --width=width Image width 23 | -h, --height=height Image height 24 | -s, --spp=spp Samples per pixel 25 | -o, --output=filename Output filename 26 | -p, --[no-]progressive-save Save file while rendering 27 | ``` 28 | To render the scene at 16 samples per pixel, run the following command, 29 | ``` 30 | $ ruby tiny-path.rb -s 16 31 | ``` 32 | It will save a file in the same directory titled `output.png`. 33 | 34 | ## Author 35 | Julian Villella 36 | 37 | ## License 38 | Tiny Path is available under the MIT license. See the LICENSE file for more info. 39 | -------------------------------------------------------------------------------- /tiny-path.rb: -------------------------------------------------------------------------------- 1 | require 'matrix' 2 | require 'optparse' 3 | require 'chunky_png' 4 | require 'byebug' 5 | 6 | Point = Col = Vec = Vector # alias 7 | 8 | EPSILON = 0.0001 9 | INVERTED_PI = 1.0 / Math::PI; TWO_PI = 2 * Math::PI 10 | MAX_DEPTH = 2 11 | CLEAR = "\r\e[K" 12 | 13 | class Numeric 14 | def clamp(min=0, max=1) 15 | self < min ? min : self > max ? max : self 16 | end 17 | end 18 | 19 | class Ray 20 | attr_accessor :org, :dir 21 | def initialize(org, dir) 22 | @org, @dir = org, dir 23 | end 24 | def hit(t) 25 | @org + @dir * t 26 | end 27 | end 28 | 29 | class Camera 30 | attr_reader :eye, :focal, :view_dist, :up 31 | def initialize(eye, focal, view_dist, up) 32 | @eye, @focal, @view_dist, @up = eye, focal, view_dist, up 33 | end 34 | def calc_orthonormal_basis 35 | @w = (@eye - @focal).normalize # right-hand coordinate system 36 | @u = @up.cross_product(@w).normalize 37 | @v = @w.cross_product(@u) # already normalized 38 | end 39 | def spawn_ray(x, y) 40 | dir = @u * x + @v * y - @w * @view_dist 41 | Ray.new(@eye, dir.normalize) 42 | end 43 | end 44 | 45 | class Mat 46 | attr_accessor :col 47 | attr_reader :emiss 48 | def initialize(col=WHITE) 49 | @col, @emiss = col, BLACK 50 | end 51 | end 52 | 53 | class Diff < Mat 54 | def f(wi, wo, normal) 55 | @col * INVERTED_PI 56 | end 57 | def sample_f(normal, wo) 58 | wi = oriented_hemi_dir(Random.rand, Random.rand, normal, 0.0) 59 | pdf = normal.inner_product(wi) * INVERTED_PI 60 | [wi, pdf] 61 | end 62 | end 63 | 64 | class Spec < Mat 65 | def f(wi, wo, normal) 66 | @col 67 | end 68 | def sample_f(normal, wo) 69 | wi = -1 * wo + normal * 2 * normal.inner_product(wo) 70 | pdf = normal.inner_product(wi) 71 | [wi.normalize, pdf] 72 | end 73 | end 74 | 75 | class Emit < Diff 76 | attr_accessor :emiss 77 | def initialize(emiss=WHITE) 78 | super(emiss) 79 | @emiss = emiss 80 | end 81 | end 82 | 83 | class Sphere 84 | attr_accessor :pos, :rad, :mat, :inv_rad 85 | def initialize(pos, rad, mat) 86 | @pos, @rad, @mat = pos, rad, mat 87 | @inv_rad = 1.0 / @rad 88 | end 89 | def intersect(ray) # TODO: Refactor 90 | op = pos - ray.org 91 | b = op.inner_product(ray.dir) 92 | deter = b * b - op.inner_product(op) + @rad * @rad 93 | return Float::INFINITY if deter < 0 94 | 95 | deter = Math.sqrt(deter) 96 | if (t = b - deter) > EPSILON; return [t, compute_normal(ray, t)]; end 97 | if (t = b + deter) > EPSILON; return [t, compute_normal(ray, t)]; end 98 | Float::INFINITY # No hit 99 | end 100 | def compute_normal(ray, t) 101 | normal = (ray.hit(t) - @pos) * @inv_rad 102 | normal.normalize 103 | end 104 | end 105 | 106 | def sample_hemi(u1, u2, exp) 107 | z = (1 - u1) ** (1.0 / (exp + 1)) 108 | phi = TWO_PI * u2 # azimuth 109 | theta = Math.sqrt([0.0, 1.0 - z*z].max) # polar 110 | Vec[theta * Math.cos(phi), theta * Math.sin(phi), z] 111 | end 112 | 113 | def oriented_hemi_dir(u1, u2, normal, exp) 114 | p = sample_hemi(u1, u2, exp) # random point on hemisphere 115 | w = normal # create orthonormal basis around normal 116 | v = Vector[0.00319, 1.0, 0.0078].cross_product(w).normalize # jittered up 117 | u = v.cross_product(w).normalize 118 | (u * p[0] + v * p[1] + w * p[2]).normalize # linear projection of hemi dir 119 | end 120 | 121 | def orient_normal(normal, wo) # ensure normal is pointing on side of wo 122 | normal.inner_product(wo) < 0 ? normal * -1 : normal 123 | end 124 | 125 | def mult_col(a, b) 126 | Col[a[0]*b[0], a[1]*b[1], a[2]*b[2]] 127 | end 128 | 129 | def gamma(x) 130 | (x.clamp**(1/2.2) * 255 + 0.5).floor 131 | end 132 | 133 | def intersect_spheres(ray) 134 | t = Float::INFINITY; hit_sphere = nil; normal = nil 135 | SPHERES.each do |sphere| 136 | dist, norm = sphere.intersect(ray) 137 | if dist < t 138 | t = dist 139 | hit_sphere = sphere 140 | normal = norm 141 | end 142 | end 143 | [t, hit_sphere, normal] 144 | end 145 | 146 | def radiance(ray, depth=0) 147 | return BLACK if depth > MAX_DEPTH 148 | t, sphere, normal = intersect_spheres(ray) 149 | return BLACK if t.infinite? # No intersection 150 | 151 | wo = ray.dir * -1 # outgoing (towards camera) 152 | normal = orient_normal(normal, wo) 153 | wi, pdf = sphere.mat.sample_f(normal, wo) 154 | f = sphere.mat.f(wi, wo, normal) 155 | 156 | result = mult_col(f, radiance(Ray.new(ray.hit(t), wi), depth + 1)) * wi.inner_product(normal) / pdf 157 | result + sphere.mat.emiss 158 | end 159 | 160 | def render(width, height, spp) 161 | CAMERA.calc_orthonormal_basis 162 | spp_inv = 1.0 / spp 163 | image_data = Array.new(height) { Array.new(width, BLACK) } 164 | 165 | inv_pixel_count = 1.0 / (width * height) 166 | i = 0 167 | (0...height).each do |y| 168 | (0...width).each do |x| 169 | pixel = Col[0,0,0] 170 | (0...spp).each do 171 | sx = (x + Random.rand) - (width * 0.5) 172 | sy = (y + Random.rand) - (height * 0.5) 173 | pixel += radiance(CAMERA.spawn_ray(sx, sy)) * spp_inv 174 | end 175 | image_data[y][x] = pixel 176 | print_progress(i, width * height) 177 | i += 1 178 | end 179 | 180 | save_if_progressive(y, image_data) 181 | end 182 | image_data 183 | end 184 | 185 | def save_if_progressive(y, image_data) 186 | if PROGRESSIVE_SAVING && (y % 40 == 0) # save every 40 lines 187 | save_image(image_data, FILENAME) # saving row 188 | end 189 | end 190 | 191 | def print_progress(current_pixel, pixel_count) 192 | if (current_pixel % (pixel_count / 100)) == 0 193 | percent = (current_pixel / pixel_count.to_f * 100).round(2) 194 | print CLEAR + "%3i%% complete" % percent 195 | STDOUT.flush 196 | end 197 | end 198 | 199 | def save_image(image_data, filename) 200 | height = image_data.length 201 | width = image_data[0].length 202 | png = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::BLACK) 203 | (0...height).each do |y| 204 | (0...width).each do |x| 205 | p = image_data[y][x] 206 | png[x, y] = ChunkyPNG::Color.rgba(gamma(p[0]), gamma(p[1]), gamma(p[2]), 255) 207 | end 208 | end 209 | png.save(filename, :interlace => true) 210 | end 211 | 212 | # Create scene 213 | RED = Col[1,0,0]; GREEN = Col[0,1,0]; BLUE = Col[0,0,1]; WHITE = Col[1,1,1]; BLACK = Col[0,0,0] 214 | D = 520 # Sphere displacement 215 | R = 500 # Sphere radius 216 | SPHERES = [ 217 | Sphere.new(Vec[ 0,-D, 0], R, Emit.new(Col[1,0.9,0.7]*0.8)), # Top 218 | Sphere.new(Vec[ 0, 0, D], R, Diff.new(WHITE)), # Front 219 | Sphere.new(Vec[ 0, 0,-D], R, Diff.new(WHITE)), # Back 220 | Sphere.new(Vec[ 0, D, 0], R, Diff.new(WHITE)), # Bottom 221 | Sphere.new(Vec[-D, 0, 0], R, Diff.new(GREEN)), # Left 222 | Sphere.new(Vec[ D, 0, 0], R, Diff.new(RED)), # Right 223 | Sphere.new(Vec[-6, 0,20], 6, Diff.new(BLUE)), # Center-left 224 | Sphere.new(Vec[ 8, 0,20], 8, Spec.new(WHITE)) # Center-right 225 | ] 226 | CAMERA = Camera.new(Vec[0,0,-20], Vec[0,0,0], 400, Vec[0,1,0]) 227 | 228 | # Command line switches 229 | args = { w: 512, h: 512, spp: 8, filename: 'output.png', prog_save: true } 230 | OptionParser.new do |opts| 231 | opts.banner = "Usage: #{$PROGRAM_NAME} [options]" 232 | opts.on("-w", "--width=width", Integer, "Image width") { |w| args[:w] = w } 233 | opts.on("-h", "--height=height", Integer, "Image height") { |h| args[:h] = h } 234 | opts.on("-s", "--spp=spp", Integer, "Samples per pixel") { |s| args[:spp] = s } 235 | opts.on("-o", "--output=filename", String, "Output filename") { |o| args[:filename] = o } 236 | opts.on("-p", "--[no-]progressive-save", "Save file while rendering") { |p| args[:prog_save] = p } 237 | end.parse! 238 | FILENAME = args[:filename] 239 | PROGRESSIVE_SAVING = args[:prog_save] 240 | 241 | # Begin rendering 242 | start_time = Time.now 243 | puts "Rendering #{args[:w]}x#{args[:h]}" 244 | save_image(render(args[:w], args[:h], args[:spp]), FILENAME) 245 | puts CLEAR + "Render completed in %.2f seconds" % (Time.now - start_time) 246 | --------------------------------------------------------------------------------