Python实现3D建模工具

一、课程介绍
1. 课程来源
本课程核心部分来自《500 lines or less》项目,作者是 Erick,Erick从事电子游戏,3D特效软件,CAD软件的开发,这是他的个人网站:erickdransch.com。项目代码使用 MIT 协议,项目文档使用 http://creativecommons.org/licenses/by/3.0/legalcode 协议。
课程内容在原文档基础上做了许多修改,增加了部分原理介绍,步骤的拆解分析及源代码注释。
2. 内容简介
本课程将基于OpenGL实现一般CAD软件都会具备的基础功能:渲染显示3D空间的画面并可以操作3D空间中物体。
3. 课程知识点
本课程项目完成过程中,我们将学习:
1. OpenGL坐标系的转换
2. 实现简单的用户输入事件回调机制
3. 设计模式中组合模式的使用
4. 基于包围盒的碰撞检测
4. 本实验源码
$ wget http://labfile.oss.aliyuncs.com/courses/561/Python3DFirst.zip
二、实验说明
1. 背景介绍
人类是那么得有创造力,我们创造、发明、设计、生产了一切大自然没有直接给予我们的东西使我们的生活变得更轻松更美好。在过去,我们只能在图纸上进行产品的创造与设计,而现在,有了计算机的帮助,有了CAD(计算机辅助设计)软件,大大节省了我们的精力与时间成本,使我们的工作更高效,能够拥有更多时间去思考设计本身。
那么CAD软件是如何写出来的呢?CAD软件种类繁多,但它们有一个共同的特点,就是对三维世界的建模,对三维世界中物体的控制,对三维设计的展示。
这,就是本课程要实现的内容了。
2. 实验工具
渲染流程决定了设计是如何呈现在屏幕上的,我们希望程序能为我们处理复杂的物体,同时我们也希望代码的复杂度能够尽可能得低。这些我们该如何做到呢?
在渲染画面之前,我们首先需要新建一个窗口,并且我们不希望直接操作图形驱动来生成画面,所以我们选择跨平台图形接口OpenGL与OpenGL的工具库GLUT来帮助我们管理窗口和渲染画面。
关于OpenGL
OpenGL是开发跨平台图形应用的接口标准,当前的OpenGL编程分为两类:现代OpenGL与传统OpenGL。
传统OpenGL采用固定管线的形式。通过对一系列全局变量的赋值,你可以启动或者禁止一些渲染流水线上的工作,比如光照,着色,隐面剔除等,之后自动地根据流水线进行画面渲染,固定管线的编程形式现在已经不推荐使用了。
现代OpenGL采用可编程管线的形式,我们只需编写称作shaders的小程序运行即可。可编程管线已经替代了固定管线。
但是在本课程中,我们仍然使用传统OpenGL,因为固定管线能够减少代码量,并且要求的线性代数知识也更少。
关于GLUT
在本课程中,GLUT负责创建窗口与注册用户接口的回调函数(处理键盘输入、鼠标输入等),如果需要一个功能更全面的窗口管理库,推荐使用GTK或者QT。
3. 基础知识
变换矩阵
在计算机图形学中,常常需要使用到不同的坐标系,比如世界坐标系、摄像机坐标系、视图坐标系等。坐标系之间的转换需要用到变换矩阵。我们可以不理会矩阵的细节,而将其看作一个函数,变换前的点的坐标作为函数的参数,通过这个公式: 我们就可以得到坐标系变换后的点的坐标了。虽然说是坐标系变换,其实只要认为坐标系是“固定不动”的,就可以看成是坐标系中的物体在坐标系中变换了。移动、旋转、缩放被称作仿射变换,其对应矩阵就是令物体在坐标系中变换使用的。
OpenGL坐标系的转换
一个3d模型映射到屏幕上会经过5次空间变换,如下图漫画所示,左上角为起始点:

漫画右半部分的坐标系转换基本可以通过OpenGL自带的函数帮助我们处理,从摄像机坐标系到齐次裁减坐标系的矩阵转换由gluPerspective函数调用完成,到视图坐标系的矩阵转换由glViewport函数调用完成。转换矩阵最终会存在GL_PROJECTION中,在本项目中,不需要了解这方面的细节内容。
当然,漫画左半部分的坐标系转换就需要我们自己处理了,从对象坐标系到摄像机坐标系的转换矩阵称作ModelView矩阵。我们之后会将其存在GL_MODELVIEW中,ModelView矩阵的生成会在之后的实验步骤中讲到。
也许你会奇怪为什么有的坐标使用的是三元组有的坐标使用的四元组,三元组还可以理解,四元组是怎么回事呢?这里我只能简短地说,物体要做平移变换必须使用四元组,四元组的第四个元素决定了该四元组究竟是一个向量还是空间中的一个点,想了解背后的数学知识可以看这篇博文:OpenGL学习脚印: 坐标和变换的数学基础(math-coordinates and transformations)
想了解更具体的变换过程可以参考这篇博文:OpenGL学习脚印: 坐标变换过程(vertex transformation)
三、实验环境
• python==3.4.2
• PyOpenGL==3.1.0
• numpy==1.14.5
$ pip3 install PyOpenGL==3.1.0 numpy==1.14.5
此外,需要安装freeglut文件
$ sudo apt-get update
$ sudo apt-get install freeglut3
$ sudo apt-get install freeglut3-dev
四、实验步骤
我们分为视图,场景,节点,组合节点和动作等几个方面来讲解。
请将代码存放在Code目录下。
Viewer类
首先新建文件viewer.py,导入项目所需的库与方法:
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
我们将在viewer.py中实现Viewer类,Viewer类控制并管理整个程序的流程,它的main_loop方法是我们程序的入口。
Viewer的初始代码如下:
class Viewer(object):
def __init__(self):
""" Initialize the viewer. """
#初始化接口,创建窗口并注册渲染函数
self.init_interface()
#初始化opengl的配置
self.init_opengl()
#初始化3d场景
self.init_scene()
#初始化交互操作相关的代码
self.init_interaction()

def init_interface(self):
""" 初始化窗口并注册渲染函数 """
glutInit()
glutInitWindowSize(640, 480)
glutCreateWindow(b"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)
#启用0号光源
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):
#初始化场景,之后实现
pass

def init_interaction(self):
#初始化交互操作相关的代码,之后实现
pass

def main_loop(self):
#程序主循环开始
glutMainLoop()

def render(self):
#程序进入主循环后每一次循环调用的渲染函数
pass

if __name__ == "__main__":
viewer = Viewer()
viewer.main_loop()
这段代码给出了Viewer类的整体框架。目前只实现了窗口的创建与OpenGL的初始化。运行它,你会看见一个绘制背景的窗口。

因为我们的渲染函数里还什么都没写,显存的缓冲区没有更新,所以显示的是背景的画面,下面进行render函数的补完:
def render(self):
#初始化投影矩阵
self.init_view()

#启动光照
glEnable(GL_LIGHTING)
#清空颜色缓存与深度缓存
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

#设置模型视图矩阵,目前为止用单位矩阵就行了。
glMatrixMode(GL_MODELVIEW)
glPushMatrix()
glLoadIdentity()

#渲染场景
#self.scene.render()

#每次渲染后复位光照状态
glDisable(GL_LIGHTING)
glPopMatrix()
#把数据刷新到显存上
glFlush()

