├── .editorconfig ├── .gitignore ├── LICENSE.txt ├── README.md └── src ├── image_io.py ├── math_tools.py ├── numpy_smallpt.py ├── ray.py ├── rng.py ├── sampling.py ├── specular.py └── sphere.py /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = tab 4 | indent_size = 4 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # User-Specific Files 3 | ###################################################################### 4 | *.suo 5 | *.user 6 | *.userosscache 7 | *.sln.docstates 8 | ###################################################################### 9 | # Python Tools for Visual Studio (PTVS) 10 | ###################################################################### 11 | __pycache__/ 12 | *.pyc 13 | ###################################################################### 14 | # Visual Studio Cache Files 15 | ###################################################################### 16 | .vs/ 17 | *.db 18 | ###################################################################### -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Matthias Moulin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all copies 13 | or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 16 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 17 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 18 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 19 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 20 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![License][s1]][li] 2 | 3 | [s1]: https://img.shields.io/badge/license-MIT-blue.svg 4 | [li]: https://raw.githubusercontent.com/matt77hias/numpy-smallpt/master/LICENSE.txt 5 | 6 | # numpy-smallpt 7 | 8 | ## About 9 | Python 2.7 & Python 3.5 with NumPy modification of Kevin Baeson's [99 line C++ path tracer](http://www.kevinbeason.com/smallpt/) 10 | 11 | **Note**: I deliberately chose for the same software design for [all programming languages](https://github.com/matt77hias/smallpt) out of clarity and performance reasons (this can conflict with the nature of declarative/functional programming languages). 12 | 13 |

