500 lines or less學(xué)習(xí)筆記(十一)——3D建模工具(modeller)

本文介紹了一個(gè)使用 OpenGL 來(lái)渲染圖形的 3D 建模工具。其中涉及到一些圖形學(xué)的知識(shí),比如各個(gè)空間的轉(zhuǎn)換,讓身為小白的我看的頭疼。

作者

Erick Dransch,Erick 是一名軟件開發(fā)人員以及 2D 和 3D 計(jì)算機(jī)圖形愛好者。他從事過電子游戲、3D 特效軟件和計(jì)算機(jī)輔助設(shè)計(jì)工具相關(guān)工作。他還了解很多模擬現(xiàn)實(shí)相關(guān)知識(shí)。他的網(wǎng)站是 ericktransch.com 。

簡(jiǎn)介

人類天生就有創(chuàng)造力。我們不斷地設(shè)計(jì)和制造新穎、有用、有趣的東西。在現(xiàn)代,我們編寫軟件來(lái)輔助設(shè)計(jì)和創(chuàng)造過程。計(jì)算機(jī)輔助設(shè)計(jì)(CAD)軟件允許創(chuàng)作者在構(gòu)建物理版本之前設(shè)計(jì)建筑物、橋梁、視頻游戲藝術(shù)、電影怪物、3D 可打印對(duì)象和許多其它東西。

其核心部分 CAD 工具是將三維設(shè)計(jì)抽象為可以在二維屏幕上查看和編輯的東西的方法。為了實(shí)現(xiàn)這一定義,CAD 工具必須提供三個(gè)基本功能。首先,它們必須有一個(gè)數(shù)據(jù)結(jié)構(gòu)來(lái)表示正在設(shè)計(jì)的對(duì)象:這是計(jì)算機(jī)對(duì)用戶正在構(gòu)建的三維世界的理解。其次,CAD 工具必須提供一些方法,在用戶界面上顯示設(shè)計(jì)。用戶正在設(shè)計(jì)一個(gè)三維物理對(duì)象,但計(jì)算機(jī)屏幕只有2個(gè)維度。CAD 工具必須對(duì)我們?nèi)绾胃兄獙?duì)象進(jìn)行建模,并將其繪制到屏幕上,用戶可以理解對(duì)象的所有3個(gè)維度。第三,CAD 工具必須提供一種與設(shè)計(jì)對(duì)象交互的方法。用戶必須能夠添加和修改設(shè)計(jì),以產(chǎn)生所需的結(jié)果。此外,所有工具都需要一種方法來(lái)將設(shè)計(jì)保存到磁盤以及從磁盤中加載設(shè)計(jì),以便用戶能夠協(xié)作、共享和保存工作。

特定領(lǐng)域的 CAD 工具為特定領(lǐng)域的特定需求提供了許多附加功能。例如,建筑 CAD 工具將提供物理模擬來(lái)測(cè)試建筑物上的氣候壓力,3D 打印工具將具有檢查對(duì)象是否可以進(jìn)行實(shí)際有效打印的功能,電氣 CAD 工具將模擬電流通過銅的物理特性,電影特效套件將包含精確模擬熱動(dòng)力學(xué)的功能。

然而,所有的 CAD 工具必須至少包括上面討論的三個(gè)特性:表示設(shè)計(jì)的數(shù)據(jù)結(jié)構(gòu)、將其顯示在屏幕上的能力以及與設(shè)計(jì)交互的方法。

考慮到這一點(diǎn),讓我們探索如何用 500 行 Python 代碼來(lái)表示 3D 設(shè)計(jì)、將其顯示在屏幕上并與之進(jìn)行交互。

渲染指導(dǎo)

三維建模器中許多設(shè)計(jì)決策背后的驅(qū)動(dòng)力是渲染過程。我們希望能夠在我們的設(shè)計(jì)中存儲(chǔ)和渲染復(fù)雜的對(duì)象,但我們希望渲染代碼的復(fù)雜性較低。讓我們檢查渲染過程,并探索設(shè)計(jì)的數(shù)據(jù)結(jié)構(gòu),該數(shù)據(jù)結(jié)構(gòu)允許我們使用簡(jiǎn)單的渲染邏輯存儲(chǔ)和繪制任意復(fù)雜的對(duì)象。

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

在開始渲染之前,我們需要進(jìn)行一些設(shè)置。首先,我們需要?jiǎng)?chuàng)建一個(gè)窗口來(lái)顯示我們的設(shè)計(jì)。其次,我們希望與圖形驅(qū)動(dòng)程序進(jìn)行通信以渲染到屏幕上。我們不想直接與圖形驅(qū)動(dòng)程序通信,所以我們使用一個(gè)稱為 OpenGL 的跨平臺(tái)抽象層和一個(gè)名為 GLUT(the OpenGL Utility Toolkit)的庫(kù)來(lái)管理我們的窗口。

關(guān)于 OpenGL 的注意事項(xiàng)

OpenGL 是一個(gè)面向跨平臺(tái)開發(fā)的圖形應(yīng)用程序編程接口。它是跨平臺(tái)開發(fā)圖形應(yīng)用程序的標(biāo)準(zhǔn) API。OpenGL 有兩個(gè)主要變體:舊版 OpenGL 和現(xiàn)代 OpenGL。

OpenGL 中的渲染是基于由頂點(diǎn)和法線定義的多邊形。例如,要渲染立方體的一側(cè),我們指定 4 個(gè)頂點(diǎn)和該邊的法線。

舊版的 OpenGL 提供了一個(gè)“固定函數(shù)管道”。通過設(shè)置全局變量,程序員可以啟用和禁用諸如照明、著色、面部剔除等功能的自動(dòng)實(shí)現(xiàn)。然后 OpenGL 使用啟用的功能自動(dòng)渲染場(chǎng)景。此功能已棄用。

另一方面,現(xiàn)代 OpenGL 具有一個(gè)可編程的渲染管道,程序員可以在上面編寫運(yùn)行在專用圖形硬件(GPU)上的名為“shaders”的小程序。現(xiàn)代 OpenGL 的可編程管道已經(jīng)取代了舊版的 OpenGL。

在這個(gè)項(xiàng)目中,盡管舊版 OpenGL 已被棄用,我們?nèi)匀皇褂盟?。因?yàn)樗峁┑墓潭üδ芸梢员3执a較少。它減少了所需的線性代數(shù)知識(shí)量,并簡(jiǎn)化了我們將要編寫的代碼。

關(guān)于 GLUT