def init_view(self):
""" 初始化投影矩阵 """
xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
#得到屏幕宽高比
aspect_ratio = float(xSize) / float(ySize)

#设置投影矩阵
glMatrixMode(GL_PROJECTION)
glLoadIdentity()

#设置视口,应与窗口重合
glViewport(0, 0, xSize, ySize)
#设置透视,摄像机上下视野幅度70度
#视野范围到距离摄像机1000个单位为止。
gluPerspective(70, aspect_ratio, 0.1, 1000.0)
#摄像机镜头从原点后退15个单位
glTranslated(0, 0, -15)
渲染函数内涉及到了对场景的渲染,而我们的viewer现在还未实现init_scene。所以我暂时将那一行注释掉了,再运行看一下效果:

画面变成灰色了,那是因为我们将清屏颜色设置成了灰色,在每一次循环开始时,都会清空一遍颜色缓存,说明我们的render函数在正常工作了。
现在请将self.scene.render()的注释取消。
scene实例在init_scene方法中创建的。除了要得到scene实例,我们现在还希望在最初的场景中能有些看得见的东西。比如一个球体,它刚好在世界坐标系的正中央。就依照这个思路来实现最初的init_scene代码,设计需要的接口。
def init_scene(self):
#创建一个场景实例
self.scene = Scene()
#初始化场景内的对象
self.create_sample_scene()

def create_sample_scene(self):
#创建一个球体
sphere_node = Sphere()
#设置球体的颜色
sphere_node.color_index = 2
#将球体放进场景中,默认在正中央
self.scene.add_node(sphere_node)
这里的Sphere实例可以看作是场景内的一个节点,凡是置入场景之中的对象都应当被看作是场景内的节点。接下来我们就要实现场景类与节点类。
场景类
首先实现场景类,在工作目录下新建scene.py,初始代码如下:
class Scene(object):

#放置节点的深度,放置的节点距离摄像机15个单位
PLACE_DEPTH = 15.0

def __init__(self):
#场景下的节点队列
self.node_list = list()

def add_node(self, node):
""" 在场景中加入一个新节点 """
self.node_list.append(node)

def render(self):
""" 遍历场景下所有节点并渲染 """
for node in self.node_list:
node.render()
目前这么点代码就已经够用了。
节点类
场景下的对象皆为节点,因此需要抽象出所有类型的对象的基类:Node类(节点类)。在工作目录下创建node.py文件,导入需要的库:
import random
from OpenGL.GL import glCallList, glColor3f, glMaterialfv, glMultMatrixf, glPopMatrix, glPushMatrix, \
GL_EMISSION, GL_FRONT
import numpy
实现节点类的初始代码:
class Node(object):
def __init__(self):
#该节点的颜色序号
self.color_index = random.randint(color.MIN_COLOR, color.MAX_COLOR)
#该节点的平移矩阵,决定了该节点在场景中的位置
self.translation_matrix = numpy.identity(4)
#该节点的缩放矩阵,决定了该节点的大小
self.scaling_matrix = numpy.identity(4)

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])
#渲染对象模型
self.render_self()
glPopMatrix()

def render_self(self):
raise NotImplementedError(
"The Abstract Node Class doesn't define 'render_self'")
注意到对象的平移与缩放操作都在基类Node的render方法中完成,当我们实现一个子类时,不需要再实现一遍平移与缩放,只需要专心考虑如何渲染模型本身就可以了,即子类必须实现render_self方法。
每一个节点都有自己的颜色属性,我们新建一个color.py文件,保存颜色信息。
MAX_COLOR = 9
MIN_COLOR = 0
COLORS = { # RGB Colors
0: (1.0, 1.0, 1.0),
1: (0.05, 0.05, 0.9),
2: (0.05, 0.9, 0.05),
3: (0.9, 0.05, 0.05),
4: (0.9, 0.9, 0.0),
5: (0.1, 0.8, 0.7),
6: (0.7, 0.2, 0.7),
7: (0.7, 0.7, 0.7),
8: (0.4, 0.4, 0.4),
9: (0.0, 0.0, 0.0),
}
同时在 node.py中引入color
import color
接着实现具体的球形类Sphere
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
咦?为什么球形类与节点类之间又多了一个Primitive类呢?primitive又称作图元,在这里,它是组成模型的基本单元,像是球体,立方体,三角等都属于图元。这些类的共通点在于它们的渲染都可以使用短小的OpenGL代码完成,同时对这些元素进行组合就可以组合出复杂的模型来,因此我们抽象出了Primitive这个类。
观察Primitive的渲染函数,发现它调用了glCallList方法,glCallList是OpenGL中一个使用起来非常便利的函数,正如它的名字,它会按序调用一个函数列表中的一系列函数,我们使用glNewList(CALL_LIST_NUMBER, GL_COMPILE)与glEndList()来标识一段代码的开始与结束,这段代码作为一个新的函数列表与一个数字关联起来,之后希望执行相同的操作时只需调用glCallList(关联的数字)就可以了,这样说也许有些抽象,在这个项目中,我们会这样应用:
#标识代码段的数字
G_OBJ_SPHERE = 2

def make_sphere():
#代码段的开始
glNewList(G_OBJ_SPHERE, GL_COMPILE)
#渲染球体模型
quad = gluNewQuadric()
gluSphere(quad, 0.5, 30, 30)
gluDeleteQuadric(quad)
#代码段的结束
glEndList()

make_sphere()
这样每一次只要调用glCallList(G_OBJ_SPHERE)就能够生成球形了,是不是很方便呢。
新建一个文件primtive.py,将渲染图元的函数列表写入文件中。
from OpenGL.GL import glBegin, glColor3f, glEnd, glEndList, glLineWidth, glNewList, glNormal3f, glVertex3f, \
GL_COMPILE, GL_LINES, GL_QUADS
from OpenGL.GLU import gluDeleteQuadric, gluNewQuadric, gluSphere

G_OBJ_SPHERE = 2

def make_sphere():
""" 创建球形的渲染函数列表 """
glNewList(G_OBJ_SPHERE, GL_COMPILE)
quad = gluNewQuadric()
gluSphere(quad, 0.5, 30, 30)
gluDeleteQuadric(quad)
glEndList()

def init_primitives():
""" 初始化所有的图元渲染函数列表 """
make_sphere()
将init_primitives() 添加到Viewer中
from primitive import init_primitives

class Viewer(object):
def __init__(self):
self.init_interface()
self.init_opengl()
self.init_scene()
self.init_interaction()
init_primitives()
在node.py中加入
from primitive import G_OBJ_SPHERE
确保viewer.py中导入了以下内容:
import color
from scene import Scene
from primitive import init_primitives
from node import Sphere
运行代码看看:

平移与改变大小
设计实现能够平移或者改变节点大小的接口,新建transformation.py,实现生成平移矩阵与缩放矩阵的方法:
import numpy

def translation(displacement):
""" 生成平移矩阵 """
t = numpy.identity(4)
t[0, 3] = displacement[0]
t[1, 3] = displacement[1]
t[2, 3] = displacement[2]
return t

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
在Node类中编写相应的平移与缩放的接口:
from transformation import scaling, translation
...

class Node(object)
...
def translate(self, x, y, z):
self.translation_matrix = numpy.dot(self.translation_matrix, translation([x, y, z]))

