Erick是一名软件开发人员,2D和3D计算机图形爱好者。他曾从事视频游戏,3D特效软件和计算机辅助设计工具。涉及模拟现实后,他有机会想了解更多。你可以在erickdransch.com上找到他。
介绍
人类具有与生俱来的创意。我们不断设计和制作小说,有用和有趣的东西。在现代,我们编写软件来协助设计和制作过程。计算机辅助设计(CAD)软件允许创作者在实物建设之前设计建筑物,桥梁,视频游戏艺术,电影怪物,3D打印对象等等。
在其核心里,CAD工具是将三维设计抽象成可在二维屏幕上查看和编辑。为了实现这一定义,CAD工具必须提供三个基本功能。首先,必须有一个数据结构来表示正在设计的对象:这是计算机对用户正在构建的三维世界的理解。其次,CAD工具要提供一些在用户屏幕上显示设计的方法。用户正在设计一个具有三维尺寸的物理对象,但电脑屏幕只有二维。 CAD工具必须使我们能够对感知对象进行建模,并以用户可以了解对象的3个维度的方式将其绘制到屏幕上。第三,CAD工具必须提供一种与被设计对象交互的方式。用户必须能够添加和修改设计才能产生所需要的结果。此外,所有工具都需要一种从磁盘中保存和加载设计的方法,以便用户能够协作,共享和保存他们的工作。
域特定的CAD工具为域的特定要求提供了许多附加功能。例如,架构CAD工具将提供物理模拟来测试建筑物的气候应力,3D打印工具将检查物体是否真实有效打印,电气CAD工具将模拟通过铜线运行的电力物理并且电影特效套件,包括准确模拟火山动力学的特征。
然而,所有CAD工具必须至少包括上述三个功能:表示设计的数据结构,将其显示到屏幕的能力以及与设计交互的方法。
基于这个,让我们来看看如何在500行Python中代表3D设计,将其显示在屏幕上并与之进行交互。
渲染指南
3D建模器设计决策背后的驱动力是渲染过程。我们希望能够在设计中存储和渲染复杂的对象,但仍然保持渲染代码的复杂性。我们来看一下渲染过程,并探索需要设计的数据结构,使我们能够使用简单的渲染逻辑来存储和绘制任意复杂的对象。
管理接口和主环
在我们开始渲染之前,我们需要设置几个东西。首先,我们需要创建一个窗口来显示我们的设计。其次,我们想与图形驱动程序通信以呈现到屏幕。我们不直接与图形驱动程序通信,所以我们使用一个称为OpenGL的跨平台抽象层,以及一个名为GLUT(OpenGL Utility Toolkit)的库来管理我们的窗口。
关于OpenGL的注意事项
OpenGL是用于跨平台开发的图形应用程序编程接口。它是跨平台开发图形应用程序的标准API。 OpenGL有两个主要的变体:Legacy OpenGL和Modern OpenGL。
OpenGL中的渲染是基于顶点和法线定义的多边形。例如,要渲染立方体的一面,我们指定4个顶点和边的法线。
Legacy OpenGL提供了一个“固定功能管道”。通过设置全局变量,程序员可以启用和禁用诸如照明,着色,脸部剔除等功能的自动化实现。然后,OpenGL会自动使用启用的功能渲染场景。此功能已弃用。
另一方面,现代OpenGL具有可编程渲染管道,程序员编写在专用图形硬件(GPU)上运行的“着色器”小程序。 Modern OpenGL的可编程管道已经取代了Legacy OpenGL。
在这个项目中,尽管它已被弃用,但我们使用的是Legacy OpenGL。 Legacy OpenGL提供的固定功能对于保持代码大小很有用。它减少了所需的线性代数知识的数量,并简化了我们将要编写的代码。
关于GLUT
GLUT与OpenGL捆绑在一起,允许我们创建操作系统窗口并注册用户界面回调。这个基本功能足以满足我们的需求。如果我们想要一个更全面的窗口管理和用户交互的库,我们将考虑使用像GTK或Qt这样的全面的窗口工具包。
Viewer
要管理GLUT和OpenGL的设置,并且驱动其他模型,我们创建一个名为Viewer
的类。我们使用Viewer
实例,它管理窗口创建和渲染,并包含我们程序的主循环。在Viewer
的初始化过程中,我们创建了GUI窗口并初始化了OpenGL。
函数init_interface
创建窗口,该模型将被渲染,并指定要在设计需要呈现时调用的函数。init_opengl
函数设置项目所需的OpenGL状态。它设置矩阵,剔除背面,注册光照亮场景,并告诉OpenGL我们希望对象被着色。init_scene
函数创建Scene
对象并放置一些初始节点以使用户启动。我们稍后会看到有关Scene
数据结构的更多信息。最后,init_interaction
注册用户交互的回调,我们将在后面讨论。
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 settings to render the scene """
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):
""" 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 = 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()
在我们进入渲染函数之前,我们应该讨论一点线性代数。
坐标空间
坐标空间是一个起点和一组3个基矢量,通常是x
, y
和z
轴。
点
3维中的任何点可以表示为原点的x
, y
和z
方向的偏移量。点是相对于点所在的坐标空间。同一点在不同坐标空间中具有不同的表示。任何三维坐标点都可以在任何三维坐标空间中表示。
向量
向量是x
, y
和z
值,表示x
, y
和z
轴之间的两个点之间的差异, 分别。
转换矩阵
在计算机图形学中,为不同类型的点使用多个不同的坐标空间是方便的。变换矩阵将点从一个坐标空间转换到另一个坐标空间。将矢量v
从一个坐标空间转换为另一个,我们乘以一个变换矩阵M:=v'= M v
。一些常见的转换矩阵比如平移,缩放和旋转。
模型,世界,视图和投影坐标空间
要画到屏幕上,我们需要在几个不同的坐标空间之间进行转换。
图13.1的右侧,包括从Eye Space到Viewport Space的所有转换都将由OpenGL处理。
通过gluPerspective
处理从eye space到均匀剪辑空间的转换,转换为归一化的设备空间和视口空间由glViewport
处理。将这两个矩阵相乘并存储为GL_PROJECTION
矩阵。我们不需要知道这些矩阵如何为此项目工作的术语或细节。
但是,我们需要管理图表的左侧。我们定义一个将模型中的点(也称为网格)从模型空间转换为世界空间的矩阵,称为模型矩阵。我们定义了将视图矩阵从世界空间转换为眼睛空间。在这个项目中,我们组合了这两个矩阵来获得ModelView
矩阵。
要了解更多有关完整图形渲染流水线和所涉及的坐标空间的信息,请参阅实时渲染的第2章或其他计算机图形图书。
用Viewer渲染
render
函数从设置渲染时需要完成的任何OpenGL状态开始。它通过init_view
初始化投影矩阵,并使用交互成员的数据通过从场景空间转换为世界空间的变换矩阵来初始化ModelView矩阵。我们将在下面看到有关“交互”类的更多信息。它使用glClear
清除屏幕,并告诉现场渲染自身,然后渲染单元格。
在渲染网格之前禁用OpenGL的照明。禁用照明后,OpenGL会渲染纯色的物品,而不是模拟光源。这样,网格与场景有视觉差异。最后,glFlush
信号到图形驱动程序,我们准备好缓冲区被刷新并显示到屏幕上。
# 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)
渲染:场景
现在我们已经初始化了渲染管线来处理世界坐标空间中的绘图,我们要渲染什么?回想一下,我们的目标是设计一个3D模型组成的设计。我们需要一个数据结构来包含设计,使用这个数据结构渲染设计。注意上面我们从Viewer的渲染循环中调用self.scene.render()
。 什么是场景?
Scene
类是我们用于表示设计的数据结构的接口。它记录数据结构的细节,并提供与设计交互所需的必要接口功能,包括渲染,添加项目和操作项目的功能。Scene
对象,由Viewer拥有。场景实例保留场景中所有项目的列表node_list
。它还跟踪所选的项目。场景上的渲染功能在node_list
的每个成员上调用渲染。
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()
节点
在场景render
功能中,我们在Scene
的node_list
中的每个项目上调用渲染。但这个列表的元素有什么?我们称之为节点。概念上,节点是可以放置在场景中的任何东西。在面向对象的软件中,我们将Node
作为抽象基类编写。任何表示要放置在场景中的对象的类都将从Node
继承。它让我们可以抽象地讲解场景。代码库的其余部分不需要知道它显示的对象的细节;它只需要知道它们是类Node
。
每种类型的Node
定义自己的渲染本身和任何其他交互的行为。节点跟踪自身的重要数据:平移矩阵,比例矩阵,颜色等。通过其缩放矩阵乘以节点的平移矩阵,给出从节点的模型坐标空间到世界坐标空间的变换矩阵。该节点还存储一个轴对齐的边界框(AABB)。当我们在下面讨论选择时,我们将看到有关AABB的更多信息。
Node
的简单实现是一个原语。原始图案可以添加场景的单一实体形状。在这个项目中,原语是Cube
和Sphere
。
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
渲染节点是基于每个节点存储的转换矩阵。节点的变换矩阵是其缩放矩阵及其平移矩阵的组合。无论节点类型,渲染的第一步是将OpenGL ModelView矩阵设置为转换矩阵,从模型坐标空间转换为视图坐标空间。一旦OpenGL矩阵是最新的,我们调用render_self来告诉节点进行必要的OpenGL调用来绘制自己。最后,我们撤消对该特定节点对OpenGL状态所做的任何更改。我们在OpenGL中使用glPushMatrix
和glPopMatrix
函数来保存并恢复渲染节点之前和之后的ModelView矩阵的状态。请注意,节点存储其颜色,位置和比例,并在渲染之前将其应用于OpenGL状态。
如果当前节点被选中,我们使它发光。这样,用户就可以看到他们选择了哪一个节点。
要渲染图元,我们使用OpenGL中的调用列表功能。 OpenGL调用列表有一系列OpenGL调用,它们被定义一次,并以单个名称捆绑在一起。可以使用glCallList(LIST_NAME)
发送呼叫。每个基元(Sphere
和Cube
)定义了渲染所需的调用列表。
例如,多维数据集的调用列表绘制多维数据集的6个面,其中心在原点和边缘恰好为1个单位长。
# Pseudocode Cube definition
# Left face
((-0.5, -0.5, -0.5), (-0.5, -0.5, 0.5), (-0.5, 0.5, 0.5), (-0.5, 0.5, -0.5)),
# Back face
((-0.5, -0.5, -0.5), (-0.5, 0.5, -0.5), (0.5, 0.5, -0.5), (0.5, -0.5, -0.5)),
# Right face
((0.5, -0.5, -0.5), (0.5, 0.5, -0.5), (0.5, 0.5, 0.5), (0.5, -0.5, 0.5)),
# Front face
((-0.5, -0.5, 0.5), (0.5, -0.5, 0.5), (0.5, 0.5, 0.5), (-0.5, 0.5, 0.5)),
# Bottom face
((-0.5, -0.5, 0.5), (-0.5, -0.5, -0.5), (0.5, -0.5, -0.5), (0.5, -0.5, 0.5)),
# Top face
((-0.5, 0.5, -0.5), (-0.5, 0.5, 0.5), (0.5, 0.5, 0.5), (0.5, 0.5, -0.5))
仅使用原语将对建模应用程序是非常有限的。 3D模型通常由多个原语(或三角形网格组成,不在本项目范围之内)。幸运的是,我们对Node
类的设计有助于由多个基元组成的Scene
节点。事实上,我们可以支持任意分组的节点,而不增加复杂度。
作为动机,让我们考虑一个非常基本的人物:一个典型的雪人,或者是由三个球体组成的积雪人物。即使该图由三个独立的图元组成,我们希望将其视为单个对象。
我们创建HierarchicalNode
类,一个包含其他节点的Node
。它管理一个“孩子”的列表。分层节点的render_self
函数简单地在每个子节点上调用render_self
。使用HierarchicalNode
类,很容易将数字添加到场景中。现在,定义雪景就像指定构成它的形状一样简单,它们的相对位置和大小。
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
对象形成树状数据结构。渲染函数通过分层节点通过树进行深度优先遍历。当它遍历时,它保存一堆ModelView
矩阵,用于转换到世界空间。在每个步骤中,它将当前的ModelView
矩阵推送到堆栈上,当它完成所有子节点的渲染时,它将矩阵从堆栈中弹出,将父节点的ModelView
矩阵留在堆栈的顶部。
通过以这种方式使Node
类可扩展,我们可以在场景中添加新的形状类型,而无需更改场景操纵和渲染的其他代码。使用节点概念来抽象出一个Scene对象可能有许多孩子,称为复合设计模式。
用户互动
现在我们的建模者能够存储和显示场景,我们需要一种与之交互的方式。有两种类型的互动,首先,我们需要改变现场观看视角的能力。我们希望能够围绕现场移动眼睛或相机。其次,我们需要能够添加新节点并修改场景中的节点。
为了使用户交互,我们需要知道用户何时按键或移动鼠标。幸运的是,操作系统已经知道这些事件何时发生。 GLUT允许我们在某个事件发生时注册一个被调用的函数。我们编写功能来解释按键和鼠标移动,并在按下相应的键时告诉GLUT调用这些功能。一旦我们知道用户按下哪个键,我们需要解释输入并将预期的操作应用于现场。
用于监听操作系统事件和解释其含义的逻辑在Interaction
类中找到。我们之前写的Viewer
类拥有Interaction
的单个实例。当按下鼠标按钮(glutMouseFunc
),鼠标移动时(glutMotionFunc
),当按下键盘按钮(glutKeyboardFunc
)时以及按下箭头键时,我们将使用GLUT回调机制来注册要调用的函数(glutSpecialFunc`)。稍后我们将看到处理输入事件的功能。
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)
操作系统回调
为了有意义地解释用户输入,我们需要结合鼠标位置,鼠标按钮和键盘的知识。因为将用户输入有意义的动作需要许多代码行,所以我们将其封装在一个单独的类中,远离主代码路径。Interaction
类隐藏了与代码库其余部分无关的复杂性,并将操作系统事件转换为应用程序级事件。
# 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()
内部回调
在上面的代码片段中,您将注意到,当Interaction
实例解释用户操作时,它会使用描述操作类型的字符串调用self.trigger
。Interaction
类的触发器函数是一个简单的回调系统的一部分,我们将用于处理应用程序级事件。 回想一下,
Viewer类的
init_interaction函数通过调用
register_callback在
Interaction`实例上注册回调。
# class Interaction
def register_callback(self, name, func):
self.callbacks[name].append(func)
当用户界面代码需要触发场景上的事件时,Interaction
类将调用该特定事件所有已保存的回调:
# class Interaction
def trigger(self, name, *args, **kwargs):
for func in self.callbacks[name]:
func(*args, **kwargs)
该应用程序级回调系统抽象出需要系统的其余部分了解操作系统输入。每个应用程序级回调表示应用程序中有意义的请求。Interaction
类作为操作系统事件和应用程序级事件之间的翻译器。 这意味着如果我们决定将模板端口移植到GLUT之外的另一个工具包中,我们只需要将Interaction
类替换为将新工具包的输入转换为同一组有意义的应用程序级回调的类。 我们在表13.1中使用回调和参数。
Table 13.1 - Interaction callbacks and arguments
Callback Arguments Purpose
pick x:number, y:number 选择鼠标指针位置处的节点
move x:number, y:number 将当前选定的节点移动到鼠标指针位置
place shape:string,
x:number, y:number 在鼠标指针位置放置指定类型的形状
rotate_color forward:boolean 通过颜色列表向前或向后旋转当前所选节点的颜色
scale up:boolean 根据参数,将当前选定的节点向上或向下缩放
这个简单的回调系统提供了我们所需的所有功能。然而,在生产3D建模器中,用户界面对象通常会被动态地创建和销毁。在这种情况下,我们需要一个更复杂的事件监听系统,其中对象可以注册和取消注册事件的回调。
场景交互
使用我们的回调机制,我们可以从Interaction
类接收有关用户输入事件的有意义的信息。我们准备好将这些动作以应用于场景。
移动场景
在这个项目中,我们通过改变场景来完成相机运动。换句话说,相机位于固定位置,用户输入移动场景,而不是移动相机。相机放置在[0,0,-15]
并面向世界空间的起点。(或者,我们可以改变透视矩阵来移动相机而不是场景,这个设计对项目的其余部分影响很小)。在Viewer
中的render
函数,我们看到Interaction
状态被用于在渲染Scene
之前转换OpenGL矩阵状态。与场景的交互有两种类型:旋转和翻译。
用轨迹球旋转场景
我们通过使用轨迹球算法来完成场景的旋转。轨迹球是三维操纵场景的直观界面。在概念上,轨迹球界面的功能就好像场景在透明球体内一样。将手放在地球表面并推动地球旋转。同样,单击鼠标右键并将其移动到屏幕上会旋转场景。您可以在OpenGL Wiki上找到关于轨迹球理论的更多信息。在这个项目中,我们使用Glumpy实现的轨迹球。
我们使用drag_to
函数与轨迹球交互,鼠标的当前位置作为起始位置,鼠标位置的变化作为参数。
self.trackball.drag_to(self.mouse_loc[0], self.mouse_loc[1], dx, dy)
当渲染场景时,得到的旋转矩阵是观察者中的trackball.matrix
。
四元数
旋转传统上以两种方式之一表示。第一个是围绕每个轴的旋转值;您可以将其存储为3元组的浮点数。旋转的其他常见表示是四元数,由具有x
,y
和z
坐标的矢量和w
旋转组成的元素。使用四元数比每轴旋转有许多好处;特别是它们在数值上更稳定。使用四元数避免诸如万向节锁等问题。不足之处在于它们不太直观,而且更难理解。如果你想了解更多关于四元数的话,可以参考这个解释。
轨迹球实现通过在内部使用四元数来避免万向节锁定来存储场景的旋转。幸运的是,我们不需要直接使用四元数,因为轨迹球上的矩阵成员将旋转转换为矩阵。
变换场景
变换场景(即滑动)比旋转场景简单得多。场景翻译随鼠标滚轮和鼠标左键提供。鼠标左键翻译x
和y
坐标中的场景。滚动鼠标滚轮将z
坐标(朝向或远离相机)的场景转换。Interaction
类存储当前的场景变换,并用translate
函数进行修改。观看者在渲染期间检索交互摄像机位置,以在glTranslated
呼叫中使用。
选择场景对象
现在用户可以移动和旋转整个场景以获得他们想要的视角,下一步是允许用户修改和操作构成场景的对象。
为了让用户操作场景中的对象,他们需要能够选择项目。
要选择一个项目,我们使用当前投影矩阵来生成代表鼠标点击的光线,就像鼠标指针将光线射入场景一样。所选节点是与光线相交的相机的最靠近的节点。因此,选择的问题减少到在场景中的射线和节点之间发现交点的问题。所以问题是:我们如何判断光线是否击中一个节点?
准确计算射线是否与节点相交是代码和性能的复杂性方面的一个具有挑战性的问题。我们需要为每种类型写一个射线对象交集检查。对于具有许多面的具有复杂网格几何的场景节点,计算精确的光线对象交点将需要对每个面进行测试,并且在计算上是昂贵的。
为了保持代码紧凑和性能合理,我们使用一个简单,快速的近似来进行射线对象交叉测试。在我们的实现中,每个节点存储一个轴对齐的边界框(AABB),它是它所占据的空间的近似值。为了测试射线是否与节点相交,我们测试射线是否与节点的AABB相交。该实现意味着所有节点共享相同的代码用于交叉测试,这意味着对于所有节点类型,性能成本是恒定的和小的。
# 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)
要确定点击哪个节点,我们遍历场景来测试光线是否击中节点。我们取消选择当前选择的节点,然后选择具有最接近射线原点的交点的节点。
# 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
函数测试光线是否与Node
轴对齐边界框相交。如果选择了节点,则select
函数切换节点的选定状态。请注意,AABB的ray_hit
函数以框的坐标空间和光线坐标空间之间的变换矩阵作为第三个参数。在调用ray_hit
函数之前,每个节点将其变换应用于矩阵。
# 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选择方法非常易于理解和实现。但是,在某些情况下,结果是错误的。
例如,在Sphere原语的情况下,球体本身仅触及每个AABB脸部中心的AABB。但是,点击球体的AABB的角落,如果用户想要点击“球体”后面的东西(图13.3),碰撞会被
Sphere`检测到。
在计算机图形学和软件工程的许多领域中,复杂度,性能和精度之间的这种折衷是常见的。
修改场景对象
接下来,我们希望允许用户操作所选节点。他们可能想要移动,调整大小或更改所选节点的颜色。 当用户输入操作节点的命令时,Interaction
类将输入转换为用户想要的动作,并调用相应的回调。
当Viewer
收到这些事件的回调时,它会调用Scene
的相应功能,该功能又将转换应用于当前选定的Node
。
# 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)
改变颜色
操作颜色通过颜色列表来完成。用户可以用箭头键循环浏览列表。场景将颜色更改命令分派到当前选定的节点。
# 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)
每个节点存储其当前颜色。rotate_color
函数只是修改节点的当前颜色。渲染节点时,颜色将通过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
缩放节点
与颜色一样,场景将对所选节点分派缩放修改(如果有)。
# class Scene
def scale_selected(self, up):
""" Scale the current selection """
if self.selected_node is None: return
self.selected_node.scale(up)
每个节点存储存储其比例的当前矩阵。通过参数x
,y
和z
在这些方向上缩放的矩阵是:
x 0 0 0 0
y 0 0 0 0
z 0 0 0 0 1
当用户修改节点的比例时,所得到的缩放矩阵被乘以节点的当前缩放矩阵。
# 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)
scaling
函数返回矩阵,基于x
, y
和`z缩放倍数。
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
移动节点
为了变换节点,我们使用与picking相同的射线计算。我们将表示当前鼠标位置的光线传递到场景的移动功能。节点的新位置应该在光线上。为了确定射线放置节点的位置,我们需要知道节点与摄像机的距离。由于我们在选择节点的位置和距离相机时(在选择功能中)存储节点的位置和距离,所以可以在这里使用这些数据。我们发现与目标射线相机距离相同的点,计算新旧位置之间的向量差。然后,我们将结果向量转换为节点。
# 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
请注意,新位置和旧位置在相机坐标空间中的定义。我们需要在世界坐标空间变换中定义。因此,我们通过乘以modelview
矩阵的倒数将相机空间转换转换为世界空间转换。
与缩放一样,每个节点存储表示其转换的矩阵。 翻译矩阵如下所示:
1 0 0 x
0 1 0 y
0 0 1 z
0 0 0 1
当节点被变换时,我们为当前变换构造一个新的平移矩阵,并乘以节点的平移矩阵,以便在渲染中使用。
# class Node
def translate(self, x, y, z):
self.translation_matrix = numpy.dot(
self.translation_matrix,
translation([x, y, z]))
translation
函数返回一个给定表示x
,y
和z
平移距离的列表的转换矩阵。
def translation(displacement):
t = numpy.identity(4)
t[0, 3] = displacement[0]
t[1, 3] = displacement[1]
t[2, 3] = displacement[2]
return t
放置节点
节点放置使用来自picking和变换的技术。我们对当前的鼠标位置使用相同的射线计算来确定放置节点的位置。
# 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)
要放置一个新节点,我们首先创建相应节点类型的新实例并将其添加到场景中。将节点放在用户的光标下面,所以我们在距离相机一定距离的地方找到一个点。然后在相机空间中表示光线,因此我们将所得到的平移矢量通过乘以逆模型视图矩阵将其转换成世界坐标空间。最后,我们通过计算的向量来转换新节点。
# 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])
总结
恭喜! 我们已经成功实施了一个小型的3D建模器!
我们知道了如何开发可扩展的数据结构来表示场景中的对象。我们注意到,使用Composite设计模式和基于树的数据结构,可以轻松地遍历场景进行渲染,并允许我们添加新类型的节点,而不会增加复杂性。我们利用这种数据结构将设计渲染到屏幕上,并在遍历场景图中操作了OpenGL矩阵。我们为应用程序级事件构建了非常简单的回调系统,并将其用于封装操作系统事件的处理。我们讨论了射线对象碰撞检测的可能实现,以及正确性,复杂性和性能之间的权衡。最后,我们实现了操纵场景内容的方法。
您可以期待在生产3D软件中找到相同的基本构建块。场景图结构和相对坐标空间在许多类型的3D图形应用程序中被发现,从CAD工具到游戏引擎。该项目的一个主要简化是在用户界面中。开发我们看到了如何开发可扩展的数据结构来表示场景中的对象。我们注意到,使用Composite设计模式和基于树的数据结构,可以轻松地遍历场景进行渲染,并允许我们添加新类型的节点,而不会增加复杂性。我们利用这种数据结构将设计渲染到屏幕上,并在遍历场景图中操纵了OpenGL矩阵。我们为应用程序级事件构建了一个非常简单的回调系统,并将其用于封装操作系统事件的处理。我们讨论了射线对象碰撞检测的可能实现,以及正确性,复杂性和性能之间的权衡。最后,我们实现了操纵场景内容的方法。
您可以期待在生产3D软件中找到相同的基本构建块。场景图结构和相对坐标空间在许多类型的3D图形应用程序中被发现,从CAD工具到游戏引擎。该项目的一个主要简化是在用户界面中。预计生产的3D建模器将具有完整的用户界面,这将需要更复杂的事件系统,而不是简单的回调系统。
我们可以进一步的实验,为这个项目添加新功能。尝试以下方法之一:
-
添加
Node
类型以支持任意形状的三角形网格。 -
添加一个撤消堆,以允许撤消/重做建模器动作。
-
使用DXF等3D文件格式保存/加载设计。
-
集成渲染引擎:导出设计以用于真实感渲染器。
-
用准确的射线对象交叉点改善碰撞检测。
进一步探索
为了进一步了解真实世界的3D建模软件,有些有趣的开源项目。
Blender是一款开源全功能3D动画套件。它提供了一个完整的3D管道,用于在视频中创建特效或创建游戏。建模器是该项目的一小部分,它是将模拟器集成到大型软件套件中的一个很好的例子。
OpenSCAD是一个开源的3D建模工具。它不是互动的而是读取一个指定如何生成场景的脚本文件。