GLUT 與 OpenGL 捆綁在一起,允許我們創(chuàng)建操作系統(tǒng)窗口并注冊(cè)用戶界面回調(diào)。此基本功能足以滿足我們的目的。如果我們想要一個(gè)功能更全的用于窗口管理和用戶交互的庫(kù),我們可以考慮使用 GTK 或 Qt 這樣的完整窗口工具集。

Viewer

為了管理 GLUT 和 OpenGL 的設(shè)置,并驅(qū)動(dòng) modeller 的其余部分,我們創(chuàng)建了一個(gè)名為 Viewer 的類。我們使用單一 Viewer 實(shí)例,它管理窗口的創(chuàng)建和渲染,并包含程序的主循環(huán)。在 Viewer 的初始化過程中,我們創(chuàng)建 GUI 窗口并初始化 OpenGL。

函數(shù) init_interface 創(chuàng)建用于渲染建模的窗口,并指定渲染設(shè)計(jì)時(shí)要調(diào)用的函數(shù)。init_opengl 函數(shù)設(shè)置項(xiàng)目所需的 OpenGL 狀態(tài)。它設(shè)置矩陣,啟用背面剔除,注冊(cè)燈光來(lái)照亮場(chǎng)景,并告訴 OpenGL 我們希望對(duì)對(duì)象進(jìn)行著色。init_scene 函數(shù)創(chuàng)建 Scene 對(duì)象,并放置一些初始節(jié)點(diǎn),以方便用戶開始。稍后我們將看到更多關(guān)于 Scene 數(shù)據(jù)結(jié)構(gòu)的信息。最后,init_interaction 注冊(cè)用于用戶交互的回調(diào),我們將在后面討論。

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

class Viewer(object):
    def __init__(self):
        """ 初始化viewer. """
        self.init_interface()
        self.init_opengl()
        self.init_scene()
        self.init_interaction()
        init_primitives()

    def init_interface(self):
        """ 初始化窗口并注冊(cè)渲染函數(shù) """
        glutInit()
        glutInitWindowSize(640, 480)
        glutCreateWindow("3D Modeller")
        glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB)
        glutDisplayFunc(self.render)

    def init_opengl(self):
        """ 初始化opengl設(shè)置以渲染場(chǎng)景 """
        self.inverseModelView = numpy.identity(4)
        self.modelView = numpy.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):
        """ 初始化場(chǎng)景對(duì)象和初始場(chǎng)景 """
        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):
        """ 初始化用戶交互和回調(diào) """
        self.interaction = Interaction()
        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()

在深入研究渲染函數(shù)之前,我們應(yīng)該先討論一下線性代數(shù)。

坐標(biāo)空間

對(duì)于我們的場(chǎng)景,坐標(biāo)空間是一個(gè)原點(diǎn)和 3 個(gè)基向量的集合,通常是x,yz軸。

點(diǎn)

三維中的任何點(diǎn)都可以表示為距原點(diǎn)在xyz方向上的偏移量。點(diǎn)的表示與該點(diǎn)所在的坐標(biāo)空間有關(guān)。同一點(diǎn)在不同的坐標(biāo)空間有不同的表示。三維空間中的任何點(diǎn)都可以在任何三維坐標(biāo)空間中表示。

向量

向量是一個(gè)x、yz值,表示在x、yz軸上兩點(diǎn)之間的差。

變換矩陣

在計(jì)算機(jī)圖形學(xué)中,為不同類型的點(diǎn)使用多個(gè)不同的坐標(biāo)空間是很方便的。變換矩陣將點(diǎn)從一個(gè)坐標(biāo)空間轉(zhuǎn)換到另一個(gè)坐標(biāo)空間。為了將向量v從一個(gè)坐標(biāo)空間轉(zhuǎn)換到另一個(gè)坐標(biāo)空間,我們用變換矩陣 M: v' = M v 相乘。常見的變換矩陣是平移、縮放和旋轉(zhuǎn)。

模型、世界、視圖和投影坐標(biāo)空間

newtranspipe.png

要在屏幕上繪制項(xiàng)目,我們需要在幾個(gè)不同的坐標(biāo)空間之間進(jìn)行轉(zhuǎn)換。

上圖的右側(cè)[1],包括從眼睛空間( Eye Space)到視口空間( Viewport Space)的所有轉(zhuǎn)換都將由 OpenGL 處理。

從眼睛空間到齊次裁剪空間(homogeneous clip space)的轉(zhuǎn)換由 gluPerspective 處理,向標(biāo)準(zhǔn)化設(shè)備空間和視口空間的轉(zhuǎn)換由 glViewport 處理。將這兩個(gè)矩陣相乘并存儲(chǔ)為 GL_PROJECTION 矩陣。我們不需要了解這些矩陣在項(xiàng)目中的工作細(xì)節(jié)和原理。

但是,圖的左側(cè)需要我們自己管理。我們定義了一個(gè)矩陣,它將模型中的點(diǎn)(也稱為網(wǎng)格)從模型空間轉(zhuǎn)換為世界空間,稱為模型矩陣。我們還定義了視圖矩陣,用于從世界空間到眼睛空間的視轉(zhuǎn)換。在本項(xiàng)目中,我們將這兩個(gè)矩陣結(jié)合起來(lái)以獲得 ModelView 矩陣。

要了解有關(guān)完整圖形渲染管道以及涉及的坐標(biāo)空間的更多信息,請(qǐng)參閱《Real Time Rendering》的第 2 章,或其它的計(jì)算機(jī)圖形學(xué)入門書籍。

使用 Viewer 渲染

render 函數(shù)從設(shè)置需要在渲染時(shí)完成的 OpenGL 狀態(tài)開始。 它通過 init_view 初始化投影矩陣,并使用來(lái)自交互成員的數(shù)據(jù)和轉(zhuǎn)換矩陣初始化 ModelView 矩陣,該轉(zhuǎn)換矩陣將場(chǎng)景空間轉(zhuǎn)換為世界空間。我們將在下面看到有關(guān)Interaction 類的更多信息。它使用 glClear 清除屏幕,并告訴場(chǎng)景渲染自身,然后渲染單位網(wǎng)格。