def scale(self, s):
self.scaling_matrix = numpy.dot(self.scaling_matrix, scaling([s,s,s]))
更新Viewer的create_sample_scene:
def create_sample_scene(self):
sphere_node = Sphere()
sphere_node.color_index = 2
sphere_node.translate(2,2,0)
sphere_node.scale(4)
self.scene.add_node(sphere_node)
运行:

组合节点
就像之前说的,复杂的模型能够从简单的图元通过组合得到,组合后的模型也应该作为一个节点来看待。所以引入组合节点。
我们在node.py中创建HierarchicalNode类,这是一个包含子节点的的节点,它将子节点存储在child_nodes中,同时作为Node的子类,它也必须实现render_self, 它的render_self函数就是简单地遍历调用子节点的render_self。
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()
为了展示组合的效果,我们以小雪人SnowFigure类为例,小雪人是由3个不同大小球体组成的模型。
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)
self.child_nodes[1].translate(0, 0.1, 0)
self.child_nodes[1].scale(0.8)
self.child_nodes[2].translate(0, 0.75, 0)
self.child_nodes[2].scale(0.7)
for child_node in self.child_nodes:
child_node.color_index = color.MIN_COLOR
更新create_sample_scene:
from node import SnowFigure
...
class Viewer(object):
def create_sample_scene(self):
sphere_node = Sphere()
sphere_node.color_index = 2
sphere_node.translate(2,2,0)
sphere_node.scale(4)
self.scene.add_node(sphere_node)
#添加小雪人
hierarchical_node = SnowFigure()
hierarchical_node.translate(-2, 0, -2)
hierarchical_node.scale(2)
self.scene.add_node(hierarchical_node)
运行:

你可能注意到了,这种组合会形成一种树形的的数据结构,叶子节点就是图元节点,每次渲染都会深度遍历这棵树,这就是设计模式中的组合模式了。一言以蔽之,节点的集合仍旧是节点,它们实现相同的接口,组合节点会在接口中遍历所有子节点的接口。

至今为止的代码
之前为了便于讲解基础部分,所以只实现了球体,下面给出这节课的完整代码。
viewer.py代码:
#-*- coding:utf-8 -*-
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, glutCloseFunc
import numpy
from numpy.linalg import norm, inv
import random
from OpenGL.GL import glBegin, glColor3f, glEnd, glEndList, glLineWidth, glNewList, glNormal3f, glVertex3f, \
GL_COMPILE, GL_LINES, GL_QUADS
from OpenGL.GLU import gluDeleteQuadric, gluNewQuadric, gluSphere

import color
from scene import Scene
from primitive import init_primitives, G_OBJ_PLANE
from node import Sphere, Cube, SnowFigure

class Viewer(object):
def __init__(self):
""" Initialize the viewer. """
#初始化接口,创建窗口并注册渲染函数
self.init_interface()
#初始化opengl的配置
self.init_opengl()
#初始化3d场景
self.init_scene()
#初始化交互操作相关的代码
self.init_interaction()
init_primitives()

def init_interface(self):
""" 初始化窗口并注册渲染函数 """
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)
#启用0号光源
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):
#创建一个场景实例
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 = 1
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):
#初始化交互操作相关的代码,之后实现
pass

def main_loop(self):
#程序主循环开始
glutMainLoop()

def render(self):
#初始化投影矩阵
self.init_view()

#启动光照
glEnable(GL_LIGHTING)
#清空颜色缓存与深度缓存
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

#设置模型视图矩阵,这节课先用单位矩阵就行了。
glMatrixMode(GL_MODELVIEW)
glPushMatrix()
glLoadIdentity()

#渲染场景
self.scene.render()

#每次渲染后复位光照状态
glDisable(GL_LIGHTING)
glCallList(G_OBJ_PLANE)
glPopMatrix()
#把数据刷新到显存上
glFlush()

def init_view(self):
""" 初始化投影矩阵 """
xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
#得到屏幕宽高比
aspect_ratio = float(xSize) / float(ySize)

#设置投影矩阵
glMatrixMode(GL_PROJECTION)
glLoadIdentity()

#设置视口,应与窗口重合
glViewport(0, 0, xSize, ySize)
#设置透视,摄像机上下视野幅度70度
#视野范围到距离摄像机1000个单位为止。
gluPerspective(70, aspect_ratio, 0.1, 1000.0)
#摄像机镜头从原点后退15个单位
glTranslated(0, 0, -15)

if __name__ == "__main__":
viewer = Viewer()
viewer.main_loop()
scene.py代码:
#-*- coding:utf-8 -*-
class Scene(object):

#放置节点的深度,放置的节点距离摄像机15个单位
PLACE_DEPTH = 15.0

def __init__(self):
#场景下的节点队列
self.node_list = list()

def add_node(self, node):
""" 在场景中加入一个新节点 """
self.node_list.append(node)

def render(self):
""" 遍历场景下所有节点并渲染 """
for node in self.node_list:
node.render()
node.py代码:
#-*- coding:utf-8 -*-
import random
from OpenGL.GL import glCallList, glColor3f, glMaterialfv, glMultMatrixf, glPopMatrix, glPushMatrix, \
GL_EMISSION, GL_FRONT
import numpy

from primitive import G_OBJ_CUBE, G_OBJ_SPHERE
from transformation import scaling, translation
import color

class Node(object):
def __init__(self):
#该节点的颜色序号
self.color_index = random.randint(color.MIN_COLOR, color.MAX_COLOR)
#该节点的平移矩阵,决定了该节点在场景中的位置
self.translation_matrix = numpy.identity(4)
#该节点的缩放矩阵,决定了该节点的大小
self.scaling_matrix = numpy.identity(4)

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])
#渲染对象模型
self.render_self()
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 scale(self, s):
self.scaling_matrix = numpy.dot(self.scaling_matrix, scaling([s,s,s]))

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

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)
self.child_nodes[1].translate(0, 0.1, 0)
self.child_nodes[1].scale(0.8)
self.child_nodes[2].translate(0, 0.75, 0)
self.child_nodes[2].scale(0.7)
for child_node in self.child_nodes:
child_node.color_index = color.MIN_COLOR
primitive.py代码:
from OpenGL.GL import glBegin, glColor3f, glEnd, glEndList, glLineWidth, glNewList, glNormal3f, glVertex3f, \
GL_COMPILE, GL_LINES, GL_QUADS
from OpenGL.GLU import gluDeleteQuadric, gluNewQuadric, gluSphere

G_OBJ_PLANE = 1
G_OBJ_SPHERE = 2
G_OBJ_CUBE = 3

def make_plane():
glNewList(G_OBJ_PLANE, GL_COMPILE)
glBegin(GL_LINES)
glColor3f(0, 0, 0)
for i in range(41):
glVertex3f(-10.0 + 0.5 * i, 0, -10)
glVertex3f(-10.0 + 0.5 * i, 0, 10)
glVertex3f(-10.0, 0, -10 + 0.5 * i)
glVertex3f(10.0, 0, -10 + 0.5 * i)

# Axes
glEnd()
glLineWidth(5)

glBegin(GL_LINES)
glColor3f(0.5, 0.7, 0.5)
glVertex3f(0.0, 0.0, 0.0)
glVertex3f(5, 0.0, 0.0)
glEnd()