14 | -------------------------------------------------------------------------------- /src/image_io.py: -------------------------------------------------------------------------------- 1 | from math_tools import to_byte 2 | 3 | def write_ppm(w, h, Ls, fname = "numpy-image.ppm"): 4 | with open(fname, 'w') as outfile: 5 | outfile.write('P3\n{0} {1}\n{2}\n'.format(w, h, 255)); 6 | for i in range(Ls.shape[0]): 7 | outfile.write('{0} {1} {2} '.format(to_byte(Ls[i,0]), to_byte(Ls[i,1]), to_byte(Ls[i,2]))); -------------------------------------------------------------------------------- /src/math_tools.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | def normalize(v): 4 | norm = np.linalg.norm(v) 5 | if norm == 0: 6 | return v 7 | return v / norm 8 | 9 | def to_byte(x, gamma = 2.2): 10 | return int(np.clip(255.0 * np.power(x, 1.0 / gamma), a_min=0.0, a_max=255.0)) -------------------------------------------------------------------------------- /src/numpy_smallpt.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from image_io import write_ppm 4 | from math_tools import normalize 5 | from ray import Ray 6 | from rng import RNG 7 | from sampling import cosine_weighted_sample_on_hemisphere 8 | from sphere import Sphere 9 | from specular import ideal_specular_reflect, ideal_specular_transmit 10 | 11 | # Scene 12 | REFRACTIVE_INDEX_OUT = 1.0 13 | REFRACTIVE_INDEX_IN = 1.5 14 | 15 | spheres = [ 16 | Sphere(1e5, np.array([1e5 + 1, 40.8, 81.6], dtype=np.float64), f=np.array([0.75,0.25,0.25], dtype=np.float64)), 17 | Sphere(1e5, np.array([-1e5 + 99, 40.8, 81.6], dtype=np.float64), f=np.array([0.25,0.25,0.75], dtype=np.float64)), 18 | Sphere(1e5, np.array([50, 40.8, 1e5], dtype=np.float64), f=np.array([0.75, 0.75, 0.75], dtype=np.float64)), 19 | Sphere(1e5, np.array([50, 40.8, -1e5 + 170], dtype=np.float64)), 20 | Sphere(1e5, np.array([50, 1e5, 81.6], dtype=np.float64), f=np.array([0.75, 0.75, 0.75], dtype=np.float64)), 21 | Sphere(1e5, np.array([50, -1e5 + 81.6, 81.6], dtype=np.float64), f=np.array([0.75, 0.75, 0.75], dtype=np.float64)), 22 | Sphere(16.5, np.array([27, 16.5, 47], dtype=np.float64), f=np.array([0.999, 0.999, 0.999], dtype=np.float64), reflection_t=Sphere.Reflection_t.SPECULAR), 23 | Sphere(16.5, np.array([73, 16.5, 78], dtype=np.float64), f=np.array([0.999, 0.999, 0.999], dtype=np.float64), reflection_t=Sphere.Reflection_t.REFRACTIVE), 24 | Sphere(600, np.array([50, 681.6 - .27, 81.6], dtype=np.float64), e=np.array([12, 12, 12], dtype=np.float64)) 25 | ] 26 | 27 | 28 | def intersect(ray): 29 | id = None 30 | hit = False 31 | for i in range(len(spheres)): 32 | if spheres[i].intersect(ray): 33 | hit = True 34 | id = i 35 | return hit, id 36 | 37 | def intersectP(ray): 38 | for i in range(len(spheres)): 39 | if spheres[i].intersect(ray): 40 | return True 41 | return False 42 | 43 | def radiance(ray, rng): 44 | r = ray 45 | L = np.zeros((3), dtype=np.float64) 46 | F = np.ones((3), dtype=np.float64) 47 | 48 | while (True): 49 | hit, id = intersect(r) 50 | if (not hit): 51 | return L 52 | 53 | shape = spheres[id] 54 | p = r(r.tmax) 55 | n = normalize(p - shape.p) 56 | 57 | L += F * shape.e 58 | F *= shape.f 59 | 60 | # Russian roulette 61 | if r.depth > 4: 62 | continue_probability = np.amax(shape.f) 63 | if rng.uniform_float() >= continue_probability: 64 | return L 65 | F /= continue_probability 66 | 67 | # Next path segment 68 | if shape.reflection_t == Sphere.Reflection_t.SPECULAR: 69 | d = ideal_specular_reflect(r.d, n) 70 | r = Ray(p, d, tmin=Sphere.EPSILON_SPHERE, depth=r.depth + 1) 71 | continue 72 | elif shape.reflection_t == Sphere.Reflection_t.REFRACTIVE: 73 | d, pr = ideal_specular_transmit(r.d, n, REFRACTIVE_INDEX_OUT, REFRACTIVE_INDEX_IN, rng) 74 | F *= pr 75 | r = Ray(p, d, tmin=Sphere.EPSILON_SPHERE, depth=r.depth + 1) 76 | continue 77 | else: 78 | w = n if n.dot(r.d) < 0 else -n 79 | u = normalize(np.cross(np.array([0.0, 1.0, 0.0], np.float64) if np.fabs(w[0]) > 0.1 else np.array([1.0, 0.0, 0.0], np.float64), w)) 80 | v = np.cross(w, u) 81 | 82 | sample_d = cosine_weighted_sample_on_hemisphere(rng.uniform_float(), rng.uniform_float()) 83 | d = normalize(sample_d[0] * u + sample_d[1] * v + sample_d[2] * w) 84 | r = Ray(p, d, tmin=Sphere.EPSILON_SPHERE, depth=r.depth + 1) 85 | continue 86 | 87 | import sys 88 | 89 | if __name__ == "__main__": 90 | rng = RNG() 91 | nb_samples = int(sys.argv[1]) // 4 if len(sys.argv) > 1 else 1 92 | 93 | w = 1024 94 | h = 768 95 | 96 | eye = np.array([50, 52, 295.6], dtype=np.float64) 97 | gaze = normalize(np.array([0, -0.042612, -1], dtype=np.float64)) 98 | fov = 0.5135 99 | cx = np.array([w * fov / h, 0.0, 0.0], dtype=np.float64) 100 | cy = normalize(np.cross(cx, gaze)) * fov 101 | 102 | Ls = np.zeros((w * h, 3), dtype=np.float64) 103 | 104 | for y in range(h): 105 | # pixel row 106 | print('\rRendering ({0} spp) {1:0.2f}%'.format(nb_samples * 4, 100.0 * y / (h - 1))) 107 | for x in range(w): 108 | # pixel column 109 | for sy in range(2): 110 | i = (h - 1 - y) * w + x 111 | # 2 subpixel row 112 | for sx in range(2): 113 | # 2 subpixel column 114 | L = np.zeros((3), dtype=np.float64) 115 | for s in range(nb_samples): 116 | # samples per subpixel 117 | u1 = 2.0 * rng.uniform_float() 118 | u2 = 2.0 * rng.uniform_float() 119 | dx = np.sqrt(u1) - 1.0 if u1 < 1 else 1.0 - np.sqrt(2.0 - u1) 120 | dy = np.sqrt(u2) - 1.0 if u2 < 1 else 1.0 - np.sqrt(2.0 - u2) 121 | d = cx * (((sx + 0.5 + dx) / 2.0 + x) / w - 0.5) + \ 122 | cy * (((sy + 0.5 + dy) / 2.0 + y) / h - 0.5) + gaze 123 | L += radiance(Ray(eye + d * 130, normalize(d), tmin=Sphere.EPSILON_SPHERE), rng) * (1.0 / nb_samples) 124 | Ls[i,:] += 0.25 * np.clip(L, a_min=0.0, a_max=1.0) 125 | 126 | write_ppm(w, h, Ls) 127 | -------------------------------------------------------------------------------- /src/ray.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | class Ray(object): 4 | 5 | def __init__(self, o, d, tmin = 0.0, tmax = np.inf, depth = 0): 6 | self.o = np.copy(o) 7 | self.d = np.copy(d) 8 | self.tmin = tmin 9 | self.tmax = tmax 10 | self.depth = depth 11 | 12 | def __call__(self, t): 13 | return self.o + self.d * t 14 | 15 | def __str__(self): 16 | return 'o: ' + str(self.o) + '\n' + 'd: ' + str(self.d) + '\n' -------------------------------------------------------------------------------- /src/rng.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | class RNG(object): 4 | 5 | def __init__(self, s = 606418532): 6 | self.rnd = np.random 7 | self.seed(s) 8 | 9 | def seed(self, s): 10 | self.rnd.seed(s) 11 | 12 | def uniform_float(self): 13 | return self.rnd.random() -------------------------------------------------------------------------------- /src/sampling.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | def uniform_sample_on_hemisphere(u1, u2): 4 | sin_theta = np.sqrt(np.maximum(0.0, 1.0 - u1 * u1)) 5 | phi = 2.0 * np.pi * u2 6 | return np.array([np.cos(phi) * sin_theta, np.sin(phi) * sin_theta, u1], dtype=np.float64) 7 | 8 | def cosine_weighted_sample_on_hemisphere(u1, u2): 9 | cos_theta = np.sqrt(1.0 - u1) 10 | sin_theta = np.sqrt(u1) 11 | phi = 2.0 * np.pi * u2 12 | return np.array([np.cos(phi) * sin_theta, np.sin(phi) * sin_theta, cos_theta], dtype=np.float64) -------------------------------------------------------------------------------- /src/specular.py: -------------------------------------------------------------------------------- 1 | from math_tools import normalize 2 | import numpy as np 3 | 4 | def reflectance0(n1, n2): 5 | sqrt_R0 = np.float64(n1 - n2) / (n1 + n2) 6 | return sqrt_R0 * sqrt_R0 7 | 8 | def schlick_reflectance(n1, n2, c): 9 | R0 = reflectance0(n1, n2) 10 | return R0 + (1.0 - R0) * c * c * c * c * c 11 | 12 | def ideal_specular_reflect(d, n): 13 | return d - 2.0 * n.dot(d) * n 14 | 15 | def ideal_specular_transmit(d, n, n_out, n_in, rng): 16 | n_out, n_in = np.float64(n_out), np.float64(n_in) 17 | d_Re = ideal_specular_reflect(d, n) 18 | 19 | out_to_in = n.dot(d) < 0 20 | nl = n if out_to_in else -n 21 | nn = n_out / n_in if out_to_in else n_in / n_out 22 | cos_theta = d.dot(nl) 23 | cos2_phi = 1.0 - nn * nn * (1.0 - cos_theta * cos_theta) 24 | 25 | # Total Internal Reflection 26 | if cos2_phi < 0: 27 | return d_Re, 1.0 28 | 29 | d_Tr = normalize(nn * d - nl * (nn * cos_theta + np.sqrt(cos2_phi))) 30 | c = 1.0 - (-cos_theta if out_to_in else d_Tr.dot(n)) 31 | 32 | Re = schlick_reflectance(n_out, n_in, c) 33 | p_Re = 0.25 + 0.5 * Re 34 | if rng.uniform_float() < p_Re: 35 | return d_Re, (Re / p_Re) 36 | else: 37 | Tr = 1.0 - Re 38 | p_Tr = 1.0 - p_Re 39 | return d_Tr, (Tr / p_Tr) -------------------------------------------------------------------------------- /src/sphere.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | class Sphere(object): 4 | 5 | EPSILON_SPHERE = 1e-4 6 | 7 | class Reflection_t(object): 8 | DIFFUSE, SPECULAR, REFRACTIVE = range(3) 9 | 10 | def __init__(self, r, p, e = np.zeros((3), dtype=np.float64), f = np.zeros((3), dtype=np.float64), reflection_t = Reflection_t.DIFFUSE): 11 | self.r = np.float64(r) 12 | self.p = np.copy(p) 13 | self.e = np.copy(e) 14 | self.f = np.copy(f) 15 | self.reflection_t = reflection_t 16 | 17 | def intersect(self, ray): 18 | # (o + t*d - p) . (o + t*d - p) - r*r = 0 19 | # <=> (d . d) * t^2 + 2 * d . (o - p) * t + (o - p) . (o - p) - r*r = 0 20 | # 21 | # Discriminant check 22 | # (2 * d . (o - p))^2 - 4 * (d . d) * ((o - p) . (o - p) - r*r) (d . (o - p))^2 - (d . d) * ((o - p) . (o - p) - r*r) (d . op)^2 - 1 * (op . op - r*r) b^2 - (op . op) + r*r D t = dop +- sqrt(D) 31 | 32 | op = self.p - ray.o 33 | dop = ray.d.dot(op) 34 | D = dop * dop - op.dot(op) + self.r * self.r 35 | 36 | if D < 0: 37 | return False 38 | 39 | sqrtD = np.sqrt(D) 40 | 41 | tmin = dop - sqrtD 42 | if (ray.tmin < tmin and tmin < ray.tmax): 43 | ray.tmax = tmin 44 | return True 45 | 46 | tmax = dop + sqrtD 47 | if (ray.tmin < tmax and tmax < ray.tmax): 48 | ray.tmax = tmax 49 | return True 50 | 51 | return False --------------------------------------------------------------------------------