我們?cè)阡秩揪W(wǎng)格之前禁用 OpenGL 的燈光。禁用燈光后,OpenGL 將使用純色渲染項(xiàng)目,而不是模擬光源。這樣,網(wǎng)格與場(chǎng)景有視覺上的區(qū)別。最后,glFlush 向圖形驅(qū)動(dòng)程序發(fā)出信號(hào),表明我們已經(jīng)準(zhǔn)備好刷新緩沖區(qū)并顯示到屏幕上。

    # class Viewer
    def render(self):
        """ 場(chǎng)景的渲染過程 """
        self.init_view()

        glEnable(GL_LIGHTING)
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

        # 從軌跡球的當(dāng)前狀態(tài)加載 modelview 矩陣
        glMatrixMode(GL_MODELVIEW)
        glPushMatrix()
        glLoadIdentity()
        loc = self.interaction.translation
        glTranslated(loc[0], loc[1], loc[2])
        glMultMatrixf(self.interaction.trackball.matrix)

        # 存儲(chǔ)當(dāng)前模型視圖的相反視圖
        currentModelView = numpy.array(glGetFloatv(GL_MODELVIEW_MATRIX))
        self.modelView = numpy.transpose(currentModelView)
        self.inverseModelView = inv(numpy.transpose(currentModelView))

        # 渲染場(chǎng)景。這將為場(chǎng)景中的每個(gè)對(duì)象調(diào)用渲染函數(shù)
        self.scene.render()

        # 繪制表格
        glDisable(GL_LIGHTING)
        glCallList(G_OBJ_PLANE)
        glPopMatrix()

        # 刷新緩沖區(qū)以便可以繪制場(chǎng)景
        glFlush()

    def init_view(self):
        """ 初始化投影矩陣 """
        xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
        aspect_ratio = float(xSize) / float(ySize)

        # 加載投影矩陣,它永遠(yuǎn)不會(huì)變化
        glMatrixMode(GL_PROJECTION)
        glLoadIdentity()

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

渲染對(duì)象:場(chǎng)景

既然我們已經(jīng)初始化了渲染管道來(lái)處理世界坐標(biāo)空間中的繪圖,那么我們要渲染什么呢?回想一下,我們的目標(biāo)是有一個(gè)由 3D 模型組成的設(shè)計(jì)。我們需要一個(gè)數(shù)據(jù)結(jié)構(gòu)來(lái)包含設(shè)計(jì),并且我們需要使用這個(gè)數(shù)據(jù)結(jié)構(gòu)來(lái)渲染設(shè)計(jì)。注意上面我們從 viewer 的渲染循環(huán)調(diào)用 self.scene.render()。scene 是什么?

Scene 類是我們用來(lái)表示設(shè)計(jì)的數(shù)據(jù)結(jié)構(gòu)的接口。它抽象出數(shù)據(jù)結(jié)構(gòu)的細(xì)節(jié),并提供與設(shè)計(jì)交互所需的必要接口函數(shù),包括渲染、添加項(xiàng)和操作項(xiàng)的函數(shù)。viewer 中有一個(gè) Scene 對(duì)象。Scene 實(shí)例保留場(chǎng)景中所有項(xiàng)的列表,稱為 node_list。它還可以跟蹤所選項(xiàng)。場(chǎng)景中的 render 函數(shù)只需對(duì) node_list 的每個(gè)成員調(diào)用 render。

class Scene(object):

    # 從相機(jī)到放置對(duì)象的默認(rèn)深度
    PLACE_DEPTH = 15.0

    def __init__(self):
        # 場(chǎng)景中顯示的節(jié)點(diǎn)
        self.node_list = list()
        # 跟蹤當(dāng)前選定的節(jié)點(diǎn)。
        # 操作可能取決于是否有對(duì)象被選中
        self.selected_node = None

    def add_node(self, node):
        """ 添加一個(gè)節(jié)點(diǎn)到場(chǎng)景中 """
        self.node_list.append(node)

    def render(self):
        """ 渲染場(chǎng)景。此函數(shù)只需為每個(gè)節(jié)點(diǎn)調(diào)用render函數(shù)。 """
        for node in self.node_list:
            node.render()

Node類

在 Scene 的 render 函數(shù)中,我們對(duì) Scene 的 node_list 中的每個(gè)項(xiàng)調(diào)用 render。但是,該列表中是什么?我們稱它們?yōu)楣?jié)點(diǎn)。從概念上講,節(jié)點(diǎn)是可以放置在場(chǎng)景中的任何東西。在面向?qū)ο蟮能浖?,我們?Node 編寫為抽象基類。任何表示要放置在場(chǎng)景中的對(duì)象的類都將從 Node 繼承。這個(gè)基類使我們可以抽象地推斷場(chǎng)景。代碼的其余部分不需要了解其顯示的對(duì)象的詳細(xì)信息;它只需要知道它們屬于 Node 類即可。

每種類型的 Node 都定義了自己的行為,用于渲染自身和進(jìn)行其他交互。Node 跟蹤有關(guān)其自身的重要數(shù)據(jù):變換矩陣,比例矩陣,顏色等。將節(jié)點(diǎn)的變換矩陣與其比例矩陣相乘即可得到從節(jié)點(diǎn)的模型坐標(biāo)空間到世界坐標(biāo)空間的變換矩陣。該 Node 還存儲(chǔ)軸對(duì)齊的邊界框(AABB)。 當(dāng)我們?cè)谙旅嬗懻撨x擇時(shí),我們將看到更多有關(guān) AABB 的信息。

Node最簡(jiǎn)單的具體實(shí)現(xiàn)是一個(gè)圖元。圖元是可以添加到場(chǎng)景中的單個(gè)實(shí)體形狀。在這個(gè)項(xiàng)目中,圖元是 CubeSphere。

class Node(object):
    """ 場(chǎng)景元素的基類 """
    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):
        """ 將項(xiàng)渲染到場(chǎng)景中 """
        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):
    """ 球體 """
    def __init__(self):
        super(Sphere, self).__init__()
        self.call_list = G_OBJ_SPHERE


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

渲染節(jié)點(diǎn)基于每個(gè)節(jié)點(diǎn)存儲(chǔ)的變換矩陣。節(jié)點(diǎn)的變換矩陣是其縮放矩陣和平移矩陣的組合。無(wú)論節(jié)點(diǎn)的類型如何,渲染的第一步是將 OpenGL 的 ModelView 矩陣設(shè)置為變換矩陣,以便從模型坐標(biāo)空間轉(zhuǎn)換到視圖坐標(biāo)空間。一旦 OpenGL 矩陣更新,我們調(diào)用 render_self 來(lái)告訴節(jié)點(diǎn)進(jìn)行必要的 OpenGL 調(diào)用來(lái)繪制自己。最后,我們撤消對(duì)這個(gè)特定節(jié)點(diǎn)的 OpenGL 狀態(tài)所做的任何更改。我們使用 OpenGL 中的 glPushMatrixglPopMatrix 函數(shù)來(lái)保存和恢復(fù)渲染節(jié)點(diǎn)前后 ModelView 矩陣的狀態(tài)。請(qǐng)注意,節(jié)點(diǎn)存儲(chǔ)其顏色、位置和比例,并在渲染之前將這些應(yīng)用于 OpenGL 狀態(tài)。