glBegin(GL_LINES)
glColor3f(0.5, 0.7, 0.5)
glVertex3f(0.0, 0.0, 0.0)
glVertex3f(0.0, 5, 0.0)
glEnd()

glBegin(GL_LINES)
glColor3f(0.5, 0.7, 0.5)
glVertex3f(0.0, 0.0, 0.0)
glVertex3f(0.0, 0.0, 5)
glEnd()

# Draw the Y.
glBegin(GL_LINES)
glColor3f(0.0, 0.0, 0.0)
glVertex3f(0.0, 5.0, 0.0)
glVertex3f(0.0, 5.5, 0.0)
glVertex3f(0.0, 5.5, 0.0)
glVertex3f(-0.5, 6.0, 0.0)
glVertex3f(0.0, 5.5, 0.0)
glVertex3f(0.5, 6.0, 0.0)

# Draw the Z.
glVertex3f(-0.5, 0.0, 5.0)
glVertex3f(0.5, 0.0, 5.0)
glVertex3f(0.5, 0.0, 5.0)
glVertex3f(-0.5, 0.0, 6.0)
glVertex3f(-0.5, 0.0, 6.0)
glVertex3f(0.5, 0.0, 6.0)

# Draw the X.
glVertex3f(5.0, 0.0, 0.5)
glVertex3f(6.0, 0.0, -0.5)
glVertex3f(5.0, 0.0, -0.5)
glVertex3f(6.0, 0.0, 0.5)

glEnd()
glLineWidth(1)
glEndList()

def make_sphere():
glNewList(G_OBJ_SPHERE, GL_COMPILE)
quad = gluNewQuadric()
gluSphere(quad, 0.5, 30, 30)
gluDeleteQuadric(quad)
glEndList()

def make_cube():
glNewList(G_OBJ_CUBE, GL_COMPILE)
vertices = [((-0.5, -0.5, -0.5), (-0.5, -0.5, 0.5), (-0.5, 0.5, 0.5), (-0.5, 0.5, -0.5)),
((-0.5, -0.5, -0.5), (-0.5, 0.5, -0.5), (0.5, 0.5, -0.5), (0.5, -0.5, -0.5)),
((0.5, -0.5, -0.5), (0.5, 0.5, -0.5), (0.5, 0.5, 0.5), (0.5, -0.5, 0.5)),
((-0.5, -0.5, 0.5), (0.5, -0.5, 0.5), (0.5, 0.5, 0.5), (-0.5, 0.5, 0.5)),
((-0.5, -0.5, 0.5), (-0.5, -0.5, -0.5), (0.5, -0.5, -0.5), (0.5, -0.5, 0.5)),
((-0.5, 0.5, -0.5), (-0.5, 0.5, 0.5), (0.5, 0.5, 0.5), (0.5, 0.5, -0.5))]
normals = [(-1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, -1.0, 0.0), (0.0, 1.0, 0.0)]

glBegin(GL_QUADS)
for i in range(6):
glNormal3f(normals[i][0], normals[i][1], normals[i][2])
for j in range(4):
glVertex3f(vertices[i][j][0], vertices[i][j][1], vertices[i][j][2])
glEnd()
glEndList()

def init_primitives():
make_plane()
make_sphere()
make_cube()
transformation.py代码:
import numpy

def translation(displacement):
t = numpy.identity(4)
t[0, 3] = displacement[0]
t[1, 3] = displacement[1]
t[2, 3] = displacement[2]
return t

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
color.py代码:
MAX_COLOR = 9
MIN_COLOR = 0
COLORS = { # RGB Colors
0: (1.0, 1.0, 1.0),
1: (0.05, 0.05, 0.9),
2: (0.05, 0.9, 0.05),
3: (0.9, 0.05, 0.05),
4: (0.9, 0.9, 0.0),
5: (0.1, 0.8, 0.7),
6: (0.7, 0.2, 0.7),
7: (0.7, 0.7, 0.7),
8: (0.4, 0.4, 0.4),
9: (0.0, 0.0, 0.0),
}
代码效果:

在本节课的完整版代码中,追加了立方体类节点与坐标平面图,但由于没有设置ModelView,所以这个角度看不清坐标平面图,在下节课引入轨迹球后就能够全方位的观察这个世界啦。下节课见~
二、用户接口
我们希望与场景实现两种交互,一种是你可以操纵场景从而能够从不同的角度观察模型,一种是你拥有添加与操作修改模型对象的能力。为了实现交互,我们需要得到键盘与鼠标的输入,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 = button
if button == GLUT_RIGHT_BUTTON:
pass
elif 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:
pass
glutPostRedisplay()
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_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()
三、内部回调
针对用户行为会调用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):
""" 鼠标选中一个节点 """
pass

def move(self, x, y):
""" 移动当前选中的节点 """
pass

def place(self, shape, x, y):
""" 在鼠标的位置上新放置一个节点 """
pass

def rotate_color(self, forward):
""" 更改选中节点的颜色 """
pass

def 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.000001

class AABB(object):

def __init__(self, center, size):
self.center = numpy.array(center)
self.size = numpy.array(size)

def scale(self, scale):
self.size *= scale

def ray_hit(self, origin, direction, modelmatrix):
""" 返回真则表示激光射中了包盒
参数说明: origin, distance -> 激光源点与方向
modelmatrix -> 世界坐标到局部对象坐标的转换矩阵 """
aabb_min = self.center - self.size
aabb_max = self.center + self.size
tmin = 0.0
tmax = 100000.0

obb_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-axis
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 t1 > t2:
t1, t2 = t2, t1
if t2 < tmax:
tmax = t2
if t1 > tmin:
tmin = t1
if tmax < tmin:
return (False, 0)
else:
if (-e + aabb_min[0] > 0.0 + EPSILON) or (-e+aabb_max[0] < 0.0 - EPSILON):
return False, 0

yaxis = numpy.array((modelmatrix[1, 0], modelmatrix[1, 1], modelmatrix[1, 2]))
e = numpy.dot(yaxis, delta)
f = numpy.dot(direction, yaxis)
# intersection in y
if math.fabs(f) > 0.0 + EPSILON:
t1 = (e + aabb_min[1])/f
t2 = (e + aabb_max[1])/f
if t1 > t2:
t1, t2 = t2, t1
if t2 < tmax:
tmax = t2
if t1 > tmin:
tmin = t1
if 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 z
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 t1 > t2:
t1, t2 = t2, t1
if t2 < tmax:
tmax = t2
if t1 > tmin:
tmin = t1
if tmax < tmin:
return (False, 0)
else:
if (-e + aabb_min[2] > 0.0 + EPSILON) or (-e+aabb_max[2] < 0.0 - EPSILON):
return False, 0

return True, tmin

def 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 = select
else:
self.selected = not self.selected
更新Scene类:
class Scene(object):
def __init__(self):
self.node_list = list()
self.selected_node = None
在Viewer类中实现通过鼠标位置获取激光的函数以及pick函数
# class Viewer
def 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 - start
direction = 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 sys

if self.selected_node is not None:
self.selected_node.select(False)
self.selected_node = None

# 找出激光击中的最近的节点。
mindist = sys.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 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下的实现
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 = None
if 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_node
depth = node.depth
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)

