一個(gè)3D模型(譯)

這是500Lines項(xiàng)目中的A 3D modeller文章的翻譯版,講述如何使用Python,OpenGL,GLUT進(jìn)行3D建模程序的設(shè)計(jì)。

項(xiàng)目封面

緒論

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

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

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

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

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

指南

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

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

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

OpenGL 筆記

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

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

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

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

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

關(guān)于 GLUT

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

觀察

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

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

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

坐標(biāo)空間

根據(jù)我們的目的,坐標(biāo)空間是一個(gè)原點(diǎn)和一組3個(gè)基向量,通常是xyz軸。

點(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值,分別表示xyz軸中兩個(gè)點(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)空間,我們乘以一個(gè)變換矩陣Mv'= Mv。 一些常見的變換矩陣是平移,縮放和旋轉(zhuǎn)。

變換流程

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

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

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

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

要了解更多關(guān)于整個(gè)圖形渲染流水線和涉及的坐標(biāo)空間的信息,請(qǐng)參閱實(shí)時(shí)渲染的第2章或其他介紹性計(jì)算機(jī)圖形書籍。

用Viewer渲染

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

在渲染網(wǎng)格之前,我們禁用OpenGL的照明。 在禁用照明的情況下,OpenGL渲染純色的項(xiàng)目,而不會(huì)去模擬光源。 這樣,網(wǎng)格就具有與場景的視覺差異。 最后,glFlush通知圖形驅(qū)動(dòng)程序我們已準(zhǔn)備好將緩沖區(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)初始化渲染管道來處理世界坐標(biāo)空間中的繪圖,那么我們將渲染什么? 回想一下,我們的目標(biāo)是有一個(gè)由三維模型組成的設(shè)計(jì)。 我們需要一個(gè)數(shù)據(jù)結(jié)構(gòu)來包含設(shè)計(jì),我們需要使用這個(gè)數(shù)據(jù)結(jié)構(gòu)來渲染設(shè)計(jì)。 注意上面,我們從查看器的渲染循環(huán)中調(diào)用self.scene.render()。 場景是什么?

Scene類是我們用來表示設(shè)計(jì)的數(shù)據(jù)結(jié)構(gòu)的接口。 它抽象出數(shù)據(jù)結(jié)構(gòu)的細(xì)節(jié),并提供與設(shè)計(jì)交互所需的必要接口功能,包括渲染,添加項(xiàng)目和操作項(xiàng)目的功能。 Viewer擁有一個(gè)Scene對(duì)象。 Scene實(shí)例保存了場景中所有項(xiàng)目的列表,名為node_list。 它也跟蹤所選項(xiàng)目。 場景中的渲染函數(shù)只需在node_list的每個(gè)成員上調(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ù)中,我們對(duì)場景中node_list的每個(gè)項(xiàng)目調(diào)用render函數(shù)。但是這些列表中的元素都是什么呢?我們稱他們?yōu)楣?jié)點(diǎn)。理論上,一個(gè)節(jié)點(diǎn)就是可以放在場景中任何東西。在面向?qū)ο蟮能浖?,我們?code>Node寫成一個(gè)抽象基類。任何在Scene中表示對(duì)象的東西都是從這個(gè)Node繼承而來的。這個(gè)基類讓我們可以抽象地解釋場景。代碼庫地其余部分不需要知道它顯示對(duì)象的細(xì)節(jié);它只需要知道它們是類節(jié)點(diǎn)。

每種Node都定義了渲染它或者和它交互的行為。這個(gè)Node保持跟蹤關(guān)于它自己的重要數(shù)據(jù):平移矩陣、縮放矩陣、顏色等。將節(jié)點(diǎn)的平移矩陣乘上它的縮放矩陣就得將它從節(jié)點(diǎn)模型坐標(biāo)空間到世界坐標(biāo)空間的轉(zhuǎn)換矩陣。該節(jié)點(diǎn)還存儲(chǔ)一個(gè)軸對(duì)齊的邊界框(AABB)。 當(dāng)我們?cè)谙旅嬗懻撨x擇時(shí),我們會(huì)看到更多關(guān)于AABB的信息。

