本文介紹了一個(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è)基向量的集合,通常是,
和
軸。
點(diǎn)
三維中的任何點(diǎn)都可以表示為距原點(diǎn)在、
和
方向上的偏移量。點(diǎn)的表示與該點(diǎn)所在的坐標(biāo)空間有關(guān)。同一點(diǎn)在不同的坐標(biāo)空間有不同的表示。三維空間中的任何點(diǎn)都可以在任何三維坐標(biāo)空間中表示。
向量
向量是一個(gè)、
和
值,表示在
、
和
軸上兩點(diǎn)之間的差。
變換矩陣
在計(jì)算機(jī)圖形學(xué)中,為不同類型的點(diǎn)使用多個(gè)不同的坐標(biāo)空間是很方便的。變換矩陣將點(diǎn)從一個(gè)坐標(biāo)空間轉(zhuǎn)換到另一個(gè)坐標(biāo)空間。為了將向量從一個(gè)坐標(biāo)空間轉(zhuǎn)換到另一個(gè)坐標(biāo)空間,我們用變換矩陣
:
相乘。常見的變換矩陣是平移、縮放和旋轉(zhuǎn)。
模型、世界、視圖和投影坐標(biāo)空間

要在屏幕上繪制項(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)目中,圖元是 Cube和 Sphere。
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 中的 glPushMatrix 和 glPopMatrix 函數(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)單。

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è)帶有、
和
坐標(biāo)的向量和一個(gè)
旋轉(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)左鍵在和
坐標(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ò)誤的。

例如,在球體中,球體本身僅接觸 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ù)、
和
在這些方向縮放的矩陣是:
當(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è)矩陣,給定、
和
縮放因子的列表。
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è)表示其平移的矩陣。平移矩陣如下所示:
當(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è)表示、
和
平移距離的列表。
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è)小型的三維建模器!

我們了解了如何開發(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è)很好的資源。
-
感謝 Anton Gerdelan 博士的圖。他的 OpenGL 教程可以在 http://antongerdelan.net/opengl/ 獲取。 ?