一個3D模型(譯)

這是500Lines項目中的A 3D modeller文章的翻譯版,講述如何使用Python,OpenGL,GLUT進行3D建模程序的設計。

項目封面

緒論

人類非常具有創(chuàng)造力。我們在不斷地設計和創(chuàng)造新穎有用并且非常有趣的東西。在現(xiàn)代,我們編寫軟件來輔助這一設計和創(chuàng)造的過程。計算機輔助設計軟件讓創(chuàng)造者們能夠設計建筑、橋梁、視頻游戲藝術、電影特效、3D打印的物體,以及很多構建實物之前的設計版本。

作為他們的核心,CAD工具是能夠?qū)?D的設計物體抽象成可以在2D屏幕上展示的方法。為了達到這種定義,CAD工具必須提供三類基礎的方法。第一,它們必須要有能夠表現(xiàn)設計的3D物體的數(shù)據(jù)結構:這是計算機理解的用戶正在構建的東西。第二,CAD工具必須提供一些方法把它展現(xiàn)在屏幕上。雖然人設計的東西是3維的,但是屏幕只有2維。CAD工具必須對我們?nèi)绾卫斫馕矬w進行建模,并且把它們繪制在屏幕上以保證人能夠理解全部的3維結構。第三,CAD工具還要提供能夠交互設計物體的方法。為了能夠讓用戶創(chuàng)造出想要的物體,必須能能夠添加或者修改這個設計。額外的,所有的工具都需要一種在磁盤上保存和加載方法以便用戶可以修改、分享、和存儲他們的工作。

一個領域特定的CAD工具可以根據(jù)這個領域的需求針對性地提供很多額外的特性。例如,一個建筑CAD工具可以提供很多物理模擬針對氣候壓力來測試建筑物,一個3D打印的工具將會測試這個設計是否真的是可以打印的,一個電氣CAD工具將會模擬電流流經(jīng)電線的物理現(xiàn)象,一個電影特效套件將會包括精細地模擬火焰術的特征。

然而,所有的CAD工具都必須包括至少三個上面討論過的特性:一個用于表達設計的數(shù)據(jù)結構,將其展現(xiàn)在屏幕上的能力,可以交互設計的方法。

記住這些東西,讓我們來探索如何表達3D設計,把這些展現(xiàn)在屏幕上,并且和它交互,用500行Python代碼。

指南

很多3D模型背后的設計決策的驅(qū)動力都是渲染過程。我們希望能夠在我們的設計中存儲和渲染復雜的對象,但是我們又希望能夠使得存儲和渲染的代碼復雜度盡量低。讓我們來考察渲染的過程,并且探索能讓我們用簡單的渲染邏輯處理任意的復雜對象。

管理接口和主循環(huán)

在我們開始渲染前,有幾樣東西我們要先建立起來。第一,我們需要創(chuàng)建一個展示我們的設計的窗口。第二,我們希望能夠和圖形驅(qū)動交流來渲染到屏幕上。我們一般不會直接和顯示驅(qū)動交流,所以我們用跨平臺的抽象層稱為OpenGL,還有一個叫GLUT(the OpenGL Utility Toolkit)來管理我們的窗口。

OpenGL 筆記

OpenGL是一個跨平臺的圖形程序編程接口開發(fā)工具。是一個開發(fā)跨平臺圖形程序的標準接口。OpenGL有兩個主要的變體:傳統(tǒng)OpenGL和現(xiàn)代OpenGL。

在OpenGL上進行渲染是基于由頂點和法線定義的多邊形。例如,要渲染方塊的一個面,我們需要指定四個頂點和這面的法線。

傳統(tǒng)OpenGL提供了“固定功能流水線”。通過設置全局變量,程序員可以啟用和禁用諸如照明,著色,表面剔除等功能的自動化實現(xiàn)。然后OpenGL自動使用啟用的功能呈現(xiàn)場景。此功能已棄用。

另一方面,現(xiàn)代OpenGL具有可編程渲染流水線,程序員在其中編寫稱為“著色器”的小程序,該程序在專用圖形硬件(GPU)上運行。 Modern OpenGL的可編程流水線已經(jīng)取代了Legacy OpenGL。

在這個項目中,盡管Legacy OpenGL已被棄用,但我們使用它。 Legacy OpenGL提供的固定功能對于保持較小的代碼尺寸非常有用。 它減少了所需的線性代數(shù)知識的數(shù)量,并簡化了我們將要編寫的代碼。

關于 GLUT

與OpenGL捆綁在一起的GLUT允許我們創(chuàng)建操作系統(tǒng)窗口并注冊用戶界面回調(diào)。 這個基本功能對我們來說已經(jīng)足夠了。 如果我們想要一個更全面的窗口管理和用戶交互庫,我們會考慮使用像GTK或Qt這樣的完整窗口工具包。

觀察

為了管理GLUT和OpenGL的建立,并且驅(qū)動下面的模型,我們創(chuàng)建一個叫Viewer的類。我們一個一個Viewer實例,這個實例可以管理窗口的創(chuàng)建和渲染,并且包括很多我們程序的主循環(huán)。在Viewer的初始化中,我們創(chuàng)建一個圖形化窗口,并且初始化OpenGL。

init_interface函數(shù)創(chuàng)建一個窗口放被渲染的模型,并指定需要渲染設計是調(diào)用的函數(shù)。init_opengl函數(shù)建立起項目中OpenGL需要的狀態(tài)。它設定矩陣,實現(xiàn)背面剔除,注冊光線以照亮場景,并告訴OpenGL我們希望哪些物體被著色。init_scence函數(shù)創(chuàng)建Scene(場景)對象并且放置一些初始節(jié)點讓用戶開始。我們很快就會看到Scene數(shù)據(jù)結構。最后,init_interaction注冊讓用戶交互的回調(diào)函數(shù),我們將在后面討論。

初始化Viewer以后,我們調(diào)用glutMainLoop來將程序執(zhí)行轉(zhuǎn)移到GLUT。這個函數(shù)從不返回。我們在GLUT事件上注冊的回調(diào)將在這些事件發(fā)生時被調(diào)用。

