下面一系列OpenGL笔记,都是LearnOpenGL-CN的学习笔记。只是我个人的要点总结,并不详细,具体内容请参考原网站内容。

概念

OpenGL被认为是一个应用程序编程接口(Application Programming Interface, API),它包含了一系列可以操作图形、图像的方法。然而,OpenGL本身并不是一个API,仅仅是一个规范,由Khronos组织制定并维护。 实际的OpenGL库的开发者通常是显卡的生产商。

核心模式(Core-profile)与立即渲染模式(Immediate mode)

早期的OpenGL使用立即渲染模式(也就是固定渲染管线),这个模式下绘制图形很方便。但OpenGL的大多数功能都被库隐藏起来,开发者很少能控制OpenGL如何进行计算。

从OpenGL3.2开始,规范书开始废弃立即渲染模式,推出核心模式,这个模式完全移除了旧的特性。当使用核心模式时,OpenGL迫使我们使用现代的做法。现代做法要求使用者真正理解OpenGL和图形编程,它有一些难度,然而提供了更多的灵活性,更高的效率,更重要的是可以更深入的理解图形编程。

所有OpenGL的更高的版本都是在3.3的基础上,添加了额外的功能,并不更改进核心架构。新版本只是引入了一些更有效率或更有用的方式去完成同样的功能。因此所有的概念和技术在现代OpenGL版本里都保持一致。

扩展(Extension)

OpenGL的一大特性就是对扩展的支持,当一个显卡公司提出一个新特性或者渲染上的大优化,通常会以扩展的方式在驱动中实现。通过这种方式,开发者不必等待一个新的OpenGL规范面世,就可以方便的检查显卡是否支持此扩展。

if(GL_ARB_extension_name)
{
    // 使用一些新的特性
}
else
{
    // 不支持此扩展: 用旧的方式去做
}

状态机(State Machine)

OpenGL自身是一个巨大的状态机:一个描述OpenGL该如何操作的所有变量的大集合。 OpenGL的状态通常被称为OpenGL上下文Context)。我们通常使用如下途径去更改OpenGL状态:设置一些选项,操作一些缓冲。最后,我们使用当前OpenGL上下文来渲染。

用OpenGL工作时,我们会遇到一些状态设置函数(State-changing Function),以及一些在这些状态的基础上状态应用的函数(State-using Function)。只要你记住OpenGL本质上是个大状态机,就能更容易理解它的大部分特性。

对象(Object)

在OpenGL中一个对象是指一些选项的集合,代表OpenGL状态的一个子集。可以把对象看做一个C风格的结构体。 使用对象的一个好处是我们在程序中不止可以定义一个对象并且设置他们的状态,在我们需要进行一个操作的时候,只需要绑定预设了需要设置的对象即可。

原始类型(Primitive Type)

使用OpenGL时,建议使用OpenGL定义的原始类型。OpenGL定义的这些GL原始类型是平台无关的内存排列方式。比如使用float时加上前缀GL(GLfloat)。int,uint,char,bool等等类似。


参考: OpenGL

FreeGLUT 和 Glew

  1. FreeGLUT: 第三方库,可以用来显示窗口,管理用户输入,以及执行一些其他操作。
  2. GLEW:跨平台第三方库,可以简化获取函数地址的过程,并且包含了可以跨平台使用的一些其他OpenGL编程方法。

有两种设置FreeGLUT和GLEW的方法:

  1. 添加FreeGLUT和GLEW的库文件到VS的目录和系统目录,然后在VS配置,最后使用。
  2. 添加FreeGLUT和GLEW的库文件到我们项目下自己建的一个目录,然后在VS中配置项目。这样当你的项目拷贝到其他没有FreeGLUT和GLEW的电脑,也可以运行。

我们使用第二种方法。