如果節(jié)點(diǎn)當(dāng)前處于選中狀態(tài),則使其發(fā)光。這樣,用戶就可以直觀地看到他們選擇了哪個(gè)節(jié)點(diǎn)。

為了渲染圖元,我們使用了 OpenGL 的調(diào)用列表功能。OpenGL 調(diào)用列表是一系列 OpenGL 調(diào)用,這些調(diào)用一次定義并綁定到單個(gè)名稱下并可以使用 glCallList(LIST_NAME) 調(diào)度。每個(gè)圖元(Sphere 和 Cube)都定義了渲染它所需的調(diào)用列表(未顯示)。

例如,立方體的調(diào)用列表繪制立方體的 6 個(gè)面,中心位于原點(diǎn),邊正好 1 個(gè)單位長(zhǎng)。

# 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))

對(duì)于建模應(yīng)用程序來(lái)說(shuō),只使用圖元是相當(dāng)有限的。3D 模型通常由多個(gè)圖元(或三角網(wǎng)格,這不在本項(xiàng)目的范圍之內(nèi))組成。幸運(yùn)的是,我們?cè)O(shè)計(jì)的 Node 類有助于實(shí)現(xiàn)由多個(gè)圖元組成的 Scene 節(jié)點(diǎn)。事實(shí)上,我們可以在不增加復(fù)雜性的情況下支持任意的節(jié)點(diǎn)分組。

讓我們考慮一個(gè)非?;镜膱D形:一個(gè)典型的雪人,由三個(gè)球體組成。即使圖形由三個(gè)獨(dú)立的圖元組成,我們還是希望能夠?qū)⑵渥鳛閱蝹€(gè)對(duì)象來(lái)處理。

我們創(chuàng)建一個(gè)名為 HierarchicalNode 的類,一個(gè)包含其他節(jié)點(diǎn)的節(jié)點(diǎn)類。它管理一個(gè)“孩子”列表。多層節(jié)點(diǎn)的render_self 函數(shù)只在每個(gè)子節(jié)點(diǎn)上調(diào)用 render_self。使用 HierarchicalNode 類,可以很容易地將圖形添加到場(chǎng)景中。現(xiàn)在,定義雪人的形狀就像指定組成它的形狀,以及它們的相對(duì)位置和大小一樣簡(jiǎn)單。

nodes.jpg
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 對(duì)象形成了一個(gè)樹型數(shù)據(jù)結(jié)構(gòu)。render 函數(shù)通過層次節(jié)點(diǎn)在樹中執(zhí)行深度優(yōu)先遍歷。當(dāng)它遍歷時(shí),它保留一堆 ModelView 矩陣,用于轉(zhuǎn)換到世界空間。在每一步,它將當(dāng)前的 ModelView 矩陣推送到堆棧上,當(dāng)它完成所有子節(jié)點(diǎn)的渲染時(shí),它將矩陣從堆棧中彈出,而父節(jié)點(diǎn)的 ModelView 矩陣留在堆棧的頂部。

通過使 Node 類以這種方式可擴(kuò)展,我們可以向場(chǎng)景中添加新的形狀類型,而無(wú)需更改任何其他用于場(chǎng)景操作和渲染的代碼。使用節(jié)點(diǎn)概念來(lái)抽象一個(gè)場(chǎng)景對(duì)象可能有許多子對(duì)象這一事實(shí)被稱為“復(fù)合設(shè)計(jì)模式”。

用戶交互

我們的 modeller 已經(jīng)能夠存儲(chǔ)和顯示場(chǎng)景,我們還需要一種與之交互的方式。我們需要促進(jìn)兩種類型的互動(dòng)。首先,我們需要改變場(chǎng)景視角的能力。我們希望能夠在場(chǎng)景周圍移動(dòng)眼睛或相機(jī)。其次,我們需要能夠添加新的節(jié)點(diǎn)和修改場(chǎng)景中的節(jié)點(diǎn)。

要實(shí)現(xiàn)用戶交互,我們需要知道用戶何時(shí)按下鍵或移動(dòng)鼠標(biāo)。幸運(yùn)的是,操作系統(tǒng)已經(jīng)知道這些事件何時(shí)發(fā)生。GLUT 允許我們注冊(cè)一個(gè)函數(shù),以便在某個(gè)事件發(fā)生時(shí)調(diào)用它。我們編寫了解釋按鍵和鼠標(biāo)移動(dòng)的函數(shù),并告訴 GLUT 在按下相應(yīng)鍵時(shí)調(diào)用這些函數(shù)。一旦我們知道用戶按的是哪個(gè)鍵,我們就需要解釋輸入并將預(yù)期的動(dòng)作應(yīng)用到場(chǎng)景中。

Interaction 類中可以找到監(jiān)聽操作系統(tǒng)事件并解釋其含義的邏輯。我們之前編寫的 Viewer 類擁有 Interaction 的單個(gè)實(shí)例。 我們將使用 GLUT 回調(diào)機(jī)制來(lái)注冊(cè)當(dāng)按下鼠標(biāo)按鈕(glutMouseFunc),移動(dòng)鼠標(biāo)(glutMotionFunc),按下鍵盤按鈕(glutKeyboardFunc)以及按下箭頭鍵( glutSpecialFunc)時(shí)要調(diào)用的函數(shù)。 稍后我們會(huì)看到處理輸入事件的函數(shù)。

class Interaction(object):
    def __init__(self):
        """ 處理用戶交互 """
        # 當(dāng)前是否按下鼠標(biāo)按鈕
        self.pressed = None
        # 相機(jī)的當(dāng)前位置
        self.translation = [0, 0, 0, 0]
        # 計(jì)算旋轉(zhuǎn)的軌跡球
        self.trackball = trackball.Trackball(theta = -25, distance=15)
        # 當(dāng)前鼠標(biāo)位置
        self.mouse_loc = None
        # 簡(jiǎn)單回調(diào)機(jī)制
        self.callbacks = defaultdict(list)
        
        self.register()

    def register(self):
        """ 使用 gult 注冊(cè)回調(diào) """
        glutMouseFunc(self.handle_mouse_button)
        glutMotionFunc(self.handle_mouse_move)
        glutKeyboardFunc(self.handle_keystroke)
        glutSpecialFunc(self.handle_keystroke)

操作系統(tǒng)回調(diào)

為了解釋用戶輸入,我們需要結(jié)合鼠標(biāo)位置、鼠標(biāo)按鈕和鍵盤的知識(shí)。因?yàn)閷⒂脩糨斎虢忉尀橛幸饬x的操作需要許多行代碼,所以我們將其封裝在一個(gè)獨(dú)立的類中,遠(yuǎn)離主代碼路徑。Interaction 類對(duì)代碼庫(kù)的其余部分隱藏不相關(guān)的復(fù)雜性,并將操作系統(tǒng)事件轉(zhuǎn)換為應(yīng)用程序級(jí)事件。