import numpy as np
from OpenGL.GL import *
from OpenGL.GLU import *
from OpenGL.GLUT import *


class Viewer(object):
    def __init__(self):
        """ Initialize the viewer. """
        self.init_interface()
        self.init_opengl()
        self.init_scene()
        self.init_interaction()
        init_primitives()

    def init_interface(self):
        """ initialize the window and register the render function """
        glutInit()
        glutInitWindowSize(640, 480)
        glutCreateWindow("3D Modeller")
        glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB)
        glutDisplayFunc(self.render)

    def init_opengl(self):
        """ initialize the opengl setting to render the scene """
        self.inverseModelView = np.identity(4)
        self.modelView = np.identity(4)

        glEnable(GL_CULL_FACE)
        glCullFace(GL_BACK)
        glEnable(GL_DEPTH_TEST)
        glDepthFunc(GL_LESS)

        glEnable(GL_LIGHT0)
        glLightfv(GL_LIGHT0, GL_POSITION, GLfloat_4(0, 0, 1, 0))
        glLightfv(GL_LIGHT0, GL_SPOT_DIRECTION, GLfloat_3(0, 0, -1))

        glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
        glEnable(GL_COLOR_MATERIAL)
        glClearColor(0.4, 0.4, 0.4, 0.0)

    def init_scene(self):
        """ initialize the scene object and initial scene """
        self.scene = Scene()
        self.create_sample_scene()

    def create_sample_scene(self):
        cube_node = Cube()
        cube_node.translate(2, 0, 2)
        cube_node.color_index = 2
        self.scene.add_node(cube_node)

        sphere_node = Sphere()
        sphere_node.translate(-2, 0, 2)
        sphere_node.color_index = 3
        self.scene.add_node(sphere_node)

        hierarchical_node = SnowFigure()
        hierarchical_node.translate(-2, 0, -2)
        self.scene.add_node(hierarchical_node)

    def init_interaction(self):
        """ init user interaction and callbacks """
        self.interaction.register_callback('pick', self.pick)
        self.interaction.register_callback('move', self.move)
        self.interaction.register_callback('place', self.place)
        self.interaction.register_callback('rotate_color', self.rotate_color)
        self.interaction.register_callback('scale', self.scale)

    def main_loop(self):
        glutMainLoop()

if __name__ == '__main__':
    viewer = Viewer()
    viewer.main_loop()

在我們深入render函數(shù)之前,我們要先討論一些線性代數(shù)。

坐標空間

根據(jù)我們的目的,坐標空間是一個原點和一組3個基向量,通常是xyz軸。

三維中的任何點都可以表示為從原點開始的xyz方向的偏移量。 點的表示與點所在的坐標空間有關。同一點在不同的坐標空間中有不同的表示。 三維中的任何點都可以在任何三維坐標空間中表示。

向量

向量是一個x,yz值,分別表示x,yz軸中兩個點之間的差異。

變換矩陣

在計算機圖形學中,為不同類型的點使用多個不同的坐標空間是很方便的。 變換矩陣將點從一個坐標空間轉(zhuǎn)換為另一個坐標空間。 為了將矢量v從一個坐標空間轉(zhuǎn)換到另一個坐標空間,我們乘以一個變換矩陣Mv'= Mv。 一些常見的變換矩陣是平移,縮放和旋轉(zhuǎn)。

變換流程

為了能夠?qū)⒁粋€東西繪制在屏幕上,我們需要在幾個不同的坐標空間中進行轉(zhuǎn)換。

在上圖的右邊,包括OpenGL將會為我們處理的所有從眼見空間到視點空間的變換。

從眼睛空間轉(zhuǎn)換到齊次投影空間由gluPerspective處理,并且轉(zhuǎn)換為標準化設備空間和視點空間由glViewport處理。 這兩個矩陣相乘并存儲為GL_PROJECTION矩陣。 我們不需要知道術語或這些矩陣如何為這個項目工作的細節(jié)。

然而,我們確實需要自己管理圖表的左側(cè)。 我們定義一個矩陣,將模型中的點(也稱為網(wǎng)格)從模型空間轉(zhuǎn)換為世界空間,稱為模型矩陣。 我們還定義了從世界空間轉(zhuǎn)換到眼睛空間的視圖矩陣。 在這個項目中,我們這兩個矩陣結合從而得到ModelView矩陣。

要了解更多關于整個圖形渲染流水線和涉及的坐標空間的信息,請參閱實時渲染的第2章或其他介紹性計算機圖形書籍。

用Viewer渲染

render函數(shù)首先設置渲染時需要完成的全部OpenGL狀態(tài)。 它通過init_view初始化投影矩陣,并使用來自交互成員的數(shù)據(jù)從場景空間轉(zhuǎn)換到世界空間的轉(zhuǎn)換矩陣初始化ModelView矩陣。 我們將在下面看到更多關于Interaction類的內(nèi)容。 它用glClear清除屏幕,再告訴場景渲染自己,然后呈現(xiàn)單元網(wǎng)格。

在渲染網(wǎng)格之前,我們禁用OpenGL的照明。 在禁用照明的情況下,OpenGL渲染純色的項目,而不會去模擬光源。 這樣,網(wǎng)格就具有與場景的視覺差異。 最后,glFlush通知圖形驅(qū)動程序我們已準備好將緩沖區(qū)刷新并顯示在屏幕上。

    # class Viewer
    def render(self):
        """ The render pass for the scene """
        self.init_view()

        glEnable(GL_LIGHTING)
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

        # Load the modelview matrix from the current state of the trackball
        glMatrixMode(GL_MODELVIEW)
        glPushMatrix()
        glLoadIdentity()
        loc = self.interaction.translation
        glTranslated(loc[0], loc[1], loc[2])
        glMultMatrixf(self.interaction.trackball.matrix)

        # store the inverse of the current modelview.
        currentModelView = numpy.array(glGetFloatv(GL_MODELVIEW_MATRIX))
        self.modelView = numpy.transpose(currentModelView)
        self.inverseModelView = inv(numpy.transpose(currentModelView))

        # render the scene. This will call the render function for each object
        # in the scene
        self.scene.render()

        # draw the grid
        glDisable(GL_LIGHTING)
        glCallList(G_OBJ_PLANE)
        glPopMatrix()

        # flush the buffers so that the scene can be drawn
        glFlush()

    def init_view(self):
        """ initialize the projection matrix """
        xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
        aspect_ratio = float(xSize) / float(ySize)

        # load the projection matrix. Always the same
        glMatrixMode(GL_PROJECTION)
        glLoadIdentity()

        glViewport(0, 0, xSize, ySize)
        gluPerspective(70, aspect_ratio, 0.1, 1000.0)
        glTranslated(0, 0, -15)