开始设置

  1. 准备资源:
    GLEW1.13.0下载GLEW,并且解压出glew-1.13.0目录。

    从FreeGLUT官网下载3.0.0版本。但是FreeGLUT并没有编译,所以需要自己编译,这个过程比较麻烦需要CMAKE,所以我直接从这里下的编译后的FreeGLUT,选for MSVC,下载后解压。

  2. 新建一个VS项目:

    打开VS2015,新建一个项目。选择Visual C++ 和 空项目。名字自己起,目录中不要有空格。

    然后在项目中新建一个 main.cpp文件。

  3. 添加GLEW:

    在项目目录下,新建一个文件夹,取名Dependencies(当然你也可以取别的名字),在Dependencies下再建一个目录glew

    到之前解压出的glew-1.13.0目录下,有一个include\GL目录,里面有三个.h文件,把这三个文件拷贝到Dependencies\glew目录下。

    在到glew-1.13.0\lib\Release目录,因为我是64位系统,所以选择x64目录下的glew32.lib拷贝到Dependencies\glew目录下。

    最后glew目录是这样:

    glew目录

  4. 添加FreeGLUT:

    新建一个freeglut文件夹在Dependencies下。

    到之前下载解压出的freeglut目录下,include\GL内,有4个.h文件,将它们拷贝到Dependencies\freeglut。

    到之前下载解压出的freeglut目录下,lib\x64内,有一个freeglut.lib文件,同样拷贝到Dependencies\freeglut。

    最后freeglut目录是这样:

    freeglut目录

  5. 配置VS项目:

    回到VS2015,在 解决方案资源管理器 选中我们的项目,点击菜单项目-显示所有文件,再刷新一下 解决方案资源管理器 ,会看到Dependencies出现,右键点击包括在项目中,再分别打开下面的目录,看看前面有红色图标的项目,也分别点包括在项目中

    再选中项目,右键属性,打开属性窗口,选择链接器-常规,在附加库目录,输入 Dependencies\freeglut;Dependencies\glew

    链接器-输入

    再选择链接器-输入,在附加依赖项,加上 opengl32.lib;freeglut.lib;glew32.lib;

    链接器-输入

    点确定。 这时候就全部配置完了。

  6. 测试:
    main.cpp输入如下代码:

	# include "Dependencies\glew\glew.h"
	# include "Dependencies\freeglut\freeglut.h"

	void myDisplay(void)   
	{   
	   glClear(GL_COLOR_BUFFER_BIT);
	   glColor3f(0.0f, 1.0f, 0.0f);
	   glRectf(-0.5f, -0.5f, 0.5f, 0.5f);
	   glFlush();   
	}

	int main(int argc, char *argv[])   
	{
	   glutInit(&argc, argv);
	   glutInitDisplayMode(GLUT_RGB | GLUT_SINGLE);
	   glutInitWindowPosition(100, 100);
	   glutInitWindowSize(640, 480);
	   glutCreateWindow("First_GL!");
	   glutDisplayFunc(myDisplay);
	   glutMainLoop();
	}   
	

注意:

F5运行,如果弹出提示找不到freeglut.dll,回到下载的freeglut\bin\x64目录,把freeglut.dll拷贝到VS项目的Debug目录(和.sln文件目录同级,x64\debug)即可。

还有对于64位系统,不要忘记在VS把平台改成x64。

Rigidbody

刚体是启用一个物体物理行为的主要组件。一旦附加刚体组件,物体将收重力影响。如果刚体附加了Collider,则可以检测碰撞。 刚体可以接受力和扭矩,但Transform不能。对于刚体的运动,请不要使用Transform的属性来进行位移和旋转,而应该对物体附加力来让物理引擎自己计算结果。

Kinematic

有时你需要一个物体附加了刚体组件但却不想使用物理引擎控制它(比如需要进行Trigger检测),这时你可以将Rigidbody 设置为Kinematic 。

Sleeping

当刚体运动速度降低当一个值,即物理引擎认为它的运动停止时,它将进入“sleeping”模式,知道下次受到力或碰撞,它都将保持这个睡眠模式。这意味着睡眠模式中,并没有处理器时间分配到更新刚体状态,知道它再次激活。

大多数时候,刚体的休眠与唤醒的发生都是非常明显的。但是,当一个静态碰撞器通过改变其Transform的位置,令其移动进入刚体或移出刚体是,这个刚体可能会无法唤醒。此时,这个刚体可以使用函数 WakeUp 来唤醒。

更多请查看:Rigidbody and Rigidbody 2D 。

Collider

Collider为物理碰撞定义了一个物体形状。包括几种基础图形碰撞:Box、Capsule、Sphere,还有Mesh Collier。另外还有Character Controller、Wheel Collider、Terrain Colllider等。 其中基本图形碰撞器效率比较高,Mesh Collider当mesh比较复杂时效率较低。