# Interaction 類
    def translate(self, x, y, z):
        """ 平移相機(jī) """
        self.translation[0] += x
        self.translation[1] += y
        self.translation[2] += z

    def handle_mouse_button(self, button, mode, x, y):
        """ 按下或者釋放鼠標(biāo)時(shí)調(diào)用 """
        xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
        y = ySize - y  # 因?yàn)?OpenGL 已經(jīng)反轉(zhuǎn),反轉(zhuǎn)y坐標(biāo)
        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:  # 向上滾動(dòng)
                self.translate(0, 0, 1.0)
            elif button == 4:  # 向下滾動(dòng)
                self.translate(0, 0, -1.0)
        else:  # 釋放鼠標(biāo)
            self.pressed = None
        glutPostRedisplay()

    def handle_mouse_move(self, x, screen_y):
        """ 鼠標(biāo)移動(dòng)時(shí)調(diào)用 """
        xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
        y = ySize - screen_y # 因?yàn)?OpenGL 已經(jīng)反轉(zhuǎn),反轉(zhuǎn)y坐標(biāo)
        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:
                # 忽略更新的相機(jī)位置,因?yàn)槲覀兿M冀K圍繞原點(diǎn)旋轉(zhuǎn)
                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):
        """ 用戶通過鍵盤輸入時(shí)調(diào)用 """
        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)

在上面的代碼片段中,你注意到,當(dāng)交互實(shí)例解釋用戶操作時(shí),它將調(diào)用 self.trigger 并帶有描述操作類型的字符串。Interaction 類上的 trigger 函數(shù)是一個(gè)簡(jiǎn)單回調(diào)系統(tǒng)的一部分,我們將使用它來(lái)處理應(yīng)用程序級(jí)事件?;叵胍幌拢?code>Viewer 類上的 init_interaction 函數(shù)通過調(diào)用 register_callback 在交互實(shí)例上注冊(cè)回調(diào)。

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

當(dāng)用戶界面代碼需要觸發(fā)場(chǎng)景上的事件時(shí),交互類調(diào)用它對(duì)該特定事件保存的所有回調(diào):

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

這個(gè)應(yīng)用程序級(jí)回調(diào)系統(tǒng)消除了系統(tǒng)其余部分了解操作系統(tǒng)輸入的需求。每個(gè)應(yīng)用程序級(jí)回調(diào)都表示應(yīng)用程序中有意義的請(qǐng)求。Interaction 類充當(dāng)操作系統(tǒng)事件和應(yīng)用程序級(jí)事件之間的轉(zhuǎn)換器。這意味著,如果我們決定將 modeller 移植到 GLUT 之外的另一個(gè)工具包中,我們只需要用一個(gè)類來(lái)替換Interaction 類,該類將新工具箱的輸入轉(zhuǎn)換為相同的一組有意義的應(yīng)用程序級(jí)回調(diào)。

Callback Arguments Purpose
pick x:number, y:number 選擇鼠標(biāo)指針位置處的節(jié)點(diǎn)。
move x:number, y:number 將當(dāng)前選定的節(jié)點(diǎn)移動(dòng)到鼠標(biāo)指針位置。
place shape:string, x:number, y:number 在鼠標(biāo)指針位置放置指定類型的形狀。
rotate_color forward:boolean 在顏色列表中向前或向后旋轉(zhuǎn)當(dāng)前選定節(jié)點(diǎn)的顏色。
scale up:boolean 根據(jù)參數(shù)縮小或放大當(dāng)前選定的節(jié)點(diǎn)。

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

與場(chǎng)景交互

使用回調(diào)機(jī)制,我們可以從 Interaction 類接收有關(guān)用戶輸入事件的相關(guān)信息。我們已經(jīng)準(zhǔn)備好將這些動(dòng)作應(yīng)用到 Scene 中。

移動(dòng)場(chǎng)景

在這個(gè)項(xiàng)目中,我們通過變換場(chǎng)景來(lái)完成相機(jī)運(yùn)動(dòng)。換句話說(shuō),照相機(jī)位于固定位置,用戶輸入移動(dòng)場(chǎng)景而不是移動(dòng)照相機(jī)。相機(jī)放置在[0,0,-15]處,面向世界空間原點(diǎn)。(或者,我們可以更改透視矩陣以移動(dòng)攝影機(jī)而不是場(chǎng)景。此設(shè)計(jì)決策對(duì)項(xiàng)目的其余部分影響很小。)在 Viewer 中重新查看 render 函數(shù),我們看到 Interaction的狀態(tài)在渲染 Scene 之前先用于轉(zhuǎn)換 OpenGL 矩陣狀態(tài)。與場(chǎng)景的交互有兩種類型:旋轉(zhuǎn)和平移。

使用軌跡球旋轉(zhuǎn)場(chǎng)景

我們使用軌跡球算法來(lái)完成場(chǎng)景的旋轉(zhuǎn)。軌跡球是一個(gè)直觀的界面,用于在三維中操縱場(chǎng)景。從概念上講,軌跡球界面的功能就像場(chǎng)景位于透明球體內(nèi)部一樣。把一只手放在地球儀的表面并推動(dòng)它使地球儀旋轉(zhuǎn)。同樣,單擊鼠標(biāo)右鍵并在屏幕上移動(dòng)它可以旋轉(zhuǎn)場(chǎng)景。你可以在 OpenGL Wiki 上找到更多關(guān)于軌跡球的理論。在這個(gè)項(xiàng)目中,我們使用作為 Glumpy 的一部分提供的軌跡球?qū)崿F(xiàn)。

我們使用 drag_to 函數(shù)與軌跡球進(jìn)行交互,以鼠標(biāo)的當(dāng)前位置作為起始位置,并將鼠標(biāo)位置的更改作為參數(shù)。

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

得到的旋轉(zhuǎn)矩陣是在場(chǎng)景被渲染時(shí) viewer 中的 trackball.matrix

旁白:四元數(shù)

旋轉(zhuǎn)通常選擇兩種方式之一來(lái)表示。第一種是圍繞每個(gè)軸的旋轉(zhuǎn)值;可以將其存儲(chǔ)為浮點(diǎn)數(shù)的三元組。另一種常見的旋轉(zhuǎn)表示法是四元數(shù),四元數(shù)是由一個(gè)帶有x、yz坐標(biāo)的向量和一個(gè)w旋轉(zhuǎn)組成的元素。與單軸旋轉(zhuǎn)相比,使用四元數(shù)有許多好處;特別是,它們?cè)跀?shù)值上更穩(wěn)定。使用四元數(shù)可以避免像萬(wàn)向節(jié)鎖這樣的問題。四元數(shù)的缺點(diǎn)是它們不太直觀,更難理解。