要渲染什么:場景

既然我們已經(jīng)初始化渲染管道來處理世界坐標空間中的繪圖,那么我們將渲染什么? 回想一下,我們的目標是有一個由三維模型組成的設計。 我們需要一個數(shù)據(jù)結構來包含設計,我們需要使用這個數(shù)據(jù)結構來渲染設計。 注意上面,我們從查看器的渲染循環(huán)中調(diào)用self.scene.render()。 場景是什么?

Scene類是我們用來表示設計的數(shù)據(jù)結構的接口。 它抽象出數(shù)據(jù)結構的細節(jié),并提供與設計交互所需的必要接口功能,包括渲染,添加項目和操作項目的功能。 Viewer擁有一個Scene對象。 Scene實例保存了場景中所有項目的列表,名為node_list。 它也跟蹤所選項目。 場景中的渲染函數(shù)只需在node_list的每個成員上調(diào)用渲染。

class Scene(object):

    # the default depth from the camera to place an object at
    PLACE_DEPTH = 15.0

    def __init__(self):
        # The scene keeps a list of nodes that are displayed
        self.node_list = list()
        # Keep track of the currently selected node.
        # Actions may depend on whether or not something is selected
        self.selected_node = None

    def add_node(self, node):
        """ Add a new node to the scene """
        self.node_list.append(node)

    def render(self):
        """ Render the scene. """
        for node in self.node_list:
            node.render()

Nodes

在場景的render函數(shù)中,我們對場景中node_list的每個項目調(diào)用render函數(shù)。但是這些列表中的元素都是什么呢?我們稱他們?yōu)楣?jié)點。理論上,一個節(jié)點就是可以放在場景中任何東西。在面向?qū)ο蟮能浖?,我們?code>Node寫成一個抽象基類。任何在Scene中表示對象的東西都是從這個Node繼承而來的。這個基類讓我們可以抽象地解釋場景。代碼庫地其余部分不需要知道它顯示對象的細節(jié);它只需要知道它們是類節(jié)點。

每種Node都定義了渲染它或者和它交互的行為。這個Node保持跟蹤關于它自己的重要數(shù)據(jù):平移矩陣、縮放矩陣、顏色等。將節(jié)點的平移矩陣乘上它的縮放矩陣就得將它從節(jié)點模型坐標空間到世界坐標空間的轉(zhuǎn)換矩陣。該節(jié)點還存儲一個軸對齊的邊界框(AABB)。 當我們在下面討論選擇時,我們會看到更多關于AABB的信息。

Node最簡單的具體實現(xiàn)是一個原語。 基元是可以添加到場景中的單個固體形狀。 在這個項目中,基元是CubeSphere。

class Node(object):
    """ Base class for scene elements """
    def __init__(self):
        self.color_index = random.randint(color.MIN_COLOR, color.MAX_COLOR)
        self.aabb = AABB([0.0, 0.0, 0.0], [0.5, 0.5, 0.5])
        self.translation_matrix = numpy.identity(4)
        self.scaling_matrix = numpy.identity(4)
        self.selected = False

    def render(self):
        """ renders the item to the screen """
        glPushMatrix()
        glMultMatrixf(numpy.transpose(self.translation_matrix))
        glMultMatrixf(self.scaling_matrix)
        cur_color = color.COLORS[self.color_index]
        glColor3f(cur_color[0], cur_color[1], cur_color[2])
        if self.selected:  # emit light if the node is selected
            glMaterialfv(GL_FRONT, GL_EMISSION, [0.3, 0.3, 0.3])

        self.render_self()

        if self.selected:
            glMaterialfv(GL_FRONT, GL_EMISSION, [0.0, 0.0, 0.0])
        glPopMatrix()

    def render_self(self):
        raise NotImplementedError(
            "The Abstract Node Class doesn't define 'render_self'")

class Primitive(Node):
    def __init__(self):
        super(Primitive, self).__init__()
        self.call_list = None

    def render_self(self):
        glCallList(self.call_list)


class Sphere(Primitive):
    """ Sphere primitive """
    def __init__(self):
        super(Sphere, self).__init__()
        self.call_list = G_OBJ_SPHERE


class Cube(Primitive):
    """ Cube primitive """
    def __init__(self):
        super(Cube, self).__init__()
        self.call_list = G_OBJ_CUBE

基于每個節(jié)點存儲的轉(zhuǎn)換矩陣對節(jié)點進行渲染。節(jié)點的變換矩陣是其縮放矩陣與其平移矩陣的組合。 無論節(jié)點是什么類型,渲染的第一步是將OpenGL ModelView矩陣設置為變換矩陣,以便從模型坐標空間轉(zhuǎn)換為視圖坐標空間。 一旦OpenGL矩陣是最新的,我們就調(diào)用render_self來通知節(jié)點進行必要的OpenGL調(diào)用來繪制自己。 最后,我們撤銷對該特定節(jié)點對OpenGL狀態(tài)所做的任何更改。 我們使用OpenGL中的glPushMatrixglPopMatrix函數(shù)在渲染節(jié)點之前和之后保存和恢復ModelView矩陣的狀態(tài)。 請注意,節(jié)點存儲其顏色,位置和比例,并在渲染之前將這些應用在OpenGL狀態(tài)。

