用户接口
我们希望与场景实现两种交互,一种是你可以操纵场景从而能够从不同的角度观察模型,一种是你拥有添加与操作修改模型对象的能力。为了实现交互,我们需要得到键盘与鼠标的输入,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
-
初始化Interaction
类,注册glut
的事件回调函数。
-
class Interaction(object):
-
-
-
-
-
-
self.trackball = trackball.Trackball(theta =
-25, distance=
15)
-
-
-
-
self.callbacks = defaultdict(list)
-
-
-
-
-
-
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)
-
-
-
-
-
-
-
if button == GLUT_RIGHT_BUTTON:
-
-
elif button == GLUT_LEFT_BUTTON:
-
self.trigger(
'pick', x, y)
-
-
-
-
-
-
def handle_mouse_move(self, x, screen_y):
-
-
xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
-
-
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)
-
-
-
-
-
-
def handle_keystroke(self, key, x, screen_y):
-
-
xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
-
-
-
self.trigger(
'place',
'sphere', x, y)
-
-
self.trigger(
'place',
'cube', x, y)
-
-
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)
-
内部回调
针对用户行为会调用self.trigger
方法,它的第一个参数指明行为期望的效果,后续参数为该效果的参数,trigger
的实现如下:
-
def trigger(self, name, *args, **kwargs):
-
for func
in self.callbacks[name]:
-
从代码可以看出trigger
会取得callbacks
词典下该效果对应的所有方法逐一调用。
那么如何将方法添加进callbacks呢?我们需要实现一个注册回调函数的方法:
-
def register_callback(self, name, func):
-
self.callbacks[name].append(func)
还记得Viewer
中未实现的self.init_interaction()
吗,我们就是在这里注册回调函数的,下面补完init_interaction
.
-
from interaction
import Interaction
-
-
-
-
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 place(self, shape, x, y):
-
-
-
-
def rotate_color(self, forward):
-
-
-
-
-
-
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
矩阵
-
-
-
-
-
-
-
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
-
-
-
glMatrixMode(GL_MODELVIEW)
-
-
-
glMultMatrixf(self.interaction.trackball.matrix)
-
-
-
-
currentModelView = numpy.array(glGetFloatv(GL_MODELVIEW_MATRIX))
-
self.modelView = numpy.transpose(currentModelView)
-
self.inverseModelView = inv(numpy.transpose(currentModelView))
-
-
-
-
-
-
-
-
运行代码:
右键拖动查看效果:
选择场景中的对象
既然要操作场景中的对象,那么必然得先能够选中对象,要怎么才能选中呢?想象你有一只指哪打哪的激光笔,当激光与对象相交时就相当于选中了对象。
我们如何判定激光穿透了对象呢?
想要真正实现对复杂形状物体进行选择判定是非常考验算法和性能的,所以在这里我们简化问题,对对象使用包围盒(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
-
-
-
-
-
-
-
-
-
-
def __init__(self, center, size):
-
self.center = numpy.array(center)
-
self.size = numpy.array(size)
-
-
-
-
-
def ray_hit(self, origin, direction, modelmatrix):
-
-
参数说明: origin, distance -> 激光源点与方向
-
modelmatrix -> 世界坐标到局部对象坐标的转换矩阵 """
-
aabb_min = self.center - self.size
-
aabb_max = self.center + self.size
-
-
-
-
obb_pos_worldspace = numpy.array([modelmatrix[
0,
3], modelmatrix[
1,
3], modelmatrix[
2,
3]])
-
delta = (obb_pos_worldspace - origin)
-
-
-
xaxis = 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])/f
-
t2 = (e + aabb_max[
0])/f
-
-
-
-
-
-
-
-
-
-
if (-e + aabb_min[
0] >
0.0 + EPSILON)
or (-e+aabb_max[
0] <
0.0 - EPSILON):
-
-
-
yaxis = numpy.array((modelmatrix[
1,
0], modelmatrix[
1,
1], modelmatrix[
1,
2]))
-
e = numpy.dot(yaxis, delta)
-
f = numpy.dot(direction, yaxis)
-
-
if math.fabs(f) >
0.0 + EPSILON:
-
t1 = (e + aabb_min[
1])/f
-
t2 = (e + aabb_max[
1])/f
-
-
-
-
-
-
-
-
-
-
if (-e + aabb_min[
1] >
0.0 + EPSILON)
or (-e+aabb_max[
1] <
0.0 - EPSILON):
-
-
-
-
zaxis = 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])/f
-
t2 = (e + aabb_max[
2])/f
-
-
-
-
-
-
-
-
-
-
if (-e + aabb_min[
2] >
0.0 + EPSILON)
or (-e+aabb_max[
2] <
0.0 - EPSILON):
-
-
-
-
-
-
""" 渲染显示包围盒,可在调试的时候使用 """
-
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)
-
glMatrixMode(GL_MODELVIEW)
-
-
glTranslated(self.center[
0], self.center[
1], self.center[
2])
-
-
-
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
更新Node
类与Scene
类,加入与选中节点有关的内容
更新Node
类:
-
-
-
-
-
-
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)
-
-
-
-
-
-
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])
-
-
glMaterialfv(GL_FRONT, GL_EMISSION, [
0.3,
0.3,
0.3])
-
-
-
-
glMaterialfv(GL_FRONT, GL_EMISSION, [
0.0,
0.0,
0.0])
-
-
-
-
def select(self, select=None):
-
-
-
-
self.selected =
not self.selected
更新Scene
类:
-
-
-
-
self.selected_node =
None
在Viewer
类中实现通过鼠标位置获取激光的函数以及pick
函数
-
-
-
-
-
-
-
-
glMatrixMode(GL_MODELVIEW)
-
-
-
-
start = numpy.array(gluUnProject(x, y,
0.001))
-
end = numpy.array(gluUnProject(x, y,
0.999))
-
-
-
-
direction = direction / norm(direction)
-
-
return (start, direction)
-
-
-
""" 是否被选中以及哪一个被选中交由Scene下的pick处理 """
-
start, direction = self.get_ray(x, y)
-
self.scene.pick(start, direction, self.modelView)
为了确定是哪个对象被选中,我们会遍历场景下的所有对象,检查激光是否与该对象相交,取离摄像机最近的对象为选中对象。
-
-
def pick(self, start, direction, mat):
-
-
参数中的mat为当前ModelView的逆矩阵,作用是计算激光在局部(对象)坐标系中的坐标
-
-
-
-
if self.selected_node
is
not
None:
-
self.selected_node.select(
False)
-
self.selected_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.depth = mindist
-
closest_node.selected_loc = start + direction * mindist
-
self.selected_node = closest_node
-
-
-
def pick(self, start, direction, mat):
-
-
-
-
numpy.dot(mat, self.translation_matrix),
-
numpy.linalg.inv(self.scaling_matrix)
-
-
results = self.aabb.ray_hit(start, direction, newmat)
-
运行代码(蓝立方体被选中):
检测包围盒也有其缺点,如下图所示,我们希望能点中球背后的立方体,然而却选中了立方体前的球体,因为我们的激光射中了球体的包围盒。为了效率我们牺牲了这部分功能。在性能,代码复杂度与功能准确度之间之间进行衡量与抉择是在计算机图形学与软件工程中常常会遇见的。
操作场景中的对象
对对象的操作主要包括在场景中加入新对象, 移动对象、改变对象的颜色与改变对象的大小。因为这部分的实现较为简单,所以仅实现加入新对象与移动对象的操作.
加入新对象的代码如下:
-
-
def place(self, shape, x, y):
-
start, direction = self.get_ray(x, y)
-
self.scene.place(shape, start, direction, self.inverseModelView)
-
-
-
-
from node
import Sphere, Cube, SnowFigure
-
-
def place(self, shape, start, direction, inv_modelview):
-
-
if shape ==
'sphere': new_node = Sphere()
-
elif shape ==
'cube': new_node = Cube()
-
elif shape ==
'figure': new_node = SnowFigure()
-
-
-
-
-
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键创建球体。
移动目标对象的代码如下:
-
-
-
start, direction = self.get_ray(x, y)
-
self.scene.move_selected(start, direction, self.inverseModelView)
-
-
-
def move_selected(self, start, direction, inv_modelview):
-
-
if self.selected_node
is
None:
return
-
-
-
node = self.selected_node
-
-
oldloc = node.selected_loc
-
-
-
newloc = (start + direction * depth)
-
-
-
translation = newloc - oldloc
-
pre_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)