Node最簡單的具體實(shí)現(xiàn)是一個(gè)原語。 基元是可以添加到場景中的單個(gè)固體形狀。 在這個(gè)項(xiàng)目中,基元是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

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

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

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

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

# 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)用程序來說是相當(dāng)有限的。 3D模型通常由多個(gè)基元組成(或三角形網(wǎng)格,這在本項(xiàng)目的范圍之外)。 幸運(yùn)的是,我們的Node類的設(shè)計(jì)使多個(gè)基元節(jié)點(diǎn)組成場景變得方便。 事實(shí)上,我們可以在不增加復(fù)雜性的情況下支持任意節(jié)點(diǎn)分組。

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

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

子類的層次結(jié)構(gòu)
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])

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

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

用戶交互

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

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

Interaction類中可以找到用于監(jiān)聽操作系統(tǒng)事件并解釋其含義的邏輯。 我們之前編寫的Viewer類擁有Interaction的單一實(shí)例。 我們將使用GLUT回調(diào)機(jī)制來注冊(cè)當(dāng)按下鼠標(biāo)按鈕時(shí)(glutMouseFunc),當(dāng)移動(dòng)鼠標(biāo)時(shí)(glutMotionFunc),按下鍵盤按鈕(glutKeyboardFunc),以及按下方向鍵時(shí)要調(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ù)

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

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

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

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

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

    # class 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)換器。 這意味著如果我們決定將建模器移植到除GLUT之外的另一個(gè)工具包中,我們只需要用一個(gè)將新工具箱的輸入轉(zhuǎn)換為同一組有意義的應(yīng)用級(jí)回調(diào)的類來替換Interaction類。 我們?cè)谙卤碇惺褂没卣{(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.

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

接入場景

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

移動(dòng)場景

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

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

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

我們使用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)

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

補(bǔ)充:四元數(shù)

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

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

場景轉(zhuǎn)換

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

選擇場景對(duì)象

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

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

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

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

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

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

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

    # 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é)點(diǎn)的軸對(duì)齊邊界框相交。 如果選擇了節(jié)點(diǎn),則選擇功能切換節(jié)點(diǎn)的選定狀態(tài)。 請(qǐng)注意,AABB的ray_hit函數(shù)接受框的坐標(biāo)空間和光線坐標(biāo)空間之間的變換矩陣作為第三個(gè)參數(shù)。 在進(jìn)行ray_hit函數(shù)調(diào)用之前,每個(gè)節(jié)點(diǎn)都將自己的變換應(yīng)用于矩陣。

    # 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選擇方法非常易于理解和實(shí)施。 但是,在某些情況下結(jié)果是錯(cuò)誤的。

AABB 錯(cuò)誤

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

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

調(diào)整場景對(duì)象

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

當(dāng)Viewer收到其中一個(gè)事件的回調(diào)時(shí),它會(huì)調(diào)用場景上的相應(yīng)功能,然后將該變換應(yīng)用于當(dāng)前選定的節(jié)點(diǎn)。

    # 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)。 場景將顏色更改命令分派給當(dāng)前選定的節(jié)點(diǎn)。

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

每個(gè)節(jié)點(diǎn)存儲(chǔ)其當(dāng)前顏色。 rotate_color函數(shù)只是修改節(jié)點(diǎn)的當(dāng)前顏色。 渲染節(jié)點(diǎn)時(shí),顏色將通過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é)點(diǎn)縮放

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

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

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

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

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

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

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

    # 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

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

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

\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]))

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

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

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

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

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

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

總結(jié)

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

簡單場景

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

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

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

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

更多拓展

為了深入了解真實(shí)世界的3D建模軟件,一些開源項(xiàng)目很有趣。

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

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

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

?著作權(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),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

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

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