如果節(jié)點當前被選中,我們使它發(fā)光。 這樣,用戶就可以看到他們選擇了哪個節(jié)點。

為了渲染基元,我們使用OpenGL的調(diào)用列表功能。 OpenGL調(diào)用列表是一系列OpenGL調(diào)用,它們被定義一次并以單一名稱捆綁在一起。 可以使用glCallList(LIST_NAME)分配調(diào)用。 每個基元(球體和立方體)定義了渲染它所需的調(diào)用列表(未顯示)。

例如,立方體的調(diào)用列表繪制了立方體的6個面,其中心位于原點,而邊緣正好為1個單位長。

# Pseudocode Cube definition
# Left face
((-0.5, -0.5, -0.5), (-0.5, -0.5, 0.5), (-0.5, 0.5, 0.5), (-0.5, 0.5, -0.5)),
# Back face
((-0.5, -0.5, -0.5), (-0.5, 0.5, -0.5), (0.5, 0.5, -0.5), (0.5, -0.5, -0.5)),
# Right face
((0.5, -0.5, -0.5), (0.5, 0.5, -0.5), (0.5, 0.5, 0.5), (0.5, -0.5, 0.5)),
# Front face
((-0.5, -0.5, 0.5), (0.5, -0.5, 0.5), (0.5, 0.5, 0.5), (-0.5, 0.5, 0.5)),
# Bottom face
((-0.5, -0.5, 0.5), (-0.5, -0.5, -0.5), (0.5, -0.5, -0.5), (0.5, -0.5, 0.5)),
# Top face
((-0.5, 0.5, -0.5), (-0.5, 0.5, 0.5), (0.5, 0.5, 0.5), (0.5, 0.5, -0.5))

僅使用基元對于建模應用程序來說是相當有限的。 3D模型通常由多個基元組成(或三角形網(wǎng)格,這在本項目的范圍之外)。 幸運的是,我們的Node類的設計使多個基元節(jié)點組成場景變得方便。 事實上,我們可以在不增加復雜性的情況下支持任意節(jié)點分組。

作為動力,讓我們考慮一個非?;镜臄?shù)字:一個典型的雪人,或由三個球體組成的雪花圖。 即使該圖由三個獨立的基元組成,我們希望能夠?qū)⑺暈閱蝹€對象。

我們創(chuàng)建一個名為HierarchicalNode的類,一個包含其他節(jié)點的節(jié)點。 它管理一系列“孩子”。HierarchicalNoderender_self函數(shù)只需在每個子節(jié)點上調(diào)用render_self。 使用HierarchicalNode類,向場景添加圖像非常簡單。 現(xiàn)在,定義雪圖與指定構成它的形狀以及它們的相對位置和大小一樣簡單。

子類的層次結構
class HierarchicalNode(Node):
    def __init__(self):
        super(HierarchicalNode, self).__init__()
        self.child_nodes = []

    def render_self(self):
        for child in self.child_nodes:
            child.render()
class SnowFigure(HierarchicalNode):
    def __init__(self):
        super(SnowFigure, self).__init__()
        self.child_nodes = [Sphere(), Sphere(), Sphere()]
        self.child_nodes[0].translate(0, -0.6, 0) # scale 1.0
        self.child_nodes[1].translate(0, 0.1, 0)
        self.child_nodes[1].scaling_matrix = numpy.dot(
            self.scaling_matrix, scaling([0.8, 0.8, 0.8]))
        self.child_nodes[2].translate(0, 0.75, 0)
        self.child_nodes[2].scaling_matrix = numpy.dot(
            self.scaling_matrix, scaling([0.7, 0.7, 0.7]))
        for child_node in self.child_nodes:
            child_node.color_index = color.MIN_COLOR
        self.aabb = AABB([0.0, 0.0, 0.0], [0.5, 1.1, 0.5])

你可能會觀察到Node對象形成了一個樹形數(shù)據(jù)結構。 渲染函數(shù)通過分層節(jié)點在樹中進行深度優(yōu)先遍歷。 在遍歷時,它會把用于轉(zhuǎn)換到世界空間的ModelView矩陣壓入棧中。 在每個步驟中,它將當前的ModelView矩陣入棧,當它完成所有子節(jié)點的渲染時,它會將矩陣從堆棧中彈出,并將父節(jié)點的ModelView矩陣留在堆棧的頂部。

通過以這種方式使Node類可擴展,我們可以向場景添加新類型的形狀,而無需更改用于場景操縱和渲染的任何其他代碼。 使用節(jié)點概念來抽象出一個場景對象可能有很多孩子的事實被稱為復合設計模式。

用戶交互

現(xiàn)在我們的建模器能夠存儲和顯示場景,我們需要一種與之交互的方式。 我們需要促進兩種類型的互動。 首先,我們需要改變場景觀看角度的能力。 我們希望能夠在場景中移動眼睛或相機。 其次,我們需要能夠添加新節(jié)點并修改場景中的節(jié)點。

要啟用用戶交互,我們需要知道用戶何時按下鍵或移動鼠標。 幸運的是,操作系統(tǒng)已經(jīng)知道這些事件何時發(fā)生。 GLUT允許我們注冊一個函數(shù),在某個事件發(fā)生時被調(diào)用。 我們編寫函數(shù)來解釋按鍵和鼠標移動,并告訴GLUT在按下相應的鍵時調(diào)用這些函數(shù)。 一旦我們知道用戶正在按下哪些按鍵,我們需要解釋輸入并將預期動作應用到場景中。

Interaction類中可以找到用于監(jiān)聽操作系統(tǒng)事件并解釋其含義的邏輯。 我們之前編寫的Viewer類擁有Interaction的單一實例。 我們將使用GLUT回調(diào)機制來注冊當按下鼠標按鈕時(glutMouseFunc),當移動鼠標時(glutMotionFunc),按下鍵盤按鈕(glutKeyboardFunc),以及按下方向鍵時要調(diào)用的函數(shù)(glutSpecialFunc)。 我們將很快看到處理輸入事件的函數(shù)。