軌跡球?qū)崿F(xiàn)通過在內(nèi)部使用四元數(shù)存儲(chǔ)場(chǎng)景的旋轉(zhuǎn)來(lái)避免萬(wàn)向節(jié)鎖。幸運(yùn)的是,我們不需要直接處理四元數(shù),因?yàn)檐壽E球上的矩陣成員將旋轉(zhuǎn)轉(zhuǎn)化為矩陣。

平移場(chǎng)景

平移場(chǎng)景(即滑動(dòng)場(chǎng)景)比旋轉(zhuǎn)場(chǎng)景簡(jiǎn)單得多。場(chǎng)景轉(zhuǎn)換由鼠標(biāo)滾輪和鼠標(biāo)左鍵提供。鼠標(biāo)左鍵在xy坐標(biāo)下轉(zhuǎn)換場(chǎng)景。滾動(dòng)鼠標(biāo)滾輪可在z坐標(biāo)系(朝向或遠(yuǎn)離相機(jī))平移場(chǎng)景。Interaction 類存儲(chǔ)當(dāng)前場(chǎng)景的轉(zhuǎn)換,并使用translate 函數(shù)對(duì)其進(jìn)行修改。查看器在渲染期間檢索Interaction 相機(jī)位置,以便在 glTranslated 調(diào)用中使用。

選擇場(chǎng)景對(duì)象

現(xiàn)在用戶可以移動(dòng)和旋轉(zhuǎn)整個(gè)場(chǎng)景以獲得所需的透視圖,下一步是允許用戶修改和操縱組成場(chǎng)景的對(duì)象。

為了讓用戶操縱場(chǎng)景中的對(duì)象,他們需要能夠選擇元素。

為了選擇一個(gè)元素,我們使用當(dāng)前的投影矩陣來(lái)生成一條表示鼠標(biāo)單擊的光線,就像鼠標(biāo)指針將一條光線射入場(chǎng)景一樣。選定節(jié)點(diǎn)是距離光線相交的相機(jī)最近的節(jié)點(diǎn)。因此,選取問題簡(jiǎn)化為在場(chǎng)景中尋找光線和節(jié)點(diǎn)之間的交點(diǎn)的問題。所以問題是:我們?nèi)绾闻袛喙饩€是否命中一個(gè)節(jié)點(diǎn)?

精確計(jì)算光線是否與節(jié)點(diǎn)相交是一個(gè)具有挑戰(zhàn)性的問題,無(wú)論是代碼的復(fù)雜性還是性能。我們需要為每種類型的圖元編寫一個(gè)光線對(duì)象相交檢查。對(duì)于具有多個(gè)面的復(fù)雜網(wǎng)格幾何體的場(chǎng)景節(jié)點(diǎn),計(jì)算精確的光線與對(duì)象相交將需要針對(duì)每個(gè)面測(cè)試光線,計(jì)算成本較高。

為了保持代碼簡(jiǎn)潔和性能合理,我們使用了一個(gè)簡(jiǎn)單、快速的近似方法來(lái)進(jìn)行光線對(duì)象相交測(cè)試。在我們的實(shí)現(xiàn)中,每個(gè)節(jié)點(diǎn)存儲(chǔ)一個(gè)軸對(duì)齊的邊界框(AABB),這是它所占空間的近似值。為了測(cè)試光線是否與節(jié)點(diǎn)相交,我們測(cè)試光線是否與節(jié)點(diǎn)的AABB相交。這種實(shí)現(xiàn)意味著所有節(jié)點(diǎn)共享用于交叉測(cè)試的相同代碼,這意味著所有節(jié)點(diǎn)類型的性能代價(jià)都是恒定的且較小的。

    # Viewer 類
    def get_ray(self, x, y):
        """ 生成一條從附近平面開始,指向x y坐標(biāo)面向方向的射線
            參數(shù) x, y:屏幕上鼠標(biāo)的x,y坐標(biāo)
            返回: 射線的 start, direcion 
        """
        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):
        """ 對(duì)一個(gè)對(duì)象執(zhí)行 pick 操作。選擇場(chǎng)景中的一個(gè)對(duì)象 """
        start, direction = self.get_ray(x, y)
        self.scene.pick(start, direction, self.modelView)

為了確定單擊了哪個(gè)節(jié)點(diǎn),我們遍歷場(chǎng)景以測(cè)試光線是否命中任何節(jié)點(diǎn)。我們?nèi)∠x擇當(dāng)前選定的節(jié)點(diǎn),然后選擇交點(diǎn)最接近光線原點(diǎn)的節(jié)點(diǎn)。

    # Scene 類
    def pick(self, start, direction, mat):
        """
        執(zhí)行選擇
        參數(shù):  start, direction: 描述光線
               mat: 場(chǎng)景當(dāng)前矩陣的反轉(zhuǎn)
        """
        if self.selected_node is not None:
            self.selected_node.select(False)
            self.selected_node = None
    
        # 跟蹤最近的命中
        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 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ù)測(cè)試光線是否與節(jié)點(diǎn)的軸對(duì)齊邊界盒(AABB)相交。如果選擇了某個(gè)節(jié)點(diǎn),則 select 函數(shù)將切換該節(jié)點(diǎn)的選定狀態(tài)。請(qǐng)注意,AABB的 ray_hit 函數(shù)接受框的坐標(biāo)空間和光線坐標(biāo)空間之間的變換矩陣作為第三個(gè)參數(shù)。每個(gè)節(jié)點(diǎn)在進(jìn)行 ray_hit 函數(shù)調(diào)用之前對(duì)矩陣應(yīng)用自己的變換。

 # Node 類
    def pick(self, start, direction, mat):
        """
        返回光線是否命中對(duì)象
        參數(shù): start, direction 需要檢查的射線
               mat 轉(zhuǎn)換光線用的 modelview 矩陣
        """

        # 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

光線-AABB選擇方法非常易于理解和實(shí)施。但是,在某些情況下結(jié)果是錯(cuò)誤的。

AABBError.png

例如,在球體中,球體本身僅接觸 AABB 每個(gè)面的中心的 AABB。但是,如果用戶單擊球體的AABB角,則會(huì)檢測(cè)到與球體的碰撞,即便用戶打算單擊球體后的某個(gè)物體。