对于复杂物体的碰撞器设置,有三种方法,一种是用多个基本图形碰撞复合模拟出大概的样子,一种是直接使用Mesh Collider,还有一种是再为模型制作一个简单的Mesh,用这个mesh做Mesh Collider。

注意: 复合Colliders多作为子物体使用,但要注意RigidBody只能有一个,必须置于最顶层的父物体上。 还有,一个Mesh Collider 无法与另一个Mesh Collider产生碰撞,此时设置其中一个为convex 才行。总之最好是尽量避免使与Mesh Collider。

静态碰撞:

对于墙、地面这类不会运动的物体,可以仅添加Collider不添加Rigidbody,这称为Static Colliders。 通常,配置好后,最好不要再重新设置静态碰撞的Transform的Position。因为这会非常影响物理引擎的性能。 添加于有Rigidbody物体的Collider则是 dynamic colliders。 静态碰撞物体可以与动态碰撞产生物理作用,但并不会对碰撞产生回应。

物理材质 Physics materials

当物体产生碰撞,它们的表面可以模拟不同的物理材质。比如冰表面很滑而橡胶球则有很大摩擦力。尽管Collider的形状在碰撞时并不会变形,但通过Physics Materials.可以设置它的摩擦力和弹力。 更多内容查看官方手册: Physic Material and Physics Material 2D 。

触发器 Triggers

可以设置一个Collider为Trigger,触发器不受物理引擎控制,允许其他碰撞器穿过。当一个Collider穿过其中,OnTriggerEnter 事件将会触发。

碰撞检测

碰撞触发函数主要有两类,一类是碰撞器的,一类是触发器的。每类有三种,分别是进入、保持和离开。 OnTriggerEnter, OnTriggerStay, OnTriggerExit 是触发类消息,参数是Collider。 OnCollisionEnter, OnCollisionStay, OnCollisionExit 是碰撞类消息,参数是Collision。

下面看一下各种情况的触发条件: 首先说明一下,碰撞器的种类。

  • Static Collider:静态碰撞器。
    有Collider,没有Rigidbody,没有Trigger。简称SC

  • Rigidbody Collider:刚体碰撞器。
    有Collider,有Rigidbody,没有Trigger。简称RC

  • Kinematic Rigidbody Collider:运动学刚体。
    有Collider,有Rigidbody,刚体是Kinematic,没有Trigger。简称KR

  • Static Trigger Collider:静态触发器。
    有Collider,没有Rigidbody,有Trigger。简称ST

  • Rigidbody Trigger Collider:刚体触发器。
    有Collider,有Rigidbody,有Trigger。简称RT

  • Kinematic Rigidbody Trigger Collider:运动学刚体触发器。
    有Collider,有Rigidbody,刚体是Kinematic,有Trigger。简称KRT

Collision 碰撞检测规则:

Trigger 触发检测规则:

MoveTowards、Lerp、Slerp这三个函数相信大家经常遇到,这些都是在做一些过渡操作时需要用到的,那么它们间的具体差别是什么呢?其实要搞清楚它们的区别,只要仔细看官方说明,明白它们的具体用途是什么。

MoveTowards

Mathf、Vector2、Vector3等许多类都有这个方法,意思都差不多。以Vector3为例,函数原型为:

1
public static Vector3 MoveTowards(Vector3 current, Vector3 target, float maxDistanceDelta);

作用是将当前值current移向目标target。(对Vector3是沿两点间直线) maxDistanceDelta就是每次移动的最大长度。 返回值是当current值加上maxDistanceDelta的值,如果这个值超过了target,返回的就是target的值。

例子:

1
2
3
4
5
//表示以每秒moveMax的速度从Current移动到Target。
//因为Current和Target距离是4,所以当moveMax 等于0.5f,用时8秒,moveMax等于2时,用时2秒。
Vector3 Current  = new Vector3(0, 0, 0);
Vector3 Target = new Vector3(0, 0, 4);
Current = Vector3.MoveTowards(Current, Target, moveMax * Time.deltaTime);

Lerp

Lerp表示线性插值,很多地方都用到了它。这里仍以Vector3为例,函数原型是:

1
public static Vector3 Lerp(Vector3 a, Vector3 b, float t);

它的作用就是按照t计算a和b之间的插值。t的取值范围是[0, 1]。 简单理解就是当t=0, 返回值是a,当t=1,返回值是b。同理,t=0.1时是a到b的10%。t就代表了a到b的百分比。