class Interaction(object):
    def __init__(self):
        """ Handles user interaction """
        # currently pressed mouse button
        self.pressed = None
        # the current location of the camera
        self.translation = [0, 0, 0, 0]
        # the trackball to calculate rotation
        self.trackball = trackball.Trackball(theta = -25, distance=15)
        # the current mouse location
        self.mouse_loc = None
        # Unsophisticated callback mechanism
        self.callbacks = defaultdict(list)

        self.register()

    def register(self):
        """ register callbacks with glut """
        glutMouseFunc(self.handle_mouse_button)
        glutMotionFunc(self.handle_mouse_move)
        glutKeyboardFunc(self.handle_keystroke)
        glutSpecialFunc(self.handle_keystroke)

操作系統(tǒng)回調(diào)函數(shù)

為了有意義地解釋用戶輸入,我們需要結合鼠標位置,鼠標按鈕和鍵盤的知識。 因為將用戶輸入解釋為有意義的動作需要很多代碼行,所以我們將它封裝在一個獨立的類中,遠離主代碼路徑。 Interaction類隱藏了與代碼庫其余部分無關的復雜性,并將操作系統(tǒng)事件轉(zhuǎn)換為應用程序級事件。

    # class Interaction 
    def translate(self, x, y, z):
        """ translate the camera """
        self.translation[0] += x
        self.translation[1] += y
        self.translation[2] += z

    def handle_mouse_button(self, button, mode, x, y):
        """ Called when the mouse button is pressed or released """
        xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
        y = ySize - y  # invert the y coordinate because OpenGL is inverted
        self.mouse_loc = (x, y)

        if mode == GLUT_DOWN:
            self.pressed = button
            if button == GLUT_RIGHT_BUTTON:
                pass
            elif button == GLUT_LEFT_BUTTON:  # pick
                self.trigger('pick', x, y)
            elif button == 3:  # scroll up
                self.translate(0, 0, 1.0)
            elif button == 4:  # scroll up
                self.translate(0, 0, -1.0)
        else:  # mouse button release
            self.pressed = None
        glutPostRedisplay()

    def handle_mouse_move(self, x, screen_y):
        """ Called when the mouse is moved """
        xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
        y = ySize - screen_y  # invert the y coordinate because OpenGL is inverted
        if self.pressed is not None:
            dx = x - self.mouse_loc[0]
            dy = y - self.mouse_loc[1]
            if self.pressed == GLUT_RIGHT_BUTTON and self.trackball is not None:
                # ignore the updated camera loc because we want to always
                # rotate around the origin
                self.trackball.drag_to(self.mouse_loc[0], self.mouse_loc[1], dx, dy)
            elif self.pressed == GLUT_LEFT_BUTTON:
                self.trigger('move', x, y)
            elif self.pressed == GLUT_MIDDLE_BUTTON:
                self.translate(dx/60.0, dy/60.0, 0)
            else:
                pass
            glutPostRedisplay()
        self.mouse_loc = (x, y)

    def handle_keystroke(self, key, x, screen_y):
        """ Called on keyboard input from the user """
        xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
        y = ySize - screen_y
        if key == 's':
            self.trigger('place', 'sphere', x, y)
        elif key == 'c':
            self.trigger('place', 'cube', x, y)
        elif key == GLUT_KEY_UP:
            self.trigger('scale', up=True)
        elif key == GLUT_KEY_DOWN:
            self.trigger('scale', up=False)
        elif key == GLUT_KEY_LEFT:
            self.trigger('rotate_color', forward=True)
        elif key == GLUT_KEY_RIGHT:
            self.trigger('rotate_color', forward=False)
        glutPostRedisplay()

內(nèi)部回調(diào)

在上面的代碼片段中,您會注意到,當Interaction實例解釋用戶操作時,它會使用描述操作類型的字符串調(diào)用self.trigger。Interaction類的觸發(fā)器函數(shù)是我們將用于處理應用程序級事件的簡單回調(diào)系統(tǒng)的一部分。 回想一下,Viewer類的init_interaction函數(shù)通過調(diào)用register_callback來注冊Interaction實例上的回調(diào)函數(shù)。

    # class Interaction
    def register_callback(self, name, func):
        self.callbacks[name].append(func)

當用戶界面代碼需要在場景中觸發(fā)事件時,Interaction類會調(diào)用它為該特定事件保存的所有回調(diào):

    # class Interaction
    def trigger(self, name, *args, **kwargs):
        for func in self.callbacks[name]:
            func(*args, **kwargs)

這個應用程序級回調(diào)系統(tǒng)抽象了系統(tǒng)其余部分了解操作系統(tǒng)輸入的需求。 每個應用程序級別的回調(diào)代表了應用程序中的有意義的請求。 Interaction類充當操作系統(tǒng)事件和應用程序級事件之間的轉(zhuǎn)換器。 這意味著如果我們決定將建模器移植到除GLUT之外的另一個工具包中,我們只需要用一個將新工具箱的輸入轉(zhuǎn)換為同一組有意義的應用級回調(diào)的類來替換Interaction類。 我們在下表中使用回調(diào)和參數(shù)

回調(diào)函數(shù) 參數(shù) 作用
pick x:number, y:number Selects the node at the mouse pointer location.
move x:number, y:number Moves the currently selected node to the mouse pointer location.
place shape:string, x:number, y:number Places a shape of the specified type at the mouse pointer location.
rotate_color forward:boolean Rotates the color of the currently selected node through the list of colors, forwards or backwards.
scale up:boolean Scales the currently selected node up or down, according to parameter.

這個簡單的回調(diào)系統(tǒng)提供了我們在這個項目中需要的所有功能。 然而,在構建3D建模器中,用戶界面對象通常是動態(tài)創(chuàng)建和銷毀的。 在這種情況下,我們需要一個更復雜的事件監(jiān)聽系統(tǒng),其中對象既可以注冊也可以取消注冊事件回調(diào)。

