<?xml version="1.0" encoding="utf-8"?> 
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-us">
    <generator uri="https://gohugo.io/" version="0.152.2">Hugo</generator><title type="html"><![CDATA[3D on Blog]]></title>
    
    
    
            <link href="https://blog.scientific-python.org/tags/3d/" rel="alternate" type="text/html" title="html" />
            <link href="https://blog.scientific-python.org/tags/3d/atom.xml" rel="self" type="application/atom" title="atom" />
    <updated>2026-04-04T04:32:36+00:00</updated>
    
    
    
    
        <id>https://blog.scientific-python.org/tags/3d/</id>
    
        
        <entry>
            <title type="html"><![CDATA[Custom 3D engine in Matplotlib]]></title>
            <link href="https://blog.scientific-python.org/matplotlib/custom-3d-engine/?utm_source=atom_feed" rel="alternate" type="text/html" />
            
                <link href="https://blog.scientific-python.org/matplotlib/warming-stripes/?utm_source=atom_feed" rel="related" type="text/html" title="Creating the Warming Stripes in Matplotlib" />
                <link href="https://blog.scientific-python.org/matplotlib/matplotlib-in-data-driven-seo/?utm_source=atom_feed" rel="related" type="text/html" title="Matplotlib in Data Driven SEO" />
                <link href="https://blog.scientific-python.org/matplotlib/using-matplotlib-to-advocate-for-postdocs/?utm_source=atom_feed" rel="related" type="text/html" title="Using Matplotlib to Advocate for Postdocs" />
            
                <id>https://blog.scientific-python.org/matplotlib/custom-3d-engine/</id>
            
            
            <published>2019-12-18T09:05:32+01:00</published>
            <updated>2019-12-18T09:05:32+01:00</updated>
            
            
            <content type="html"><![CDATA[<blockquote>3D rendering is really easy once you&rsquo;ve understood a few concepts. To demonstrate that, we&rsquo;ll design a simple custom 3D engine that with 60 lines of Python and one Matplotlib call. That is, we&rsquo;ll render the bunny without using the 3D axis.</blockquote><p><img src="/matplotlib/custom-3d-engine/bunny.jpg" alt="A colourful outline of a bunny."></p>
<p>Matplotlib has a really nice <a href="https://matplotlib.org/mpl_toolkits/mplot3d/tutorial.html">3D
interface</a> with many
capabilities (and some limitations) that is quite popular among users. Yet, 3D
is still considered to be some kind of black magic for some users (or maybe
for the majority of users). I would thus like to explain in this post that 3D
rendering is really easy once you&rsquo;ve understood a few concepts. To demonstrate
that, we&rsquo;ll render the bunny above with 60 lines of Python and one Matplotlib
call. That is, without using the 3D axis.</p>
<p><strong>Advertisement</strong>: This post comes from an upcoming open access book on
scientific visualization using Python and Matplotlib. If you want to
support my work and have an early access to the book, go to
<a href="https://github.com/rougier/scientific-visualization-book">https://github.com/rougier/scientific-visualization-book</a>.</p>
<h1 id="loading-the-bunny">Loading the bunny<a class="headerlink" href="#loading-the-bunny" title="Link to this heading">#</a></h1>
<p>First things first, we need to load our model. We&rsquo;ll use a <a href="/matplotlib/custom-3d-engine/bunny.obj">simplified
version</a> of the <a href="https://en.wikipedia.org/wiki/Stanford_bunny">Stanford
bunny</a>. The file uses the
<a href="https://en.wikipedia.org/wiki/Wavefront_.obj_file">wavefront format</a> which is
one of the simplest format, so let&rsquo;s make a very simple (but error-prone)
loader that will just do the job for this post (and this model):</p>


<div class="highlight">
  <pre class="chroma"><code><span class="line"><span class="cl"><span class="n">V</span><span class="p">,</span> <span class="n">F</span> <span class="o">=</span> <span class="p">[],</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl"><span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="s2">&#34;bunny.obj&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">   <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">f</span><span class="o">.</span><span class="n">readlines</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">       <span class="k">if</span> <span class="n">line</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s1">&#39;#&#39;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">           <span class="k">continue</span>
</span></span><span class="line"><span class="cl">       <span class="n">values</span> <span class="o">=</span> <span class="n">line</span><span class="o">.</span><span class="n">split</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">       <span class="k">if</span> <span class="ow">not</span> <span class="n">values</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">           <span class="k">continue</span>
</span></span><span class="line"><span class="cl">       <span class="k">if</span> <span class="n">values</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">==</span> <span class="s1">&#39;v&#39;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">           <span class="n">V</span><span class="o">.</span><span class="n">append</span><span class="p">([</span><span class="nb">float</span><span class="p">(</span><span class="n">x</span><span class="p">)</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">values</span><span class="p">[</span><span class="mi">1</span><span class="p">:</span><span class="mi">4</span><span class="p">]])</span>
</span></span><span class="line"><span class="cl">       <span class="k">elif</span> <span class="n">values</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">==</span> <span class="s1">&#39;f&#39;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">           <span class="n">F</span><span class="o">.</span><span class="n">append</span><span class="p">([</span><span class="nb">int</span><span class="p">(</span><span class="n">x</span><span class="p">)</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">values</span><span class="p">[</span><span class="mi">1</span><span class="p">:</span><span class="mi">4</span><span class="p">]])</span>
</span></span><span class="line"><span class="cl"><span class="n">V</span><span class="p">,</span> <span class="n">F</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">array</span><span class="p">(</span><span class="n">V</span><span class="p">),</span> <span class="n">np</span><span class="o">.</span><span class="n">array</span><span class="p">(</span><span class="n">F</span><span class="p">)</span><span class="o">-</span><span class="mi">1</span></span></span></code></pre>
</div>
<p><code>V</code> is now a set of vertices (3D points if you prefer) and <code>F</code> is a set of
faces (= triangles). Each triangle is described by 3 indices relatively to the
vertices array. Now, let&rsquo;s normalize the vertices such that the overall bunny
fits the unit box:</p>

<div class="highlight">
  <pre>V = (V-(V.max(0)&#43;V.min(0))/2)/max(V.max(0)-V.min(0))</pre>
</div>

<p>Now, we can have a first look at the model by getting only the x,y coordinates of the vertices and get rid of the z coordinate. To do this we can use the powerful
<a href="https://matplotlib.org/3.1.1/api/collections_api.html#matplotlib.collections.PolyCollection">PolyCollection</a>
object that allow to render efficiently a collection of non-regular
polygons. Since, we want to render a bunch of triangles, this is a perfect
match. So let&rsquo;s first extract the triangles and get rid of the <code>z</code> coordinate:</p>

<div class="highlight">
  <pre>T = V[F][...,:2]</pre>
</div>

<p>And we can now render it:</p>

<div class="highlight">
  <pre>fig = plt.figure(figsize=(6,6))
ax = fig.add_axes([0,0,1,1], xlim=[-1,&#43;1], ylim=[-1,&#43;1],
                  aspect=1, frameon=False)
collection = PolyCollection(T, closed=True, linewidth=0.1,
                            facecolor=&#34;None&#34;, edgecolor=&#34;black&#34;)
ax.add_collection(collection)
plt.show()</pre>
</div>

<p>You should obtain something like this (<a href="/matplotlib/custom-3d-engine/bunny-1.py">bunny-1.py</a>):</p>
<p><img src="/matplotlib/custom-3d-engine/bunny-1.png" alt="A black and white outline of a bunny facing left side."></p>
<h1 id="perspective-projection">Perspective Projection<a class="headerlink" href="#perspective-projection" title="Link to this heading">#</a></h1>
<p>The rendering we&rsquo;ve just made is actually an <a href="https://en.wikipedia.org/wiki/Orthographic_projection">orthographic
projection</a> while the
top bunny uses a <a href="https://en.wikipedia.org/wiki/3D_projection#Perspective_projection">perspective projection</a>:</p>
<p><img src="/matplotlib/custom-3d-engine/projections.png" alt="Difference in perspective projection and orthographic projection. The near clip plane appears smaller in the perspective projective than in the orthographic projection."></p>
<p>In both cases, the proper way of defining a projection is first to define a
viewing volume, that is, the volume in the 3D space we want to render on the
screen. To do that, we need to consider 6 clipping planes (left, right, top,
bottom, far, near) that enclose the viewing volume (frustum) relatively to the
camera. If we define a camera position and a viewing direction, each plane can
be described by a single scalar. Once we have this viewing volume, we can
project onto the screen using either the orthographic or the perspective
projection.</p>
<p>Fortunately for us, these projections are quite well known and can be expressed
using 4x4 matrices:</p>

<div class="highlight">
  <pre>def frustum(left, right, bottom, top, znear, zfar):
    M = np.zeros((4, 4), dtype=np.float32)
    M[0, 0] = &#43;2.0 * znear / (right - left)
    M[1, 1] = &#43;2.0 * znear / (top - bottom)
    M[2, 2] = -(zfar &#43; znear) / (zfar - znear)
    M[0, 2] = (right &#43; left) / (right - left)
    M[2, 1] = (top &#43; bottom) / (top - bottom)
    M[2, 3] = -2.0 * znear * zfar / (zfar - znear)
    M[3, 2] = -1.0
    return M

def perspective(fovy, aspect, znear, zfar):
    h = np.tan(0.5*radians(fovy)) * znear
    w = h * aspect
    return frustum(-w, w, -h, h, znear, zfar)</pre>
</div>

<p>For the perspective projection, we also need to specify the aperture angle that
(more or less) sets the size of the near plane relatively to the far
plane. Consequently, for high apertures, you&rsquo;ll get a lot of &ldquo;deformations&rdquo;.</p>
<p>However, if you look at the two functions above, you&rsquo;ll realize they return 4x4
matrices while our coordinates are 3D. How to use these matrices then ? The
answer is <a href="https://en.wikipedia.org/wiki/Homogeneous_coordinates">homogeneous
coordinates</a>. To make
a long story short, homogeneous coordinates are best to deal with transformation
and projections in 3D. In our case, because we&rsquo;re dealing with vertices (and
not vectors), we only need to add 1 as the fourth coordinate (<code>w</code>) to all our
vertices. Then we can apply the perspective transformation using the dot
product.</p>

<div class="highlight">
  <pre>V = np.c_[V, np.ones(len(V))] @ perspective(25,1,1,100).T</pre>
</div>

<p>Last step, we need to re-normalize the homogeneous coordinates. This means we
divide each transformed vertices with the last component (<code>w</code>) such as to
always have <code>w</code>=1 for each vertices.</p>

<div class="highlight">
  <pre>V /= V[:,3].reshape(-1,1)</pre>
</div>

<p>Now we can display the result again (<a href="/matplotlib/custom-3d-engine/bunny-2.py">bunny-2.py</a>):</p>
<p><img src="/matplotlib/custom-3d-engine/bunny-2.png" alt=""></p>
<p>Oh, weird result. What&rsquo;s wrong? What is wrong is that the camera is actually
inside the bunny. To have a proper rendering, we need to move the bunny away
from the camera or move the camera away from the bunny. Let&rsquo;s do the latter. The
camera is currently positioned at (0,0,0) and looking up in the z direction
(because of the frustum transformation). We thus need to move the camera away a
little bit in the z negative direction and <strong>before the perspective
transformation</strong>:</p>

<div class="highlight">
  <pre>V = V - (0,0,3.5)
V = np.c_[V, np.ones(len(V))] @ perspective(25,1,1,100).T
V /= V[:,3].reshape(-1,1)</pre>
</div>

<p>An now you should obtain (<a href="/matplotlib/custom-3d-engine/bunny-3.py">bunny-3.py</a>):</p>
<p><img src="/matplotlib/custom-3d-engine/bunny-3.png" alt=""></p>
<h1 id="model-view-projection-mvp">Model, view, projection (MVP)<a class="headerlink" href="#model-view-projection-mvp" title="Link to this heading">#</a></h1>
<p>It might be not obvious, but the last rendering is actually a perspective
transformation. To make it more obvious, we&rsquo;ll rotate the bunny around. To do
that, we need some rotation matrices (4x4) and we can as well define the
translation matrix in the meantime:</p>

<div class="highlight">
  <pre>def translate(x, y, z):
    return np.array([[1, 0, 0, x],
                     [0, 1, 0, y],
                     [0, 0, 1, z],
                     [0, 0, 0, 1]], dtype=float)

def xrotate(theta):
    t = np.pi * theta / 180
    c, s = np.cos(t), np.sin(t)
    return np.array([[1, 0,  0, 0],
                     [0, c, -s, 0],
                     [0, s,  c, 0],
                     [0, 0,  0, 1]], dtype=float)

def yrotate(theta):
    t = np.pi * theta / 180
    c, s = np.cos(t), np.sin(t)
    return  np.array([[ c, 0, s, 0],
                      [ 0, 1, 0, 0],
                      [-s, 0, c, 0],
                      [ 0, 0, 0, 1]], dtype=float)</pre>
</div>

<p>We&rsquo;ll now decompose the transformations we want to apply in term of model
(local transformations), view (global transformations) and projection such that
we can compute a global MVP matrix that will do everything at once:</p>

<div class="highlight">
  <pre>model = xrotate(20) @ yrotate(45)
view  = translate(0,0,-3.5)
proj  = perspective(25, 1, 1, 100)
MVP   = proj  @ view  @ model</pre>
</div>

<p>and we now write:</p>

<div class="highlight">
  <pre>V = np.c_[V, np.ones(len(V))] @ MVP.T
V /= V[:,3].reshape(-1,1)</pre>
</div>

<p>You should obtain (<a href="/matplotlib/custom-3d-engine/bunny-4.py">bunny-4.py</a>):</p>
<p><img src="/matplotlib/custom-3d-engine/bunny-4.png" alt=""></p>
<p>Let&rsquo;s now play a bit with the aperture such that you can see the difference.
Note that we also have to adapt the distance to the camera in order for the bunnies to have the same apparent size (<a href="/matplotlib/custom-3d-engine/bunny-5.py">bunny-5.py</a>):</p>
<p><img src="/matplotlib/custom-3d-engine/bunny-5.png" alt=""></p>
<h1 id="depth-sorting">Depth sorting<a class="headerlink" href="#depth-sorting" title="Link to this heading">#</a></h1>
<p>Let&rsquo;s try now to fill the triangles (<a href="/matplotlib/custom-3d-engine/bunny-6.py">bunny-6.py</a>):</p>
<p><img src="/matplotlib/custom-3d-engine/bunny-6.png" alt=""></p>
<p>As you can see, the result is &ldquo;interesting&rdquo; and totally wrong. The problem is
that the PolyCollection will draw the triangles in the order they are given
while we would like to have them from back to front. This means we need to sort
them according to their depth. The good news is that we already computed this
information when we applied the MVP transformation. It is stored in the new z
coordinates. However, these z values are vertices based while we need to sort
the triangles. We&rsquo;ll thus take the mean z value as being representative of the
depth of a triangle. If triangles are relatively small and do not intersect,
this works beautifully:</p>

<div class="highlight">
  <pre>T =  V[:,:,:2]
Z = -V[:,:,2].mean(axis=1)
I = np.argsort(Z)
T = T[I,:]</pre>
</div>

<p>And now everything is rendered right (<a href="/matplotlib/custom-3d-engine/bunny-7.py">bunny-7.py</a>):</p>
<p><img src="/matplotlib/custom-3d-engine/bunny-7.png" alt=""></p>
<p>Let&rsquo;s add some colors using the depth buffer. We&rsquo;ll color each triangle
according to it depth. The beauty of the PolyCollection object is that you can
specify the color of each of the triangle using a NumPy array, so let&rsquo;s just do
that:</p>

<div class="highlight">
  <pre>zmin, zmax = Z.min(), Z.max()
Z = (Z-zmin)/(zmax-zmin)
C = plt.get_cmap(&#34;magma&#34;)(Z)
I = np.argsort(Z)
T, C = T[I,:], C[I,:]</pre>
</div>

<p>And now everything is rendered right (<a href="/matplotlib/custom-3d-engine/bunny-8.py">bunny-8.py</a>):</p>
<p><img src="/matplotlib/custom-3d-engine/bunny-8.png" alt=""></p>
<p>The final script is 57 lines (but hardly readable):</p>

<div class="highlight">
  <pre>import numpy as np
import matplotlib.pyplot as plt
from matplotlib.collections import PolyCollection

def frustum(left, right, bottom, top, znear, zfar):
    M = np.zeros((4, 4), dtype=np.float32)
    M[0, 0] = &#43;2.0 * znear / (right - left)
    M[1, 1] = &#43;2.0 * znear / (top - bottom)
    M[2, 2] = -(zfar &#43; znear) / (zfar - znear)
    M[0, 2] = (right &#43; left) / (right - left)
    M[2, 1] = (top &#43; bottom) / (top - bottom)
    M[2, 3] = -2.0 * znear * zfar / (zfar - znear)
    M[3, 2] = -1.0
    return M
def perspective(fovy, aspect, znear, zfar):
    h = np.tan(0.5*np.radians(fovy)) * znear
    w = h * aspect
    return frustum(-w, w, -h, h, znear, zfar)
def translate(x, y, z):
    return np.array([[1, 0, 0, x], [0, 1, 0, y],
                     [0, 0, 1, z], [0, 0, 0, 1]], dtype=float)
def xrotate(theta):
    t = np.pi * theta / 180
    c, s = np.cos(t), np.sin(t)
    return np.array([[1, 0,  0, 0], [0, c, -s, 0],
                     [0, s,  c, 0], [0, 0,  0, 1]], dtype=float)
def yrotate(theta):
    t = np.pi * theta / 180
    c, s = np.cos(t), np.sin(t)
    return  np.array([[ c, 0, s, 0], [ 0, 1, 0, 0],
                      [-s, 0, c, 0], [ 0, 0, 0, 1]], dtype=float)
V, F = [], []
with open(&#34;bunny.obj&#34;) as f:
    for line in f.readlines():
        if line.startswith(&#39;#&#39;):  continue
        values = line.split()
        if not values:            continue
        if values[0] == &#39;v&#39;:      V.append([float(x) for x in values[1:4]])
        elif values[0] == &#39;f&#39; :   F.append([int(x) for x in values[1:4]])
V, F = np.array(V), np.array(F)-1
V = (V-(V.max(0)&#43;V.min(0))/2) / max(V.max(0)-V.min(0))
MVP = perspective(25,1,1,100) @ translate(0,0,-3.5) @ xrotate(20) @ yrotate(45)
V = np.c_[V, np.ones(len(V))]  @ MVP.T
V /= V[:,3].reshape(-1,1)
V = V[F]
T =  V[:,:,:2]
Z = -V[:,:,2].mean(axis=1)
zmin, zmax = Z.min(), Z.max()
Z = (Z-zmin)/(zmax-zmin)
C = plt.get_cmap(&#34;magma&#34;)(Z)
I = np.argsort(Z)
T, C = T[I,:], C[I,:]
fig = plt.figure(figsize=(6,6))
ax = fig.add_axes([0,0,1,1], xlim=[-1,&#43;1], ylim=[-1,&#43;1], aspect=1, frameon=False)
collection = PolyCollection(T, closed=True, linewidth=0.1, facecolor=C, edgecolor=&#34;black&#34;)
ax.add_collection(collection)
plt.show()</pre>
</div>

<p>Now it&rsquo;s your turn to play. Starting from this simple script, you can achieve
interesting results:</p>
<p><img src="/matplotlib/custom-3d-engine/checkered-sphere.png" alt="">
<img src="/matplotlib/custom-3d-engine/platonic-solids.png" alt="">
<img src="/matplotlib/custom-3d-engine/surf.png" alt="">
<img src="/matplotlib/custom-3d-engine/bar.png" alt="">
<img src="/matplotlib/custom-3d-engine/contour.png" alt=""></p>
]]></content>
            
                 
                    
                 
                    
                         
                        
                            
                             
                                <category scheme="taxonomy:Tags" term="tutorials" label="tutorials" />
                             
                                <category scheme="taxonomy:Tags" term="3d" label="3D" />
                             
                                <category scheme="taxonomy:Tags" term="matplotlib" label="matplotlib" />
                            
                        
                    
                
            
        </entry>
    
</feed>