线性插值图示

例1:

1
2
3
4
5
6
7
8
9
10
11
//在time时间内移动物体
private IEnumerator MoveObject(Vector3 startPos, Vector3 endPos, float time)
{        
        var dur = 0.0f;
        while (dur <= time)
        {
            dur += Time.deltaTime;
            transform.position = Vector3.Lerp(startPos, endPos, dur / time);
            yield return null;
        }
}

例2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//以指定速度speed移动物体
private IEnumerator MoveObject_Speed(Vector3 startPos, Vector3 endPos, float speed)
{
        float startTime = Time.time;
        float length = Vector3.Distance(startPos, endPos);
        float frac = 0;

        while (frac < 1.0f)
        {
            float dist = (Time.time - startTime) * speed;
            frac = dist / length;
            transform.position = Vector3.Lerp(startPos, endPos, frac);
            yield return null;
        }
}

Slerp

球形插值在Vector3、Quaternion等类都有使用,一般多在Quaternion的旋转操作时使用。

对于Vector3:

1
public static Vector3 Slerp(Vector3 a, Vector3 b, float t);

这里球形插值与线性插值不同的地方在于,它将Vectors视为方向而不再是点。返回的向量方向,它的角度是根据a和b的角度插值,而它的长度是根据a和b的长度插值。 可以用其模拟太阳的升降变化等操作。

对于Quaternion:

1
public static Quaternion Slerp(Quaternion a, Quaternion b, float t);

计算a与b的球形插值。 Quaternion的Lerp与Slerp结果都是一样的,Lerp的效率会比Slerp高些,但是当旋转值a和b离得比较远时,Lerp的效果会非常差。 这是一段Lerp和Slerp的视频演示:https://www.youtube.com/watch?v=uNHIPVOnt-Y

或者看下图:

Lerp与Slerp

红点是起点,绿点是终点。蓝色线是Lerp的轨迹,白线是Slerp的轨迹。

注意:

对于插值(Slerp和Lerp)运算中的t,要明白它代表的是百分比,直接对齐赋值时间如Time.deltaTime是没意义的,因为如果t为1的话,永远都不会到达目标值。

方向和距离

一个物体到另一个物体的方向:

1
2
//获得player位置到target位置的向量
var heading = target.position - player.position;

距离与方向向量:

1
2
var distance = heading.magnitude;  // 距离
var direction = heading / distance;  // 方向向量

如果只是需要进行距离的判断,最好不要使用magnitude,因为它包含开平方运算,对CPU消耗比较大,可以换为使用sqrMagnitude :

1
2
3
if (heading.sqrMagnitude < maxRange * maxRange) {
    // Target 在 maxRange 范围内
}

这比使用 magnitude 有效率的多。

有时会需要水平方向的向量。比如一个地面上的palyer要接近空中的Target。如果直接用target减去player,会得到指向上的向量,而我们需要的是在地面上指向Target正下方的向量。只需要设置向量的Y分量为0即可:

1
2
var heading = target.position - player.position;
heading.y = 0;  // 地面上的heading.

对其他平面同理。

计算法线/垂线向量

在mesh生成、路径跟随等很多情况,都需要用到垂直向量。 给定任意三个点,比如一个mesh的三个顶点,就很容易算出法线。只需两两相减求得两个向量,在利用向量叉乘,求出法向量:

1
2
3
Vector3 a, b, c;
Vector3 side1 = b - a;
Vector3 side2 = c - a;

两个向量的叉乘会得到垂直于平面的向量,根据“左手法则”可以决定叉乘的顺序。从平面顶部向下看,垂直向量指向外(自己),则叉乘的第一个向量顺时针转向第二个向量:

1
Vector3 perp = Vector3.Cross(side1, side2);

如果交换side1,side2的顺序,垂直向量将指向相反方向。

对于mesh来说,求其标准化法向量时,除了使用normalized属性,也可以用前面介绍的方法:

1
2
var perpLength = perp.magnitude;
perp /= perpLength;

事实证明三角形的面积等于perpLength / 2,这对于求整个mesh的平面面积或根据相对面积随机选取三角形是很有用的。

向量大小在某一方向的分量:

当需要求一个向量在某一方向的分量大小,需要用点乘,比如求汽车向前的速度:

1
var fwdSpeed = Vector3.Dot(rigidbody.velocity, transform.forward);

当然,transform.forward方向可以是任意的,但必须是单位向量。

旋转和方向:

3D中的旋转通常使用欧拉角或者四元数。这两种方式各有优缺点。关于欧拉角和四元数更详细的内容,前面已经介绍。

我们知道,Vector既可以表示一个点(point),也可以表示一个方向(direction)(从原点测量的方向)。对应的,一个Quaternion既可以表示方位(orientation),也可以表示旋转(rotation)(从原始位置或Identity测量的旋转)。四元数测量的旋转是指从一个指向到另一个指向,并不能代表一个大于180°的旋转。

Unity3D内部所有旋转都是使用四元数的,但是为了便于理解和编辑,在监视面板显示的是对应的欧拉角。 这样的一个副作用就是,比如输入一个欧拉角值:( 0, 365, 0 ),因为四元数无法表示,所以运行后就变成了( 0, 5, 0 )。

脚本操作:

在脚本中处理旋转时,应该使用Quaternion类和它的函数来创建和改变旋转值。有些时候使用欧拉角也是有效的,但你要时刻记住:应该使用四元数来处理欧拉角。

创建和操作四元数: Unity的Quaternion类有许多函数来帮助创建和操作旋转,从而避免使用欧拉角:

创建:

1
2
3
Quaternion.LookRotation
Quaternion.AngleAxis
Quaternion.FromToRotation

操作:

1
2
3
4
Quaternion.Slerp
Quaternion.Inverse
Quaternion.RotateTowards
Transform.Rotate & Transform.RotateAround

然而有时使用欧拉角也是必要的,但在这时你要注意,必须保持你的角度为变量,并且只将它们作为欧拉角来用于旋转。虽然可能从Quaternion获取Euler角,但如果对其取值、修改或重置,会引起问题。

下面是一些应该避免的错误的例子,它们的目的是让物体绕X轴每秒旋转10度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//这里的问题是我们修改了一个quaternion的x的值,
//但这个值并不代表一个角度,所以并不会产生想要的结果
void Update () {    
    var rot = transform.rotation;
    rot.x += Time.deltaTime * 10;
    transform.rotation = rot;       
}

//这里的问题是,我们读取、修改并写入来自quaternion的欧拉角的值。
//因为这些值是计算自quaternion,所以每个新的旋转都会返回迥然不同的欧拉角,
//这可能引起万向节死锁。
void Update () {        
    var angles = transform.rotation.eulerAngles;
    angles.x += Time.deltaTime * 10;
    transform.rotation = Quaternion.Euler(angles);
}

下面是使用欧拉角的正确例子:

1
2
3
4
5
6
7
//我们将角度值存于变量并将其应用于欧拉角,
//但我们并未依赖读取回的欧拉值。
float x;
void Update () {        
    x += Time.deltaTime * 10;
    transform.rotation = Quaternion.Euler(x,0,0);
}

动画操作:

在许多动画编辑方案包括unity内置的动画窗口,都支持欧拉角旋转的动画过程。 这些旋转值可能经常超过四元数可表示的值。比如一个物体旋转720度,欧拉角可以表示为( 0, 720, 0 ),四元数则无法表示。

Unity动画窗口:

在Unity动画窗口,有选项允许你指定如何旋转插值 - 四元数或者欧拉角。当指定为欧拉角插值,意味着你希望执行角度指定的完整旋转。而指定为四元数时,Unity将执行最短路径的旋转到最终值。 更多参考 Using Animation Curves

外部动画资源:

对于外部动画资源,一般都包含欧拉角格式的旋转关键帧。Unity的默认操作会将其重采样,并对每一个动画关键帧都生成新的四元数关键帧,以尝试避免某些关键帧间的旋转超出四元数的表示范围。

比如一个动画,6帧长度从0度到270度,不重新采样直接使用四元数旋转,会直接旋转反方向90度,因为这是相反路径,如果重采样了,就会变成每一帧旋转45度,从而达到同样的效果。

但仍有许多情况重采样不能完全达到原始动画的效果,所以Unity有选项可以关闭animation resampling,直接使用原始动画的欧拉值。 更多参考  Animation Import of Euler Curve Rotations


参考:
Vector Cookbook
Rotation and Orientation in Unity