接入場景

通過我們的回調(diào)機制,我們可以從Interaction類接收關于用戶輸入事件的有意義的信息。 我們準備將這些操作應用到場景中。

移動場景

在這個項目中,我們通過變換場景來完成相機運動。換句話說,相機處于固定位置,用戶輸入移動場景而不是移動相機。相機放置在[0, 0, -15]并且對著世界空間的中心(或者,我們可以改變透視矩陣來移動相機而不是場景。 這個設計決定對其余的項目影響很小。)重新瀏覽Viewer中的render函數(shù),我們看到Interaction狀態(tài)用于在渲染場景之前轉(zhuǎn)換OpenGL矩陣狀態(tài)。 有兩種與Scene交互的類型:旋轉(zhuǎn)和平移。

用一個軌跡球旋轉(zhuǎn)場景

我們通過使用軌跡球算法來完成場景的旋轉(zhuǎn)。 軌跡球是用于三維操縱場景的直觀界面。 從概念上講,軌跡球界面的功能就好像場景在透明球體內(nèi)一樣。 將一只手放在地球表面并推動它旋轉(zhuǎn)地球。 同樣,單擊鼠標右鍵并在屏幕上移動它可以旋轉(zhuǎn)場景。你可以在OpenGL Wiki中找到更多關于軌跡球理論的信息。 在這個項目中,我們使用作為Glumpy的一部分提供的軌跡球?qū)嵤?/p>

我們使用drag_to函數(shù)與軌跡球進行交互,將鼠標的當前位置作為起始位置,并將鼠標位置的變化作為參數(shù)。

self.trackball.drag_to(self.mouse_loc[0], self.mouse_loc[1], dx, dy)

當渲染場景時,生成的旋轉(zhuǎn)矩陣是Viewer中的trackball.matrix。

補充:四元數(shù)

旋轉(zhuǎn)有兩種傳統(tǒng)的方式表示。 第一個是圍繞每個軸的旋轉(zhuǎn)值; 你可以將它存儲為浮點數(shù)的三元組。 旋轉(zhuǎn)的另一種常見表示是四元數(shù),由具有x,yz坐標的矢量以及w旋轉(zhuǎn)組成的元素。 使用四元數(shù)對于每軸旋轉(zhuǎn)有許多好處; 特別是它們在數(shù)值上更穩(wěn)定。 使用四元數(shù)可以避免類似萬向節(jié)鎖的問題。 四元數(shù)的缺點是它們不太直觀,難以理解。 如果你很勇敢并想了解更多關于四元數(shù)的內(nèi)容,可以參考這個解釋。

軌跡球的實現(xiàn)使通過在內(nèi)部使用四元數(shù)存儲場景的旋轉(zhuǎn)來避免萬向節(jié)鎖定。 幸運的是,我們不需要直接使用四元數(shù),因為軌跡球上的矩陣成員會將旋轉(zhuǎn)轉(zhuǎn)換為矩陣。

場景轉(zhuǎn)換

場景轉(zhuǎn)移(即滑動場景)比旋轉(zhuǎn)場景要簡單得多。 提供隨鼠標滾輪和鼠標左鍵一起的場景轉(zhuǎn)換。 鼠標左鍵在xy坐標中轉(zhuǎn)換場景。 滾動鼠標滾輪可以將場景轉(zhuǎn)換為z坐標(朝向或遠離攝像機)。 Interaction類存儲當前的場景轉(zhuǎn)換并使用平移功能修改它。 查看器在渲染過程中檢索交互攝像頭位置以用于glTranslated調(diào)用。

選擇場景對象

現(xiàn)在,用戶可以移動和旋轉(zhuǎn)整個場景以獲得他們想要的視角,下一步是允許用戶修改和操作構成場景的對象。

為了讓用戶操作場景中的對象,他們需要能夠選擇場景中的對象。

要選擇一個項目,我們使用當前投影矩陣生成代表鼠標點擊的光線,就好像鼠標指針將射線投射到場景中一樣。 所選節(jié)點是射線與射線相交的最近節(jié)點。 因此,拾取問題簡化為在光線和場景中的節(jié)點之間找到交點的問題。 所以問題是:我們?nèi)绾闻袛喙饩€是否碰到節(jié)點?

準確計算光線是否與節(jié)點相交是一個在代碼復雜性和性能方面具有挑戰(zhàn)性的問題。 我們需要為每種類型的基元編寫一個光線對象交叉檢查。 對于具有許多面的復雜網(wǎng)格幾何形狀的場景節(jié)點,計算精確的光線對象相交將需要測試每個面的光線,并且計算起來會有很高的代價。

為了保持代碼緊湊和性能合理,我們使用簡單,快速的近似值進行光線對象相交測試。 在我們的實現(xiàn)中,每個節(jié)點都保存一個軸對齊的邊界框(AABB),它是節(jié)點占據(jù)的空間的近似值。 為了測試光線是否與節(jié)點相交,我們測試光線是否與節(jié)點的AABB相交。 這種實現(xiàn)意味著所有節(jié)點共享相同的代碼進行相交測試,對于所有節(jié)點類型而言這意味著性能開銷都是固定的小的。

    # class Viewer
    def get_ray(self, x, y):
        """ 
        Generate a ray beginning at the near plane, in the direction that
        the x, y coordinates are facing 

        Consumes: x, y coordinates of mouse on screen 
        Return: start, direction of the ray 
        """
        self.init_view()

        glMatrixMode(GL_MODELVIEW)
        glLoadIdentity()

        # get two points on the line.
        start = numpy.array(gluUnProject(x, y, 0.001))
        end = numpy.array(gluUnProject(x, y, 0.999))

        # convert those points into a ray
        direction = end - start
        direction = direction / norm(direction)

        return (start, direction)

    def pick(self, x, y):
        """ Execute pick of an object. Selects an object in the scene. """
        start, direction = self.get_ray(x, y)
        self.scene.pick(start, direction, self.modelView)