在計(jì)算機(jī)圖形學(xué)和軟件工程的許多領(lǐng)域中,復(fù)雜性,性能和準(zhǔn)確性之間的這種折衷是常見的。

修改場(chǎng)景對(duì)象

接下來(lái),我們希望允許用戶操作選定的節(jié)點(diǎn)。它們可能需要移動(dòng)、調(diào)整大小或更改選定節(jié)點(diǎn)的顏色。當(dāng)用戶輸入一個(gè)命令來(lái)操作一個(gè)節(jié)點(diǎn)時(shí),Interaction 類將輸入轉(zhuǎn)換為用戶想要的操作,并調(diào)用相應(yīng)的回調(diào)。

當(dāng)查看器接收到其中一個(gè)事件的回調(diào)時(shí),它會(huì)調(diào)用場(chǎng)景中相應(yīng)的函數(shù),該函數(shù)反過來(lái)將轉(zhuǎn)換應(yīng)用于當(dāng)前選定的節(jié)點(diǎn)。

    # Viewer 類
    def move(self, x, y):
        """ 執(zhí)行移動(dòng)命令 """
        start, direction = self.get_ray(x, y)
        self.scene.move_selected(start, direction, self.inverseModelView)
    
    def rotate_color(self, forward):
        """ 改變選定節(jié)點(diǎn)的顏色。布爾值 forward 表示改變方向。 """
        self.scene.rotate_selected_color(forward)
    
    def scale(self, up):
        """ 縮放選定節(jié)點(diǎn),布爾值 up 表示放大"""
        self.scene.scale_selected(up)

更改顏色

操縱顏色通過顏色列表完成。用戶可以使用箭頭鍵在列表中循環(huán)。場(chǎng)景將顏色更改命令調(diào)度到當(dāng)前選定的節(jié)點(diǎn)。

    # Scene 類
    def rotate_selected_color(self, forwards):
        """ 旋轉(zhuǎn)當(dāng)前選定節(jié)點(diǎn)的顏色 """
        if self.selected_node is None: return
        self.selected_node.rotate_color(forwards)

每個(gè)節(jié)點(diǎn)存儲(chǔ)其當(dāng)前顏色。rotate_color 函數(shù)只修改節(jié)點(diǎn)的當(dāng)前顏色。渲染節(jié)點(diǎn)時(shí),顏色將通過 glColor 傳遞給OpenGL。

    # 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é)點(diǎn)

與顏色一樣,場(chǎng)景會(huì)將任何縮放修改發(fā)送到選定節(jié)點(diǎn)(如果存在)。

    # Scene 類
    def scale_selected(self, up):
        """ 縮放當(dāng)前選擇節(jié)點(diǎn) """
        if self.selected_node is None: return
        self.selected_node.scale(up)

每個(gè)節(jié)點(diǎn)存儲(chǔ)一個(gè)當(dāng)前矩陣,該矩陣存儲(chǔ)其比例。按參數(shù)xyz在這些方向縮放的矩陣是:

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

當(dāng)用戶修改節(jié)點(diǎn)的比例時(shí),生成的縮放矩陣乘以該節(jié)點(diǎn)的當(dāng)前縮放矩陣。

    # 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)

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

def scaling(scale):
    s = numpy.identity(4)
    s[0, 0] = scale[0]
    s[1, 1] = scale[1]
    s[2, 2] = scale[2]
    s[3, 3] = 1
    return s

移動(dòng)節(jié)點(diǎn)

為了平移節(jié)點(diǎn),我們和選擇對(duì)象一樣,使用光線計(jì)算。我們將表示當(dāng)前鼠標(biāo)位置的光線傳遞到場(chǎng)景的 move 函數(shù)中。節(jié)點(diǎn)的新位置應(yīng)位于光線上。為了確定在光線上放置節(jié)點(diǎn)的位置,我們需要知道節(jié)點(diǎn)與相機(jī)的距離。因?yàn)槲覀冊(cè)谶x擇節(jié)點(diǎn)時(shí)(在 pick 函數(shù)中)存儲(chǔ)了節(jié)點(diǎn)的位置和與相機(jī)的距離,所以我們可以在這里使用這些數(shù)據(jù)。我們找到沿目標(biāo)光線距離相機(jī)的距離相同的點(diǎn),并計(jì)算新位置和舊位置之間的向量差。然后我們根據(jù)得到的向量來(lái)轉(zhuǎn)換節(jié)點(diǎn)。

    # Scene 類
    def move_selected(self, start, direction, inv_modelview):
        """
        如果有選中節(jié)點(diǎn),移動(dòng)它
        
        參數(shù):  start, direction: 描述光線
               inv_modelview: 場(chǎng)景當(dāng)前矩陣的反轉(zhuǎn)
        """
        if self.selected_node is None: return
    
        # 獲取選中節(jié)點(diǎn)的當(dāng)前深度和位置
        node = self.selected_node
        depth = node.depth
        oldloc = node.selected_loc
    
        # 節(jié)點(diǎn)的新位置與新光線的深度相同
        newloc = (start + direction * depth)
    
        # 使用modelview矩陣進(jìn)行轉(zhuǎn)換
        translation = newloc - oldloc
        pre_tran = numpy.array([translation[0], translation[1], translation[2], 0])
        translation = inv_modelview.dot(pre_tran)
    
        # 平移節(jié)點(diǎn)并追蹤其位置
        node.translate(translation[0], translation[1], translation[2])
        node.selected_loc = newloc

請(qǐng)注意,新位置和舊位置是在相機(jī)坐標(biāo)空間中定義的。我們需要在世界坐標(biāo)系中定義我們的平移。因此,我們通過乘以 modelview 矩陣的倒數(shù)將相機(jī)空間平移轉(zhuǎn)換為世界空間平移。

與縮放一樣,每個(gè)節(jié)點(diǎn)都存儲(chǔ)一個(gè)表示其平移的矩陣。平移矩陣如下所示:

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

當(dāng)節(jié)點(diǎn)被平移時(shí),我們?yōu)楫?dāng)前的平移構(gòu)造一個(gè)新的平移矩陣,并將其乘以節(jié)點(diǎn)的平移矩陣,以便在渲染期間使用。

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

translation 函數(shù)返回一個(gè)平移矩陣,給定一個(gè)表示x、yz平移距離的列表。

def translation(displacement):
    t = numpy.identity(4)
    t[0, 3] = displacement[0]
    t[1, 3] = displacement[1]
    t[2, 3] = displacement[2]
    return t

放置節(jié)點(diǎn)

