Skip to content

Commit bd47c71

Browse files
committed
3D rendering with ipycanvas
Signed-off-by: martinRenou <[email protected]>
1 parent c745196 commit bd47c71

File tree

4 files changed

+2859
-33
lines changed

4 files changed

+2859
-33
lines changed

examples/3d_monkey.ipynb

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"# ipycanvas + NumPy = 3D"
8+
]
9+
},
10+
{
11+
"cell_type": "markdown",
12+
"metadata": {},
13+
"source": [
14+
"Disclaimer: The following is a stupid implementation of an .obj loader, **do not use it in your codebase**"
15+
]
16+
},
17+
{
18+
"cell_type": "code",
19+
"execution_count": null,
20+
"metadata": {},
21+
"outputs": [],
22+
"source": [
23+
"import numpy as np"
24+
]
25+
},
26+
{
27+
"cell_type": "code",
28+
"execution_count": null,
29+
"metadata": {},
30+
"outputs": [],
31+
"source": [
32+
"with open('monkey.obj', 'r') as fobj:\n",
33+
" lines = [line.strip() for line in fobj.readlines()]"
34+
]
35+
},
36+
{
37+
"cell_type": "code",
38+
"execution_count": null,
39+
"metadata": {},
40+
"outputs": [],
41+
"source": [
42+
"vertices = []\n",
43+
"vertex_normals = []\n",
44+
"faces = []\n",
45+
"faces_normals = []"
46+
]
47+
},
48+
{
49+
"cell_type": "code",
50+
"execution_count": null,
51+
"metadata": {},
52+
"outputs": [],
53+
"source": [
54+
"# Extract vertices\n",
55+
"for line in lines:\n",
56+
" splitted = line.split(' ')\n",
57+
"\n",
58+
" # Vertex\n",
59+
" if splitted[0] == 'v':\n",
60+
" _, v1, v2, v3 = splitted\n",
61+
" vertices.append([float(v1), float(v2), float(v3)])\n",
62+
"\n",
63+
" # Normal\n",
64+
" if splitted[0] == 'vn':\n",
65+
" _, v1, v2, v3 = splitted\n",
66+
" vertex_normals.append([float(v1), float(v2), float(v3)])\n",
67+
"\n",
68+
"# Extract faces\n",
69+
"for line in lines:\n",
70+
" splitted = line.split(' ')\n",
71+
"\n",
72+
" # Face\n",
73+
" if splitted[0] == 'f':\n",
74+
" # This file is triangulated, so this is fine\n",
75+
" _, v1, v2, v3 = splitted\n",
76+
"\n",
77+
" # It happens that this mesh is flat-shaded, so the normal is \n",
78+
" # the same on all vertices\n",
79+
" v1_index, _, v1_normal = v1.split('/')\n",
80+
" v2_index, _, _ = v2.split('/')\n",
81+
" v3_index, _, _ = v3.split('/')\n",
82+
"\n",
83+
" faces.append([int(v1_index) - 1, int(v2_index) - 1, int(v3_index) - 1])\n",
84+
" faces_normals.append(int(v1_normal) - 1)"
85+
]
86+
},
87+
{
88+
"cell_type": "code",
89+
"execution_count": null,
90+
"metadata": {
91+
"scrolled": true
92+
},
93+
"outputs": [],
94+
"source": [
95+
"vertices = np.array(vertices)\n",
96+
"faces = np.array(faces)\n",
97+
"vertex_normals = np.array(vertex_normals)\n",
98+
"faces_normals = np.array(faces_normals)"
99+
]
100+
},
101+
{
102+
"cell_type": "code",
103+
"execution_count": null,
104+
"metadata": {},
105+
"outputs": [],
106+
"source": [
107+
"vertices"
108+
]
109+
},
110+
{
111+
"cell_type": "code",
112+
"execution_count": null,
113+
"metadata": {},
114+
"outputs": [],
115+
"source": [
116+
"faces"
117+
]
118+
},
119+
{
120+
"cell_type": "markdown",
121+
"metadata": {},
122+
"source": [
123+
"# First let's display our monkey as a PointCloud"
124+
]
125+
},
126+
{
127+
"cell_type": "code",
128+
"execution_count": null,
129+
"metadata": {},
130+
"outputs": [],
131+
"source": [
132+
"from ipycanvas import Canvas, hold_canvas"
133+
]
134+
},
135+
{
136+
"cell_type": "code",
137+
"execution_count": null,
138+
"metadata": {},
139+
"outputs": [],
140+
"source": [
141+
"from py3d_engine import OrbitCamera, project_vector"
142+
]
143+
},
144+
{
145+
"cell_type": "code",
146+
"execution_count": null,
147+
"metadata": {},
148+
"outputs": [],
149+
"source": [
150+
"class MonkeyCloud(Canvas):\n",
151+
" def __init__(self):\n",
152+
" super(MonkeyCloud, self).__init__(width=500, height=500)\n",
153+
"\n",
154+
" self.dragging = False\n",
155+
" \n",
156+
" self.x = vertices[:,0]\n",
157+
" self.y = vertices[:,1]\n",
158+
" self.z = vertices[:,2]\n",
159+
" \n",
160+
" self.dx = 0\n",
161+
" self.dy = 0\n",
162+
" self.radius = 10\n",
163+
"\n",
164+
" self.camera = OrbitCamera(self.radius, [0, 0, 0], self.width/self.height)\n",
165+
" self.x2, self.y2, self.z2 = project_vector(self.x, self.y, self.z, self.camera.matrix)\n",
166+
" self.draw()\n",
167+
"\n",
168+
" self.on_mouse_down(self.mouse_down_handler)\n",
169+
" self.on_mouse_move(self.mouse_move_handler)\n",
170+
" self.on_mouse_up(self.mouse_up_handler)\n",
171+
" self.on_mouse_out(self.mouse_out_handler)\n",
172+
"\n",
173+
" def update_matrix(self, dx=None, dy=None):\n",
174+
" dx = dx if dx is not None else self.dx\n",
175+
" dy = dy if dy is not None else self.dy\n",
176+
" \n",
177+
" self.camera.update_position(dy, dx)\n",
178+
" self.x2, self.y2, self.z2 = project_vector(self.x, self.y, self.z, self.camera.matrix)\n",
179+
" self.draw()\n",
180+
"\n",
181+
" def draw(self):\n",
182+
" x = self.x2 * self.width + self.width / 2\n",
183+
" y = self.y2 * self.height + self.height / 2\n",
184+
" with hold_canvas(self):\n",
185+
" self.clear()\n",
186+
" self.fill_circles(x, y, 2)\n",
187+
"\n",
188+
" def mouse_down_handler(self, pixel_x, pixel_y):\n",
189+
" self.dragging = True\n",
190+
" self.x_mouse = pixel_x\n",
191+
" self.y_mouse = pixel_y\n",
192+
"\n",
193+
" def mouse_move_handler(self, pixel_x, pixel_y):\n",
194+
" if self.dragging:\n",
195+
" self.dx_new = self.dx + pixel_x - self.x_mouse\n",
196+
" self.dy_new = self.dy + pixel_y - self.y_mouse\n",
197+
" \n",
198+
" self.update_matrix(self.dx_new, self.dy_new)\n",
199+
" \n",
200+
" def mouse_up_handler(self, pixel_x, pixel_y):\n",
201+
" if self.dragging:\n",
202+
" self.dragging = False\n",
203+
" self.dx = self.dx_new\n",
204+
" self.dy = self.dy_new\n",
205+
" \n",
206+
" def mouse_out_handler(self, pixel_x, pixel_y):\n",
207+
" if self.dragging:\n",
208+
" self.dragging = False\n",
209+
" self.dx = self.dx_new\n",
210+
" self.dy = self.dy_new"
211+
]
212+
},
213+
{
214+
"cell_type": "code",
215+
"execution_count": null,
216+
"metadata": {},
217+
"outputs": [],
218+
"source": [
219+
"cloud = MonkeyCloud()\n",
220+
"cloud"
221+
]
222+
},
223+
{
224+
"cell_type": "markdown",
225+
"metadata": {},
226+
"source": [
227+
"# Looks good, but the monkey is made of triangles, so let's display them"
228+
]
229+
},
230+
{
231+
"cell_type": "code",
232+
"execution_count": null,
233+
"metadata": {},
234+
"outputs": [],
235+
"source": [
236+
"triangles = vertices[faces]\n",
237+
"triangles_positions = np.mean(triangles, axis=1)\n",
238+
"triangles_normals = vertex_normals[faces_normals]"
239+
]
240+
},
241+
{
242+
"cell_type": "code",
243+
"execution_count": null,
244+
"metadata": {},
245+
"outputs": [],
246+
"source": [
247+
"light_direction = np.array([-1, 1, 0])"
248+
]
249+
},
250+
{
251+
"cell_type": "code",
252+
"execution_count": null,
253+
"metadata": {},
254+
"outputs": [],
255+
"source": [
256+
"class Monkey(Canvas):\n",
257+
" def __init__(self):\n",
258+
" super(Monkey, self).__init__(width=500, height=500)\n",
259+
"\n",
260+
" self.dragging = False\n",
261+
" \n",
262+
" self.x = vertices[:,0]\n",
263+
" self.y = vertices[:,1]\n",
264+
" self.z = vertices[:,2]\n",
265+
" \n",
266+
" self.dx = 0\n",
267+
" self.dy = 0\n",
268+
" self.radius = 10\n",
269+
"\n",
270+
" self.camera = OrbitCamera(self.radius, [0, 0, 0], self.width/self.height)\n",
271+
" self.update_matrix()\n",
272+
"\n",
273+
" self.on_mouse_down(self.mouse_down_handler)\n",
274+
" self.on_mouse_move(self.mouse_move_handler)\n",
275+
" self.on_mouse_up(self.mouse_up_handler)\n",
276+
" self.on_mouse_out(self.mouse_out_handler)\n",
277+
"\n",
278+
" def update_matrix(self, dx=None, dy=None):\n",
279+
" dx = dx if dx is not None else self.dx\n",
280+
" dy = dy if dy is not None else self.dy\n",
281+
"\n",
282+
" self.camera.update_position(dy, dx)\n",
283+
"\n",
284+
" dist = np.linalg.norm(self.camera.position - triangles_positions, axis=1)\n",
285+
" \n",
286+
" # Face culling: Get rid of the triangles that are not facing the camera\n",
287+
" triangles_facing_camera = np.dot(triangles_normals, self.camera.front) < 0\n",
288+
"\n",
289+
" self.triangles = triangles[triangles_facing_camera]\n",
290+
" self.triangles_normals = triangles_normals[triangles_facing_camera]\n",
291+
" self.dist = dist[triangles_facing_camera]\n",
292+
" \n",
293+
" # Face sorting: Sort triangle by depth (distance to camera) so we can draw further triangles first\n",
294+
" self.order = np.flip(np.argsort(self.dist))\n",
295+
" \n",
296+
" # Project triangles\n",
297+
" triangle_vertices = self.triangles.reshape(self.triangles.shape[0] * self.triangles.shape[1], 3)\n",
298+
" proj_x, proj_y, _ = project_vector(triangle_vertices[:,0], triangle_vertices[:,1], triangle_vertices[:,2], self.camera.matrix)\n",
299+
" proj_x = proj_x * self.width + self.width / 2\n",
300+
" proj_y = proj_y * self.height + self.height / 2\n",
301+
"\n",
302+
" self.proj_triangles = np.stack((proj_x, proj_y), axis=1).reshape(self.triangles.shape[0], self.triangles.shape[1], 2)\n",
303+
"\n",
304+
" self.draw()\n",
305+
"\n",
306+
" def draw(self):\n",
307+
" with hold_canvas(self):\n",
308+
" self.clear()\n",
309+
"\n",
310+
" # Now let's draw triangles\n",
311+
" for i in self.order:\n",
312+
" triangle = self.proj_triangles[i]\n",
313+
" normal = self.triangles_normals[i]\n",
314+
" \n",
315+
" # Shading depending on light direction and face normal\n",
316+
" light = np.dot(light_direction, normal)\n",
317+
" if light < 0.4:\n",
318+
" light = 0.4\n",
319+
" elif light > 1:\n",
320+
" light = 1\n",
321+
" r, g, b = int(214 * light), int(224 * light), int(125 * light)\n",
322+
" self.fill_style = 'rgb({}, {}, {})'.format(r, g, b)\n",
323+
" \n",
324+
" self.fill_polygon(triangle)\n",
325+
"\n",
326+
" def mouse_down_handler(self, pixel_x, pixel_y):\n",
327+
" self.dragging = True\n",
328+
" self.x_mouse = pixel_x\n",
329+
" self.y_mouse = pixel_y\n",
330+
"\n",
331+
" def mouse_move_handler(self, pixel_x, pixel_y):\n",
332+
" if self.dragging:\n",
333+
" self.dx_new = self.dx + pixel_x - self.x_mouse\n",
334+
" self.dy_new = self.dy + pixel_y - self.y_mouse\n",
335+
" \n",
336+
" self.update_matrix(self.dx_new, self.dy_new)\n",
337+
" \n",
338+
" def mouse_up_handler(self, pixel_x, pixel_y):\n",
339+
" if self.dragging:\n",
340+
" self.dragging = False\n",
341+
" self.dx = self.dx_new\n",
342+
" self.dy = self.dy_new\n",
343+
" \n",
344+
" def mouse_out_handler(self, pixel_x, pixel_y):\n",
345+
" if self.dragging:\n",
346+
" self.dragging = False\n",
347+
" self.dx = self.dx_new\n",
348+
" self.dy = self.dy_new"
349+
]
350+
},
351+
{
352+
"cell_type": "code",
353+
"execution_count": null,
354+
"metadata": {},
355+
"outputs": [],
356+
"source": [
357+
"monkey = Monkey()\n",
358+
"monkey"
359+
]
360+
}
361+
],
362+
"metadata": {
363+
"kernelspec": {
364+
"display_name": "Python 3",
365+
"language": "python",
366+
"name": "python3"
367+
},
368+
"language_info": {
369+
"codemirror_mode": {
370+
"name": "ipython",
371+
"version": 3
372+
},
373+
"file_extension": ".py",
374+
"mimetype": "text/x-python",
375+
"name": "python",
376+
"nbconvert_exporter": "python",
377+
"pygments_lexer": "ipython3",
378+
"version": "3.9.0"
379+
}
380+
},
381+
"nbformat": 4,
382+
"nbformat_minor": 4
383+
}

0 commit comments

Comments
 (0)