Python实现3D建模工具
一、课程介绍
1. 课程来源
本课程核心部分来自《500 lines or less》项目,作者是 Erick,Erick从事电子游戏,3D特效软件,CAD软件的开发,这是他的个人网站:erickdransch.com。项目代码使用 MIT 协议,项目文档使用 http://creativecommons.org/licenses/by/3.0/legalcode 协议。
课程内容在原文档基础上做了许多修改,增加了部分原理介绍,步骤的拆解分析及源代码注释。
2. 内容简介
本课程将基于OpenGL实现一般CAD软件都会具备的基础功能:渲染显示3D空间的画面并可以操作3D空间中物体。
3. 课程知识点
本课程项目完成过程中,我们将学习:
1. OpenGL坐标系的转换
2. 实现简单的用户输入事件回调机制
3. 设计模式中组合模式的使用
4. 基于包围盒的碰撞检测
4. 本实验源码
$ wget http://labfile.oss.aliyuncs.com/courses/561/Python3DFirst.zip
二、实验说明
1. 背景介绍
人类是那么得有创造力,我们创造、发明、设计、生产了一切大自然没有直接给予我们的东西使我们的生活变得更轻松更美好。在过去,我们只能在图纸上进行产品的创造与设计,而现在,有了计算机的帮助,有了CAD(计算机辅助设计)软件,大大节省了我们的精力与时间成本,使我们的工作更高效,能够拥有更多时间去思考设计本身。
那么CAD软件是如何写出来的呢?CAD软件种类繁多,但它们有一个共同的特点,就是对三维世界的建模,对三维世界中物体的控制,对三维设计的展示。
这,就是本课程要实现的内容了。
2. 实验工具
渲染流程决定了设计是如何呈现在屏幕上的,我们希望程序能为我们处理复杂的物体,同时我们也希望代码的复杂度能够尽可能得低。这些我们该如何做到呢?
在渲染画面之前,我们首先需要新建一个窗口,并且我们不希望直接操作图形驱动来生成画面,所以我们选择跨平台图形接口OpenGL与OpenGL的工具库GLUT来帮助我们管理窗口和渲染画面。
关于OpenGL
OpenGL是开发跨平台图形应用的接口标准,当前的OpenGL编程分为两类:现代OpenGL与传统OpenGL。
传统OpenGL采用固定管线的形式。通过对一系列全局变量的赋值,你可以启动或者禁止一些渲染流水线上的工作,比如光照,着色,隐面剔除等,之后自动地根据流水线进行画面渲染,固定管线的编程形式现在已经不推荐使用了。
现代OpenGL采用可编程管线的形式,我们只需编写称作shaders的小程序运行即可。可编程管线已经替代了固定管线。
但是在本课程中,我们仍然使用传统OpenGL,因为固定管线能够减少代码量,并且要求的线性代数知识也更少。
关于GLUT
在本课程中,GLUT负责创建窗口与注册用户接口的回调函数(处理键盘输入、鼠标输入等),如果需要一个功能更全面的窗口管理库,推荐使用GTK或者QT。
3. 基础知识
变换矩阵
在计算机图形学中,常常需要使用到不同的坐标系,比如世界坐标系、摄像机坐标系、视图坐标系等。坐标系之间的转换需要用到变换矩阵。我们可以不理会矩阵的细节,而将其看作一个函数,变换前的点的坐标作为函数的参数,通过这个公式: 我们就可以得到坐标系变换后的点的坐标了。虽然说是坐标系变换,其实只要认为坐标系是“固定不动”的,就可以看成是坐标系中的物体在坐标系中变换了。移动、旋转、缩放被称作仿射变换,其对应矩阵就是令物体在坐标系中变换使用的。
OpenGL坐标系的转换
一个3d模型映射到屏幕上会经过5次空间变换,如下图漫画所示,左上角为起始点:

漫画右半部分的坐标系转换基本可以通过OpenGL自带的函数帮助我们处理,从摄像机坐标系到齐次裁减坐标系的矩阵转换由gluPerspective函数调用完成,到视图坐标系的矩阵转换由glViewport函数调用完成。转换矩阵最终会存在GL_PROJECTION中,在本项目中,不需要了解这方面的细节内容。
当然,漫画左半部分的坐标系转换就需要我们自己处理了,从对象坐标系到摄像机坐标系的转换矩阵称作ModelView矩阵。我们之后会将其存在GL_MODELVIEW中,ModelView矩阵的生成会在之后的实验步骤中讲到。
也许你会奇怪为什么有的坐标使用的是三元组有的坐标使用的四元组,三元组还可以理解,四元组是怎么回事呢?这里我只能简短地说,物体要做平移变换必须使用四元组,四元组的第四个元素决定了该四元组究竟是一个向量还是空间中的一个点,想了解背后的数学知识可以看这篇博文:OpenGL学习脚印: 坐标和变换的数学基础(math-coordinates and transformations)
想了解更具体的变换过程可以参考这篇博文:OpenGL学习脚印: 坐标变换过程(vertex transformation)
三、实验环境
• python==3.4.2
• PyOpenGL==3.1.0
• numpy==1.14.5
$ pip3 install PyOpenGL==3.1.0 numpy==1.14.5
此外,需要安装freeglut文件
$ sudo apt-get update
$ sudo apt-get install freeglut3
$ sudo apt-get install freeglut3-dev
四、实验步骤
我们分为视图,场景,节点,组合节点和动作等几个方面来讲解。
请将代码存放在Code目录下。
Viewer类
首先新建文件viewer.py,导入项目所需的库与方法:
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
我们将在viewer.py中实现Viewer类,Viewer类控制并管理整个程序的流程,它的main_loop方法是我们程序的入口。
Viewer的初始代码如下:
class Viewer(object):
def __init__(self):
""" Initialize the viewer. """
#初始化接口,创建窗口并注册渲染函数
self.init_interface()
#初始化opengl的配置
self.init_opengl()
#初始化3d场景
self.init_scene()
#初始化交互操作相关的代码
self.init_interaction()

def init_interface(self):
""" 初始化窗口并注册渲染函数 """
glutInit()
glutInitWindowSize(640, 480)
glutCreateWindow(b"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)
#启用0号光源
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):
#初始化场景,之后实现
pass

def init_interaction(self):
#初始化交互操作相关的代码,之后实现
pass

def main_loop(self):
#程序主循环开始
glutMainLoop()

def render(self):
#程序进入主循环后每一次循环调用的渲染函数
pass

if __name__ == "__main__":
viewer = Viewer()
viewer.main_loop()
这段代码给出了Viewer类的整体框架。目前只实现了窗口的创建与OpenGL的初始化。运行它,你会看见一个绘制背景的窗口。

因为我们的渲染函数里还什么都没写,显存的缓冲区没有更新,所以显示的是背景的画面,下面进行render函数的补完:
def render(self):
#初始化投影矩阵
self.init_view()

#启动光照
glEnable(GL_LIGHTING)
#清空颜色缓存与深度缓存
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

#设置模型视图矩阵,目前为止用单位矩阵就行了。
glMatrixMode(GL_MODELVIEW)
glPushMatrix()
glLoadIdentity()

#渲染场景
#self.scene.render()

#每次渲染后复位光照状态
glDisable(GL_LIGHTING)
glPopMatrix()
#把数据刷新到显存上
glFlush()