節(jié)點(diǎn)放置使用選擇節(jié)點(diǎn)和平移節(jié)點(diǎn)相同的技術(shù)。我們對(duì)當(dāng)前鼠標(biāo)位置使用相同的光線計(jì)算來(lái)確定節(jié)點(diǎn)的放置位置。

    # Viewer 類
    def place(self, shape, x, y):
        """ 執(zhí)行place,將一個(gè)基本體放置到場(chǎng)景中 """
        start, direction = self.get_ray(x, y)
        self.scene.place(shape, start, direction, self.inverseModelView)

要放置新節(jié)點(diǎn),我們首先創(chuàng)建相應(yīng)類型節(jié)點(diǎn)的新實(shí)例并將其添加到場(chǎng)景中。我們想把節(jié)點(diǎn)放在用戶光標(biāo)的下方,這樣我們就可以在光線上找到一個(gè)點(diǎn),與相機(jī)保持固定的距離。同樣,光線是在相機(jī)空間中表示的,因此我們將生成的平移向量乘以逆模型視圖矩陣,將其轉(zhuǎn)換為世界坐標(biāo)空間。最后,根據(jù)計(jì)算出的向量對(duì)新節(jié)點(diǎn)進(jìn)行平移。

    # Scene 類
    def place(self, shape, start, direction, inv_modelview):
        """
        放置新節(jié)點(diǎn)
        
        參數(shù):  shape: 要添加的形狀
               start, direction: 描述光線
               inv_modelview: 場(chǎng)景當(dāng)前矩陣的反轉(zhuǎn)
        """
        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)
    
        # 將節(jié)點(diǎn)放置在相機(jī)空間中的光標(biāo)處
        translation = (start + direction * self.PLACE_DEPTH)
    
        # 將 translation 轉(zhuǎn)換為世界空間
        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])

概要

祝賀你!我們成功地實(shí)現(xiàn)了一個(gè)小型的三維建模器!

StartScene.png

我們了解了如何開發(fā)可擴(kuò)展的數(shù)據(jù)結(jié)構(gòu)來(lái)表示場(chǎng)景中的對(duì)象。我們注意到,使用復(fù)合設(shè)計(jì)模式和基于樹的數(shù)據(jù)結(jié)構(gòu)可以很容易地遍歷場(chǎng)景進(jìn)行渲染,并允許我們?cè)诓辉黾訌?fù)雜性的情況下添加新類型的節(jié)點(diǎn)。我們利用這種數(shù)據(jù)結(jié)構(gòu)將設(shè)計(jì)渲染到屏幕上,并在場(chǎng)景圖的遍歷中操縱 OpenGL 矩陣。我們?yōu)閼?yīng)用程序級(jí)事件構(gòu)建了一個(gè)非常簡(jiǎn)單的回調(diào)系統(tǒng),并用它來(lái)封裝操作系統(tǒng)事件的處理。我們討論了光線對(duì)象碰撞檢測(cè)的可能實(shí)現(xiàn),以及正確性、復(fù)雜性和性能之間的權(quán)衡。最后,我們實(shí)現(xiàn)了對(duì)場(chǎng)景內(nèi)容進(jìn)行操作的方法。

你可以期望在生產(chǎn) 3D 軟件中找到這些相同的基本構(gòu)建塊。從 CAD 工具到游戲引擎,場(chǎng)景圖結(jié)構(gòu)和相對(duì)坐標(biāo)空間在許多類型的 3D 圖形應(yīng)用程序中都可以找到。這個(gè)項(xiàng)目的一個(gè)主要簡(jiǎn)化是用戶界面。一個(gè)生產(chǎn)的 3D 建模器應(yīng)該有一個(gè)完整的用戶界面,這就需要一個(gè)更復(fù)雜的事件系統(tǒng),而不是我們簡(jiǎn)單的回調(diào)系統(tǒng)。

我們可以做進(jìn)一步的實(shí)驗(yàn),為這個(gè)項(xiàng)目添加新功能。試試以下方法:

  • 添加節(jié)點(diǎn)類型以支持任意形狀的三角形網(wǎng)格。
  • 添加撤消堆棧,以允許撤消/重做建模器操作。
  • 使用 DXF 等 3D 文件格式保存/加載設(shè)計(jì)。
  • 集成渲染引擎:導(dǎo)出設(shè)計(jì)以用于照片級(jí)渲染器。
  • 通過精確的光線對(duì)象相交來(lái)改進(jìn)碰撞檢測(cè)。

進(jìn)一步探索

為了進(jìn)一步深入了解真實(shí)世界的三維建模軟件,一些開源項(xiàng)目很有趣。

Blender 是一個(gè)開源的全功能三維動(dòng)畫套件。它提供了一個(gè)完整的三維管道,以建立視頻特效,或游戲創(chuàng)作。建模器是這個(gè)項(xiàng)目的一小部分,它是將建模器集成到大型軟件套件中的一個(gè)很好的例子。

OpenSCAD 是一個(gè)開源的三維建模工具。它不是交互式的,而是讀取指定如何生成場(chǎng)景的腳本文件。這讓設(shè)計(jì)師“完全控制建模過程”。

有關(guān)計(jì)算機(jī)圖形學(xué)中的算法和技術(shù)的更多信息,Graphics Gems 是一個(gè)很好的資源。


  1. 感謝 Anton Gerdelan 博士的圖。他的 OpenGL 教程可以在 http://antongerdelan.net/opengl/ 獲取。 ?

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

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

  • 1. 介紹 人類天生具有創(chuàng)造力。我們不斷設(shè)計(jì)和構(gòu)建新穎,實(shí)用和有趣的東西。在現(xiàn)代,我們編寫軟件來(lái)協(xié)助設(shè)計(jì)和創(chuàng)作過程...
    博士倫2014閱讀 1,285評(píng)論 0 1
  • 這是500Lines項(xiàng)目中的A 3D modeller文章的翻譯版,講述如何使用Python,OpenGL,GLU...
    今天又忘記密碼閱讀 1,335評(píng)論 0 2
  • 還在苦惱于沒有一款好的3D建模軟件嗎?今天給大家?guī)?lái)的Modo13Mac是一款非常優(yōu)秀的軟件,是英國(guó)The Fou...
    過客_fad6閱讀 971評(píng)論 0 0
  • 還在苦惱于沒有一款好的3D建模軟件嗎?今天給大家?guī)?lái)的The Foundry Modo 13 Mac是一款非常優(yōu)秀...
    過客_fad6閱讀 519評(píng)論 0 0
  • 放假啦放假啦放假啦?。?!回家啦回家啦回家啦?。。?0x00 引言 這學(xué)期計(jì)算機(jī)圖形學(xué)課程的一項(xiàng)大作業(yè),前半學(xué)期一直...
    我喜歡藍(lán)色兒閱讀 4,906評(píng)論 0 1

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