#Python实现3D建模工具
###用户接口
我们希望与场景实现两种交互,一种是你可以操纵场景从而能够从不同的角度观察模型,一种是你拥有添加与操作修改模型对象的能力。为了实现交互,我们需要得到键盘与鼠标的输入,GLUT
允许我们在键盘或鼠标事件上注册对应的回调函数。
新建interaction.py
文件,用户接口在Interaction
类中实现。
导入需要的库
from collections import defaultdict
from OpenGL.GLUT import glutGet, glutKeyboardFunc, glutMotionFunc, glutMouseFunc, glutPassiveMotionFunc, \glutPostRedisplay, glutSpecialFunc
from OpenGL.GLUT import GLUT_LEFT_BUTTON, GLUT_RIGHT_BUTTON, GLUT_MIDDLE_BUTTON, \GLUT_WINDOW_HEIGHT, GLUT_WINDOW_WIDTH, \GLUT_DOWN, GLUT_KEY_UP, GLUT_KEY_DOWN, GLUT_KEY_LEFT, GLUT_KEY_RIGHT
import trackball
初始化Interaction
类,注册glut
的事件回调函数。
class Interaction(object):def __init__(self):""" 处理用户接口 """#被按下的键self.pressed = None#轨迹球,会在之后进行说明self.trackball = trackball.Trackball(theta = -25, distance=15)#当前鼠标位置self.mouse_loc = None#回调函数词典self.callbacks = defaultdict(list)self.register()def register(self):""" 注册glut的事件回调函数 """glutMouseFunc(self.handle_mouse_button)glutMotionFunc(self.handle_mouse_move)glutKeyboardFunc(self.handle_keystroke)glutSpecialFunc(self.handle_keystroke)
回调函数的实现:
def handle_mouse_button(self, button, mode, x, y):""" 当鼠标按键被点击或者释放的时候调用 """xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)y = ySize - y # OpenGL原点在窗口左下角,窗口原点在左上角,所以需要这种转换。self.mouse_loc = (x, y)if mode == GLUT_DOWN:#鼠标按键按下的时候self.pressed = buttonif button == GLUT_RIGHT_BUTTON:passelif button == GLUT_LEFT_BUTTON: self.trigger('pick', x, y)else: # 鼠标按键被释放的时候self.pressed = None#标记当前窗口需要重新绘制glutPostRedisplay()def handle_mouse_move(self, x, screen_y):""" 鼠标移动时调用 """xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)y = ySize - screen_y 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:# 变化场景的角度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:passglutPostRedisplay()self.mouse_loc = (x, y)def handle_keystroke(self, key, x, screen_y):""" 键盘输入时调用 """xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)y = ySize - screen_yif 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()
###内部回调
针对用户行为会调用self.trigger
方法,它的第一个参数指明行为期望的效果,后续参数为该效果的参数,trigger
的实现如下:
def trigger(self, name, *args, **kwargs):for func in self.callbacks[name]:func(*args, **kwargs)
从代码可以看出trigger
会取得callbacks
词典下该效果对应的所有方法逐一调用。
那么如何将方法添加进callbacks呢?我们需要实现一个注册回调函数的方法:
def register_callback(self, name, func):self.callbacks[name].append(func)
还记得Viewer
中未实现的self.init_interaction()
吗,我们就是在这里注册回调函数的,下面补完init_interaction
.
from interaction import Interaction
...
class Viewer(object):...def init_interaction(self):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 pick(self, x, y):""" 鼠标选中一个节点 """passdef move(self, x, y):""" 移动当前选中的节点 """passdef place(self, shape, x, y):""" 在鼠标的位置上新放置一个节点 """passdef rotate_color(self, forward):""" 更改选中节点的颜色 """passdef scale(self, up):""" 改变选中节点的大小 """pass
pick
、move
等函数的说明如下表所示
回调函数 | 参数 | 说明 |
---|---|---|
pick | x:number, y:number | 鼠标选中一个节点 |
move | x:number, y:number | 移动当前选中的节点 |
place | shape:string, x:number, y:number | 在鼠标的位置上新放置一个节点 |
rotate_color | forward:boolean | 更改选中节点的颜色 |
scale | up:boolean | 改变选中节点的大小 |
我们将在之后实现这些函数。
Interaction
类抽象出了应用层级别的用户输入接口,这意味着当我们希望将glut
更换为别的工具库的时候,只要照着抽象出来的接口重新实现一遍底层工具的调用就行了,也就是说仅需改动Interaction
类内的代码,实现了模块与模块之间的低耦合。
这个简单的回调系统已满足了我们的项目所需。在真实的生产环境中,用户接口对象常常是动态生成和销毁的,所以真实生产中还需要实现解除注册的方法,我们这里就不用啦。
###与场景交互
####旋转场景
在这个项目中摄像机是固定的,我们主要靠移动场景来观察不同角度下的3d模型。摄像机固定在距离原点15个单位的位置,面对世界坐标系的原点。感观上是这样,但其实这种说法不准确,真实情况是在世界坐标系里摄像机是在原点的,但在摄像机坐标系中,摄像机后退了15个单位,这就等价于前者说的那种情况了。
####使用轨迹球
我们使用轨迹球算法来完成场景的旋转,旋转的方法理解起来很简单,想象一个可以向任意角度围绕球心旋转的地球仪,你的视线是不变的,但是通过你的手在拨这个球,你可以想看哪里拨哪里。在我们的项目中,这个拨球的手就是鼠标右键,你点着右键拖动就能实现这个旋转场景的效果了。
想要更多的理解轨迹球可以参考OpenGL Wiki,在这个项目中,我们使用Glumpy中轨迹球的实现。
下载trackball.py
文件,并将其置于工作目录下:
$ wget http://labfile.oss.aliyuncs.com/courses/561/trackball.py
drag_to
方法实现与轨迹球的交互,它会比对之前的鼠标位置和移动后的鼠标位置来更新旋转矩阵。
self.trackball.drag_to(self.mouse_loc[0], self.mouse_loc[1], dx, dy)
得到的旋转矩阵保存在viewer的trackball.matrix
中。
更新viewer.py
下的ModelView
矩阵
class Viewer(object):...def render(self):self.init_view()glEnable(GL_LIGHTING)glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)# 将ModelView矩阵设为轨迹球的旋转矩阵glMatrixMode(GL_MODELVIEW)glPushMatrix()glLoadIdentity()glMultMatrixf(self.interaction.trackball.matrix)# 存储ModelView矩阵与其逆矩阵之后做坐标系转换用currentModelView = numpy.array(glGetFloatv(GL_MODELVIEW_MATRIX))self.modelView = numpy.transpose(currentModelView)self.inverseModelView = inv(numpy.transpose(currentModelView))self.scene.render()glDisable(GL_LIGHTING)glCallList(G_OBJ_PLANE)glPopMatrix()glFlush()
运行代码:
####选择场景中的对象
既然要操作场景中的对象,那么必然得先能够选中对象,要怎么才能选中呢?想象你有一只指哪打哪的激光笔,当激光与对象相交时就相当于选中了对象。
我们如何判定激光穿透了对象呢?
想要真正实现对复杂形状物体进行选择判定是非常考验算法和性能的,所以在这里我们简化问题,对对象使用包围盒(axis-aligned bounding box, 简称AABB),包围盒可以想象成一个为对象量身定做的盒子,你刚刚好能将模型放进去。这样做的好处就是对于不同形状的对象你都可以使用同一段代码处理选中判定,并能保证较好的性能。
新建aabb.py
,编写包围盒类:
from OpenGL.GL import glCallList, glMatrixMode, glPolygonMode, glPopMatrix, glPushMatrix, glTranslated, \GL_FILL, GL_FRONT_AND_BACK, GL_LINE, GL_MODELVIEW
from primitive import G_OBJ_CUBE
import numpy
import math#判断误差
EPSILON = 0.000001class AABB(object):def __init__(self, center, size):self.center = numpy.array(center)self.size = numpy.array(size)def scale(self, scale):self.size *= scaledef ray_hit(self, origin, direction, modelmatrix):""" 返回真则表示激光射中了包盒参数说明: origin, distance -> 激光源点与方向modelmatrix -> 世界坐标到局部对象坐标的转换矩阵 """aabb_min = self.center - self.sizeaabb_max = self.center + self.sizetmin = 0.0tmax = 100000.0obb_pos_worldspace = numpy.array([modelmatrix[0, 3], modelmatrix[1, 3], modelmatrix[2, 3]])delta = (obb_pos_worldspace - origin)# test intersection with 2 planes perpendicular to OBB's x-axisxaxis = numpy.array((modelmatrix[0, 0], modelmatrix[0, 1], modelmatrix[0, 2]))e = numpy.dot(xaxis, delta)f = numpy.dot(direction, xaxis)if math.fabs(f) > 0.0 + EPSILON:t1 = (e + aabb_min[0])/ft2 = (e + aabb_max[0])/fif t1 > t2:t1, t2 = t2, t1if t2 < tmax:tmax = t2if t1 > tmin:tmin = t1if tmax < tmin:return (False, 0)else:if (-e + aabb_min[0] > 0.0 + EPSILON) or (-e+aabb_max[0] < 0.0 - EPSILON):return False, 0yaxis = numpy.array((modelmatrix[1, 0], modelmatrix[1, 1], modelmatrix[1, 2]))e = numpy.dot(yaxis, delta)f = numpy.dot(direction, yaxis)# intersection in yif math.fabs(f) > 0.0 + EPSILON:t1 = (e + aabb_min[1])/ft2 = (e + aabb_max[1])/fif t1 > t2:t1, t2 = t2, t1if t2 < tmax:tmax = t2if t1 > tmin:tmin = t1if tmax < tmin:return (False, 0)else:if (-e + aabb_min[1] > 0.0 + EPSILON) or (-e+aabb_max[1] < 0.0 - EPSILON):return False, 0# intersection in zzaxis = numpy.array((modelmatrix[2, 0], modelmatrix[2, 1], modelmatrix[2, 2]))e = numpy.dot(zaxis, delta)f = numpy.dot(direction, zaxis)if math.fabs(f) > 0.0 + EPSILON:t1 = (e + aabb_min[2])/ft2 = (e + aabb_max[2])/fif t1 > t2:t1, t2 = t2, t1if t2 < tmax:tmax = t2if t1 > tmin:tmin = t1if tmax < tmin:return (False, 0)else:if (-e + aabb_min[2] > 0.0 + EPSILON) or (-e+aabb_max[2] < 0.0 - EPSILON):return False, 0return True, tmindef render(self):""" 渲染显示包围盒,可在调试的时候使用 """glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)glMatrixMode(GL_MODELVIEW)glPushMatrix()glTranslated(self.center[0], self.center[1], self.center[2])glCallList(G_OBJ_CUBE)glPopMatrix()glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
更新Node
类与Scene
类,加入与选中节点有关的内容
更新Node
类:
from aabb import AABB
...
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: # 选中的对象会发光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 select(self, select=None):if select is not None:self.selected = selectelse:self.selected = not self.selected
更新Scene
类:
class Scene(object):def __init__(self):self.node_list = list()self.selected_node = None
在Viewer
类中实现通过鼠标位置获取激光的函数以及pick
函数
# class Viewerdef get_ray(self, x, y):""" 返回光源和激光方向"""self.init_view()glMatrixMode(GL_MODELVIEW)glLoadIdentity()# 得到激光的起始点start = numpy.array(gluUnProject(x, y, 0.001))end = numpy.array(gluUnProject(x, y, 0.999))# 得到激光的方向direction = end - startdirection = direction / norm(direction)return (start, direction)def pick(self, x, y):""" 是否被选中以及哪一个被选中交由Scene下的pick处理 """start, direction = self.get_ray(x, y)self.scene.pick(start, direction, self.modelView)
为了确定是哪个对象被选中,我们会遍历场景下的所有对象,检查激光是否与该对象相交,取离摄像机最近的对象为选中对象。
# Scene 下实现
def pick(self, start, direction, mat):""" 参数中的mat为当前ModelView的逆矩阵,作用是计算激光在局部(对象)坐标系中的坐标"""import sysif self.selected_node is not None:self.selected_node.select(False)self.selected_node = None# 找出激光击中的最近的节点。mindist = sys.maxintclosest_node = Nonefor 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 = mindistclosest_node.selected_loc = start + direction * mindistself.selected_node = closest_node# Node下的实现
def pick(self, start, direction, mat):# 将modelview矩阵乘上节点的变换矩阵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
运行代码(蓝立方体被选中):
检测包围盒也有其缺点,如下图所示,我们希望能点中球背后的立方体,然而却选中了立方体前的球体,因为我们的激光射中了球体的包围盒。为了效率我们牺牲了这部分功能。在性能,代码复杂度与功能准确度之间之间进行衡量与抉择是在计算机图形学与软件工程中常常会遇见的。
####操作场景中的对象
对对象的操作主要包括在场景中加入新对象, 移动对象、改变对象的颜色与改变对象的大小。因为这部分的实现较为简单,所以仅实现加入新对象与移动对象的操作.
加入新对象的代码如下:
# Viewer下的实现
def place(self, shape, x, y):start, direction = self.get_ray(x, y)self.scene.place(shape, start, direction, self.inverseModelView)# Scene下的实现
import numpy
from node import Sphere, Cube, SnowFigure
...
def place(self, shape, start, direction, inv_modelview):new_node = Noneif shape == 'sphere': new_node = Sphere()elif shape == 'cube': new_node = Cube()elif shape == 'figure': new_node = SnowFigure()self.add_node(new_node)# 得到在摄像机坐标系中的坐标translation = (start + direction * self.PLACE_DEPTH)# 转换到世界坐标系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])
效果如下,按C键创建立方体,按S键创建球体。
移动目标对象的代码如下:
# Viewer下的实现
def move(self, x, y):start, direction = self.get_ray(x, y)self.scene.move_selected(start, direction, self.inverseModelView)# Scene下的实现
def move_selected(self, start, direction, inv_modelview):if self.selected_node is None: return# 找到选中节点的坐标与深度(距离)node = self.selected_nodedepth = node.deptholdloc = node.selected_loc# 新坐标的深度保持不变newloc = (start + direction * depth)# 得到世界坐标系中的移动坐标差translation = newloc - oldlocpre_tran = numpy.array([translation[0], translation[1], translation[2], 0])translation = inv_modelview.dot(pre_tran)# 节点做平移变换node.translate(translation[0], translation[1], translation[2])node.selected_loc = newloc
移动了一下立方体:
##五、一些探索
到这里我们就已经实现了一个简单的3D建模工具了,想一下这个程序还能在什么地方进行改进,或是增加一些新的功能?比如说:
- 编写新的节点类,支持三角形网格能够组合成任意形状。
- 增加一个撤销栈,支持撤销命令功能。
- 能够保存/加载3d设计,比如保存为 DXF 3D 文件格式
- 改进程序,选中目标更精准。
你也可以从开源的3d建模软件汲取灵感,学习他人的技巧,比如参考三维动画制作软件Blender的建模部分,或是三维建模工具OpenSCAD。
##六、参考资料与延伸阅读
- A 3D Modeller
- A 3D Modeller 源代码
- Real Time Rendering
- OpenGL学习脚印: 坐标变换过程(vertex transformation)
- OpenGL学习脚印: 坐标和变换的数学基础(math-coordinates and transformations)