def init_view(self):
""" 初始化投影矩阵 """
xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
#得到屏幕宽高比
aspect_ratio = float(xSize) / float(ySize)

#设置投影矩阵
glMatrixMode(GL_PROJECTION)
glLoadIdentity()

#设置视口,应与窗口重合
glViewport(0, 0, xSize, ySize)
#设置透视,摄像机上下视野幅度70度
#视野范围到距离摄像机1000个单位为止。
gluPerspective(70, aspect_ratio, 0.1, 1000.0)
#摄像机镜头从原点后退15个单位
glTranslated(0, 0, -15)
渲染函数内涉及到了对场景的渲染,而我们的viewer现在还未实现init_scene。所以我暂时将那一行注释掉了,再运行看一下效果:

画面变成灰色了,那是因为我们将清屏颜色设置成了灰色,在每一次循环开始时,都会清空一遍颜色缓存,说明我们的render函数在正常工作了。
现在请将self.scene.render()的注释取消。
scene实例在init_scene方法中创建的。除了要得到scene实例,我们现在还希望在最初的场景中能有些看得见的东西。比如一个球体,它刚好在世界坐标系的正中央。就依照这个思路来实现最初的init_scene代码,设计需要的接口。
def init_scene(self):
#创建一个场景实例
self.scene = Scene()
#初始化场景内的对象
self.create_sample_scene()

def create_sample_scene(self):
#创建一个球体
sphere_node = Sphere()
#设置球体的颜色
sphere_node.color_index = 2
#将球体放进场景中,默认在正中央
self.scene.add_node(sphere_node)
这里的Sphere实例可以看作是场景内的一个节点,凡是置入场景之中的对象都应当被看作是场景内的节点。接下来我们就要实现场景类与节点类。
场景类
首先实现场景类,在工作目录下新建scene.py,初始代码如下:
class Scene(object):

#放置节点的深度,放置的节点距离摄像机15个单位
PLACE_DEPTH = 15.0

def __init__(self):
#场景下的节点队列
self.node_list = list()

def add_node(self, node):
""" 在场景中加入一个新节点 """
self.node_list.append(node)

def render(self):
""" 遍历场景下所有节点并渲染 """
for node in self.node_list:
node.render()
目前这么点代码就已经够用了。
节点类
场景下的对象皆为节点,因此需要抽象出所有类型的对象的基类:Node类(节点类)。在工作目录下创建node.py文件,导入需要的库:
import random
from OpenGL.GL import glCallList, glColor3f, glMaterialfv, glMultMatrixf, glPopMatrix, glPushMatrix, \
GL_EMISSION, GL_FRONT
import numpy
实现节点类的初始代码:
class Node(object):
def __init__(self):
#该节点的颜色序号
self.color_index = random.randint(color.MIN_COLOR, color.MAX_COLOR)
#该节点的平移矩阵,决定了该节点在场景中的位置
self.translation_matrix = numpy.identity(4)
#该节点的缩放矩阵,决定了该节点的大小
self.scaling_matrix = numpy.identity(4)

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])
#渲染对象模型
self.render_self()
glPopMatrix()

def render_self(self):
raise NotImplementedError(
"The Abstract Node Class doesn't define 'render_self'")
注意到对象的平移与缩放操作都在基类Node的render方法中完成,当我们实现一个子类时,不需要再实现一遍平移与缩放,只需要专心考虑如何渲染模型本身就可以了,即子类必须实现render_self方法。
每一个节点都有自己的颜色属性,我们新建一个color.py文件,保存颜色信息。
MAX_COLOR = 9
MIN_COLOR = 0
COLORS = { # RGB Colors
0: (1.0, 1.0, 1.0),
1: (0.05, 0.05, 0.9),
2: (0.05, 0.9, 0.05),
3: (0.9, 0.05, 0.05),
4: (0.9, 0.9, 0.0),
5: (0.1, 0.8, 0.7),
6: (0.7, 0.2, 0.7),
7: (0.7, 0.7, 0.7),
8: (0.4, 0.4, 0.4),
9: (0.0, 0.0, 0.0),
}
同时在 node.py中引入color
import color
接着实现具体的球形类Sphere
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
咦?为什么球形类与节点类之间又多了一个Primitive类呢?primitive又称作图元,在这里,它是组成模型的基本单元,像是球体,立方体,三角等都属于图元。这些类的共通点在于它们的渲染都可以使用短小的OpenGL代码完成,同时对这些元素进行组合就可以组合出复杂的模型来,因此我们抽象出了Primitive这个类。
观察Primitive的渲染函数,发现它调用了glCallList方法,glCallList是OpenGL中一个使用起来非常便利的函数,正如它的名字,它会按序调用一个函数列表中的一系列函数,我们使用glNewList(CALL_LIST_NUMBER, GL_COMPILE)与glEndList()来标识一段代码的开始与结束,这段代码作为一个新的函数列表与一个数字关联起来,之后希望执行相同的操作时只需调用glCallList(关联的数字)就可以了,这样说也许有些抽象,在这个项目中,我们会这样应用:
#标识代码段的数字
G_OBJ_SPHERE = 2

def make_sphere():
#代码段的开始
glNewList(G_OBJ_SPHERE, GL_COMPILE)
#渲染球体模型
quad = gluNewQuadric()
gluSphere(quad, 0.5, 30, 30)
gluDeleteQuadric(quad)
#代码段的结束
glEndList()

make_sphere()
这样每一次只要调用glCallList(G_OBJ_SPHERE)就能够生成球形了,是不是很方便呢。
新建一个文件primtive.py,将渲染图元的函数列表写入文件中。
from OpenGL.GL import glBegin, glColor3f, glEnd, glEndList, glLineWidth, glNewList, glNormal3f, glVertex3f, \
GL_COMPILE, GL_LINES, GL_QUADS
from OpenGL.GLU import gluDeleteQuadric, gluNewQuadric, gluSphere

G_OBJ_SPHERE = 2

def make_sphere():
""" 创建球形的渲染函数列表 """
glNewList(G_OBJ_SPHERE, GL_COMPILE)
quad = gluNewQuadric()
gluSphere(quad, 0.5, 30, 30)
gluDeleteQuadric(quad)
glEndList()

def init_primitives():
""" 初始化所有的图元渲染函数列表 """
make_sphere()
将init_primitives() 添加到Viewer中
from primitive import init_primitives

class Viewer(object):
def __init__(self):
self.init_interface()
self.init_opengl()
self.init_scene()
self.init_interaction()
init_primitives()
在node.py中加入
from primitive import G_OBJ_SPHERE
确保viewer.py中导入了以下内容:
import color
from scene import Scene
from primitive import init_primitives
from node import Sphere
运行代码看看:

平移与改变大小
设计实现能够平移或者改变节点大小的接口,新建transformation.py,实现生成平移矩阵与缩放矩阵的方法:
import numpy

def translation(displacement):
""" 生成平移矩阵 """
t = numpy.identity(4)
t[0, 3] = displacement[0]
t[1, 3] = displacement[1]
t[2, 3] = displacement[2]
return t

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
在Node类中编写相应的平移与缩放的接口:
from transformation import scaling, translation
...

class Node(object)
...
def translate(self, x, y, z):
self.translation_matrix = numpy.dot(self.translation_matrix, translation([x, y, z]))

