├── .gitignore ├── LICENSE ├── README.md ├── bunny.obj ├── bunny.png ├── bunny.py ├── head-texture.png ├── head.obj ├── head.png ├── head.py └── uv-grid.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Nicolas P. Rougier 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### A tiny software 3D renderer in 100 lines of Python 2 | 3 | Translated from https://github.com/ssloy/tinyrenderer/wiki - Model by [Vidar Rapp](https://se.linkedin.com/in/vidarrapp) 4 | 2492 z-tested, textured & lighted triangles rendered in 0.35 second (~3 FPS) on a Macbook Pro 5 | 6 | ![](./head.png) 7 | 8 | ![](./bunny.png) 9 | -------------------------------------------------------------------------------- /bunny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rougier/tiny-renderer/c4b8c41edd22fcd0ef65af8cd7cd404f92c2bcf5/bunny.png -------------------------------------------------------------------------------- /bunny.py: -------------------------------------------------------------------------------- 1 | # Tiny 3D Renderer (with outlines) 2 | import numpy as np 3 | 4 | def triangle(t, v0, v1, v2, intensity): 5 | global coords, image, zbuffer 6 | 7 | # Barycentric coordinates of points inside the triangle bounding box 8 | xmin = int(max(0, min(v0[0], v1[0], v2[0]))) 9 | xmax = int(min(image.shape[1], max(v0[0], v1[0], v2[0])+1)) 10 | ymin = int(max(0, min(v0[1], v1[1], v2[1]))) 11 | ymax = int(min(image.shape[0], max(v0[1], v1[1], v2[1])+1)) 12 | P = coords[:, xmin:xmax, ymin:ymax].reshape(2,-1) 13 | B = np.dot(t, np.vstack((P, np.ones((1, P.shape[1]), dtype=int)))) 14 | 15 | # Cartesian coordinates of points inside the triangle 16 | I = np.argwhere(np.all(B >= 0, axis=0)) 17 | X, Y, Z = P[0,I], P[1,I], v0[2]*B[0,I] + v1[2]*B[1,I] + v2[2]*B[2,I] 18 | 19 | # Z-Buffer test 20 | I = np.argwhere(zbuffer[Y,X] < Z)[:,0] 21 | X, Y, Z = X[I], Y[I], Z[I] 22 | zbuffer[Y, X] = Z 23 | image[Y, X] = intensity, intensity, intensity, 255 24 | 25 | # Outline (black color) 26 | P = [] 27 | P.extend(line(v0,v1)) 28 | P.extend(line(v1,v2)) 29 | P.extend(line(v2,v0)) 30 | P = np.array(P).T 31 | B = np.dot(t, np.vstack((P, np.ones((1, P.shape[1]), dtype=int)))) 32 | I = np.argwhere(np.all(B >= 0, axis=0)) 33 | X, Y, Z = P[0,I], P[1,I], v0[2]*B[0,I] + v1[2]*B[1,I] + v2[2]*B[2,I] 34 | I = np.argwhere(zbuffer[Y,X] <= Z)[:,0] 35 | X, Y, Z = X[I], Y[I], Z[I] 36 | image[Y, X] = 0, 0, 0, 255 37 | 38 | 39 | def line(A, B): 40 | (x0, y0, _), (x1, y1, _) = np.array(A).astype(int), np.array(B).astype(int) 41 | P = [] 42 | steep = False 43 | if abs(x0-x1) < abs(y0-y1): 44 | steep, x0, y0, x1, y1 = True, y0, x0, y1, x1 45 | if x0 > x1: x0, x1, y0, y1 = x1, x0, y1, y0 46 | dx, dy = x1-x0, y1-y0 47 | y, error2, derror2 = y0, 0, abs(dy)*2 48 | for x in range(x0,x1+1): 49 | if steep: P.append((y,x)) 50 | else: P.append((x,y)) 51 | error2 += derror2; 52 | if error2 > dx: 53 | y += 1 if y1 > y0 else -1 54 | error2 -= dx*2 55 | return P 56 | 57 | def obj_load(filename): 58 | V, Vi = [], [] 59 | with open(filename) as f: 60 | for line in f.readlines(): 61 | if line.startswith('#'): continue 62 | values = line.split() 63 | if not values: continue 64 | if values[0] == 'v': 65 | V.append([float(x) for x in values[1:4]]) 66 | elif values[0] == 'f' : 67 | Vi.append([int(x) for x in values[1:4]]) 68 | return np.array(V), np.array(Vi)-1 69 | 70 | 71 | def lookat(eye, center, up): 72 | normalize = lambda x: x/np.linalg.norm(x) 73 | M = np.eye(4) 74 | z = normalize(eye-center) 75 | x = normalize(np.cross(up,z)) 76 | y = normalize(np.cross(z,x)) 77 | M[0,:3], M[1,:3], M[2,:3], M[:3,3] = x, y, z, -center 78 | return M 79 | 80 | def viewport(x, y, w, h, d): 81 | return np.array([[w/2, 0, 0, x+w/2], 82 | [0, h/2, 0, y+h/2], 83 | [0, 0, d/2, d/2], 84 | [0, 0, 0, 1]]) 85 | 86 | 87 | if __name__ == '__main__': 88 | import time 89 | import PIL.Image 90 | 91 | width, height = 1200,1200 92 | light = np.array([0,0,-1]) 93 | eye = np.array([-1,1,3]) 94 | center = np.array([0,0,0]) 95 | up = np.array([0,1,0]) 96 | 97 | image = np.zeros((height,width,4), dtype=np.uint8) 98 | zbuffer = -1000*np.ones((height,width)) 99 | coords = np.mgrid[0:width, 0:height].astype(int) 100 | 101 | V, Vi = obj_load("bunny.obj") 102 | 103 | # Centering and scaling 104 | vmin, vmax = V.min(), V.max() 105 | V = (2*(V-vmin)/(vmax-vmin) - 1)*1.25 106 | xmin, xmax = V[:,0].min(), V[:,0].max() 107 | V[:,0] = V[:,0] - xmin - (xmax-xmin)/2 108 | ymin, ymax = V[:,1].min(), V[:,1].max() 109 | V[:,1] = V[:,1] - ymin - (ymax-ymin)/2 110 | 111 | viewport = viewport(32, 32, width-64, height-64, 1000) 112 | modelview = lookat(eye, center, up) 113 | 114 | Vh = np.c_[V, np.ones(len(V))] # Homogenous coordinates 115 | V = Vh @ modelview.T # World coordinates 116 | Vs = V @ viewport.T # Screen coordinates 117 | V, Vs = V[:,:3], Vs[:,:3] # Back to cartesian coordinates 118 | 119 | V, Vs = V[Vi], Vs[Vi] 120 | 121 | # Pre-compute tri-linear coordinates 122 | T = np.transpose(Vs, axes=[0,2,1]).copy() 123 | T[:,2,:] = 1 124 | T = np.linalg.inv(T) 125 | 126 | # Pre-compute normal vectors and intensity 127 | N = np.cross(V[:,2]-V[:,0], V[:,1]-V[:,0]) 128 | N = N / np.linalg.norm(N,axis=1).reshape(len(N),1) 129 | I = np.dot(N, light)*255 130 | 131 | start = time.time() 132 | for i in np.argwhere(I>=0)[:,0]: 133 | (vs0, vs1, vs2) = Vs[i] 134 | triangle(T[i], vs0, vs1, vs2, I[i]) 135 | #line(vs0, vs1, (0,0,0,255)) 136 | #line(vs1, vs2, (0,0,0,255)) 137 | #line(vs2, vs0, (0,0,0,255)) 138 | 139 | end = time.time() 140 | 141 | print("Rendering time: {}".format(end-start)) 142 | PIL.Image.fromarray(image[::-1,:,:]).save("bunny.png") 143 | -------------------------------------------------------------------------------- /head-texture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rougier/tiny-renderer/c4b8c41edd22fcd0ef65af8cd7cd404f92c2bcf5/head-texture.png -------------------------------------------------------------------------------- /head.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rougier/tiny-renderer/c4b8c41edd22fcd0ef65af8cd7cd404f92c2bcf5/head.png -------------------------------------------------------------------------------- /head.py: -------------------------------------------------------------------------------- 1 | # Tiny 3D Renderer 2 | import numpy as np 3 | 4 | def triangle(t, A, B, C, intensity): 5 | global coords, texture, image, zbuffer 6 | (v0, uv0), (v1, uv1), (v2, uv2) = A, B, C 7 | 8 | # Barycentric coordinates of points inside the triangle bounding box 9 | # t = np.linalg.inv([[v0[0],v1[0],v2[0]], [v0[1],v1[1],v2[1]], [1,1,1]]) 10 | xmin = int(max(0, min(v0[0], v1[0], v2[0]))) 11 | xmax = int(min(image.shape[1], max(v0[0], v1[0], v2[0])+1)) 12 | ymin = int(max(0, min(v0[1], v1[1], v2[1]))) 13 | ymax = int(min(image.shape[0], max(v0[1], v1[1], v2[1])+1)) 14 | P = coords[:, xmin:xmax, ymin:ymax].reshape(2,-1) 15 | B = np.dot(t, np.vstack((P, np.ones((1, P.shape[1]))))) 16 | 17 | # Cartesian coordinates of points inside the triangle 18 | I = np.argwhere(np.all(B >= 0, axis=0)) 19 | X, Y, Z = P[0,I], P[1,I], v0[2]*B[0,I] + v1[2]*B[1,I] + v2[2]*B[2,I] 20 | 21 | # Texture coordinates of points inside the triangle 22 | U = ( (uv0[0]*B[0,I] + uv1[0]*B[1,I] + uv2[0]*B[2,I]))*(texture.shape[0]-1) 23 | V = (1.0-(uv0[1]*B[0,I] + uv1[1]*B[1,I] + uv2[1]*B[2,I]))*(texture.shape[1]-1) 24 | C = texture[V.astype(int), U.astype(int)] 25 | 26 | # Z-Buffer test 27 | I = np.argwhere(zbuffer[Y,X] < Z)[:,0] 28 | X, Y, Z, C = X[I], Y[I], Z[I], C[I] 29 | zbuffer[Y, X] = Z 30 | image[Y, X] = C * (intensity, intensity, intensity, 1) 31 | 32 | def obj_load(filename): 33 | V, T, Vi, Ti = [], [], [], [] 34 | with open(filename) as f: 35 | for line in f.readlines(): 36 | if line.startswith('#'): continue 37 | values = line.split() 38 | if not values: continue 39 | if values[0] == 'v': 40 | V.append([float(x) for x in values[1:4]]) 41 | elif values[0] == 'vt': 42 | T.append([float(x) for x in values[1:3]]) 43 | elif values[0] == 'f' : 44 | Vi.append([int(indices.split('/')[0]) for indices in values[1:]]) 45 | Ti.append([int(indices.split('/')[1]) for indices in values[1:]]) 46 | return np.array(V), np.array(T), np.array(Vi)-1, np.array(Ti)-1 47 | 48 | def lookat(eye, center, up): 49 | normalize = lambda x: x/np.linalg.norm(x) 50 | M = np.eye(4) 51 | z = normalize(eye-center) 52 | x = normalize(np.cross(up,z)) 53 | y = normalize(np.cross(z,x)) 54 | M[0,:3], M[1,:3], M[2,:3], M[:3,3] = x, y, z, -center 55 | return M 56 | 57 | def viewport(x, y, w, h, d): 58 | return np.array([[w/2, 0, 0, x+w/2], 59 | [0, h/2, 0, y+h/2], 60 | [0, 0, d/2, d/2], 61 | [0, 0, 0, 1]]) 62 | 63 | if __name__ == '__main__': 64 | import time 65 | import PIL.Image 66 | 67 | width, height = 1200,1200 68 | light = np.array([0,0,-1]) 69 | eye = np.array([1,1,3]) 70 | center = np.array([0,0,0]) 71 | up = np.array([0,1,0]) 72 | 73 | image = np.zeros((height,width,4), dtype=np.uint8) 74 | zbuffer = -1000*np.ones((height,width)) 75 | coords = np.mgrid[0:width, 0:height] 76 | 77 | V, UV, Vi, UVi = obj_load("head.obj") 78 | texture = np.asarray(PIL.Image.open("uv-grid.png")) 79 | viewport = viewport(32, 32, width-64, height-64, 1000) 80 | modelview = lookat(eye, center, up) 81 | 82 | start = time.time() 83 | Vh = np.c_[V, np.ones(len(V))] # Homogenous coordinates 84 | V = Vh @ modelview.T # World coordinates 85 | Vs = V @ viewport.T # Screen coordinates 86 | V, Vs = V[:,:3], Vs[:,:3] # Back to cartesian coordinates 87 | V, Vs, UV = V[Vi], Vs[Vi], UV[UVi] 88 | 89 | # Pre-compute tri-linear coordinates 90 | T = np.transpose(Vs, axes=[0,2,1]).copy() 91 | T[:,2,:] = 1 92 | T = np.linalg.inv(T) 93 | 94 | # Pre-compute normal vectors and intensity 95 | N = np.cross(V[:,2]-V[:,0], V[:,1]-V[:,0]) 96 | N = N / np.linalg.norm(N,axis=1).reshape(len(N),1) 97 | I = np.dot(N, light) 98 | 99 | for i in np.argwhere(I>=0)[:,0]: 100 | (vs0, vs1, vs2), (uv0, uv1, uv2) = Vs[i], UV[i] 101 | triangle(T[i], (vs0,uv0), (vs1,uv1), (vs2,uv2), I[i]) 102 | end = time.time() 103 | 104 | print("Rendering time: {}".format(end-start)) 105 | PIL.Image.fromarray(image[::-1,:,:]).save("head.png") 106 | -------------------------------------------------------------------------------- /uv-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rougier/tiny-renderer/c4b8c41edd22fcd0ef65af8cd7cd404f92c2bcf5/uv-grid.png --------------------------------------------------------------------------------