├── .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) 0
23 | # <=> (d . (o - p))^2 - (d . d) * ((o - p) . (o - p) - r*r) 0
24 | # <=> (d . op)^2 - 1 * (op . op - r*r) 0
25 | # <=> b^2 - (op . op) + r*r 0
26 | # <=> D 0
27 | #
28 | # Solutions
29 | # t = (- 2 * d . (o - p) +- 2 * sqrt(D)) / (2 * (d . d))
30 | # <=> 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
--------------------------------------------------------------------------------