3D 建模器
介绍
计算机辅助设计(Computer-aided design, CAD)工具允许我们在2D屏幕上查看和编辑3D对象。为此,CAD工具必须具有3个基本功能:
- 表示对象:使用一种数据结构保存和表示3D对象。
- 显示: 在屏幕上显示
- 交互:与对象进行交互,例如移动对象。
渲染作为指南
在 3D 建模器中,许多设计决策背后的驱动力是渲染(render)过程。我们希望能够在设计中存储和渲染复杂的对象,但尽可能地让代码复杂性较低。让我们检查渲染过程,并探索模型的数据结构,该结构允许我们使用简单的渲染逻辑存储和绘制任意复杂的对象。
管理接口和主循环
在开始渲染之前,我们需要设置一些内容。
- 首先,我们需要创建一个窗口来显示我们的设计。
- 其次,我们希望与图形驱动程序进行通信以渲染到屏幕。我们不直接与图形驱动程序通信,而是使用一个名为
OpenGL
的跨平台抽象层和一个名为GLUT
(OpenGL 实用工具包)的库来管理我们的窗口。
OpenGL:OpenGL 是一个用于跨平台开发的图形应用程序编程接口。它是用于跨平台开发图形应用程序的标准 API。OpenGL 有两个主要变体:传统OpenGL 和现代OpenGL。本章节我们使用传统OpenGL。
GLUT:GLUT 与 OpenGL 捆绑在一起,允许我们创建操作系统窗口并注册用户界面回调。GLUT的基本功能足以满足我们的目的。如果我们想要一个更全面的窗口管理和用户交互库,考虑使用像 GTK 或 Qt 这样的完整窗口工具包。
The Viewer
为了管理 GLUT 和 OpenGL 的设置,并驱动建模器的其余部分,我们创建了一个名为 Viewer 的类。我们使用单个 Viewer 实例来管理窗口的创建和渲染,并包含程序的主循环。
在初始 Viewer 化过程中,我们创建 GUI 窗口并初始化 OpenGL。
init_interface 函数创建建模器将在其中渲染的窗口,并指定在需要渲染设计时要调用的函数。
init_opengl 函数设置项目所需的 OpenGL 状态。它设置矩阵,启用背面剔除,注册光源以照亮场景,并告诉 OpenGL 我们希望对象着色。
init_scene 函数创建 Scene 对象并放置一些初始节点。
init_interaction 注册回调以进行用户交互初始化 Viewer
后,我们调用 glutMainLoop
将程序执行传输到 GLUT。此函数永远不会返回。我们在 GLUT 事件上注册的回调将在这些事件发生时被调用。
from OpenGL.GL import glCallList, glClear, glClearColor, glColorMaterial, glCullFace, glDepthFunc, glDisable, glEnable,\ glFlush, glGetFloatv, glLightfv, glLoadIdentity, glMatrixMode, glMultMatrixf, glPopMatrix, \ glPushMatrix, glTranslated, glViewport, \ GL_AMBIENT_AND_DIFFUSE, GL_BACK, GL_CULL_FACE, GL_COLOR_BUFFER_BIT, GL_COLOR_MATERIAL, \ GL_DEPTH_BUFFER_BIT, GL_DEPTH_TEST, GL_FRONT_AND_BACK, GL_LESS, GL_LIGHT0, GL_LIGHTING, \ GL_MODELVIEW, GL_MODELVIEW_MATRIX, GL_POSITION, GL_PROJECTION, GL_SPOT_DIRECTION from OpenGL.constants import GLfloat_3, GLfloat_4 from OpenGL.GLU import gluPerspective, gluUnProject from OpenGL.GLUT import glutCreateWindow, glutDisplayFunc, glutGet, glutInit, glutInitDisplayMode, \ glutInitWindowSize, glutMainLoop, \ GLUT_SINGLE, GLUT_RGB, GLUT_WINDOW_HEIGHT, GLUT_WINDOW_WIDTH import numpy from numpy.linalg import norm, inv from interaction import Interaction from primitive import init_primitives, G_OBJ_PLANE from node import Sphere, Cube, SnowFigure from scene import Scene class Viewer: def __init__(self): """ 初始化 viewer""" self.init_interface() self.init_opengl() self.init_scene() self.init_interaction() init_primitives() def init_interface(self): """ 初始化窗口, 注册render函数 """ glutInit() glutInitWindowSize(640, 480) glutCreateWindow("3D Modeller") glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB) glutDisplayFunc(self.render) def init_opengl(self): """ 初始化opengl """ 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): """ 初始化 scene object和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): """ 初始化 user interaction和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() def render(self): """ 场景的渲染通道 """ ... def init_view(self): """ initialize the projection matrix """ ... if __name__ == "__main__": viewer = Viewer() viewer.main_loop()
在我们深入研究函数 render 之前,我们应该讨论一点线性代数。
一点线性代数
坐标空间
我们的坐标空间是一个原点加一组基向量(x,y,z)。
点
点使用相对于原点的x,y,z的偏移量表示。
向量
向量是一个 (x,y,z)值,分别表示 x、 y 和 z 轴的两点之间的差值。
变换矩阵
变换矩阵将点从一个坐标空间转换为另一个坐标空间。为了将向量 v
从一个坐标空间转换为另一个坐标空间,我们乘以变换矩阵M : v ′ = M v M: v′=MvM:v′=Mv 。一些常见的转换矩阵是平移、缩放和旋转。
模型、世界、视图和投影坐标空间
model, world, view, projection
要将物体绘制到屏幕上,需要在几个不同的坐标空间之间进行转换。
图的右侧 ,包括从Eye space到Viewport 的所有转换都将由 OpenGL 为我们处理。
从Eye space到 homogeneous clip space的转换由 gluPerspective 处理,到normalized device space和 viewport space的转换由 glViewport 处理。这两个矩阵相乘并存储为GL_PROJECTION矩阵。我们不需要知道这些矩阵在这个项目中如何工作的术语或细节。
但是,我们确实需要自己管理图的左侧。我们定义了一个矩阵,它将模型中的点(或网格)从model space(图中的local space)转换为world space,称为model矩阵。我们定义了view矩阵,该矩阵从world space转换为Eye space。在这个项目中,我们将这两个矩阵组合在一起,得到 ModelView 矩阵(即执行model sapce到eys space的转换)。
要了解有关完整图形渲染管线以及所涉及的坐标空间的更多信息,请参阅 Real Time Rendering的第 2 章或其他介绍性计算机图形书籍。
使用Viewer进行渲染
render 函数首先设置需要在渲染时完成的任何 OpenGL 状态。它通过 init_view 并使用交互成员的数据初始化投影矩阵,以从场景空间转换为世界空间的变换矩阵初始化 ModelView 矩阵。我们将在下面看到有关 Interaction 类的更多信息。使用glClear清空屏幕并告诉场景自行渲染,然后渲染单元网格。
在渲染网格之前,我们禁用了 OpenGL 的照明。禁用照明后,OpenGL 会使用纯色渲染项目,而不是模拟光源。这样,网格在视觉上与场景区分开来。最后, glFlush
向图形驱动程序发出信号,表明我们已准备好刷新缓冲区并显示到屏幕上。
# class Viewer def render(self): """ 场景的渲染通道 """ self.init_view() glEnable(GL_LIGHTING) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) # 从trackball加载 modelview matrix glMatrixMode(GL_MODELVIEW) glPushMatrix() glLoadIdentity() loc = self.interaction.translation glTranslated(loc[0], loc[1], loc[2]) glMultMatrixf(self.interaction.trackball.matrix) # 存储当前modelview的反转 currentModelView = numpy.array(glGetFloatv(GL_MODELVIEW_MATRIX)) self.modelView = numpy.transpose(currentModelView) self.inverseModelView = inv(numpy.transpose(currentModelView)) # 渲染场景. 对场景中的每个物体调用render self.scene.render() # 绘制网格 glDisable(GL_LIGHTING) glCallList(G_OBJ_PLANE) glPopMatrix() # 刷新缓冲区以便绘制 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有一个 Scene对象,保留场景中所有项的列表,称为 node_list。它还跟踪选中的项目。scene的 render 函数在每个 node_list 的成员上调用 render
。
class Scene(object): # 放置物体时距相机的默认深度 PLACE_DEPTH = 15.0 def __init__(self): # 场景需要显示的节点列表 self.node_list = list() # 当前选中的节点 self.selected_node = None def add_node(self, node): """ 向场景添加新节点 """ self.node_list.append(node) def render(self): """ 渲染场景 """ for node in self.node_list: node.render()
Nodes
在 Scene 的函数render 中,我们对Scene 的node_list调用 render。但是node_list的元素是什么?我们称它们为节点(node)。从概念上讲,节点是可以放置在场景中的任何内容。在面向对象的软件中,我们编写 Node 为抽象基类。任何表示要放置在 Scene 中的对象的类都将继承自 Node 。这个基类允许我们抽象地推理场景。代码库的其余部分不需要知道它所显示对象的详细信息,只需要知道他们是一个Node 。
每种类型的 Node 都定义了自己的行为,用于渲染自身和任何其他对象交互。 Node 跟踪有关自身的重要数据:平移矩阵、比例矩阵、颜色等。将节点的平移矩阵乘以其缩放矩阵,得到从节点的模型坐标空间到世界坐标空间的变换矩阵。该节点还存储轴对齐的边界框 (axis-aligned bounding box, AABB)。我们将在后面看到更多关于 AABB 的信息。
最简单的具体实现Node
是基元(primitive)。基元是可以添加到场景中的单个实体形状。在这个项目中,基元是 Cube
和 Sphere
。
class Node(object): """ 场景元素的基类 """ 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): """ 渲染物体到屏幕 """ 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'") def translate(self, x, y, z): self.translation_matrix = numpy.dot(self.translation_matrix, translation([x, y, z])) 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 def scale(self, up): s = 1.1 if up else 0.9 self.scaling_matrix = numpy.dot(self.scaling_matrix, scaling([s, s, s])) def pick(self, start, direction, mat): """ Return whether the ray hits the object Consume: start, direction the ray to check mat 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): """ 切换 选中/不选中 状态 """ if select is not None: self.selected = select else: self.selected = not self.selected 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 ) 定义呈现它所需的调用列表(未显示)。
例如,立方体(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 的类,一个包含其他节点的节点。它管理着一个“children”列表。 HierarchicalNode 的 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 对象形成了树状数据结构。render 函数通过分层节点在树中执行深度优先遍历。当它遍历时,它会保留一堆 ModelView 矩阵,用于转换为世界空间。在每一步中,它都会将当前 ModelView 矩阵推送到堆栈上,当它完成所有子节点的渲染时,它会将矩阵从堆栈中弹出,将父节点的 ModelView 矩阵留在堆栈的顶部。
通过以这种方式使 Node 类可扩展,我们可以向场景添加新类型的形状,而无需更改任何其他用于场景操作和渲染的代码。使用节点概念来抽象出一个 Scene 对象可能具有多个子对象这一事实,称为复合(Composite)设计模式。
用户交互
我们想实现两种类型的交互:
- 改变视角
- 添加/修改节点
为了实现用户交互,我们需要直到用户是否按下键盘或移动鼠标。操作系统知道这些事情是否发生。GLUT允许我们注册一个函数,在特定事件发生时调用。因此我们可以编写函数处理按键和鼠标移动事件。
在 Interaction 类中可以找到用于侦听操作系统事件的相关逻辑。之前的 Viewer类拥有Interaction 实例。我们将使用 GLUT 回调机制来注册鼠标按钮 ( glutMouseFunc )、移动鼠标 ( glutMotionFunc )、按下键盘按钮 ( glutKeyboardFunc ) 和按下箭头键 ( 时要调用的函数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
。trigger
函数是简单回调系统的一部分,我们将使用它来处理应用程序级事件。回想一下,Viewer
类的 init_interaction
函数通过在 Interaction
实例上调用 register_callback
注册回调。
# 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 该类替换为一个类,该类将新工具包中的输入转换为同一组有意义的应用程序级回调。我们在表 1 中使用回调和参数。
表1 交互回调和参数
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
类中接收有关用户输入事件的有意义的信息。我们已准备好将这些操作应用于 Scene
。
移动场景
在这个项目中,我们通过转换场景来实现摄像机运动。换言之,摄像机位于固定位置,用户输入移动场景而不是移动摄像机。相机放置在 [0, 0, -15] 世界空间原点并面向世界空间原点。(或者,我们可以更改透视矩阵来移动摄像机而不是场景。此设计决策对项目的其余部分影响很小。)重新访问Viewer中的render 函数,我们看到Interaction 状态用于在渲染 Scene之前改变OpenGL矩阵状态。与场景的交互有两种类型:旋转和平移。
使用轨迹球旋转场景
我们通过使用轨迹球(trackball)算法来完成场景的旋转。轨迹球是一个直观的界面,用于在三维空间中操纵场景。从概念上讲,轨迹球界面的功能就好像场景位于透明地球仪内一样。将一只手放在地球仪的表面上并推动它旋转地球仪。同样,单击鼠标右键并在屏幕上移动它会旋转场景。您可以在 OpenGL Wiki 上找到有关轨迹球理论的更多信息。在这个项目中,我们使用了作为 Glumpy 的一部分提供的轨迹球实现。
我们使用drag_to
函数与轨迹球进行交互,以鼠标的当前位置为起始位置,以鼠标位置的变化为参数。
self.trackball.drag_to(self.mouse_loc[0], self.mouse_loc[1], dx, dy)
渲染场景时,生成的旋转矩阵位于viewer的trackball.matrix
中。
旁白:四元数
传统上,旋转有两种表示方式。第一个是围绕每个轴的旋转值;您可以将其存储为 3 元组的浮点数。旋转的另一种常见表示形式是四元数,四元数是由具有 x、 y 和 z坐标的向量和 w 旋转组成的元素。与每轴旋转相比,使用四元数有很多好处;特别是,它们在数值上更加稳定。使用四元数可以避免云台锁定等问题。四元数的缺点是它们不太直观,更难理解。如果你胆子大,想多了解四元数,可以参考这个
解释。
轨迹球实现通过在内部使用四元数来存储场景的旋转来避免云台锁定。幸运的是,我们不需要直接使用四元数,因为轨迹球上的矩阵成员将旋转转换为矩阵。
平移场景
平移场景(即滑动场景)比旋转场景简单得多。场景平移随鼠标滚轮和鼠标左键提供。鼠标左键在 x 和 y 坐标中转换场景。滚动鼠标滚轮可平移 z 坐标中的场景(朝向或远离相机)。Interaction 类存储当前场景转换并使用函数 translate 对其进行修改。Viewer在渲染期间检索 Interaction 摄像机位置用于 glTranslated 调用。
选择场景中的对象
现在,用户可以移动和旋转整个场景以获得他们想要的视角,下一步是允许用户修改和操作构成场景的对象。
首先用户需要选择对象。为了选择一个对象,我们使用当前的投影矩阵来生成一个表示鼠标点击的光线,就好像鼠标指针将光线射入场景一样。所选节点是距离光线相交的相机最近的节点。因此,拾取问题简化为查找场景中光线和节点之间的交点的问题。那么问题来了:我们如何判断光线是否击中了节点?
准确计算射线是否与节点相交在代码复杂性和性能方面都是一个具有挑战性的问题。我们需要为每种类型的基元编写一个射线-对象交集检查。对于具有多个面的复杂网格几何图形的场景节点,计算精确的射线-物体相交需要针对每个面测试光线,并且计算成本很高。
为了保持代码紧凑和性能合理,我们使用简单、快速的近似方法进行射线-物体交集测试。在我们的实现中,每个节点都存储一个轴对齐的边界框 (axis-aligned bounding box, 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.maxsize # py2 --> py3 maxint-->maxsize 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 的一角,即使用户打算单击球体到其后面的物体上,也会检测到与球体的碰撞(图3)。
复杂性、性能和准确性之间的这种权衡在计算机图形学和软件工程的许多领域中很常见。
修改场景中的对象
接下来,我们希望允许用户操作选定的节点。他们可能想要移动、调整大小或更改所选节点的颜色。当用户输入命令来操作节点时,Interaction
类会将输入转换为用户预期的操作,并调用相应的回调。
# 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方向缩放
在这些各自的方向上是:
当用户修改节点的缩放矩阵时,生成的缩放矩阵将乘以节点的当前缩放矩阵。
# class Node def scale(self, up): s = 1.1 if up else 0.9 self.scaling_matrix = numpy.dot(self.scaling_matrix, scaling([s, s, s])) self.aabb.scale(s)
给定 x 、 y 和 z 缩放因子的列表, scaling
函数 返回这样的矩阵:
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
移动节点
为了平移节点,我们使用与拾取相同的光线计算。我们将表示当前鼠标位置的光线传递到场景 move 的函数中。节点的新位置应位于射线上。为了确定节点在光线上的位置,我们需要知道节点与相机的
距离。由于我们在选择节点时( pick 在函数中)存储了节点的位置和与相机的距离,因此我们可以在此处使用该数据。我们沿着目标光线找到与相机距离相同的点,并计算新旧位置之间的矢量差异。然后,我们通过生成的向量平移节点。
# 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
请注意,新位置和旧位置是在照相机坐标空间中定义的。我们需要在世界坐标空间中定义我们的平移。因此,我们通过乘以模型视图矩阵的倒数将相机空间平移转换为世界空间平移。
与缩放一样,每个节点都存储一个表示其平移的矩阵。平移矩阵如下所示:
当节点被平移时,我们为当前平移构造一个新的平移矩阵,并将其乘以节点的平移矩阵,以便在渲染期间使用。
# class Node def translate(self, x, y, z): self.translation_matrix = numpy.dot( self.translation_matrix, translation([x, y, z]))
给定一个表示 x、 y和 z方向平移距离的列表,translation
函数返回一个平移矩阵
def translation(displacement): t = numpy.identity(4) t[0, 3] = displacement[0] t[1, 3] = displacement[1] t[2, 3] = displacement[2] return t
放置节点
节点放置使用拾取和平移技术。我们对当前鼠标位置使用相同的光线计算来确定节点的放置位置。
# 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建模器!
我们了解了如何开发可扩展的数据结构来表示场景中的对象。我们注意到,使用复合设计模式和基于树的数据结构可以很容易地遍历场景进行渲染,并允许我们在不增加复杂性的情况下添加新类型的节点。我们利用这种数据结构将设计渲染到屏幕上,并在场景图的遍历中操作 OpenGL 矩阵。我们为应用程序级事件构建了一个非常简单的回调系统,并使用它来封装操作系统事件的处理。我们讨论了射线-物体碰撞检测的可能实现,以及正确性、复杂性和性能之间的权衡。最后,我们实现了操作场景内容的方法。
您可以期望在生产 3D 软件中找到这些相同的基本构建块。场景图形结构和相对坐标空间存在于许多类型的 3D 图形应用程序中,从 CAD 工具到游戏引擎。该项目的一个主要简化是用户界面。生产 3D 建模器应该有一个完整的用户界面,这将需要一个更复杂的事件系统,而不是我们简单的回调系统。
我们可以做进一步的实验,为这个项目添加新功能。请尝试以下方法之一:
- 添加 Node 类型以支持任意形状的三角形网格。
- 添加撤消堆栈,以允许撤消/重做建模器操作。
- 使用 DXF 等 3D 文件格式保存/加载设计。
- 集成渲染引擎:导出设计以在逼真的渲染器中使用。
- 通过精确的射线-物体交叉改进碰撞检测。
进一步探索
为了进一步了解现实世界的 3D 建模软件,一些开源项目很有趣。
Blender 是一个开源的全功能 3D 动画套件。它提供了一个完整的 3D 管线,用于在视频中构建特效或创建游戏。建模器只是该项目的一小部分,它是将建模器集成到大型软件套件中的一个很好的例子。
OpenSCAD 是一个开源的 3D 建模工具。它不是交互式的;相反,它读取一个脚本文件,该文件指定如何生成场景。这使设计师能够“完全控制建模过程”。
有关计算机图形学中的算法和技术的更多信息,图形宝石是一个很好的资源。
小结
要实现的功能是什么?
显示和操作3D物体。
显示和操作3D物体。
怎么实现?
拆分为两部分,显示和操作。
显示通过Scene类表示我们要显示/渲染的画面,使用Node类作为基类,并通过继承或组合Node得到复杂对象。
操作通过Interaction类表示。Interaction是一个中间人,将键鼠输入翻译成程序操作,例如将鼠标左击翻译为选中。然后通过注册和回调执行任务。
【个人理解】程序有两层注册回调。首先是Interaction类内对GLUT的注册回调,然后是Viewer类对Interaction的注册回调。
此外,使用Viewer类来管理整个流程,包括各种初始化、渲染管道、程序的主循环等。
我们注意到,该项目并不是从零开始的,而是调用了OpenGL处理渲染,GLUT处理窗口。
拆分为两部分,显示和操作。
显示通过Scene类表示我们要显示/渲染的画面,使用Node类作为基类,并通过继承或组合Node得到复杂对象。
操作通过Interaction类表示。Interaction是一个中间人,将键鼠输入翻译成程序操作,例如将鼠标左击翻译为选中。然后通过注册和回调执行任务。
【个人理解】程序有两层注册回调。首先是Interaction类内对GLUT的注册回调,然后是Viewer类对Interaction的注册回调。
此外,使用Viewer类来管理整个流程,包括各种初始化、渲染管道、程序的主循环等。
我们注意到,该项目并不是从零开始的,而是调用了OpenGL处理渲染,GLUT处理窗口。
问题说明
由于这个代码是很久很久之前(大概十年前)的,当时使用的是Python2版本。现在已经是2024年了,很多东西都变了,但好在本文使用的openGL接口没怎么变。所以你只需要很少的改动就可以运行了。
问题1
OpenGL.error.NullFunctionError: Attempt to call an undefined function glutInit, check for bool(glutInit) before calling
https://stackoverflow.com/questions/65699670/pyopengl-opengl-error-nullfunctionerror-attempt-to-call-an-undefined-functio
解决方案,手动安装:
git clone https://github.com/mcfletch/pyopengl
cd pyopengl pip install -e . cd accelerate pip install -e .
PS:中间可能报错,要求安装Visual C++工具。你需要去VS官网下载vs_BuildTools然后安装。
问题2
py2 到 py3
项目中的没有太多不兼容的地方,除了这两个不兼容的要改:
# py2 --> py3 xrange --> range sys.maxint --> sys.maxsize glutCreateWindow("3D Modeller") --> glutCreateWindow("3D Modeller".encode("cp932")) if key == 's'--> if key == b's'
其他的可以选择性升级到py3语法:
class Scene(object): --> class Scene: super(Primitive, self).__init__() --> super().__init__()