為了確定哪個節(jié)點被點擊,我們遍歷場景來測試光線是否碰到任何節(jié)點。 我們?nèi)∠x擇當前選擇的節(jié)點,然后選擇最靠近射線源的交點。

    # class Scene
    def pick(self, start, direction, mat):
        """
        Execute selection.

        start, direction describe a Ray. 
        mat is the inverse of the current modelview matrix for the scene.
        """
        if self.selected_node is not None:
            self.selected_node.select(False)
            self.selected_node = None

        # Keep track of the closest hit.
        mindist = sys.maxint
        closest_node = None
        for node in self.node_list:
            hit, distance = node.pick(start, direction, mat)
            if hit and distance < mindist:
                mindist, closest_node = distance, node

        # If we hit something, keep track of it.
        if closest_node is not None:
            closest_node.select()
            closest_node.depth = mindist
            closest_node.selected_loc = start + direction * mindist
            self.selected_node = closest_node

Node類中,pick函數(shù)測試光線是否與節(jié)點的軸對齊邊界框相交。 如果選擇了節(jié)點,則選擇功能切換節(jié)點的選定狀態(tài)。 請注意,AABB的ray_hit函數(shù)接受框的坐標空間和光線坐標空間之間的變換矩陣作為第三個參數(shù)。 在進行ray_hit函數(shù)調(diào)用之前,每個節(jié)點都將自己的變換應用于矩陣。

    # class Node
    def pick(self, start, direction, mat):
        """ 
        Return whether or not the ray hits the object

        Consume:  
        start, direction form the ray to check
        mat is the modelview matrix to transform the ray by 
        """

        # transform the modelview matrix by the current translation
        newmat = numpy.dot(
            numpy.dot(mat, self.translation_matrix), 
            numpy.linalg.inv(self.scaling_matrix)
        )
        results = self.aabb.ray_hit(start, direction, newmat)
        return results

    def select(self, select=None):
       """ Toggles or sets selected state """
       if select is not None:
           self.selected = select
       else:
           self.selected = not self.selected

ray-AABB選擇方法非常易于理解和實施。 但是,在某些情況下結果是錯誤的。

AABB 錯誤

例如,在Sphere基元的情況下,球體本身只觸及每個AABB面的中心的AABB。 但是,如果用戶點擊Sphere的AABB的角落,即使用戶打算點擊Sphere后面的某個東西,碰撞也會被Sphere檢測到。

復雜性,性能和準確性之間的這種折衷在計算機圖形學和軟件工程的許多領域中是常見的。

調(diào)整場景對象

接下來,我們希望允許用戶操縱選定的節(jié)點。 他們可能想要移動,調(diào)整大小或更改所選節(jié)點的顏色。 當用戶輸入命令來操作節(jié)點時,Interaction類將輸入轉(zhuǎn)換為用戶所需的操作,并調(diào)用相應的回調(diào)。

Viewer收到其中一個事件的回調(diào)時,它會調(diào)用場景上的相應功能,然后將該變換應用于當前選定的節(jié)點。

    # class Viewer
    def move(self, x, y):
        """ Execute a move command on the scene. """
        start, direction = self.get_ray(x, y)
        self.scene.move_selected(start, direction, self.inverseModelView)

    def rotate_color(self, forward):
        """ 
        Rotate the color of the selected Node. 
        Boolean 'forward' indicates direction of rotation. 
        """
        self.scene.rotate_selected_color(forward)

    def scale(self, up):
        """ Scale the selected Node. Boolean up indicates scaling larger."""
        self.scene.scale_selected(up)

改變顏色

操作顏色是通過一系列可能的顏色來完成的。 用戶可以通過箭頭鍵在列表中循環(huán)。 場景將顏色更改命令分派給當前選定的節(jié)點。

    # class Scene
    def rotate_selected_color(self, forwards):
        """ Rotate the color of the currently selected node """
        if self.selected_node is None: return
        self.selected_node.rotate_color(forwards)

每個節(jié)點存儲其當前顏色。 rotate_color函數(shù)只是修改節(jié)點的當前顏色。 渲染節(jié)點時,顏色將通過glColor傳遞給OpenGL。

    # class Node
    def rotate_color(self, forwards):
        self.color_index += 1 if forwards else -1
        if self.color_index > color.MAX_COLOR:
            self.color_index = color.MIN_COLOR
        if self.color_index < color.MIN_COLOR:
            self.color_index = color.MAX_COLOR

節(jié)點縮放

與顏色一樣,場景會將所有縮放修改分派給所選節(jié)點(如果有的話)。

    # class Scene
    def scale_selected(self, up):
        """ Scale the current selection """
        if self.selected_node is None: return
        self.selected_node.scale(up)

每個節(jié)點有一個存儲其比例的當前矩陣。 在這些相應方向上通過參數(shù)x,y和z縮放的矩陣是:

\begin{bmatrix} x & 0 & 0 & 0\\\\ 0 & y & 0 & 0\\\\ 0 & 0 & z & 0\\\\ 0 & 0 & 0 & 1 \end{bmatrix}

當用戶修改節(jié)點的縮放比例時,就把生成的縮放矩陣乘以該節(jié)點的當前縮放矩陣。

    # class Node
    def scale(self, up):
        s =  1.1 if up else 0.9
        self.scaling_matrix = numpy.dot(self.scaling_matrix, scaling([s, s, s]))
        self.aabb.scale(s)

給定一個含x,yz縮放因子的列表,函數(shù)scaling返回這樣一個矩陣。

移動節(jié)點