def scale(self, s):
self.scaling_matrix = numpy.dot(self.scaling_matrix, scaling([s,s,s]))
更新Viewer的create_sample_scene:
def create_sample_scene(self):
sphere_node = Sphere()
sphere_node.color_index = 2
sphere_node.translate(2,2,0)
sphere_node.scale(4)
self.scene.add_node(sphere_node)
运行:

组合节点
就像之前说的,复杂的模型能够从简单的图元通过组合得到,组合后的模型也应该作为一个节点来看待。所以引入组合节点。
我们在node.py中创建HierarchicalNode类,这是一个包含子节点的的节点,它将子节点存储在child_nodes中,同时作为Node的子类,它也必须实现render_self, 它的render_self函数就是简单地遍历调用子节点的render_self。
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()
为了展示组合的效果,我们以小雪人SnowFigure类为例,小雪人是由3个不同大小球体组成的模型。
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)
self.child_nodes[1].translate(0, 0.1, 0)
self.child_nodes[1].scale(0.8)
self.child_nodes[2].translate(0, 0.75, 0)
self.child_nodes[2].scale(0.7)
for child_node in self.child_nodes:
child_node.color_index = color.MIN_COLOR
更新create_sample_scene:
from node import SnowFigure
...
class Viewer(object):
def create_sample_scene(self):
sphere_node = Sphere()
sphere_node.color_index = 2
sphere_node.translate(2,2,0)
sphere_node.scale(4)
self.scene.add_node(sphere_node)
#添加小雪人
hierarchical_node = SnowFigure()
hierarchical_node.translate(-2, 0, -2)
hierarchical_node.scale(2)
self.scene.add_node(hierarchical_node)
运行:

你可能注意到了,这种组合会形成一种树形的的数据结构,叶子节点就是图元节点,每次渲染都会深度遍历这棵树,这就是设计模式中的组合模式了。一言以蔽之,节点的集合仍旧是节点,它们实现相同的接口,组合节点会在接口中遍历所有子节点的接口。

至今为止的代码
之前为了便于讲解基础部分,所以只实现了球体,下面给出这节课的完整代码。
viewer.py代码:
#-*- coding:utf-8 -*-
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, glutCloseFunc
import numpy
from numpy.linalg import norm, inv
import random
from OpenGL.GL import glBegin, glColor3f, glEnd, glEndList, glLineWidth, glNewList, glNormal3f, glVertex3f, \
GL_COMPILE, GL_LINES, GL_QUADS
from OpenGL.GLU import gluDeleteQuadric, gluNewQuadric, gluSphere

import color
from scene import Scene
from primitive import init_primitives, G_OBJ_PLANE
from node import Sphere, Cube, SnowFigure

class Viewer(object):
def __init__(self):
""" Initialize the viewer. """
#初始化接口,创建窗口并注册渲染函数
self.init_interface()
#初始化opengl的配置
self.init_opengl()
#初始化3d场景
self.init_scene()
#初始化交互操作相关的代码
self.init_interaction()
init_primitives()

def init_interface(self):
""" 初始化窗口并注册渲染函数 """
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)
#启用0号光源
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):
#创建一个场景实例
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 = 1
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):
#初始化交互操作相关的代码,之后实现
pass

def main_loop(self):
#程序主循环开始
glutMainLoop()

def render(self):
#初始化投影矩阵
self.init_view()

#启动光照
glEnable(GL_LIGHTING)
#清空颜色缓存与深度缓存
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

#设置模型视图矩阵,这节课先用单位矩阵就行了。
glMatrixMode(GL_MODELVIEW)
glPushMatrix()
glLoadIdentity()

#渲染场景
self.scene.render()

#每次渲染后复位光照状态
glDisable(GL_LIGHTING)
glCallList(G_OBJ_PLANE)
glPopMatrix()
#把数据刷新到显存上
glFlush()

def init_view(self):
""" 初始化投影矩阵 """
xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
#得到屏幕宽高比
aspect_ratio = float(xSize) / float(ySize)

#设置投影矩阵
glMatrixMode(GL_PROJECTION)
glLoadIdentity()

#设置视口,应与窗口重合
glViewport(0, 0, xSize, ySize)
#设置透视,摄像机上下视野幅度70度
#视野范围到距离摄像机1000个单位为止。
gluPerspective(70, aspect_ratio, 0.1, 1000.0)
#摄像机镜头从原点后退15个单位
glTranslated(0, 0, -15)

if __name__ == "__main__":
viewer = Viewer()
viewer.main_loop()
scene.py代码:
#-*- coding:utf-8 -*-
class Scene(object):

#放置节点的深度,放置的节点距离摄像机15个单位
PLACE_DEPTH = 15.0

def __init__(self):
#场景下的节点队列
self.node_list = list()

def add_node(self, node):
""" 在场景中加入一个新节点 """
self.node_list.append(node)

def render(self):
""" 遍历场景下所有节点并渲染 """
for node in self.node_list:
node.render()
node.py代码:
#-*- coding:utf-8 -*-
import random
from OpenGL.GL import glCallList, glColor3f, glMaterialfv, glMultMatrixf, glPopMatrix, glPushMatrix, \
GL_EMISSION, GL_FRONT
import numpy

from primitive import G_OBJ_CUBE, G_OBJ_SPHERE
from transformation import scaling, translation
import color

class Node(object):
def __init__(self):
#该节点的颜色序号
self.color_index = random.randint(color.MIN_COLOR, color.MAX_COLOR)
#该节点的平移矩阵,决定了该节点在场景中的位置
self.translation_matrix = numpy.identity(4)
#该节点的缩放矩阵,决定了该节点的大小
self.scaling_matrix = numpy.identity(4)

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])
#渲染对象模型
self.render_self()
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 scale(self, s):
self.scaling_matrix = numpy.dot(self.scaling_matrix, scaling([s,s,s]))

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

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)
self.child_nodes[1].translate(0, 0.1, 0)
self.child_nodes[1].scale(0.8)
self.child_nodes[2].translate(0, 0.75, 0)
self.child_nodes[2].scale(0.7)
for child_node in self.child_nodes:
child_node.color_index = color.MIN_COLOR
primitive.py代码:
from OpenGL.GL import glBegin, glColor3f, glEnd, glEndList, glLineWidth, glNewList, glNormal3f, glVertex3f, \
GL_COMPILE, GL_LINES, GL_QUADS
from OpenGL.GLU import gluDeleteQuadric, gluNewQuadric, gluSphere

G_OBJ_PLANE = 1
G_OBJ_SPHERE = 2
G_OBJ_CUBE = 3

def make_plane():
glNewList(G_OBJ_PLANE, GL_COMPILE)
glBegin(GL_LINES)
glColor3f(0, 0, 0)
for i in range(41):
glVertex3f(-10.0 + 0.5 * i, 0, -10)
glVertex3f(-10.0 + 0.5 * i, 0, 10)
glVertex3f(-10.0, 0, -10 + 0.5 * i)
glVertex3f(10.0, 0, -10 + 0.5 * i)

# Axes
glEnd()
glLineWidth(5)

glBegin(GL_LINES)
glColor3f(0.5, 0.7, 0.5)
glVertex3f(0.0, 0.0, 0.0)
glVertex3f(5, 0.0, 0.0)
glEnd()

glBegin(GL_LINES)
glColor3f(0.5, 0.7, 0.5)
glVertex3f(0.0, 0.0, 0.0)
glVertex3f(0.0, 5, 0.0)
glEnd()

glBegin(GL_LINES)
glColor3f(0.5, 0.7, 0.5)
glVertex3f(0.0, 0.0, 0.0)
glVertex3f(0.0, 0.0, 5)
glEnd()

# Draw the Y.
glBegin(GL_LINES)
glColor3f(0.0, 0.0, 0.0)
glVertex3f(0.0, 5.0, 0.0)
glVertex3f(0.0, 5.5, 0.0)
glVertex3f(0.0, 5.5, 0.0)
glVertex3f(-0.5, 6.0, 0.0)
glVertex3f(0.0, 5.5, 0.0)
glVertex3f(0.5, 6.0, 0.0)

# Draw the Z.
glVertex3f(-0.5, 0.0, 5.0)
glVertex3f(0.5, 0.0, 5.0)
glVertex3f(0.5, 0.0, 5.0)
glVertex3f(-0.5, 0.0, 6.0)
glVertex3f(-0.5, 0.0, 6.0)
glVertex3f(0.5, 0.0, 6.0)

# Draw the X.
glVertex3f(5.0, 0.0, 0.5)
glVertex3f(6.0, 0.0, -0.5)
glVertex3f(5.0, 0.0, -0.5)
glVertex3f(6.0, 0.0, 0.5)

glEnd()
glLineWidth(1)
glEndList()

def make_sphere():
glNewList(G_OBJ_SPHERE, GL_COMPILE)
quad = gluNewQuadric()
gluSphere(quad, 0.5, 30, 30)
gluDeleteQuadric(quad)
glEndList()

def make_cube():
glNewList(G_OBJ_CUBE, GL_COMPILE)
vertices = [((-0.5, -0.5, -0.5), (-0.5, -0.5, 0.5), (-0.5, 0.5, 0.5), (-0.5, 0.5, -0.5)),
((-0.5, -0.5, -0.5), (-0.5, 0.5, -0.5), (0.5, 0.5, -0.5), (0.5, -0.5, -0.5)),
((0.5, -0.5, -0.5), (0.5, 0.5, -0.5), (0.5, 0.5, 0.5), (0.5, -0.5, 0.5)),
((-0.5, -0.5, 0.5), (0.5, -0.5, 0.5), (0.5, 0.5, 0.5), (-0.5, 0.5, 0.5)),
((-0.5, -0.5, 0.5), (-0.5, -0.5, -0.5), (0.5, -0.5, -0.5), (0.5, -0.5, 0.5)),
((-0.5, 0.5, -0.5), (-0.5, 0.5, 0.5), (0.5, 0.5, 0.5), (0.5, 0.5, -0.5))]
normals = [(-1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, -1.0, 0.0), (0.0, 1.0, 0.0)]

glBegin(GL_QUADS)
for i in range(6):
glNormal3f(normals[i][0], normals[i][1], normals[i][2])
for j in range(4):
glVertex3f(vertices[i][j][0], vertices[i][j][1], vertices[i][j][2])
glEnd()
glEndList()

def init_primitives():
make_plane()
make_sphere()
make_cube()
transformation.py代码:
import numpy

def translation(displacement):
t = numpy.identity(4)
t[0, 3] = displacement[0]
t[1, 3] = displacement[1]
t[2, 3] = displacement[2]
return t

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
color.py代码:
MAX_COLOR = 9
MIN_COLOR = 0
COLORS = { # RGB Colors
0: (1.0, 1.0, 1.0),
1: (0.05, 0.05, 0.9),
2: (0.05, 0.9, 0.05),
3: (0.9, 0.05, 0.05),
4: (0.9, 0.9, 0.0),
5: (0.1, 0.8, 0.7),
6: (0.7, 0.2, 0.7),
7: (0.7, 0.7, 0.7),
8: (0.4, 0.4, 0.4),
9: (0.0, 0.0, 0.0),
}
代码效果:

在本节课的完整版代码中,追加了立方体类节点与坐标平面图,但由于没有设置ModelView,所以这个角度看不清坐标平面图,在下节课引入轨迹球后就能够全方位的观察这个世界啦。下节课见~
二、用户接口
我们希望与场景实现两种交互,一种是你可以操纵场景从而能够从不同的角度观察模型,一种是你拥有添加与操作修改模型对象的能力。为了实现交互,我们需要得到键盘与鼠标的输入,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 = button
if button == GLUT_RIGHT_BUTTON:
pass
elif 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:
pass
glutPostRedisplay()
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_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()
三、内部回调
针对用户行为会调用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):
""" 鼠标选中一个节点 """
pass

def move(self, x, y):
""" 移动当前选中的节点 """
pass

def place(self, shape, x, y):
""" 在鼠标的位置上新放置一个节点 """
pass

def rotate_color(self, forward):
""" 更改选中节点的颜色 """
pass

def 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.000001

class AABB(object):

def __init__(self, center, size):
self.center = numpy.array(center)
self.size = numpy.array(size)

def scale(self, scale):
self.size *= scale

def ray_hit(self, origin, direction, modelmatrix):
""" 返回真则表示激光射中了包盒
参数说明: origin, distance -> 激光源点与方向
modelmatrix -> 世界坐标到局部对象坐标的转换矩阵 """
aabb_min = self.center - self.size
aabb_max = self.center + self.size
tmin = 0.0
tmax = 100000.0

obb_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-axis
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 t1 > t2:
t1, t2 = t2, t1
if t2 < tmax:
tmax = t2
if t1 > tmin:
tmin = t1
if tmax < tmin:
return (False, 0)
else:
if (-e + aabb_min[0] > 0.0 + EPSILON) or (-e+aabb_max[0] < 0.0 - EPSILON):
return False, 0

yaxis = numpy.array((modelmatrix[1, 0], modelmatrix[1, 1], modelmatrix[1, 2]))
e = numpy.dot(yaxis, delta)
f = numpy.dot(direction, yaxis)
# intersection in y
if math.fabs(f) > 0.0 + EPSILON:
t1 = (e + aabb_min[1])/f
t2 = (e + aabb_max[1])/f
if t1 > t2:
t1, t2 = t2, t1
if t2 < tmax:
tmax = t2
if t1 > tmin:
tmin = t1
if 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 z
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 t1 > t2:
t1, t2 = t2, t1
if t2 < tmax:
tmax = t2
if t1 > tmin:
tmin = t1
if tmax < tmin:
return (False, 0)
else:
if (-e + aabb_min[2] > 0.0 + EPSILON) or (-e+aabb_max[2] < 0.0 - EPSILON):
return False, 0

return True, tmin

def 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 = select
else:
self.selected = not self.selected
更新Scene类:
class Scene(object):
def __init__(self):
self.node_list = list()
self.selected_node = None
在Viewer类中实现通过鼠标位置获取激光的函数以及pick函数
# class Viewer
def 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 - start
direction = 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 sys

if self.selected_node is not None:
self.selected_node.select(False)
self.selected_node = None

# 找出激光击中的最近的节点。
mindist = sys.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 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下的实现
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 = None
if 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_node
depth = node.depth
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)