為了轉(zhuǎn)化節(jié)點,我們使用與選取對象相同的射線計算方法。 我們將代表當前鼠標位置的射線傳遞給場景的move函數(shù)。 節(jié)點的新位置應該在射線上。 為了確定光線放置節(jié)點的位置,我們需要知道節(jié)點距相機的距離。 由于我們存儲節(jié)點的位置和相機在選中時的位置(在pick函數(shù)中),我們可以在這里使用這些數(shù)據(jù)。 我們發(fā)現(xiàn)沿著目標光線與相機距離相同的點,并計算新舊位置之間的矢量差。 然后我們通過結果向量來轉(zhuǎn)換節(jié)點。

    # class Scene
    def move_selected(self, start, direction, inv_modelview):
        """
        Move the selected node, if there is one.

        Consume:
        start, direction describes the Ray to move to
        mat is the modelview matrix for the scene 
        """
        if self.selected_node is None: return

        # Find the current depth and location of the selected node
        node = self.selected_node
        depth = node.depth
        oldloc = node.selected_loc

        # The new location of the node is the same depth along the new ray
        newloc = (start + direction * depth)

        # transform the translation with the modelview matrix
        translation = newloc - oldloc
        pre_tran = numpy.array([translation[0], translation[1], translation[2], 0])
        translation = inv_modelview.dot(pre_tran)

        # translate the node and track its location
        node.translate(translation[0], translation[1], translation[2])
        node.selected_loc = newloc

請注意,新位置和舊位置是在相機坐標空間中定義的。 我們需要在世界坐標空間中定義我們的平移。 因此,我們通過乘以模型視圖矩陣的逆,從攝像機空間平移到世界空間。

與縮放一樣,每個節(jié)點存儲代表其平移的矩陣。平移矩陣如下所示:

\begin{bmatrix} 1 & 0 & 0 & x \\\\ 0 & 1 & 0 & y \\\\ 0 & 0 & 1 & z \\\\ 0 & 0 & 0 & 1 \end{bmatrix}

當節(jié)點被平移時,我們?yōu)楫斍捌揭茦嫿ㄒ粋€新的平移矩陣,并將其乘以節(jié)點的平移矩陣以便在渲染過程中使用。

    # class Node
    def translate(self, x, y, z):
        self.translation_matrix = numpy.dot(
            self.translation_matrix, 
            translation([x, y, z]))

平移函數(shù)返回給定表示x,y和z平移距離的列表的平移矩陣。

放置節(jié)點

節(jié)點布局使用拾取和平移技術。 我們對當前鼠標位置使用相同的光線計算來確定放置節(jié)點的位置。

    # class Viewer
    def place(self, shape, x, y):
        """ Execute a placement of a new primitive into the scene. """
        start, direction = self.get_ray(x, y)
        self.scene.place(shape, start, direction, self.inverseModelView)

要放置一個新節(jié)點,我們首先創(chuàng)建相應類型節(jié)點的新實例并將其添加到場景中。 我們希望將節(jié)點放置在用戶的光標下,因此我們在距離相機固定距離的光線上找到一個點。 因為,光線是在相機空間中表示的,所以我們通過將其與逆模型視圖矩陣相乘,將得到的平移向量轉(zhuǎn)換為世界坐標空間。 最后,我們通過計算出的矢量來轉(zhuǎn)換新節(jié)點。

    # class Scene
    def place(self, shape, start, direction, inv_modelview):
        """
        Place a new node.

        Consume:
        shape the shape to add
        start, direction describes the Ray to move to
        inv_modelview is the inverse modelview matrix for the scene 
        """
        new_node = None
        if shape == 'sphere': new_node = Sphere()
        elif shape == 'cube': new_node = Cube()
        elif shape == 'figure': new_node = SnowFigure()

        self.add_node(new_node)

        # place the node at the cursor in camera-space
        translation = (start + direction * self.PLACE_DEPTH)

        # convert the translation to world-space
        pre_tran = numpy.array([translation[0], translation[1], translation[2], 1])
        translation = inv_modelview.dot(pre_tran)

        new_node.translate(translation[0], translation[1], translation[2])

總結

恭喜! 我們已經(jīng)成功實現(xiàn)了一個小型3D建模器!

簡單場景

我們看了如何開發(fā)一個可擴展的數(shù)據(jù)結構來表示場景中的對象。 我們注意到,使用Composite設計模式和基于樹的數(shù)據(jù)結構可以輕松遍歷場景進行渲染,并允許我們添加新類型的節(jié)點而不增加復雜性。 我們利用這個數(shù)據(jù)結構將設計渲染到屏幕上,并在場景圖的遍歷中操縱OpenGL矩陣。 我們?yōu)閼贸绦蚣壥录嫿艘粋€非常簡單的回調(diào)系統(tǒng),并使用它來封裝操作系統(tǒng)事件的處理。 我們討論了射線 - 物體碰撞檢測的可能實現(xiàn)方式,以及正確性,復雜性和性能之間的權衡。 最后,我們實現(xiàn)了處理場景內(nèi)容的方法。

你可以在工業(yè)3D軟件中找到這些相同的基本構建模塊。場景圖結構和相對坐標空間可用于許多類型的3D圖形應用程序,從CAD工具到游戲引擎。 該項目的一個主要簡化是在用戶界面上。工業(yè)3D建模器要求具有完整的用戶界面,這將需要更復雜的事件系統(tǒng)而不是我們設計的簡單的回調(diào)系統(tǒng)。

我們可以做進一步的實驗來為這個項目添加新的功能。 嘗試其中之一:

  • 添加Node類型以支持任意形狀的三角形網(wǎng)格。
  • 添加撤消堆棧,以允許撤消/重做模型操作。
  • 使用DXF等3D文件格式保存/加載設計。
  • 整合渲染引擎:導出設計以用于照片級渲染器。
  • 通過精確的光線對象交叉來改善碰撞檢測。

更多拓展

為了深入了解真實世界的3D建模軟件,一些開源項目很有趣。

Blender是一款開源的全功能3D動畫套件。 它提供了一個完整的3D管道,用于在視頻中創(chuàng)建特殊效果或創(chuàng)建游戲。 建模器是該項目的一小部分,它是將建模器集成到大型軟件套件中的一個很好的例子。

OpenSCAD是一款開源3D建模工具。 它不是互動的; 相反,它讀取指定如何生成場景的腳本文件。 這可以讓設計師“完全控制建模過程”。

有關計算機圖形學算法和技術的更多信息,Graphics Gems是一個很好的資源。

?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容