安卓 OpenGL ES 高级教程(一)
零、简介
1985 年,我带回家一台崭新闪亮的海军准将 Amiga 1000,大约在它们发布一周后。它配备了巨大的 512K 内存、可编程色彩映射表、摩托罗拉 68K CPU 和现代多任务操作系统,写满了“令人敬畏”的字样。当然是打个比方。我想它可能会成为一个天文学项目的良好平台,因为我现在可以控制那些恒星的颜色,而不是不得不满足于像 Hercules 或 C64 这样的人强加给我的蹩脚的固定调色板。所以我编写了一个 24 行的基本程序来绘制一个随机的星域,关掉灯,心想,“哇!我打赌我能为那东西写一个很酷的天文学程序!”26 年后,我仍在研究它,并希望有一天能搞清楚。那时候,我的梦想设备是一种我可以放入口袋,需要时拿出来,对准天空告诉我正在看什么星星或星座的东西。
这叫智能手机。
我先想到的。
虽然这些东西很适合播放音乐、打电话或向小猪扔小鸟,但当你接触到 3D 东西时,它真的会发光。毕竟,3D 就在我们周围——除非你是一个海盗,并且戴上了眼罩,在这种情况下,你的深度感知将非常有限。啊啊啊。
另外,向人们炫耀 3D 应用很有趣。他们会“明白”事实上,他们将会得到比所有孩子都在谈论的地膜购买指南应用更多的东西。(除非他们在 3D 中展示他们的覆盖物,但那会浪费一个完美的维度。)
因此,3D 应用看起来很有趣,互动起来很有趣,编程起来也很有趣。这让我想到了这本书。我绝不是这方面的专家。真正的大师是那些可以在早餐前击倒几个 NVIDIA 驱动程序,在午餐前击倒 4 维超立方体模拟器,并在晚上的 SyFy 上的 Firefly 马拉松之前将光晕移植到 TokyoFlash 手表的人。我做不到。但我是一个体面的作家,对这个主题有足够的工作知识使我无害,并且知道如何拼写“3D”所以我们在这里。
首先,这本书是为那些想要至少学习一点 3D 语言的有经验的 Android 程序员准备的。至少在下一次游戏程序员的鸡尾酒会上,你也可以和他们中最好的人一起笑四元数笑话。
这本书涵盖了 3D 理论和使用行业标准 OpenGL ES toolkit 实现小型设备的基础知识。虽然 Android 可以支持这两种风格——版本 1.x 用于简单的方式,版本 2.x 用于那些喜欢获得细节的人——但我主要介绍前者,除了在最后一章介绍后者和可编程着色器的使用。
第一章沿着漫长而曲折的计算机图形学历史介绍 OpenGL ES。第二章是基本 3D 渲染背后的数学,而第三章到第八章将带你慢慢了解所有图形程序员最终会遇到的各种问题,比如如何投射阴影、渲染多个 OpenGL 屏幕、添加镜头光晕等等。最终这变成了一个简单的(S-I-M-P-L-E!)由太阳、地球和一些恒星组成的太阳系模型——传统的 3D 练习。第九章着眼于最佳实践和开发工具,第十章作为 OpenGL ES 2 和着色器使用的简要概述。
所以,玩得开心点,给我发一些 M & Ms,当你在玩的时候,可以随意看看我自己的应用:遥远的太阳 3。是的,这就是 1985 年在 Commodore Amiga 1000 上启动的同一应用,它是一个 24 行的 basic 程序,在屏幕上随机画了几百颗星星。
现在更大了。
–迈克·史密斯威克
一、计算机图形学:从那时到现在
要预测未来,欣赏现在,你必须了解过去。
——可能是某个时候某个人说的
计算机图形一直是软件世界的宠儿。外行人更容易欣赏计算机图形,比如说,把排序算法的速度提高 3%,或者给电子表格程序增加自动色调控制。你可能会听到更多的人说“Coooool!”比起 Microsoft Word 中的 Visual Basic 脚本(当然,除非 Microsoft Word 中的 Visual Basic 脚本可以渲染土星;那真的会很酷)。当这些渲染图放在一个你可以放在后兜里随身携带的设备上时,酷的因素就更大了。让我们面对现实吧——硅谷的人们让科幻电影的艺术导演的日子变得非常艰难。毕竟,想象一下设计一个看起来比三星 Galaxy Tab 或 iPad 更具未来感的道具有多难。(甚至在苹果 iPhone 上市销售之前,美国广播公司 Lost 的道具部门就借用了一些苹果的屏幕图标,用于一名神秘的直升机飞行员携带的双向无线电中。)
如果你正在读这本书,那么你很可能已经有了一台基于 Android 的设备,或者正在考虑在不久的将来买一台。如果你有一个,现在就把它放在你的手里,想想这是 21 世纪工程学的一个奇迹。数百万小时的工作时间,数十亿美元的研究,数百年的加班,大量的通宵工作,以及大量喝着烈酒,穿着 t 恤,热爱漫画的工程师在夜晚的寂静中编写代码,这些都是为了制作那个小小的玻璃和塑料奇迹盒,这样你就可以在重播《流言终结者》时玩《愤怒的小鸟》。
你的第一个 OpenGL ES 程序
一些软件指南书籍会仔细地为他们的特定主题(“无聊的东西”)建立案例,但在第 655 页左右就会看到编码和例子(“有趣的东西”)。其他人会立即开始做一些练习来满足你的好奇心,把无聊的事情留到以后再做。本书将试图属于后一类。
注: OpenGL ES 是一种基于 OpenGL 库的 3D 图形标准,于 1992 年出现在硅图形实验室。它被广泛应用于整个行业,从运行游戏的便携式机器到为 NASA 运行流体动力学模拟的超级计算机(以及玩非常非常快的游戏)。ES 系列代表嵌入式系统,意为小型、便携、低功耗的设备。
安装后,Android SDK 附带了许多非常好的简明示例,从近场通信(NFC)到 UI,再到 OpenGL ES 项目。我们最早的例子将利用您将在广泛的 ApiDemos 代码库中找到的那些例子。不像它的 Apple-lovin 表亲 Xcode,它有一个很好的项目向导选择,包括一个 OpenGL 项目,不幸的是,Android dev 系统几乎没有。因此,与库比蒂诺的人们相比,我们不得不从一点点劣势开始。所以,你需要创建一个通用的 Android 项目,我相信你已经知道如何去做。完成后,添加一个名为Square.java的新类,由清单 1–1 中的代码组成。下面是详细的分析清单。
清单 1–1。 一个使用 OpenGL ES 的 2D 广场
`package book.BouncySquare;
import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.FloatBuffer; import java.nio.IntBuffer;
import javax.microedition.khronos.opengles.GL10; //1 import javax.microedition.khronos.opengles.GL11;
/**
* A vertex shaded square.
*/
class Square
{
public Square()
{
float vertices[] = //2 {
-1.0f, -1.0f,
1.0f, -1.0f,
-1.0f, 1.0f,
1.0f, 1.0f
};
byte maxColor=(byte)255;
byte colors[] = //3 { maxColor,maxColor, 0,maxColor, 0, maxColor,maxColor,maxColor, 0, 0, 0,maxColor, maxColor, 0,maxColor,maxColor };
byte indices[] = //4 { 0, 3, 1, 0, 2, 3 };
ByteBuffer vbb = ByteBuffer.allocateDirect(vertices.length * 4); //5 vbb.order(ByteOrder.nativeOrder()); mFVertexBuffer = vbb.asFloatBuffer(); mFVertexBuffer.put(vertices); mFVertexBuffer.position(0);
mColorBuffer = ByteBuffer.allocateDirect(colors.length); mColorBuffer.put(colors); mColorBuffer.position(0);
mIndexBuffer = ByteBuffer.allocateDirect(indices.length); mIndexBuffer.put(indices); mIndexBuffer.position(0);
}
public void draw(GL10 gl) //6
{
gl.glFrontFace(GL11.GL_CW); //7
gl.glVertexPointer(2, GL11.GL_FLOAT, 0, mFVertexBuffer); //8
gl.glColorPointer(4, GL11.GL_UNSIGNED_BYTE, 0, mColorBuffer); //9
gl.glDrawElements(GL11.GL_TRIANGLES, 6, //10
GL11.GL_UNSIGNED_BYTE, mIndexBuffer);
gl.glFrontFace(GL11.GL_CCW); //11
} private FloatBuffer mFVertexBuffer;
private ByteBuffer mColorBuffer;
private ByteBuffer mIndexBuffer;
}`
在我进入下一阶段之前,我将分解清单 1–1 中构建多色正方形的代码:
- Java 拥有几种不同的 OpenGL 接口。父类仅仅叫做
GL,而 OpenGL ES 1.0 使用的是GL10,1.1 版本导入为GL11,如第 1 行所示。如果您的图形硬件支持,您还可以通过 GL11ExtensionPack 提供的GL10Ext package访问一些扩展。后来的版本仅仅是早期版本的子类;然而,仍然有一些调用被定义为只接受GL10对象,但是如果你正确地转换对象,这些调用也可以工作。 - 在第 2 行中,我们定义了我们的正方形。你很少会这样做,因为许多对象可能有数千个顶点。在这些情况下,您可能会从任意数量的 3D 文件格式导入它们,如 Imagination Technologies 的 POD 文件、3D Studio 的
.3ds文件等等。在这里,因为我们描述的是一个 2D 正方形,所以只需要指定 x 和 y 坐标。如你所见,这个正方形边长是两个单位。 - 颜色的定义类似,但在这种情况下,在第 3ff 行中,每种颜色有四种成分:红色、绿色、蓝色和 alpha(透明度)。这些直接映射到前面显示的四个顶点,所以第一个颜色与第一个顶点相配,依此类推。您可以使用颜色的浮点或固定或字节表示,如果要导入非常大的模型,后者可以节省大量内存。因为我们使用字节,颜色值从 0 到 255,这意味着第一个颜色设置红色为 255,绿色为 255,蓝色为 0。这将使一个可爱的,否则眩目的黄色阴影。如果使用浮点或定点,它们最终会在内部转换为字节值。与它的桌面兄弟不同,它可以渲染四边的对象,OpenGL ES 仅限于三角形。在第 4ff 行中,创建了连接性数组。这将顶点匹配到特定的三角形。第一个三元组表示顶点 0、3 和 1 构成三角形 0,而第二个三角形由顶点 0、2 和 3 组成。
- 一旦创建了颜色、顶点和连接性数组,我们可能需要处理这些值,将它们的内部 Java 格式转换成 OpenGL 可以理解的格式,如第 5ff 行所示。这主要确保字节的排序是正确的;否则,根据硬件的不同,它们的顺序可能会相反。
- 第 6 行中的
draw方法由SquareRenderer.drawFrame()调用,稍后介绍。 - 第 7 行告诉 OpenGL 顶点如何排列它们的面。为了让你的软件发挥出最佳性能,顶点排序是至关重要的。这有助于在整个模型中保持统一的顺序,这可以表明三角形是朝向还是背离您的视点。后者被称为背面三角形物体的背面,所以它们可以被忽略,大大减少渲染时间。因此,通过指定三角形的正面是
GL_CW,或顺时针,所有逆时针三角形被剔除。注意,在第 11 行,它们被重置为GL_CCW,这是默认值。 - 在第 8、9 和 10 行中,指向数据缓冲区的指针被移交给渲染器。对
glVertexPointer()的调用指定了每个顶点的元素数量(在本例中是两个),数据是浮点的,并且“步幅”是 0 字节。数据可以是八种不同的格式,包括浮点、固定、整数、短整数和字节。后三种有签名和无签名两种形式。Stride 是一种方便的方法,只要数据结构不变,就可以将 OpenGL 数据与您自己的数据交错。Stride 仅仅是打包在 GL 数据之间的用户信息的字节数,因此系统可以跳过它到它将理解的下一位。 - 在第 9 行,颜色缓冲区以四个元素的大小发送,RGBA 四元组使用无符号字节(我知道,Java 没有无符号的东西,但 GL 不需要知道),它也有一个 stride=0。
- 最后,给出实际的
draw命令,它需要连接性数组。第一个参数表示几何图形的格式,换句话说,三角形、三角形列表、点或线。 - 第 11 行让我们成为一个好邻居,并在先前的对象使用默认值的情况下将正面排序重置回
GL_CCW。
现在我们的广场需要一个驱动程序和方式来在屏幕上展示它多彩的自我。创建另一个名为SquareRenderer.java的文件,并用清单 1–2 中的代码填充它。
清单 1–2。 我们第一个 OpenGL 项目的驱动
`package book.BouncySquare;
import javax.microedition.khronos.egl.EGL10; //1 import javax.microedition.khronos.egl.EGLConfig; import javax.microedition.khronos.opengles.GL10;
import android.opengl.GLSurfaceView; //2
import java.lang.Math; class SquareRenderer implements GLSurfaceView.Renderer
{
public SquareRenderer(boolean useTranslucentBackground)
{
mTranslucentBackground = useTranslucentBackground;
mSquare = new Square(); //3
}
public void onDrawFrame(GL10 gl) //4 {
gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT); //5
gl.glMatrixMode(GL10.GL_MODELVIEW); //6 gl.glLoadIdentity(); //7 gl.glTranslatef(0.0f,(float)Math.sin(mTransY), -3.0f); //8
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY); //9 gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
mSquare.draw(gl); //10
mTransY += .075f; }
public void onSurfaceChanged(GL10 gl, int width, int height) //11 { gl.glViewport(0, 0, width, height); //12
float ratio = (float) width / height; gl.glMatrixMode(GL10.GL_PROJECTION); //13 gl.glLoadIdentity(); gl.glFrustumf(-ratio, ratio, -1, 1, 1, 10); //14 }
public void onSurfaceCreated(GL10 gl, EGLConfig config) //15 { gl.glDisable(GL10.GL_DITHER); //16
gl.glHint(GL10.GL_PERSPECTIVE_CORRECTION_HINT, //17 GL10.GL_FASTEST);
if (mTranslucentBackground) //18
{
gl.glClearColor(0,0,0,0);
}
else
{ gl.glClearColor(1,1,1,1);
}
gl.glEnable(GL10.GL_CULL_FACE); //19
gl.glShadeModel(GL10.GL_SMOOTH); //20
gl.glEnable(GL10.GL_DEPTH_TEST); //21
}
private boolean mTranslucentBackground;
private Square mSquare;
private float mTransY;
private float mAngle;
}`
这里发生了很多事情:
- 第 1 行中的 EGL 库将 OpenGL 绘图表面绑定到系统,但是在本例中被隐藏在
GLSurfaceview中,如第 2 行所示。EGL 主要用于分配和管理绘图表面,是 OpenGL ES 扩展的一部分,因此它是平台无关的。 - 在第 3 行,分配并缓存了 square 对象。
- 第 4 行中的
onDrawFrame()是根刷新方法;每次都是这样构建图像,每秒钟很多次。第一个调用通常是清除整个屏幕,如第 5 行所示。考虑到一个帧可以由几个组件构成,您可以选择每帧清除哪些组件。颜色缓冲区保存所有的 RGBA 颜色数据,而深度缓冲区用于确保较近的项目正确地遮挡了较远的项目。 - 第 6 行和第 7 行开始使用实际的 3D 参数;这些细节将在后面介绍。这里所做的只是设置值,以确保示例几何图形立即可见。
- 接下来,第 8 行将盒子上下平移。为了获得漂亮、平滑的运动,实际的平移值基于正弦波。值
mTransY仅用于产生范围从 1 到+1 的最终上下值。每次通过drawFrame(),平移增加. 075。因为我们取的是正弦值,所以没有必要将值返回给它自己,因为正弦会为我们做这件事。尝试将mTransY的值增加到. 3,看看会发生什么。 - 第 9f 行告诉 OpenGL 期待顶点和颜色数据。
- 最后,在所有这些设置代码之后,我们可以调用您之前见过的
mSquare的实际绘制例程,如第 10 行所示。 - 第 11 行中的
onSurfaceChanged(),每当屏幕改变尺寸或在启动时创建时被调用。在这里,它也被用来设置观看截锥,这是空间的体积,定义了你实际可以看到的东西。如果你的任何场景元素位于截锥之外,它们被认为是不可见的,因此被剪切或剔除,以防止对它们进行进一步的操作。 - 仅允许您指定 OpenGL 窗口的实际尺寸和位置。这通常是主屏幕的大小,位置为 0。
- 在第 13 行,我们设置了矩阵模式。这样做的目的是设置当前的工作矩阵,当您进行任何通用矩阵管理调用时,将对其进行操作。在这种情况下,我们切换到
GL_PROJECTION矩阵,也就是将 3D 场景投影到你的 2D 屏幕的矩阵。glLoadIdentity()将矩阵重置为初始值,清除之前的所有设置。 - 现在,您可以使用纵横比和六个裁剪平面来设置实际的截锥:近/远、左/右和顶/底。
- 在清单 1–2 的最后一个方法中,一些初始化是在表面创建线 15 上完成的。第 16 行确保任何抖动被关闭,因为它默认为开。OpenGL 中的抖动使得有限调色板的屏幕看起来更好,但当然是以牺牲性能为代价的。
- 第 17 行中的
glHint()用于通过接受某些折衷来推动 OpenGL ES 做它认为最好的事情:通常是速度与质量。其他可提示的设置包括雾和各种平滑选项。 - 我们可以设置的许多状态中的另一个是背景被清除时呈现的颜色。在这种情况下,如果背景是半透明的,则为黑色;如果背景不是半透明的,则为白色(所有颜色的最大值为 1)。继续,稍后更改这些,看看会发生什么。
- 最后,这个清单的结尾设置了一些其他方便的模式。第 19 行说剔除那些背对我们的面(三角形)。第 20 行告诉它使用平滑阴影,使颜色在表面上混合。唯一的另一个值是
GL_FLAT,当它被激活时,将以最后绘制的顶点的颜色显示面。第 21 行启用深度测试,也称为 z 缓冲,稍后介绍。
最后,需要修改活动文件,使其看起来像清单 1–3 中的。
清单 1–3。 活动文件
package book.BouncySquare;
`import android.app.Activity;
import android.opengl.GLSurfaceView;
import android.os.Bundle;
import android.view.WindowManager;
import book.BouncySquare.*;
public class BouncySquareActivity extends Activity { /** Called when the activity is first created. */ @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); GLSurfaceView view = new GLSurfaceView(this); view.setRenderer(new SquareRenderer(true)); setContentView(view); } }`
我们的活动文件与默认文件相比修改很少。这里,GLSurfaceView实际上被分配并绑定到我们的自定义渲染器SquareRenderer。
现在编译并运行。您应该会看到类似于 Figure 1–1 的东西。
图 1–1。 一个有弹性的广场。如果这是你看到的,给自己一个击掌。
作为工程师,我们都喜欢摆弄和调整我们的作品,看看会发生什么。因此,让我们通过将顶点数组中的第一个数字替换为-2.0 而不是-1.0 来改变欢乐弹跳方块的形状。并用 0 替换颜色数组中的第一个值maxcolor。这将使左下角的顶点突出相当的方式,应该把它变成绿色。编译,敬畏地退后。你应该有类似图 1-2 的东西。
图 1–2。 扭捏过后
不要担心第一个练习的简单性;在某个时候,你会造出比跳动的彩虹色果冻方块更奇特的东西。主要项目是基于《遥远的太阳 3》中使用的一些代码构建一个简单的太阳系模拟器。但是现在,是时候进入无聊的话题了:计算机图形学从何而来,又将走向何方。
注意:Android 模拟器是出了名的漏洞百出,速度也是出了名的慢。强烈建议您在真实的硬件上完成所有的 OpenGL 工作,尤其是当练习变得稍微复杂一些的时候。你会省去很多悲伤。
一部不稳定的计算机图形学史
说 3D 在今天风靡一时是一种保守的说法。虽然“3D”图像的形式可以追溯到一个多世纪以前,但它似乎终于成熟了。首先让我们看看什么是 3D,什么不是。
好莱坞的 3D
1982 年,迪斯尼发行了第一部广泛使用电脑图形描绘电子游戏中的生活的电影。尽管这部电影在评论界和财务上都是失败的,但它最终还是加入了与《??》和《洛基恐怖电影秀》齐名的最受欢迎的行列。好莱坞已经把苹果咬了一口,没有回头路了。
追溯到 19 世纪,我们今天所说的“3D”更通常被称为立体视觉。流行的维多利亚时代的立体幻灯在当时的许多客厅里都能找到。将这种技术视为早期的 Viewmaster。用户将把立体视觉仪举到他们的面前,将立体照片放入远端,并看到一些远处土地的景色,但是是立体的,而不是平面的 2D 图片。每只眼睛只能看到卡片的一半,上面有两张几乎一模一样的照片,相距只有几英寸。
立体视觉给了我们视野深度分量的概念。我们的两只眼睛向大脑传递两个略有不同的图像,然后大脑以一种我们称为深度感知的方式解释它们。一张图片不会有这种效果。最终这种情况转移到了电影中,早在 1903 年就有过短暂而不成功的调情(据说短暂的L ' arrivé渡厄火车曾让观众从电影院跑出来,以避开明显开往他们的方向的火车*)并在 20 世纪 50 年代初复兴,其中 Bwana Devil 可能是最著名的。*
*3D 电影的原始形式通常使用“立体”技术,要求观众戴上廉价的塑料眼镜,一只眼睛戴上红色滤光片,另一只眼睛戴上蓝色滤光片。偏振系统在 20 世纪 50 年代早期出现,并允许彩色电影以立体声观看,它们仍然与今天非常相似。由于担心电视会扼杀电影业,好莱坞需要一些在电视上不可能实现的噱头来继续卖票,但由于所需的摄像机和放映机都太不切实际和昂贵,这种形式不再受欢迎,电影业挣扎着勉强度日。
随着 20 世纪 90 年代数字投影系统的出现,以及《玩具总动员》等完全渲染的电影,立体电影以及最终的电视终于变得既实用又实惠,足以超越噱头阶段。特别是,全长 3D 动画功能(玩具总动员是第一个)使它成为一个没有大脑转换成立体。所有人需要做的只是简单地从一个稍微不同的角度重新放映整部电影。这是立体和三维计算机图形混合的地方。
计算机图形学的曙光
关于计算机图形和一般计算机的历史的一个迷人的事情是,这项技术仍然是如此的新,以至于许多巨人仍然大步走在我们中间。很难追查到底是谁发明了马鞭,但如果你想直接了解 20 世纪 60 年代阿波罗登月舱计算机是如何编程的,我知道该找谁。
计算机图形(通常称为 CG)有三种总体风格:用户界面的 2D,飞行或其他形式的模拟以及游戏的实时 3D,以及非实时使用的质量胜过速度的 3D 渲染。
用
1961 年,一位名叫伊凡·苏泽兰的麻省理工学院工程系学生为他的博士论文创建了一个名为画板的系统,使用了矢量示波器、一支简陋的光笔和一台定制的林肯 TX-2 计算机(TX-2 小组的一个分支将成为 DEC)。Sketchpad 革命性的图形用户界面展示了许多现代 UI 设计的核心原则,更不用说面向对象架构的巨大帮助了。
**注:**关于画板运行的视频,去 YouTube 搜索画板或者伊凡·苏泽兰。
萨瑟兰的一个同学史蒂夫·拉塞尔发明了也许是有史以来最大的时间接收器之一,电脑游戏。Russell 在 1962 年创造了传奇游戏 Spacewar,该游戏在 PDP-1 上运行,如图 1–3 所示。
**图 1–3。**1962 年的太空战游戏在加州山景城的计算机历史博物馆复活,在一个老式的 PDP-1 上。照片由 Joi Itoh 拍摄,根据知识共享署名 2.0 通用许可([creativecommons.org/licenses/by/2.0/deed.en](http://creativecommons.org/licenses/by/2.0/deed.en))进行许可。
到 1965 年,IBM 将发布被认为是第一个广泛使用的商业图形终端,2250。与低成本的 IBM-1130 计算机或 IBM S/340 配对,该终端主要用于科学界。
也许最早在电视上使用计算机图形的例子之一是 1965 年 12 月 CBS 新闻报道双子座 6 号和双子座 7 号载人航天任务时使用的 a 2250(IBM 建造了双子座的机载计算机系统)。这个终端被用来在电视直播中演示从发射到会合的几个阶段。在 1965 年,它的价格约为 10 万美元,相当于一栋漂亮的房子。参见图 1–4。
**图 1–4。**1965 年的 IBM-2250 终端。由美国宇航局提供。
犹他大学
萨瑟兰于 1968 年被犹他大学招入计算机科学项目,他自然专注于图形学。在接下来的几年里,许多接受训练的计算机图形梦想家将会通过大学的实验室。
例如,艾德·卡姆尔热爱经典动画,但对自己不会画画感到沮丧——这是当时艺术家的必备条件。意识到计算机可能是制作电影的途径,Catmull 制作了第一个计算机动画,是他的手张开和合拢的动画。这个片段会出现在 1976 年的电影《未来世界》中。
在此期间,他开创了两项主要的计算机图形学创新:纹理映射和双三次曲面。前者可以通过使用纹理图像来增加简单形状的复杂性,而不是使用离散的点和表面来创建纹理和粗糙度,如图图 1–5 所示。后者用于生成算法化的曲面,比传统的多边形网格要高效得多。
图 1–5。 有纹理和无纹理的土星
卡特莫尔最终找到了去卢卡斯影业和皮克斯的路,并最终成为迪士尼动画工作室的总裁,在那里他终于可以制作他想看的电影。不错的演出。
行业中的许多其他顶级品牌也同样会通过犹他大学的大门,并受到萨瑟兰的影响:
- 约翰·沃诺克,他在开发一种称为 PostScript 和可移植文档格式(PDF)的独立于设备的显示和打印图形的方法中起了重要作用,是 Adobe 的创始人之一。
- 吉姆·克拉克是 Silicon Graphics 的创始人,该公司为好莱坞提供了当时最好的图形工作站,并创建了现在被称为 OpenGL 的 3D 框架。在 SGI 之后,他共同创立了网景通信公司,这将带领我们进入万维网的领域。
- 吉姆·布林,凹凸贴图和环境贴图的发明者,凹凸贴图是给物体添加真实 3D 纹理的有效方法,环境贴图用于创建真正闪亮的东西。也许他最出名的是为美国宇航局的旅行者项目创作革命性的动画,描绘了外行星的飞越,如图 1-6 所示(与使用现代设备的图 1-7 相比)。对于布林,萨瑟兰会说,“大约有十几个伟大的计算机图形人,吉姆·布林是其中之一。”Blinn 后来领导创建了微软的 OpenGL 的竞争对手,即 Direct3D。
图 1–6。 吉姆·布林描绘了 1981 年 8 月旅行者 2 号与土星的相遇。注意穿过环面时由冰粒形成的条纹。由美国宇航局提供。
图 1–7。 与比较图 1–6,使用一些当时最好的图形计算机和软件,在 500 美元的 iPad 上运行从遥远的太阳 3 看土星的类似视图。
好莱坞的成年
由于好莱坞和功能越来越强大同时成本越来越低的机器,计算机图形学在 20 世纪 80 年代开始真正发挥作用。例如,1985 年推出的广受欢迎的 Commodore Amiga 价格不到 2,000 美元,它为消费者市场带来了先进的多任务操作系统和彩色图形,而这在以前是价格超过 10 万美元的工作站的领域。参见图 1–8。
图 1–8。 阿米加 1000,1985 年左右。照片由 Kaivv 提供,根据知识共享署名 2.0 通用许可([creativecommons.org/licenses/by/2.0/deed.en](http://creativecommons.org/licenses/by/2.0/deed.en))进行许可。
相比之下,不到 18 个月前发布的黑白原版 Mac 电脑的价格也差不多。它配备了非常原始的操作系统、平面文件系统和 1 位显示器,是各种阵营之间爆发的“宗教战争”的沃土,这场战争是关于谁的机器更好(战争也包括 Atari ST)。
**注意:**原始 Amiga 上的一种特殊图形模式可以将 4,096 种颜色压缩到一个系统中,通常最大值为 32 种颜色。它被称为保持和修改(HAM 模式),最初由设计师 Jay Miner 出于实验原因包含在一个主要芯片上。虽然他想去除公认的产生大量色彩失真图像的杂牌,但结果会在芯片上留下一个大的空白点。考虑到未使用的芯片景观是任何一个自尊的工程师都无法容忍的,他把它留了下来,让 Miner 非常惊讶的是,人们开始使用它。
堪萨斯州的一家名为 NewTek 的公司率先使用 Amigas 来渲染高质量的 3D 图形,并将其与名为视频烤面包机的特殊硬件相结合。结合一个叫做 Lightwave 3D 的复杂的 3D 渲染软件包,NewTek 为任何有几千美元花费的人打开了廉价的网络质量图形的领域。这一发展为精心制作的科幻节目打开了大门,如巴比伦 5 或海上任务考虑到他们广泛的特效需求,在经济上是可行的。
在 20 世纪 80 年代,更多的技术和创新在 CG 领域得到了普遍应用:
- 洛伦·卡彭特开发了一种技术,利用一种叫做分形的东西,通过算法生成高度精细的景观。卡彭特受雇于卢卡斯影业,为一家名为皮克斯的新公司制作渲染包。结果是 REYES,它代表着渲染你所见过的一切。
- 特纳·惠特德开发了一种叫做光线追踪的技术,可以产生高度逼真的场景(以显著的 CPU 成本),特别是当它们包括具有各种反射和折射属性的对象时。玻璃物品是各种早期光线追踪工作中的常见对象,如图图 1–9 所示。
- Frank Crow 开发了计算机图形学中第一个实用的反走样方法。混叠是由于显示器分辨率相对较低而产生锯齿状边缘(锯齿)的现象。Crow 的方法可以平滑从线条到文本的一切,使其看起来更加自然和令人愉悦。注意卢卡斯影业的早期游戏之一叫做《分形上的营救》。这些坏家伙被命名为美洲豹。
- 《星际迷航 II:可汗之怒》带来了第一个完全由计算机生成的序列,用来说明一个叫做创世机器的设备如何在一个没有生命的星球上产生生命。那个模拟被称为“不会死的效果”,因为它在火焰和粒子动画以及分形景观方面的开创性技术。
图 1–9。 像这样复杂的图像是开源 POV-Ray 等程序的爱好者所能接受的。照片由 Gilles Tran 拍摄,2006 年。
20 世纪 90 年代带来了终结者 2:审判日中的 ??“液态金属”终结者、玩具总动员的第一部完全由计算机生成的全长故事片、侏罗纪公园中的可信动画恐龙,以及詹姆斯·卡梅隆的泰坦尼克号,所有这些都有助于巩固 CG 作为好莱坞导演军火库中的常用工具。
到这个十年结束时,很难找到任何电影在实际效果或后期制作中没有计算机图形来帮助清理各种场景。新技术仍在以更加壮观的方式被开发和应用,就像迪斯尼令人愉快的 Up!或者詹姆斯·卡梅隆美丽的头像*。*
现在,再一次,拿出你的智能设备,意识到这是一个多么小的技术奇迹。随意用安静的、尊重的语气说“哇”。
工具包
如果没有软件,前面提到的所有 3D 魔法都不可能实现。很多 CG 软件程序是高度专业化的,还有一些是比较通用的,比如本书重点介绍的 OpenGL ES。因此,下面是许多可用工具包中的几个。
OpenGL
开放图形库(OpenGL)来自高端图形工作站和大型机制造商硅图形(SGI)的开拓性努力。它自己的专有图形框架 IRIS-GL 已经发展成为业界事实上的标准。随着竞争的加剧,为了留住客户,SGI 选择将 IRIS-GL 转变为一个开放的框架,以加强其作为行业领导者的声誉。IRIS-GL 剥离了与图形无关的功能和硬件相关的特性,更名为 OpenGL,并于 1992 年初发布。在撰写本文时,版本 4.1 是最新的。
随着小型手持设备变得越来越普遍,嵌入式系统 OpenGL(OpenGL ES)被开发出来,它是桌面版本的精简版本。它删除了许多冗余的 API 调用,并简化了其他元素,使其能够在市场上的低功耗 CPU 上高效运行。因此,它已经在许多平台上被广泛采用,如 Android、iOS、惠普的 WebOS、任天堂 3DS 和黑莓(OS 5.0 及更新版本)。
OpenGL ES 主要有两种风格,1。 x 和 2。 x 。许多设备都支持这两者。版本 1。 x 是更高级的变体,基于最初的 OpenGL 规范。版本 2。 x (是的,我知道这令人困惑)的目标是更专业的渲染杂务,可以由可编程图形硬件处理。
Direct3D
Direct3D (D3D)是微软对 OpenGL 的回应,主要面向游戏开发者。1995 年,微软收购了一家名为 RenderMorphics 的小公司,该公司专门为编写游戏创建一个名为 RealityLab 的 3D 框架。RealityLab 变成了 Direct3D,并于 1996 年夏天首次发布。尽管它是基于 Windows 系统的专利,但它在微软的所有平台上都有庞大的用户群:Windows、Windows 7 Mobile,甚至 Xbox。OpenGL 和 Direct3D 阵营之间一直在争论哪个更强大、更灵活、更易于使用。其他因素包括硬件制造商更新其驱动程序以支持新功能的速度、易于理解性(Direct3D 使用微软的 COM 接口,这对新手来说可能非常混乱)、稳定性和行业支持。
其他人
虽然 OpenGL 和 Direct3D 在采用和功能方面仍然处于领先地位,但图形领域充斥着许多其他框架,其中许多框架在当今的设备上都受到支持。
在计算机图形世界中,图形库有两种非常广泛的风格:以 OpenGL 和 Direct3D 为代表的低级渲染机制,以及通常在游戏引擎中发现的高级系统,这些系统专注于资源管理,并具有扩展到常见游戏元素(声音、网络、得分等)的特殊附加功能。后者通常建立在前者之上,用于 3D 部分。如果做得好的话,更高层次的系统甚至可能被足够抽象,使得同时与 GL 和 D3D 一起工作成为可能。
快速绘制 3D
高级通用库的一个例子是 QuickDraw 3D (QD3D)。作为苹果 2D QuickDraw 的 3D 兄弟,QD3D 有一种优雅的方式,以一种易于理解的分层方式生成和链接对象(一个场景图)。它同样有自己的文件格式来加载 3D 模型和标准查看器,并且是独立于平台的。QD3D 的高级部分将计算场景,并确定每个对象以及每个对象的每个部分如何在 2D 绘图表面上显示。在 QD3D 下面有一个非常薄的层,叫做 RAVE,它将处理这些位的特定于设备的呈现。
用户可以使用 RAVE 的标准版本,这将按照预期渲染场景。但是更有野心的用户可以编写自己的程序,以更艺术的方式显示场景。例如,一家公司生成 RAVE 输出,以便看起来像他们的对象是在洞穴的一侧手绘的。当你可以拿着这幅现代版的洞穴画并旋转它的时候,那真是太酷了。插件架构也使得 QD3D 高度可移植到其他机器。当潜在用户因为 QD3D 在 PC 上没有硬件解决方案而不愿使用 QD3D 时,RAVE 的一个版本问世了,它将通过实际使用其竞争对手作为其光栅化器来使用 Direct3D 可用的硬件加速。可悲的是,QD3D 在史蒂夫·乔布斯第二次到来时几乎立即被扼杀,他确定 OpenGL 应该是未来 MAC 的 3D 标准。这是一个奇怪的说法,因为 QD3D 不是另一个的竞争对手,而是一个使程序员的生活更容易的附加产品。在乔布斯拒绝让 QD3D 开源的请求后,Quesa 项目成立了,以尽可能多地重新创建原始库,在撰写本文时仍在支持原始库。毫无疑问,Quesa 使用 OpenGL 作为渲染引擎。
这里声明:我写 QD3D 的 RAVE/Direct3D 层只是为了在“gold master”(准备发货)几天后取消项目。呸。
食人魔
另一个场景图形系统是面向对象的渲染引擎(OGRE)。OGRE 于 2005 年首次发布,可以同时使用 OpenGL 和 Direct3D 作为底层光栅化解决方案,同时为用户提供许多商业产品中使用的稳定和免费的工具包。用户群体的规模令人印象深刻。在撰写本文时,快速浏览一下论坛就会发现,仅一般讨论部分就有超过 6500 个主题。
OpenSceneGraph
最近为 iOS 设备发布的 OpenSceneGraph 大致与 QuickDraw 3D 一样,提供了一种在更高层次上创建对象、将它们链接在一起、在 OpenGL 层之上执行场景管理任务和额外效果的方法。其他功能包括导入多种文件格式、文本支持、粒子效果(用于火花、火焰或云),以及在 3D 应用中显示视频内容的能力。强烈推荐了解 OpenGL,因为许多 OSG 函数仅仅是它们的 OpenGL 对应物的薄薄的包装。
三维统一
与 OGRE、QD3D 或 OpenSceneGraph 不同,Unity3D 是一个跨平台的成熟游戏引擎,可以在 Android 和 iOS 上运行。区别在于产品的范围。虽然前两者集中在围绕 OpenGL 创建一个更抽象的包装,但游戏引擎走得更远,提供了游戏通常需要的大多数(如果不是全部)其他支持功能,如声音、脚本、网络扩展、物理、用户界面和记分模块。此外,一个好的引擎可能有工具来帮助生成素材,并且是独立于平台的。
Unity3D 具备所有这些特性,因此对于许多较小的项目来说是多余的。此外,作为一种商业产品,其来源是不可获得的,并且它不是免费使用的,而是仅花费适中的数量(与过去可能要花费 100,000 美元或更多的其他产品相比)。
还有其他人
我们也不要忽略 A6,冒险游戏工作室,C4,水晶空间,VTK,Coin3D,SDL,QT,Delta3D,Glint3D,Esenthel,FlatRedBall,Horde3D,Irrlicht,Leadwerks3D,Lightfeather,Raydium,Panda3D(来自迪士尼工作室和 CMU),Torque 等等。虽然它们很强大,但是使用游戏引擎的一个缺点是,你的世界通常是在它们的环境中运行的。所以,如果你需要一种特定的微妙行为,而这种行为是不可获得的,那你可能就不走运了。
OpenGL 架构
现在,既然我们已经对一个简单的 OpenGL 程序进行了深入的分析,那么让我们来简单地看一下在图形管道中到底发生了什么。
术语管道通常用于说明一个紧密结合的事件序列是如何相互关联的,如图图 1–10 所示。在 OpenGL ES 的例子中,这个过程在一端接受一串数字,在另一端输出一些看起来很酷的东西,可能是土星的图像,也可能是核磁共振成像的结果。
**图 1–10。**OpenGL ES 1 的基本概述。 x 管道
-
第一步是获取描述一些几何图形的数据,以及如何处理照明、颜色、材质和纹理的信息,并将其发送到管道中。
-
接下来数据被移动和旋转,之后每个物体上的光照被计算和存储。场景——比如说一个太阳系模型——必须根据你设置的视点进行移动、旋转和缩放。视点采用了一个截头圆锥体的形式,一种矩形的圆锥体,它将场景限制在理想的可管理的水平。
-
Next the scene is clipped, meaning that only stuff that is likely to be visible is actually processed. All of the other stuff is culled out as early as possible and discarded. Much of the history of real-time graphics development has to do with object culling techniques, some of which are very complex.
让我们回到太阳系的例子。如果你在看地球,而月亮在你的视角后面,就没有必要处理月亮数据。剪裁级别就是这样做的,一端是对象级别,另一端是顶点级别。当然,如果您能在提交到管道之前自己预先挑选对象,那就更好了。也许最简单的方法就是简单地判断你身后是否有物体,让它完全可以被跳过。如果对象太远而看不见或者被其他对象完全遮挡,也可以进行剔除。
-
剩下的物体现在被投影到“视口”上,这是一种虚拟显示。
-
这里是光栅化发生的地方。光栅化将图像分割成实际上是单个像素的片段。
-
现在,碎片可以应用纹理和雾效果。例如,如果雾遮住了更远的碎片,同样可以进行额外的剔除。
-
最后一个阶段是将幸存的碎片写入帧缓冲区,但前提是它们满足一些最后的操作。这里是片段的 alpha 值应用于半透明的地方,还有深度测试以确保最近的片段绘制在更远的片段之前,以及用于渲染非矩形视口的模板测试。
完成后,你可能会看到类似于图 1–11b 所示茶壶的东西。
**注:**你对计算机图形学研究得越多,你就会越多地看到一个小茶壶出现在从书籍到电视和电影的例子中(辛普森一家,玩具总动员)。茶壶的传说,有时被称为犹他州茶壶(一切都可以追溯到犹他州),始于 1975 年一位名叫马丁·纽维尔的博士生。他需要一个具有挑战性的形状,但除此之外,这是他博士工作中的一个普通对象。他的妻子推荐了他们的白色茶壶,在这一点上,纽维尔费力地用手工将它数字化。当他将数据发布到公共领域时,它很快就成为了“Hello World!”图形编程。甚至苹果开发者网站上的一个早期 OpenGL ES 例子也有一个茶壶演示。最初的茶壶现在存放在加州山景城的计算机历史博物馆,离谷歌只有几个街区。参见图 1–11 的左侧。
图 1–11a,b. 左边是纽厄尔用过的真正的茶壶,目前陈列在加利福尼亚州山景城的计算机历史博物馆。照片由史蒂夫·贝克拍摄。右边是苹果开发者网站上的一个 OpenGL 应用示例。
总结
在这一章中,我们讲述了一点计算机图形学的历史,一个简单的示例程序,以及最重要的,犹他茶壶。接下来是对 3D 图像背后的数学的深入的、无疑是过分详细的研究。*
二、所有那些数学爵士
如果没有至少一章关于 3D 转换背后的数学知识,任何一本关于 3D 编程的书都是不完整的。如果你对此毫不在乎,那就继续前进——这里没什么可看的。毕竟 OpenGL 不是会自动处理这些东西吗?当然可以。但是熟悉里面发生的事情是有帮助的,如果没有什么比理解 3D 语言更有帮助的话。
让我们先定义一些术语:
- 平移:从初始位置移动物体(见图 2–1,左)
- 旋转:围绕原点中心点旋转物体(见图 2–1,右图)
- 缩放:改变对象的大小
- 转换:以上全部
图 2–1。 平移(左)和旋转(右)
2D 变换
在不知道的情况下,你可能已经以简单翻译的形式使用了 2D 变换。如果您创建了一个 UIImageView 对象,并希望根据用户触摸屏幕的位置来移动它,您可以抓取它的框架并更新原点的 x 和 y 值。
翻译
你有两种方法来想象这个过程。第一是物体本身相对于一个共同的原点运动。这叫做几何变换。第二种方法是移动世界原点,而对象保持静止。这叫做*坐标变换。*在 OpenGL ES 中,两种描述通常一起使用。
平移操作可以这样表达:
x′=x+Tx**y′=y+Ty
原来的坐标是 x 和 y ,而平移后的 T ,会把这些点移动到一个新的位置。很简单。如你所知,翻译自然会很快。
**注:**小写字母如 xyz 为坐标,大写字母如 XYZ 为参考轴。
旋转
现在让我们来看看旋转。在这种情况下,我们将首先围绕世界原点旋转,以保持简单(见图 2–2)。
图 2–2。 围绕共同原点旋转
当我们必须重温高中的三角函数时,事情自然会变得更加复杂。所以,现在的任务是找出任意旋转后正方形的角在哪里。目光呆滞地注视着大地。
**注:**按照惯例,逆时针旋转被认为是正的,而顺时针旋转是负的。
所以,把 x 和 y 作为我们正方形的一个顶点的坐标,这个正方形就被归一化了。不旋转的话,任何顶点都会自然地直接映射到我们的坐标系中,即 x 和 y 。很公平。现在我们要将正方形旋转一个角度 a 。虽然它的角在正方形自己的局部坐标系中仍然在“相同”的位置,但在我们的坐标系中它们是不同的,如果我们想要实际绘制该对象,我们需要知道新的坐标 x 和 y 。
现在我们可以直接跳到可靠的旋转方程,因为最终这就是代码要表达的内容:
x′=xcos(a)–ysin(a)y′=xsin(a)+ycos(a)
做一个真正快速的检查,你可以看到如果 a 是 0 度(无旋转), x 和 y 减少到原来的 x 和 y 坐标。如果旋转 90 度,那么 sin(a)=1 , cos(a)=0 ,所以x′=-y,并且y′= x。果然不出所料。
数学家总是喜欢用尽可能简洁的形式来表达事物。因此,2D 旋转可以用矩阵符号来“简化”:
**注:**在星际迷航中使用最多的一个词是母体。这里是模式矩阵,那里是缓冲矩阵,“第一,我头疼,需要小睡一会儿。”(不要让我开始使用 24 中的协议。)每一个自重的星际迷航喝酒游戏(好像任何喝酒游戏都会自重)在选词上都要用 matrix 。除非你的一个朋友有酗酒问题,在这种情况下,用 matrix 代替 rabid badger。我几乎可以肯定,在《星际迷航》的万神殿里,从来没有提到过獾,不管是不是狂犬病。
Ra是我们的 2D 旋转矩阵的简写。虽然矩阵可能看起来很忙,但它们实际上非常简单,易于编码,因为它们遵循精确的模式。在这种情况下, x 和 y 可以表示为一个微小的矩阵:
翻译也可以以矩阵形式编码。因为平移仅仅是移动点,所以平移后的值 x 和 y 来自于增加点的移动量。如果你想在同一个物体上做旋转和平移会怎么样?翻译矩阵只需要一点点非显而易见的想法。这里显示的第一个和第二个,哪个是正确的?
答案是显然第二个,也可能没那么明显。第一个结尾如下,没有太大意义:
x′=x+yTx和y′=x+yTy
因此,为了创建一个用于翻译的矩阵,我们需要我们的 2D 点的第三个组件,通常写成 (x,y,1) ,如第二个表达式中的情况。暂时忽略 1 的来源,请注意,它可以很容易地简化为:
x′=x+Tx和y′=y+Ty
1 的值不要与第三维的 z 相混淆;更确切地说,它是一种用来表达直线方程的方法(在本例中为 2D 空间),与我们在小学学过的斜率/截距略有不同。这种形式的一组坐标被称为齐次坐标,在这种情况下,它有助于创建一个 3×3 矩阵,该矩阵现在可以与其他 3×3 矩阵合并或连接。我们为什么要这么做?如果我们想一起做旋转和平移呢?每个点可以使用两个独立的矩阵,这样就很好了。相反,我们可以使用矩阵乘法(也称为串联)预先计算出几个矩阵中的一个矩阵,这反过来表示了各个转换的累积效果。这不仅可以节省一些空间,而且可以大大提高性能。
在 Java2D 中,您会在某个时候偶然发现 Java . awt . geom . affinite transform。所有可能的 2D 仿射变换都可以表示为x′=ax+cy+e和y=bx+dy+f。这构成了一个非常好的矩阵,一个可爱的矩阵:
下面是一段简单的代码,展示了如何使用 AffineTransform 进行转换和缩放。如您所见,这非常简单。
public void paint(Graphics g) { AffineTransform transform = new AffineTransform(); transform.translate(5,5); transform.scale(2,2); Graphics2D g2d = (Graphics2D)g; g2d.setTransform(transform); }
缩放
对于其他两种变换,让我们来看看对象的缩放或简单的大小调整:
x′=xSx??y=ySy
在矩阵形式中,这变成如下:
与其他两种变换一样,缩放的顺序在应用到几何体时非常重要。比方说,你想旋转和移动你的对象。根据你是先翻译还是后翻译,结果会明显不同。更常见的顺序是先旋转物体,然后平移,如图 2–3 左侧所示。但是如果你颠倒顺序,你会得到类似于图 2–3 中右图的东西。在这两种情况下,旋转都是围绕原点进行的。如果你想让物体围绕它自己的原点旋转,那么第一个例子就是为你准备的。如果你想让它和其他东西一起旋转,第二个就可以了。(一个典型的情况可能是让你将物体平移到世界原点,旋转它,然后再平移回来。)
图 2–3。 绕原点旋转后平移(左)vs 平移后旋转(右)
那么,这和 3D 有什么关系呢?简单!如果不是全部的话,大部分原理可以应用于 3D 变换,并且用一个更少的维度更清楚地说明。
3D 转换
当你将所学的一切转移到 3D 空间(也称为3-空间)时,你会发现,就像在 2D 一样,3D 变换同样可以表示为一个矩阵,因此可以与其他矩阵连接。 Z 的额外维度现在是进出屏幕的场景深度。OpenGL ES 有 +Z 出来了
和-Z进去。其他系统可能会有相反的情况,甚至有 Z 是垂直的, Y 现在假设深度。我将继续使用 OpenGL 惯例,如图图 2–4 所示。
注意:从一个参照系到另一个参照系来回移动是除了试图找出福克斯为什么取消萤火虫之外最快的疯狂之路。1973 年出版的经典著作《交互式计算机图形的 ?? 原理》中有 Z 上升和+ Y 进入屏幕。在他的书中,微软飞行模拟器的创造者 Bruce Artwick 展示了观察平面中的 X 和 Y,但是+Z 将进入屏幕。然而,另一本书已经(得到这个!) Z 向上, Y 向右前进,X向观者走来。应该有一部法律…
**图 2–4。**z 轴朝向观察者。
首先我们来看看 3D 变换。正如 2D 的变化仅仅是在原来的位置上增加所需的增量一样,同样的事情也适用于 3D。描述这一点的矩阵如下所示:
当然,这会产生以下结果:
x′=x+Tx,y′=y+Ty和z′=z+Tz
请注意添加的额外 1;这和 2D 的情况一样,所以我们的点位置现在是均匀的形式。
那么,让我们来看看旋转。人们可以有把握地假设,如果我们绕着 z 轴旋转(图 2–5),方程将直接映射到 2D 版本。使用矩阵来表达这一点,下面是我们得到的结果(注意新的符号,其中 R(z,a) 用于明确哪个轴被寻址)。请注意,z 保持不变,因为它要乘以 1:
图 2–5。绕 z 轴旋转
这看起来几乎和 2D 的一模一样,只不过多了一个 z*=z。但是现在我们也可以绕着 x 或者 y 旋转。对于 x ,我们得到如下:*
*
当然,对于 y 我们得到如下:
但是一个在另一个之上的多个转换呢?现在我们在谈论丑陋。幸运的是,您不必太担心这一点,因为您可以让 OpenGL 来完成繁重的工作。这就是它的用途。
假设我们想先绕着 y- 轴旋转,接着是 x ,然后是 z 。得到的矩阵可能如下所示(使用 a 作为围绕 x 的旋转, b 代表 y ,而 c 代表 z ):
简单,嗯?难怪 3D 引擎作者的口头禅是优化,优化,优化。事实上,在最初的 Amiga 版本《遥远的太阳》中,我的一些内部循环需要在 68K 汇编中。请注意,这甚至不包括缩放或平移。
现在让我们来看看写这本书的原因:所有这些都可以通过下面三行代码来完成:
glRotatef(b,0.0,1.0,0.0); glRotatef(a,1.0,0.0,0.0); glRotatef(c,0.0,0.0,1.0);
**注意:**OpenGL ES 1.1 中有很多功能是 2.0 中没有的。后者面向底层操作,为了灵活性和控制牺牲了一些易于使用的工具。转换函数已经消失,留给开发者去计算他们自己的矩阵。幸运的是,有许多不同的库来模拟这些操作并简化转换任务。
在处理 OpenGL 时,这个特殊的矩阵被称为 modelview,因为它适用于你绘制的任何东西,无论是模型还是灯光。稍后我们将处理另外两种类型:投影矩阵和 ?? 纹理矩阵。
需要重申的是,当试图让这个东西工作时,旋转的实际顺序是绝对重要的。例如,一个常见的任务是用完整的六个自由度(三个平移分量和三个旋转分量)对飞机或航天器进行建模。转动部分通常称为滚转、俯仰、偏航 (RPY)。滚转是绕着 z 轴旋转,俯仰是绕着 x- 轴旋转(换句话说,瞄准机头向上或向下),偏航当然是绕着 y 轴旋转,左右移动机头。图 2–6 显示了 20 世纪 60 年代阿波罗飞船登月时的工作情况。正确的顺序是偏航、俯仰和滚动,或者绕着 y、x 旋转,最后绕着 z 旋转。(这需要 12 次乘法和 6 次加法,而对三个旋转矩阵进行预乘可以将其减少到 9 次乘法和 6 次加法。)变换将是递增的,包括自上次更新以来 RPY 角度的变化,而不是从开始的全部变化。在过去,舍入误差可能会使矩阵复合变形,导致非常酷但出乎意料的结果(但仍然很酷)。
图 2–6。阿波罗的参照系、操纵杆和人工地平线的图示
想象一下:将物体投射到屏幕上
咻,即使做了这一切,我们还没有完全完成。一旦你完成了对象的所有旋转、缩放和平移,你仍然需要将它们投影到你的屏幕上。自从他在洞穴墙壁上画出第一只猛犸象草图以来,将 3D 场景转换到 2D 表面就一直困扰着人类。但是,与转换相反,它实际上很容易掌握。
这里主要有两种投射在起作用:透视和平行。透视投影是我们在 2D 视网膜上看到三维世界的方式。透视图由消失点和透视缩小组成。消失点是所有平行线在远处汇聚的地方,提供了深度感(想象铁轨朝着地平线)。结果是越靠近的东西看起来越大,反之亦然,如图图 2–7 所示。平行变体,也称为正交投影,通过有效地将每个顶点的 z 分量设置为 0(我们观察平面的位置),简单地消除了距离的影响,如图图 2–8 所示。
图 2–7。透视投影
图 2–8。平行投影
在透视投影中,距离分量 z 用于缩放最终将成为屏幕 x 和屏幕 y 的值。所以, z,或者离观看者的距离越大,作品在视觉上就越小。我们需要的是视窗 (OpenGL 版本的窗口或显示屏)的尺寸及其中心点,通常是 XY 平面的原点。
这最后一个阶段包括设置视图视锥。平截头体建立了六个裁剪平面(顶部、底部、左侧、右侧、近侧和远侧),以精确确定用户应该看到什么,以及如何将其投影到他们的视口,这是 OpenGL 版本的窗口或屏幕。这就像是你的 OpenGL 虚拟世界中的一个镜头。通过更改这些值,您可以放大或缩小并剪切很远的内容或根本不剪切,如图图 2–9 和图 2–10 所示。这些值定义了透视矩阵。
图 2–9。 窄边框的平截头体给你一个高倍镜头。
图 2–10。更宽的边界像一个广角镜头。
随着这些边界的建立,最后一个转换是到视窗,你的屏幕的 OpenGL 版本。这是 OpenGL 接收屏幕尺寸、显示区域尺寸和原点(可能是屏幕的左下角)的地方。在手机或平板电脑等小型设备上,你可能会填满整个屏幕,因此会使用屏幕的整个宽度。但是如果您想将图像放在主显示的子窗口中,您可以简单地将较小的值传递给 viewport。相似三角形法则在这里发挥作用。
在图 2–11 中,我们想要找出投影的x′是什么,给定模型上任意顶点的 x 。考虑两个三角形,一个由角 CBA 形成,另一个由 COA' 形成(其中 0 表示原点)。从 C(眼睛所在的位置)到 0 的距离为 d 。从 C 到 B 的距离是 d+z 。所以,就拿这些的比率来说,如下:
产生以下结果:
图 2–11。使用相似三角形法则将一个顶点映射到视口
图 2–12 显示了最终的翻译。那些可以加到x′和y′:
图 2–12。将 x 和 y 投影到设备屏幕上。您可以将此想象为将您的设备平移到对象的坐标(左),或将对象平移到设备的坐标(右)。
当像素尘埃落定,我们有一个很好的矩阵形式:
通常需要一些最终的缩放,例如,如果视口被规格化。但这取决于你。
现在穿着高跟鞋倒着做
据说这是金格尔·罗杰斯在谈到她与伟大的弗雷德·阿斯泰尔共舞的感受时所说的一句话。他的回答是,虽然他很棒,但她必须做他做的每件事和倒着穿高跟鞋做。(罗杰斯显然从未说过这句话,因为它的用法可以追溯到连环漫画《弗兰克和欧内斯特》中的一句插科打诨的台词。)
那么,这和转换有什么关系呢?假设您想通过触摸屏幕来判断是否有人拿走了您的某个对象。你怎么知道你的哪个对象被选中了?您必须能够进行逆变换,将屏幕坐标“解映射”回 3D 空间中可识别的位置。但是由于 z 值在这个过程中会丢失,所以有必要在对象列表中进行搜索,以找出哪个是最有可能的目标。不改变某些东西需要你向后做所有的事情(如果你喜欢这种事情,还可以穿高跟鞋)。这是通过以下方式完成的:
- 将模型视图矩阵与投影矩阵相乘。
- 反转结果。
- 将触摸点的屏幕坐标转换为视窗的参考框架。
- 取其结果并乘以步骤 2 中的逆矩阵。
不要担心,这将在本书的后面有更详细的介绍。
四元数呢?
四元数是超复数,可以将 RPY 信息存储在四维向量类型的东西中。它们在性能和空间上都非常有效,通常用于在飞行模拟中模拟飞机或航天器的瞬时航向。它们是一种奇特的生物,具有很好的特性,但留待以后使用,因为 OpenGL 不直接支持它们。
总结
在本章中,您学习了 3D 数学的基础知识。本章首先介绍了 2D 变换(旋转、平移和缩放),然后介绍了 3D,还介绍了投影。虽然你可能不需要自己编写任何转换代码,但是熟悉这一章是理解大部分 OpenGL 术语的关键。我头疼。*
三、从 2D 到 3D:增加一个额外的维度
在前两章,我们讨论了酷的东西和数学的东西(可能很酷也可能很无聊)。在第三章中,我们将把弹跳立方体的例子从 2D 版本转移到 3D 版本(4D 超立方体不在本文讨论范围之内)。在这个过程中,更多的关于投影、旋转等的 3D 理论将被加入进来。但是,请注意,OpenGL 不仅仅用于 3D,还可以很容易地用于将 2D 控件放在 3D 可视化的前面。
首先,多一点理论
记住 OpenGL ES 对象是 3D 空间中的点的集合;也就是说,它们的位置由三个值定义。这些值连接在一起形成面,面是看起来非常像三角形的平面。这些三角形然后被连接在一起形成物体或物体的碎片。
为了得到一串形成顶点的数字,形成颜色的其他数字,以及在屏幕上组合顶点和颜色的其他数字,有必要告诉系统它的图形环境。完成 3D 电路需要视点位置、接收图像的窗口(或视口)、长宽比以及其他各种数字碎片。更具体地说,我将介绍 OpenGL 的坐标,它们如何与平截头体相关,如何从场景中裁剪或挑选对象,以及如何在设备的显示器上绘图。
注意:你可能想知道我们什么时候会谈到酷酷的行星。很快,小蚱蜢,很快。
OpenGL 坐标
如果你曾经在任何系统上绘制过任何类型的图形,你就会熟悉普通的 X-Y 坐标系统。x 始终是水平轴,右为正,而 Y 始终是垂直轴,下为正,将原点放在左上角。被称为屏幕坐标,它们很容易与数学坐标混淆,后者将原点放在左下角,对于 Y,up 为正。
现在跳到 OpenGL 3D 坐标,我们有一个稍微不同的系统,使用笛卡尔坐标,空间中表示位置的标准。通常,对于 2D 显示器上的屏幕坐标,原点在左上角,X 向右,Y 向下。但是,OpenGL 的原点在左下角,+Y 向上。但是现在我们增加了一个第三维度,表示为 Z。在这种情况下,+Z 指向你,如图图 3–1 所示。
图 3–1。 OpenGL ES 3D 笛卡尔坐标系(图片由 Jorge Stolfi 提供)
事实上,在 OpenGL 中我们有几种坐标系,或者说空间,每个空间都被转换到下一个空间:
- 对象空间,相对于你的每一个对象。
- 相机,或眼,空间,局部于你的视点。
- 投影,或剪辑,空间,这是显示最终图像的 2D 屏幕或视窗。
- 切线空间,用于更高级的效果,如凹凸贴图,这将在后面介绍。
- 标准化设备坐标(NDCs),表示从-1 到 1 的标准化 xyz 值*。也就是说,该值(或一组值)被规范化,以使其适合边长为 2 个单位的立方体。*
- 窗口,或屏幕,坐标,这是你的场景在实际屏幕上显示时的最终位置(无疑会赢得热烈的掌声)。
自然,前者可以用流水线形式表示,如图图 3–2 所示。
图 3–2。 顶点变换流水线
对象、视点和剪辑空间是你通常需要担心的三个问题。例如,用本地原点生成物体坐标,然后移动并旋转到眼空间。例如,如果你有一堆战斗游戏的飞机,每一架都有自己的本地来源。你应该能够通过移动,或者平移,将平面移动到你世界的任何地方,只移动原点,让其余的几何体跟着移动。在这一点上,对象的可见性是针对视见平截头体进行测试的,视见平截头体是定义虚拟相机实际可以看到什么的空间体积。如果它们位于平截头体之外,它们被认为是不可见的,因此被剪切或剔除,以便不对它们进行进一步的操作。你可能还记得《??》第一章,图形引擎设计的大部分工作都集中在引擎的裁剪部分,以便尽早转储尽可能多的对象,从而产生更快更高效的系统。
最后,在所有这些之后,OpenGL 面向屏幕的部分准备好转换,或者说投影,剩余的对象。这些对象是你的飞机、齐柏林飞艇、导弹、路上的卡车、海上的船只、投石机,以及任何你想塞进应用的东西。
注意: OpenGL 并没有真正定义任何东西为“世界空间”然而,眼睛坐标是下一个最好的东西,因为你可以定义与你的位置相关的一切。
眼睛坐标
OpenGL 中没有神奇的眼点对象。因此,不是移动眼点,而是移动与眼点相关的所有对象。是的,这很容易混淆,你会发现自己不断改变数值的符号,画有趣的图表,并以奇怪的方式伸出手,试图找出为什么你的投石机是颠倒的。在眼点相对坐标中,对象实际上是远离您,而不是远离对象。想象一下,你正在制作一个汽车飞驰而过的视频。在 OpenGL 下,汽车会静止不动;你和你周围的一切都会被它感动。这主要是通过glTranslate*()和glRotate*()调用来完成的,稍后您将会看到。正是在这一点上,上一章提到的 OpenGL 的模型视图矩阵发挥了作用。您可能还记得模型视图矩阵处理基本的 3D 转换(与投影矩阵相对,投影矩阵将 3D 视图投影到屏幕的 2D 空间,或者纹理矩阵,帮助将图像应用到您的对象)。你会经常提到它。
查看视锥和投影矩阵
在几何学中,平截头体是(典型的)棱锥或圆锥体的一部分,由两个平行平面切割而成。换句话说,想想顶部三分之一被砍掉的吉萨大金字塔(我不是在纵容对埃及古物的破坏)。在图形中,视见平截头体定义了我们的虚拟相机可以实际看到的世界部分,如图图 3–3 所示。
图 3–3。 视锥
与 OpenGL 中的许多东西不同,视见平截头体的定义非常简单,并且通过简单地定义空间中的一个体积,即视见金字塔,来紧密地遵循概念图形。只要不被任何更近的物体遮挡,任何整体或部分在平截头体内的物体最终都可能找到它们到屏幕的路径。
平截头体也用于指定你的视野(FOV),就像你的相机的广角镜头与长焦镜头。侧平面与中心轴相比形成的角度越大(即它们如何散开),FOV 就越大。一个更大的 FOV 将允许你的世界的更多部分可见,但也会导致更低的帧速率。
到目前为止,平移和旋转使用 ModelView 矩阵,使用调用 gl 很容易设置。glMatrixMode(GL_MODELVIEW);。但是现在在渲染管道的这个阶段,您将定义并使用投影矩阵。这很大程度上是通过第二章的“图片”一节中的视锥定义来完成的。而且这也是一个惊人的紧凑的手段,可以做很多操作。
将变换后的顶点转换成 2D 图像的最后步骤如下:
- 平截头体内的 3D 点被映射到归一化立方体,以将 XYZ 值转换为 NDC。NDC 代表归一化设备坐标,这是一个描述位于平截头体内的坐标空间的中间系统,与分辨率无关。这在将每个顶点和每个对象映射到设备屏幕时非常有用,无论它有多大或多少像素,无论它是手机、平板电脑还是屏幕尺寸完全不同的新产品。一旦有了这种形式,坐标就“移动”了,但仍然保持着它们之间的相对关系。当然,在 NDC,它们的值在-1 到 1 之间。请注意,Z 值在内部翻转。现在 Z 向你走来,而+Z 正在离开,但谢天谢地,那巨大的不愉快都被隐藏起来了。
- 然后这些新的 NDC 被映射到屏幕上,考虑到屏幕的纵横比和顶点到屏幕的“距离”,如由近裁剪平面指定的。结果是,越远的东西越小。大多数数学只是用来确定截锥内这个或那个的比例。
前面的步骤描述了透视投影,这是我们平常看待世界的方式。也就是说,越远的东西,看起来就越小。当那些固有的扭曲被移除时,我们得到了正投影。此时,无论物体有多远,它仍然显示相同的大小。当任何透视变形会破坏原始艺术品的意图时,正交渲染通常用于机械工程图中。
**注意:**你将经常需要直接处理你正在处理的矩阵。对gl.glMatrixMode()的调用用于指定当前矩阵,所有后续操作都将应用于当前矩阵,直到改变为止。忘记哪个矩阵是当前矩阵是一个容易犯的错误。
回到有趣的事情:超越蹦蹦跳跳的广场
现在我们可以回到在第一章中使用的例子。既然我们已经开始认真对待 3D,那么需要添加一些东西来处理 Z 维,包括立方体几何和颜色的更大数据集,将数据传递给 OpenGL 的方法,更复杂的平截头体定义,任何需要的面剔除技术,以及旋转而不仅仅是平移。
注: 翻译是指在你的世界中上下左右前后移动物体,而旋转是指绕任意轴旋转物体。并且两者都被认为是变换。
添加几何图形
从第一章的开始,您将记住清单 3–1 中定义的数据。首先是四个角的位置,顶点,顶点是如何连接在一起的,以及它们是如何着色的。
清单 3–1。 定义 2D 广场
`float vertices[] = { -1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f };
byte maxColor=(byte)255;
byte colors[] = { maxColor,maxColor, 0,maxColor, 0, maxColor, maxColor,maxColor, 0, 0, 0,maxColor, maxColor, 0, maxColor, maxColor };
byte indices[] = { 0, 3, 1, 0, 2, 3 };`
现在这可以扩展到包括 z 组件,从额外的顶点开始,如清单 3–2 的第 1 行所示。代码的其他细节将在列表后讨论。
清单 3–2。 定义 3D 立方体
`float vertices[] = { -1.0f, 1.0f, 1.0f, //1 1.0f, 1.0f, 1.0f, 1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f,
-1.0f, 1.0f,-1.0f, 1.0f, 1.0f,-1.0f, 1.0f, -1.0f,-1.0f, -1.0f, -1.0f,-1.0f };
byte maxColor=(byte)255;
byte colors[] = //2 { maxColor,maxColor, 0,maxColor, 0, maxColor,maxColor,maxColor, 0, 0, 0,maxColor, maxColor, 0,maxColor,maxColor,
maxColor, 0, 0,maxColor, 0, maxColor, 0,maxColor, 0, 0,maxColor,maxColor, 0, 0, 0,maxColor };
byte tfan1[] = { 1,0,3, 1,3,2, 1,2,6, 1,6,5, 1,5,4, 1,4,0
};
byte tfan2[] = { 7,4,5, 7,5,6, 7,6,2, 7,2,3, 7,3,0, 7,0,4 };`
第 1 行将顶点扩展到三维,而颜色数组,第 2ff 行,对颜色做同样的事情。
图 3–4 显示了顶点排序的方式。在正常情况下,您将永远不必以这种方式定义几何图形。您可能会从以一种标准 3D 数据格式存储的文件中加载对象,例如 3D Studio 或 Modeler 3D 所使用的格式。考虑到这样的文件可能有多复杂,不建议您自己编写,因为大多数主要格式的导入程序都是可用的。
图 3–4。 注意不同的轴:X 向右,Y 向上,Z 朝向观察者。
颜色数组的大小,如清单 3–2 的第 2 行所示,因为顶点的数量加倍而加倍;在其他方面,它们与第一个示例中的相同,只是背面的颜色有所不同。
现在需要一些新数据来告诉 OpenGL 顶点的使用顺序。对于正方形来说,手动排序或排序数据是显而易见的,这样四个顶点就可以代表两个三角形。立方体使这变得相当复杂。我们可以用单独的顶点数组来定义立方体的六个面,但是对于更复杂的对象来说,这种方法不太适用。而且它的效率比不得不通过图形硬件传输六组数据要低。从内存和速度的角度来看,将所有数据保存在一个数组中是最有效的。那么,我们如何告诉 OpenGL 数据的布局呢?在这种情况下,我们将使用如图图 3–5 所示的三角扇的绘图模式。
图 3–5。 三角形扇形与所有三角形有一个公共点。
有许多不同的方法可以将数据存储并呈现给 OpenGL ES。一种格式可能更快,但使用更多的内存,而另一种格式可能使用更少的内存,但会有一点额外的开销。如果您要从其中一个 3D 文件导入数据,很可能它已经针对其中一种方法进行了优化,但是如果您真的想要手动调整系统,您可能需要在某些时候将顶点重新打包为您的应用喜欢的格式。
除了三角扇,你会发现其他方式可以存储或表示数据,称为模式。
- 点和线只是说明:点和线。OpenGL ES 可以把你的顶点仅仅渲染成可定义大小的点,或者可以渲染点之间的线来显示线框版本。分别使用
GL10.GL_POINTS和GL10.GL_LINES。 - 线条、
GL10.GL_LINE_STRIP是 OpenGL 在一个镜头中绘制一系列线条的一种方式,而线条循环、GL10.GL_LINE_LOOP类似于线条,但总是将第一个和最后一个顶点连接在一起。 - 三角形,三角条,三角扇,圆出 OpenGL ES 图元列表:
GL10.GL_TRIANGLES,GL10。GL_TRIANGLE_STRIP和GL10.GL_TRIANGLE_FAN。OpenGL 本身可以处理额外的模式,如四边形(有四个顶点/边的面)、四边形带和多边形。
**注:**术语图元表示图形系统中数据的基本形状或形式。基本体的例子包括立方体、球体和圆锥体。该术语也可用于更简单的形状,如点、线,以及在 OpenGL ES 中的三角形和三角扇。
当使用这些低级对象时,你可能会想起在《??》第一章第一个例子中,有一个索引数组来告诉你哪些顶点与哪些三角形匹配。在定义三角形数组(在清单 3–2 中称为tfan1和tfan2)时,您使用了类似的方法,除了所有的索引集合都从同一个顶点开始。例如,数组tfan1中的前三个数字是 1、0 和 3。这意味着第一个三角形依次由顶点 1、0 和 3 组成。于是,回到数组 vertices ,顶点 1 位于 x=1.0f,y=1.0f,z=1.0f,顶点 0 是 x=-1.0f,y=1.0f,z=1.0f 的点,而我们的三角形的第三个角位于 x=-1.0,y=-1.0,z=1.0。好处是这使得创建数据集变得容易得多,因为实际的顺序现在无关紧要了,而坏处是它使用了更多的内存来存储额外的信息。
立方体可以分成两个不同的三角形扇形,这就是为什么有两个索引数组。第一个包含正面、正面和顶面,而第二个包含背面、底面和左面,如图图 3–6 所示。
图 3–6。 第一个三角形扇面的顶点 1 为公共顶点。
将它们缝合在一起
现在必须修改呈现代码来处理新数据。清单 3–3 展示了新constructor方法的其余部分,就在清单 3–2 中的数据定义下面。这将复制第一章示例的大部分内容,除了使用三角形扇形代替连接数组和两个对gl.glDrawArray()的调用。这是必需的,因为立方体分为两部分,必须分别绘制,一部分用于定义两个三角形扇的三个面或六个三角形。
清单 3–3。 其余的构造函数方法
` ByteBuffer vbb = ByteBuffer.allocateDirect(vertices.length * 4); vbb.order(ByteOrder.nativeOrder()); mFVertexBuffer = vbb.asFloatBuffer(); mFVertexBuffer.put(vertices); mFVertexBuffer.position(0);
mColorBuffer = ByteBuffer.allocateDirect(colors.length); mColorBuffer.put(colors); mColorBuffer.position(0);
mTfan1 = ByteBuffer.allocateDirect(tfan1.length); mTfan1.put(tfan1); mTfan1.position(0);
mTfan2 = ByteBuffer.allocateDirect(tfan2.length); mTfan2.put(tfan2); mTfan2.position(0);`
**注意:**你会注意到很多 OpenGL ES 调用都以 f 结尾,比如gl.glScalef()、gl.glRotatef()等等。 f 表示传递的参数是浮点数。OpenGL ES 中唯一需要特殊调用的其他参数类型是定点值,所以glScale现在应该是gl.glScalex()。定点对于旧的和较慢的设备是有用的,但是对于较新的硬件,建议使用浮点调用。您会注意到,颜色数组和其他属性可以作为字节、整数、长整型等等来收集。但是他们没有考虑拥有一套专用的 API 调用。
清单 3–4 展示了前一个例子中更新的draw方法。这与第一章中的基本相同,但是它自然有了我们的新朋友,三角粉丝,而不是索引数组。
清单 3–4。 更新了draw方法
`public void draw(GL10 gl) { gl.glVertexPointer(3, GL11.GL_FLOAT, 0, mFVertexBuffer); gl.glColorPointer(4, GL11.GL_UNSIGNED_BYTE, 0, mColorBuffer);
gl.glDrawElements( gl.GL_TRIANGLE_FAN, 6 * 3, gl.GL_UNSIGNED_BYTE, mTfan1); gl.glDrawElements( gl.GL_TRIANGLE_FAN, 6 * 3, gl.GL_UNSIGNED_BYTE, mTfan2); }`
清单 3–5 显示了对CubeRenderer.java的调整onDrawFrame。
清单 3–5。 略加修改后的onDrawFrame
`public void onDrawFrame(GL10 gl) { gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT); gl.glClearColor(0.0f,0.5f,0.5f,1.0f); //1
gl.glMatrixMode(GL10.GL_MODELVIEW); gl.glLoadIdentity(); gl.glTranslatef(0.0f,(float)Math.sin(mTransY), -7.0f); //2 gl.glEnableClientState(GL10.GL_VERTEX_ARRAY); gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
mCube.draw(gl);
mTransY += .075f; }`
这里没有真正的戏剧性变化:
- 在第 1 行,我已经添加了
gl.glClearColor到这个组合中。这指定了清除框架时应该使用的颜色。这里的颜色是迷人的蓝绿色。 - 在第 2 行中,翻译中的 z 值被提高到-7,只是为了使立方体的描绘看起来更自然一些。
最后一组变化如清单 3–6 所示,它定义了视见平截头体。这里有更多的信息,以便处理不同的显示大小,并使代码更容易理解。
清单 3–6。 新的平截体代码
`public void onSurfaceChanged(GL10 gl, int width, int height) { gl.glViewport(0, 0, width, height);
float aspectRatio; float zNear =.1f; float zFar =1000; float fieldOfView = 30.0f/57.3f; //1 float size;
gl.glEnable(GL10.GL_NORMALIZE);
aspectRatio=(float)width/(float)height; //2
gl.glMatrixMode(GL10.GL_PROJECTION); //3
size = zNear * (float)(Math.tan((double)(fieldOfView/2.0f))); //4
gl.glFrustumf(-size, size, -size /aspectRatio, //5 size /aspectRatio, zNear, zFar);
gl.glMatrixMode(GL10.GL_MODELVIEW); //6 }`
下面是正在发生的事情:
- 第 1 行用给定的 FOV 定义了平截头体,在选择值时更加直观。按照 Java 数学库的要求,30 度的字段被转换成弧度,而 OpenGL 坚持使用度。
- 第 2 行中的纵横比基于宽度除以高度。因此,如果宽度为 1024×768,长宽比为 1.33。这有助于确保图像的比例适当缩放。否则,如果视图不考虑长宽比,它的对象看起来会被压扁。
- 接下来,第 3 行确保当前矩阵模式被设置为投影矩阵。
- 第 4 行的任务是计算尺寸值,该值需要指定观察体积的左/右和上/下限值,如图图 3–3 所示。这可以被认为是你进入三维空间的虚拟窗口。以屏幕中心为原点,您需要在两个维度上从-size 到+size。这就是磁场被一分为二的原因——因此,对于 60 度的磁场,窗口将从-30 度变为+30 度。将尺寸乘以
zNear仅仅是增加了某种缩放暗示。最后,用长宽比除上下限,以确保你的正方形是真正的正方形。 - 现在我们可以将这些数字输入到
gl.glFrustum(),如第 5 行所示,然后将当前矩阵重置回GL10.GL_MODELVIEW。
如果工作正常,您应该会看到一些看起来完全像原来的弹性立方体!等等,你没被打动?好的,如果你要那样,让我们给它添加一些旋转。
带她出去兜风
现在是时候给场景添加一些更有趣的动画了。除了上下弹跳之外,我们还要慢慢旋转它。在 glondrawFrames()的底部添加以下一行:
mAngle+=.4;
右侧在GL 之前。glTranslatef()调用,添加以下内容:
gl.glRotatef(mAngle, 0.0f, 1.0f, 0.0f); gl.glRotatef(mAngle, 1.0f, 0.0f, 0.0f);
并且自然地将private float mAngle;和其他定义一起添加到文件的底部。
现在再跑一次。“嘿!嗯?”将是最有可能的反应。立方体似乎没有旋转,而是围绕你的视点旋转(同时弹跳),如图 3–7 所示。这说明了基本 3D 动画中最令人困惑的元素之一:获得正确的平移和旋转顺序。(还记得第二章里的讨论吗?)
考虑我们的立方体。如果你想让一个立方体在你面前旋转,正确的顺序是什么?旋转然后平移?还是平移然后旋转?追溯到五年级的数学课,你可能记得学过加法和乘法是可交换的。也就是说,运算的顺序并不重要:a+b=b+a,或者 ab=ba。嗯,3D 变换是而不是可交换的(最后,一个我从未想过我会需要的东西的用途!).也就是说,旋转平移和平移旋转不一样。参见图 3–8。
右边是你现在在旋转立方体例子中看到的。立方体首先被平移,然后被旋转,但是因为旋转是围绕“世界”原点(视点的位置)进行的,所以你会看到它好像在你的头周围旋转。
现在到了明显的不计算时刻:在示例代码中,旋转没有放在平移之前吗?
图 3–7。 平移第一,旋转第二
所以,这应该是导致全国突然皱起眉头的原因:
`gl.glRotatef(mAngle, 0.0f, 1.0f, 0.0f); gl.glRotatef(mAngle, 1.0f, 0.0f, 0.0f);
gl.glTranslatef(0.0f, (float)(sinf(mTransY)/2.0f), z);`
图 3–8。 先旋转还是先平移?
然而,在现实中,变换的顺序实际上是从最后到第一个应用的。现在将gl.glTranslatef() 放在两个旋转的前面,你应该会看到类似于图 3–9 的东西,这正是我们最初想要的。下面是完成这项工作所需的代码:
` gl.glTranslatef(0.0f, (float)(sinf(mTransY)/2.0f), z);
gl.glRotatef(mAngle, 0.0f, 1.0f, 0.0f); gl.glRotatef(mAngle, 1.0f, 0.0f, 0.0f);`
有两种不同的方法来可视化变换排序:局部坐标和世界坐标方法。在前一种情况下,你将物体移动到它们最终的静止位置,然后和进行旋转。由于坐标系是局部的,所以对象将围绕自己的原点旋转,这使得从上到下时前面的序列有意义。如果您选择世界方法,这实际上是 OpenGL 正在做的,您必须在执行平移之前首先围绕对象的局部轴执行旋转。这样,转换实际上是自下而上发生的。最终结果是一样的,代码也是一样的,两者都令人困惑,很容易出现混乱。这就是为什么你会看到许多 3D 男孩或女孩拿着一臂长的东西,同时四处移动,以帮助他们弄清楚为什么他们漂亮的弹射器模型在地面下飞行。我称之为 3D 洗牌。
图 3–9。 让立方体旋转
现在需要注意的最后一个转换命令是 gl。glScale(),用于沿所有三个轴调整模型大小。假设你需要将立方体的高度增加一倍。您可以使用行glScalef(1,2,1)。记住高度是和 Y 轴对齐的,而宽度和深度是 X 和 Z,这是我们不想碰的。
现在的问题是,在调用onDrawFrame()中的gl.glRotatef()之前或之后,你会把线放在哪里,以确保立方体的几何形状是唯一受影响的东西,如图图 3–10 中的左图所示?
如果你说了之后,比如这样:
` gl.glTranslatef(0.0f, (GLfloat)(sinf(mTransY)/2.0f), z);
gl.glRotatef(mAngle, 0.0, 1.0, 0.0); gl.glRotatef(mAngle, 1.0, 0.0, 0.0);
gl.glScalef(1,2,1);`
你是对的。这样做的原因是,由于列表中的最后一个变换实际上是第一个执行的变换,如果您想要调整对象几何体的大小,必须将缩放放在任何其他变换之前。把它放在其他任何地方,你可能会得到类似于图 3–10 中右图的东西。那么,那里发生了什么?这是由代码片段生成的:
` gl.glTranslatef(0.0f, (float)(sinf(mTransY)/2.0f), z);
gl.glScalef(1,2,1);
gl.glRotatef(mAngle, 0.0f, 1.0f, 0.0f);
gl.glRotatef(mAngle, 1.0f, 0.0f, 0.0f);`
图 3–10。 执行旋转前的缩放(左)和旋转后的缩放(右)
首先旋转几何体,然后旋转立方体的局部轴,该轴不再与原点的轴对齐。随后缩放,它沿着世界的 Y 轴伸展,而不是它自己的 Y 轴。这就好像你已经从一个旋转了一半的立方体的顶点列表开始,并且只缩放了它。所以,如果你在最后做了缩放,你的整个世界也缩放了。
调整数值
现在更多的乐趣来了,当我们可以开始玩不同的值。这一节将展示许多不同的原则,这些原则不仅与 OpenGL ES 相关,而且几乎在你可能偶然发现的每一个 3D 工具包中都可以找到。
裁剪区域
有了工作演示,我们可以通过调整一些值和观察变化来获得乐趣。首先,我们将通过将zFar的值从 1000 降低到 6.0 来改变平截头体中的远裁剪平面。为什么呢?记住立方体的本地原点是 7.0,它的大小是 2.0。因此,当直接面对我们时,最接近的点将是 6.0,因为每一边都将跨在原点上,每一边都有 1.0。因此,通过将zFar的值更改为 6.0,当立方体正对着我们时,它将被隐藏。但是有些部分会透过来,看起来就像一片漂浮在水面上的漂浮物。原因是当它旋转时,角自然会更靠近观察平面,如图图 3–11 所示。
图 3–11。 躲猫猫!当立方体的任何部分位于比zFar更远的地方时,立方体被剪切。
那么,当我将近剪裁平面移动得更远时会发生什么呢?将zFar重置为 1000(一个足够大的任意数字,以确保我们可以看到我们练习中的所有内容),并将zNear从. 1 设置为 6.0。你认为它会是什么样子?这将与前面的例子相反。结果见图 3–12。
图 3-12。zFar平面被重置,而zNear平面被移回以剪切立方体中任何过于靠近的部分。
像这样的 z 裁剪在处理大型复杂的世界时非常有用。您可能不希望所有您可能“正在看”的对象都被渲染,因为大多数对象可能离得太远而无法真正看到。设置zFar和zNear来限制可视距离可以加快系统速度。然而,这并不是在对象进入管道之前预切割对象的最佳替代方法。
视野
记住,观众的 FOV 也可以在视锥设置中改变。再次回到我们有弹性的朋友那里,确保你的zNear和zFar设置回到正常值 0.1 和 1000。现在将gl.onDrawFrame()中的z值改为-20 并再次运行。图 3–13 中最左边的图像是你应该看到的。
接下来我们要放大。转到setClipping(),将fieldOfView=10的度数从 30 度更改为。结果显示在图 3–13 的中间图像中。请注意,与最右边的图像相比,立方体没有明显的消失点或透视效果。当你在相机上使用变焦镜头时,你会看到同样的效果,因为缩短效果是不存在的,使事物看起来像正交投影。
图 3–13。 将物体移开(左),然后用 10 FOV(中)放大。最右边的图像的默认 FOV 值设置为 50,立方体仅在 4 个单位之外。
人脸剔除
让我们回到几页前你可能记得的一行代码:
gl.glEnable(GL_CULL_FACE);
这使得背面剔除成为可能,在第一章中有介绍,这意味着物体背面的面不会被画出来,因为它们无论如何都不会被看到。它最适用于凸面对象和基本体,如球体或立方体。该系统计算每个三角形的面法线,这是一种判断一个面是朝向我们还是远离我们的方法。默认情况下,面绕组为逆时针方向。因此,如果一个 CCW 面是针对我们的,它将被渲染,而所有其他的将被剔除。如果您的数据是非标准的,您可以用两种不同的方法来改变这种行为。您可以指定正面三角形按顺时针方向排序,或者剔除转储正面而不是背面。要查看这一点,请将下面一行添加到onSurfaceCreated()方法中:gl.glCullFace(GL10.GL_FRONT);。
图 3–14 显示了移除前面的三角形,只显示后面的三角形的结果。
注意: gl.glEnable()是一个频繁调用,用于改变各种状态,从消除背面,如前所示,到平滑点(GL10.GL_POINT-SMOOTH)到执行深度测试(GL10.GL_DEPTH_TEST)。
图 3–14。 背面现在可见,而正面被剔除。
建造一个太阳系
有了这些 3D 武器库中的基本工具,我们实际上可以开始建造一个小型太阳系模型的主要项目。太阳系之所以如此理想,是因为它有一个非常基本的简单形状,几个必须都围绕彼此运动的物体,以及一个单一的光源。最初使用立方体示例的原因是,它的形状是 3D 所能得到的最基本的形状,所以代码中没有多余的几何图形。当你到达像一个球体这样的东西时,正如你将看到的,大部分代码将会创建这个对象。
尽管 OpenGL 是一个很好的底层平台,但当涉及到任何更高层次的东西时,它仍有许多不足之处。正如你在第一章中看到的,当谈到建模工具时,许多可用的第三方框架最终可以用来完成这项工作,但目前我们只是坚持使用基本的 OpenGL ES。
**注:**除了 OpenGL 本身,还有一个流行的助手工具包叫做 GL Utility Toolkit(GLUT)。 GLUT 为基本的窗口 UI 任务和管理功能提供了可移植的 API 支持。它可以构造一些基本的原语,包括一个球体,因此在做小项目时非常方便。不幸的是,在撰写本文时,还没有官方的 Android 或 iOS 库,尽管目前正在进行一些努力。
首先要做的是创建一个新项目或导入一个以前的项目,该项目建立了通常的 OpenGL 框架,包括渲染器和几何对象。在这种情况下,它是一个由清单 3–7 描述的球体。
清单 3–7。 建造我们的 3D 星球
`package book.SolarSystem;
import java.util.; import java.nio.; import javax.microedition.khronos.opengles.GL10;
public class Planet { FloatBuffer m_VertexData; FloatBuffer m_NormalData; FloatBuffer m_ColorData;
float m_Scale; float m_Squash; float m_Radius; int m_Stacks, m_Slices;
public Planet(int stacks, int slices, float radius, float squash) { this.m_Stacks = stacks; //1 this.m_Slices = slices; this.m_Radius = radius; this.m_Squash=squash;
init(m_Stacks,m_Slices,radius,squash,"dummy"); }
private void init(int stacks,int slices, float radius, float squash, String textureFile) { float[] vertexData; float[] colorData; //2 float colorIncrement=0f;
float blue=0f; float red=1.0f; int numVertices=0; int vIndex=0; //vertex index int cIndex=0; //color index
m_Scale=radius; m_Squash=squash;
colorIncrement=1.0f/(float)stacks; //3
{ m_Stacks = stacks; m_Slices = slices;
//vertices
vertexData = new float[ 3*((m_Slices*2+2) * m_Stacks)]; //4
//color data
colorData = new float[ (4*(m_Slices*2+2) * m_Stacks)]; //5
int phiIdx, thetaIdx;
//latitude
for(phiIdx=0; phiIdx < m_Stacks; phiIdx++) //6 { //starts at -90 degrees (-1.57 radians) goes up to +90 degrees (or +1.57 radians)
//the first circle //7 float phi0 = (float)Math.PI * ((float)(phiIdx+0) * (1.0f/(float)(m_Stacks)) - 0.5f);
//the next, or second one. //8 float phi1 = (float)Math.PI * ((float)(phiIdx+1) * (1.0f/(float)(m_Stacks)) - 0.5f);
float cosPhi0 = (float)Math.cos(phi0); //9 float sinPhi0 = (float)Math.sin(phi0); float cosPhi1 = (float)Math.cos(phi1); float sinPhi1 = (float)Math.sin(phi1);
float cosTheta, sinTheta;
//longitude
for(thetaIdx=0; thetaIdx < m_Slices; thetaIdx++) //10 { //increment along the longitude circle each "slice"
float theta = (float) (-2.0f*(float)Math.PI * ((float)thetaIdx) * (1.0/(float)(m_Slices-1))); cosTheta = (float)Math.cos(theta); sinTheta = (float)Math.sin(theta);
//we're generating a vertical pair of points, such //as the first point of stack 0 and the first point of stack 1 //above it. This is how TRIANGLE_STRIPS work, //taking a set of 4 vertices and essentially drawing two triangles //at a time. The first is v0-v1-v2 and the next is v2-v1-v3. Etc.
//get x-y-z for the first vertex of stack
vertexData[vIndex+0] = m_ScalecosPhi0cosTheta; //11 vertexData[vIndex+1] = m_Scale*(sinPhi0m_Squash); vertexData[vIndex+2] = m_Scale(cosPhi0*sinTheta);
vertexData[vIndex+3] = m_ScalecosPhi1cosTheta; vertexData[vIndex+4] = m_Scale*(sinPhi1m_Squash); vertexData[vIndex+5] = m_Scale(cosPhi1*sinTheta);
colorData[cIndex+0] = (float)red; //12 colorData[cIndex+1] = (float)0f; colorData[cIndex+2] = (float)blue; colorData[cIndex+4] = (float)red; colorData[cIndex+5] = (float)0f; colorData[cIndex+6] = (float)blue; colorData[cIndex+3] = (float)1.0; colorData[cIndex+7] = (float)1.0;
cIndex+=24; //13 vIndex+=23; //14 }
blue+=colorIncrement; //15 red-=colorIncrement;
// create a degenerate triangle to connect stacks and maintain winding order
//16 vertexData[vIndex+0] = vertexData[vIndex+3] = vertexData[vIndex-3]; vertexData[vIndex+1] = vertexData[vIndex+4] = vertexData[vIndex-2]; vertexData[vIndex+2] = vertexData[vIndex+5] = vertexData[vIndex-1]; }
} m_VertexData = makeFloatBuffer(vertexData); //17 m_ColorData = makeFloatBuffer(colorData); }`
好的,所以创建一个像球体一样基本的东西需要很多代码。在标准 OpenGL 中,使用三角形列表比使用四边形列表更复杂,但这就是我们必须要做的。
基本算法是计算栈的边界,一次两个作为伙伴。堆栈平行于地面,X-Z 平面,它们形成了三角形带的边界。因此,计算堆栈 A 和 B,并根据围绕圆的切片数将其细分为三角形。下一次通过将采取堆栈 B 和 C,然后冲洗和重复。两个边界条件适用:
- 第一个和最后一个堆栈包含我们的伪行星的两极,在这种情况下,他们更像一个三角形的风扇,而不是一个带。然而,为了简化代码,我们将把它们视为条带。
- 每个条带的末端必须与起点相连,以形成一组连续的三角形。
所以,让我们来分解一下:
-
在第 1 行中,您可以看到初始化例程使用堆栈和切片的概念来定义球体的分辨率。拥有更多的切片和堆栈意味着一个更平滑的球体,但会使用更多的内存,更不用说额外的处理时间了。将切片想象成类似于苹果楔,代表球体从底部到顶部的一部分。堆叠是横向的切片,定义纬度的部分。参见图 3–15。
*
radius*参数是比例因子的一种形式。你可以选择规格化你的所有对象并使用glScalef(),但是这确实增加了额外的 CPU 开销,所以在这种情况下radius被用作预缩放的一种形式。Squash用于创建木星和土星所必需的扁平球体。因为它们都有很高的转速,所以它们被压平了一点。木星的一天只有十个小时左右,而它的直径是地球的十多倍。因此,它的极地直径大约是赤道直径的 93%。土星更加扁平,极地直径只有赤道直径的 90%。挤压值是相对于赤道直径测量的极地直径。值为 1.0 意味着该对象是完美的球形,而土星的挤压值为 0.90,即 90%。 -
在第 2 行中,我引入了一个颜色数组。因为我们想看一些有趣的东西,直到我们在第五章中看到很酷的纹理,让我们从上到下改变颜色。上面是蓝色的,下面是红色的。第 3 行中计算的颜色增量仅仅是从一个堆栈到另一个堆栈的颜色增量。红色从 1.0 开始向下,蓝色从 0.0 开始向上。
图 3–15。 栈上下,切片周而复始,面细分成三角条。
-
第 4 行和第 5 行为顶点和颜色分配内存。稍后将需要其他数组来保存光照所需的纹理坐标和面法线,但现在让我们保持简单。请注意,我们使用的是 32 位颜色,就像立方体一样。三个值形成 RGB 三元组,而第四个值用于 alpha (半透明),但在本例中并不需要。
m_Slices*2值考虑了由切片和堆栈边界限定的每个面需要两个三角形的事实。正常情况下,这应该是一个正方形,但这里必须是两个三角形。+2 处理这样一个事实,即前两个顶点也是最后一个顶点,所以是重复的。当然,我们需要价值m_Stacks的所有这些东西。 -
第 6 行开始外环,从最底部的堆栈(或我们星球的南极地区或海拔-90 度)到北极,在+90 度。
这里用了一些希腊标识符来表示球面坐标。 Phi 通常用于类纬度值,而 theta 用于经度。
-
第 7 行和第 8 行生成特定条带边界的纬度。首先,当
phiIdx为 0 时,我们希望phi0为-90,或-1.57 弧度。. 5 把所有东西向下推 90 度。否则,我们的值会从 0 到 180。 -
在第 9ff 行中,一些值是预先计算好的,以最小化 CPU 负载。
-
线 10ff 形成从 0°到 360°的内环,并定义切片。数学是相似的,所以没有必要进入极端的细节,除了我们通过第 11ff 行计算圆上的点。
m_Scale和m_Squash都在这里发挥作用。但是现在,就假设他们都是 1.0 的数据规范化。注意这里寻址的是顶点 0 和顶点 2。
vertexData[0]是 x ,而vertexData[2]是z——处理两个平行于地面的部件。由于顶点 1 与 y 相同,所以它对于每个循环都保持不变,并与其表示的纬度一致。因为我们是成对循环,数组元素 3、4 和 5 指定的顶点覆盖了下一个更高的堆栈边界。实际上,我们正在生成成对的点,即每个点和它上面的另一个点。这是 OpenGL 期望的三角形条带的格式,如图 Figure 3–16 所示。
图 3–16。 由六个顶点组成的三角形带
- 在第 12ff 行中,生成了颜色数组,并且与顶点一样,它们是成对生成的。红色和蓝色组件被藏在这里的数组中,暂时没有绿色组件。第 13 行增加了颜色索引,考虑到我们每个循环生成两个四元素颜色条目。
- 与颜色索引一样,顶点索引也是递增的,如第 14 行所示,但这次只针对三个组件。
- 在第 15ff 行中,我们增加蓝色,减少红色,确保底部的“极点”是纯红色,而顶部是纯蓝色。
- 在每个条带的末尾,我们需要创建一些“退化”的三角形,如第 16ff 行所示。术语退化指定三角形实际上由三个相同的顶点组成。实际上,它只是一个点;从逻辑上讲,它是一个三角形,用来连接当前堆栈。
- 最后,当该说的都说了,该做的都做了,第 17f 行的顶点和颜色数据被转换成 OpenGL 在渲染时可以理解的字节数组。清单 3–8 是完成这项工作的辅助函数。
清单 3–8。 辅助函数生成 OpenGL 能理解的浮点数组
protected static FloatBuffer makeFloatBuffer(float[] arr) { ByteBuffer bb = ByteBuffer.*allocateDirect*(arr.length*4); bb.order(ByteOrder.*nativeOrder*()); FloatBuffer fb = bb.asFloatBuffer(); fb.put(arr); fb.position(0); return fb; }
既然几何图形已经不碍事了,我们需要把注意力集中在绘制方法上。参见清单 3–9。
清单 3–9。 渲染星球
`public void draw(GL10 gl) {
gl.glFrontFace(GL10.GL_CW); //1 gl.glVertexPointer(3, GL10.GL_FLOAT, 0, m_VertexData); //2 gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glColorPointer(4, GL10.GL_FLOAT, 0, m_ColorData); gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
//3 gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, (m_Slices+1)2(m_Stacks-1)+2); }`
现在,您应该能够识别多维数据集示例中的许多元素:
- 首先在第 1 行,我们指定顺时针方向的面是前面的面。
- 第 2ff 行将颜色和顶点数据提交给渲染器,确保它能够接受这些数据。
- 最后(!!)我们现在可以画出我们的小球体了。哎呀,嗯,还没有,还是要分配的。
既然行星对象对于这个例子来说已经足够完整了,让我们来做驱动程序。您可以将弹跳立方体渲染器重命名为类似于SolarSystemRenderer的名称,这将是GLSurfaceView.Renderer接口的一个实例。更改构造函数,看起来像清单 3–10。这会将行星分配到一个相当粗略的分辨率,即十个堆栈和十个切片,半径为 1.0,挤压值为 1.0(即,完美的圆形)。确保声明mPlanet,当然,还要导入 GL10 库。
清单 3–10。 建造师SolarSystemRenderer
public SolarSystemRenderer() { mPlanet=new Planet(10,10,1.0f, 1.0f); }
顶层刷新方法gl.onDrawFrame(),与立方体方法没有太大区别,如清单 3–11 所示。
清单 3–11。 主绘制方法,位于SolarSystemRenderer
`private float mTransY; private float mAngle;
public void onDrawFrame(GL10 gl) { gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT); gl.glClearColor(0.0f,0.0f,0.0f,1.0f); gl.glMatrixMode(GL10.GL_MODELVIEW); gl.glLoadIdentity();
gl.glTranslatef(0.0f,(float)Math.sin(mTransY), -4.0f);
gl.glRotatef(mAngle, 1, 0, 0); gl.glRotatef(mAngle, 0, 1, 0);
mPlanet.draw(gl);
mTransY+=.075f; mAngle+=.4; }`
这里没有什么奇特的,因为它实际上与渲染立方体的方法是一样的。从立方体的代码中复制onSurfaceChanged()和onSurfaceCreated(),同时暂时注释掉initLighting()和initGeometry()。您现在应该能够编译和运行它了。图 3–17 应该是结果。
图 3–17。 未来星球地球
当它旋转时,没有任何特征,你很难看到它的运动。
和前面的例子一样,让我们试验一些参数,看看会发生什么。首先,让我们在SolarSystemRenderer 构造函数中改变堆栈和切片的数量,从 10 到 20。您应该会看到类似于图 3–18 的内容。
图 3–18。 拥有双栈和切片的星球
如果你想让弯曲的物体看起来更平滑,通常有三种方法可以实现:
- 有尽可能多的三角形。
- 使用 OpenGL 内置的一些特殊的照明和阴影工具。
- 使用纹理。
下一章将讨论第二种选择。但是现在,看看需要多少切片和堆叠才能形成一个真正光滑的球体。(两者数量相等时效果最佳。)每个 100 就真的开始好看了。现在,回到 20 人一组。
如果你想看看球体的实际线框结构,将行星的绘制方法中的GL10.GL_TRIANGLE_STRIP改为 GL10。GL_LINE_STRIP。您可能希望将背景颜色改为中灰色,以使线条更加突出(图 3–19,左侧)。作为练习,看看怎样才能得到图 3–19 中的右图。现在问问你自己,为什么我们在那里没有看到三角形,而是看到了那个奇怪的螺旋图案。它只是 OpenGL 绘制和连接线条的方式。我们可以通过指定一个连接数组来呈现三角形轮廓。但是对于我们的最终目标来说,这是不必要的。
图 3–19。 线框模式下的星球
自行将GL10.GL_LINE_STRIP改为GL10.GL_POINTS。你会看到每个顶点都被渲染成一个点。
然后再次尝试截锥。将zNear从. 1 设置为 3.2。(为什么不是 4?物体的距离?)你就会得到图 3–20。
图 3–20。 有人把zNear剪裁平面设置得太近了。
最后一个练习:怎样才能得到看起来像 Figure 3–21 的东西?(这是木星和土星所需要的;因为它们旋转如此之快,它们不是球形而是扁球形。)
图 3–21。 需要什么才能得到这个?
最后,为了加分,让它像立方体一样弹跳。
总结
在这一章中,我们从把 2D 立方体变成三维立方体开始,然后学习如何旋转和平移它。我们还了解了视见平截头体,以及如何使用它来剔除对象和放大缩小我们的场景。最后,我们构建了一个更加复杂的物体,它将成为太阳系模型的基础。下一章将介绍阴影、照明和材质,并将添加第二个物体。
四、开灯
你现在必须坚强。你绝不能放弃。当人们(或代码)让你哭泣,你害怕黑暗时,不要忘记光明一直在那里。
—作者未知
光是画家之首。没有什么东西是如此肮脏,以至于强烈的光线也不会使它变得美丽。
拉尔夫·瓦尔多·爱默生 Ralph Waldo Emerson
船长,一切都很闪亮。不要担心。
——凯莉·弗莱,《萤火虫》
这一章将涵盖 OpenGL ES 的一个最大的主题:照亮、着色和着色虚拟景观的过程。我们在上一章中提到了颜色,但是因为它对照明和阴影都是不可或缺的,所以我们将在这里更深入地讨论它。
光与色的故事
没有光,世界将是一个黑暗的地方(咄)。如果没有颜色,就很难区分交通信号灯。
我们都认为光的奇妙本质是理所当然的——从晨雾中柔和柔和的照明到航天飞机主引擎的点火,再到仲冬雪原上满月羞涩苍白的光芒。关于光的物理学、它的本质和感知已经写了很多。一个艺术家可能要花一辈子的时间才能完全理解如何将悬浮在油中的彩色颜料涂在画布上,在瀑布底部创造出可信的彩虹。这就是 OpenGL ES 在打开场景中的灯光时的任务。
没有诗意,光仅仅是我们的眼睛敏感的全部电磁光谱的一部分。同样的光谱还包括我们的 iPhones 使用的无线电信号,帮助医生的 X 射线,数十亿年前从一颗垂死的恒星发出的伽马射线,以及可以用来加热上周四 Wii 保龄球之夜剩下的一些披萨的微波。
据说光有四个主要特性:波长、强度、偏振和方向。波长决定了我们感知的颜色,或者说我们是否能首先看到任何东西。可见光谱从波长约为 380 纳米的紫色范围开始,一直到波长约为 780 纳米的红色范围。紧接在它下面的是紫外线,在可见光范围的正上方你会发现红外线,我们不能直接看到它,但可以以热的形式间接探测到它。
我们感知物体颜色的方式与物体或其材质吸收的波长有关,否则会干扰迎面而来的光。除了吸收,它还可能被散射(给我们天空的蓝色或日落的红色)、反射和折射。
如果有人说他们最喜欢的颜色是白色,他们一定是说所有的颜色都是他们最喜欢的,因为白色是可见光谱中所有颜色的总和。如果是黑色,他们不喜欢任何颜色,因为黑色就是没有颜色。事实上,这就是为什么你不应该在一个温暖的晴天穿黑色。你的衣服吸收了如此多的能量,却反射了如此少的能量(以光和红外线的形式),以至于其中的一些最终转化为热量。
**注:**当太阳直射头顶时,它可以发出每平方米约 1 千瓦的辐照度。其中,超过一半的是红外线,感觉非常温暖,而不到一半是可见光,只有少得可怜的 32 瓦用于紫外线。
据说亚里士多德发展了第一个已知的颜色理论。他考虑了四种颜色,每种颜色对应于气、土、水、火四种元素中的一种。
然而,当我们观察可见光谱时,你会注意到从一端的紫色到另一端的红色有一个很好的连续分布,其中既没有水也没有火。你也看不到红色、绿色或蓝色的离散值,它们通常被用来定义不同的色调。19 世纪初,英国学者托马斯·杨开发了三色模型,用三种颜色来模拟所有可见的色调。杨提出视网膜是由成束的神经纤维组成的,它们会对不同强度的红光、绿光或紫光做出反应。德国科学家赫尔曼·赫尔姆霍茨后来在 19 世纪中叶扩展了这一理论。
**注:**扬是个特别有趣的家伙。(总得有人说。)他不仅是生理光学领域的创始人,在业余时间他还发展了光的波动理论,包括发明了经典的双缝实验,这是大学物理的主要内容。但是等等!还有呢!他还提出了毛细现象理论,第一个使用现代意义上的术语“??”能量“?? ”,部分破译了罗塞塔石碑的埃及部分,并设计了一种改进的乐器调音方法。这位女士肯定严重睡眠不足。
如今,颜色最常通过红绿蓝(RGB)三元组及其相对强度来描述。每种颜色在强度为零的情况下逐渐变为黑色,并随着强度的增加而显示出不同的色调,最终被感知为白色。因为三种颜色需要加在一起才能产生整个光谱,所以这个系统是一个加法模型。
除了 RGB 模式,打印机还使用一种被称为 CMYK 的减色模式,用于青色-品红色-黄色-黑色(键)。因为这三种颜色不能产生真正深的黑色,所以添加黑色作为深阴影或图形细节的强调。
另一个常见的模型是 HSV 的色调-饱和度-值,你会经常发现它是许多图形包或颜色选择器中 RGB 的替代物。HSV 是 20 世纪 70 年代专门为计算机图形开发的,它将颜色描绘成一个 3D 圆柱体(图 4–1)。饱和度从内到外,值从下到上,色调围绕边缘。这方面的一个变体是 HSL,用值代替亮度。图 4–2 显示了多种版本的 Mac OS X 拾色器。
图 4–1。 HSV 色轮或圆柱体(来源:维基共享资源)
图 4–2。 苹果的 OS-X 标准拾色器——RGB、CMYK、HSV,以及一直流行的克雷奥拉模型
让那里有光
在现实世界中,光从四面八方以各种颜色射向我们,当结合起来时,可以创造日常生活的细节和丰富的场景。OpenGL 并不试图复制任何像真实世界的照明模型,因为这些模型非常复杂和耗时,并且通常保留给迪士尼的渲染农场。但是它可以以一种对实时游戏动作来说足够好的方式来近似它。
OpenGL ES 中使用的光照模型允许我们在场景中放置不同类型的灯光。我们可以随意打开或关闭它们,指定方向、强度、颜色等等。但这还不是全部,因为我们还需要描述模型的各种属性,以及它如何与入射光相互作用。照明定义了光源与物体互动的方式以及创建这些物体的材质。阴影根据照明和材质具体决定像素的颜色。请注意,一张白纸反射的光线与一件粉红色的镜面圣诞装饰品完全不同。综合起来,这些特性被捆绑成一种叫做材质的物体。将材质属性和灯光属性混合在一起会生成最终的场景。
OpenGL 灯光的颜色最多可以由三种不同的成分组成:
- 传播
- 环境的
- 镜子的
漫射光可以说是来自一个方向,如太阳或手电筒。它撞击一个物体,然后向四面八方散射,产生一种令人愉快的柔软感。当漫射光照射到表面时,反射量很大程度上取决于入射角。当直接面对光线时,它会最亮,但随着倾斜得越来越远,它会越来越暗。
环境光是来自没有特定方向的光,被构成环境的所有表面反射。环顾你所在的房间,从天花板、墙壁和家具上反射回来的光线组合在一起形成了环境光。如果你是一名摄影师,你会知道环境照明对于拍摄比单一光源更真实的场景有多重要,特别是在人像摄影中,你会用柔和的“补光”来抵消较亮的主光。
镜面光是从光亮的表面反射回来的光。它来自一个特定的方向,但是在一个表面上以一种更加定向的方式反弹。它会成为我们在迪斯科球或刚清洗上蜡的汽车上看到的热点。当观察点与光源成直线时,光线最亮,当我们绕着物体移动时,光线迅速减弱。
当谈到漫反射和镜面反射照明时,它们通常是相同的颜色。但是,即使我们被限制在八个灯光对象,每个组件有不同的颜色实际上意味着一个单一的 OpenGL“光”可以同时表现得像三个不同的。在这三种颜色中,你可以考虑让环境光有不同的颜色,通常是与主色相反的颜色,以使场景在视觉上更有趣。在太阳系模型中,暗淡的蓝色环境光有助于照亮行星的黑暗面,并赋予其更高的 3D 质量。
**注意:**你不必为一个给定的灯光指定所有三种类型。漫反射通常在简单的场景中工作得很好。
回到有趣的事情(暂时)
我们还没有完成理论,但是让我们暂时回到编码上来。在那之后,我将会涉及更多关于光线和阴影的理论。
在前面的例子中,你看到了用标准 RGB 版本逐顶点定义的颜色如何让我们在没有任何光照的情况下看到它。现在,我们将创建各种类型的灯,并将它们放置在我们所谓的星球周围。OpenGL ES 必须总共支持至少八个灯光,这是 Android 的情况。但是你当然可以创建更多的,并根据需要添加或删除它们。如果你真的很挑剔,你可以在运行时检查一个特定的 OpenGL 实现支持多少个灯光,方法是使用glGet*的许多变体之一来检索这个值:
ByteBuffer byteBuffer = ByteBuffer.allocateDirect( 4 ); byteBuffer.order( ByteOrder.LITTLE_ENDIAN ); IntBuffer intBuffer = byteBuffer.asIntBuffer(); drawable.getGL().glGetIntegerv( GL10.GL_MAX_LIGHTS, intBuffer);
注: OpenGL ES 有很多效用函数,glGet*()是最常用的家族之一。glGet*调用可以让你查询各种参数的状态,比如当前的 modelview 矩阵到当前的线宽。确切的调用取决于请求的数据类型。
让我们回到第三章中的示例代码,其中有一个压扁的红色和蓝色星球上下跳动,并进行以下更改:
-
从太阳系控制器的方法
initGeometry()中更改地球的squash值,从. 7f 更改为 1.0f 以再次圆化球体,并将分辨率(堆叠和切片)更改为 20。 -
In
Planet.java, comment out the lineblue+=colorIncrmentat the end of theinit()method.你应该看到什么?来吧,不要偷看。掩盖图 4–3 并猜测。明白了吗?现在你可以编译和运行了。图 4–3 中左边的图像是你应该看到的。现在回到
initGeometry方法,将切片和堆栈的数量分别增加到 100。这应该会产生右边的图像。因此,通过简单地改变一些数字,我们有一个粗略的照明和阴影方案。但这只是一个固定的照明方案,当你想要开始移动东西时,它就会坏掉。这时,我们让 OpenGL 来承担重任。不幸的是,添加灯光的过程比仅仅调用类似于
glMakeAwesomeLightsDude()的东西要复杂一些,我们将会看到。图 4–3。 从下面模拟光照(左)和一个更高的多边形数来模拟明暗处理(右)
-
回到第三章的中太阳系渲染器的
onSurfaceCreated方法,修改成这样:public void onSurfaceCreated(GL10 gl, EGLConfig config) { gl.glDisable(GL10.*GL_DITHER*); gl.glHint(GL10.GL_PERSPECTIVE_CORRECTION_HINT, GL10.*GL_FASTEST*); gl.glEnable(GL10.*GL_CULL_FACE*); gl.glCullFace(GL10.*GL_BACK*); gl.glShadeModel(GL10.*GL_SMOOTH*); gl.glEnable(GL10.*GL_DEPTH_TEST*); gl.glDepthMask(false); initGeometry(gl); initLighting(gl); } -
并添加到渲染器:
public final static int *SS_SUNLIGHT* = GL10.*GL_LIGHT0*; -
然后添加清单 4–1 中的代码来打开灯。
清单 4–1。 初始化灯光
private void initLighting(GL10 gl) { float[] diffuse = {0.0f, 1.0f, 0.0f, 1.0f}; //1 float[] pos = {0.0f, 10.0f, -3.0f, 1.0f}; //2 gl.glLightfv(SS_SUNLIGHT, GL10.GL_POSITION, makeFloatBuffer(pos)); //3 gl.glLightfv(SS_SUNLIGHT, GL10.GL_DIFFUSE, makeFloatBuffer(diffuse)); //4 gl.glShadeModel(GL10.GL_FLAT); //5 gl.glEnable(GL10.GL_LIGHTING); //6 gl.glEnable(SS_SUNLIGHT); //7 }
这是怎么回事:
- 照明组件采用标准的 RGBA 标准化形式。所以在这种情况下,没有红色,没有完整的绿色,也没有蓝色。alpha 的最终值现在应该保持在 1.0,因为后面会有更详细的介绍。
- 线 2 是灯的位置。它的 y 轴为+10,z 轴为-3,行星也是如此。所以,它会在我们的上空盘旋。
- 在第 3 行和第 4 行中,我们将灯光的位置和漫射组件设置为漫射颜色。
glLightfv()是一个新调用,用于设置各种与灯光相关的参数。您可以在以后使用glGetLightfv()来检索这些数据,它可以从特定的灯光中检索任何参数。 - 在第 5 行我们指定了一个阴影模型。平坦意味着一个面是单一的纯色,而将其设置为
GL_SMOOTH将使颜色在整个面上以及面与面之间平滑混合。 - 最后,第 6 行告诉系统我们想要使用灯光,而第 7 行启用我们创建的一个灯光。
注意:glLightfv()的最终参数取四个GLfloat值的数组;fv 后缀表示“浮点向量”还有一个glLightf()调用来设置单值参数。
现在编译并运行。呃?你说那是什么?你在星系 M31 的中心只看到一个超大质量黑洞大小的黑色东西?哦,对了,我们忘了点东西,抱歉。如前所述,各种版本的 OpenGL 仍然是一个相对低级的库,所以由程序员来处理各种各样的内务处理任务,这些任务是你期望更高级别的系统来管理的(这在 OpenGL ES 2.0 上变得更糟)。一旦灯光打开,预定义的顶点颜色被忽略,所以我们得到黑色。考虑到这一点,我们的球体模型需要一个额外的数据层来告诉系统如何照亮它的小平面,这是通过每个顶点的一组法线来完成的。
什么是顶点法线?面法线是与平面或面正交(垂直)的归一化向量。但是在 OpenGL 中,使用顶点法线来代替,因为它们提供了更好的着色。听起来很奇怪,一个顶点可以有自己的“法线”。说到底,一个顶点的“方向”是什么?这实际上在概念上很简单,因为顶点法线仅仅是与顶点相邻的面的法线的归一化总和。参见图 4–4。
图 4–4。面法线显示在右边,而三角形扇形的顶点法线显示在左边。
OpenGL 需要所有这些信息来判断顶点的“方向”,这样它就可以计算出有多少光照落在它上面。当它直接瞄准光源时最亮,当它开始倾斜时最暗。这意味着我们需要修改我们的行星生成器来创建一个法线数组以及顶点和颜色数组,如清单 4–2 所示。
清单 4–2。将普通发电机添加到 Planet.java
`private void init(int stacks,int slices, float radius, float squash, String textureFile) { float[] vertexData; float[] colorData; float[] normalData; float colorIncrement=0f; float blue=0f; float red=1.0f; int numVertices=0; int vIndex=0; //Vertex index int cIndex=0; //Color index int nIndex =0; //Normal index
m_Scale=radius; m_Squash=squash;
colorIncrement=1.0f/(float)stacks; m_Stacks = stacks; m_Slices = slices;
//Vertices vertexData = new float[ 3*((m_Slices*2+2) * m_Stacks)];
//Color data colorData = new float[ (4*(m_Slices2+2) * m_Stacks)]; // Normalize data normalData = new float [ (3(m_Slices2+2) m_Stacks)]; //1
int phiIdx, thetaIdx;
//Latitude for(phiIdx=0; phiIdx < m_Stacks; phiIdx++) { //Starts at -90 degrees (-1.57 radians) and goes up to +90 degrees (or +1.57 radians). //The first circle float phi0 = (float)Math.PI * ((float)(phiIdx+0) * (1.0f/(float) (m_Stacks)) - 0.5f);
//The next, or second one. float phi1 = (float)Math.PI * ((float)(phiIdx+1) * (1.0f/(float) (m_Stacks)) - 0.5f);
float cosPhi0 = (float)Math.cos(phi0); float sinPhi0 = (float)Math.sin(phi0);
float cosPhi1 = (float)Math.cos(phi1);
float sinPhi1 = (float)Math.sin(phi1);
float cosTheta, sinTheta;
//Longitude for(thetaIdx=0; thetaIdx < m_Slices; thetaIdx++) { //Increment along the longitude circle each "slice."
float theta = (float) (2.0f*(float)Math.PI * ((float)thetaIdx) * (1.0/(float)(m_Slices-1))); cosTheta = (float)Math.cos(theta); sinTheta = (float)Math.sin(theta);
//We're generating a vertical pair of points, such //as the first point of stack 0 and the first point of stack 1 //above it. This is how TRIANGLE_STRIPS work, //taking a set of 4 vertices and essentially drawing two triangles //at a time. The first is v0-v1-v2 and the next is v2-v1-v3, etc.
//Get x-y-z for the first vertex of stack.
vertexData[vIndex+0] = m_ScalecosPhi0cosTheta; vertexData[vIndex+1] = m_Scale*(sinPhi0m_Squash); vertexData[vIndex+2] = m_Scale(cosPhi0*sinTheta);
vertexData[vIndex+3] = m_ScalecosPhi1cosTheta; vertexData[vIndex+4] = m_Scale*(sinPhi1m_Squash); vertexData[vIndex+5] = m_Scale(cosPhi1*sinTheta);
colorData[cIndex+0] = (float)red; colorData[cIndex+1] = (float)0f; colorData[cIndex+2] = (float)blue; colorData[cIndex+4] = (float)red; colorData[cIndex+5] = (float)0f; colorData[cIndex+6] = (float)blue; colorData[cIndex+3] = (float)1.0; colorData[cIndex+7] = (float)1.0;
// Normalize data pointers for lighting. normalData[nIndex + 0] = cosPhi0*cosTheta; //2 normalData[nIndex + 1] = sinPhi0; normalData[nIndex + 2] = cosPhi0*sinTheta;
normalData[nIndex + 3] = cosPhi1*cosTheta; //3 normalData[nIndex + 4] = sinPhi1;
normalData[nIndex + 5] = cosPhi1*sinTheta;
cIndex+=2*4; vIndex+=23; nIndex+=23; }
//Blue+=colorIncrement; red-=colorIncrement;
//Create a degenerate triangle to connect stacks and maintain winding order. vertexData[vIndex+0] = vertexData[vIndex+3] = vertexData[vIndex-3]; vertexData[vIndex+1] = vertexData[vIndex+4] = vertexData[vIndex-2]; vertexData[vIndex+2] = vertexData[vIndex+5] = vertexData[vIndex-1]; } m_VertexData = makeFloatBuffer(vertexData); m_ColorData = makeFloatBuffer(colorData); m_NormalData = makeFloatBuffer(normalData);
}`
- 在第 1 行中,法线数组的分配与顶点内存相同,这是一个简单的数组,每个法线包含 3 个组件。
- 第二部分和第三部分生成正常数据。它看起来并不像前面提到的任何花哨的正常平均方案,那么是什么原因呢?由于我们处理的是一个非常简单的对称形式的球体,法线与没有任何缩放值的顶点相同(以确保它们是单位向量,即长度为 1.0)。请注意,
vPtr值和nPtrs值的计算结果实际上是相同的。
注意:你很少需要生成你自己的法线。如果您在 OpenGL ES 中进行任何实际工作,您可能会从第三方应用(如 3D-Studio 或 Strata)中导入模型。他们将为您生成普通数组和其他数组。
还有最后一步,那就是修改Planet.java中的draw()方法,使其看起来像清单 4–3。
**清单 4–3。**新draw套路
public void draw(GL10 gl) { gl.glMatrixMode(GL10.GL_MODELVIEW); gl.glEnable(GL10.GL_CULL_FACE); gl.glCullFace(GL10.GL_BACK); ` gl.glNormalPointer(GL10.GL_FLOAT, 0, m_NormalData); //1
gl.glEnableClientState(GL10.GL_NORMAL_ARRAY); //2
gl.glVertexPointer(3, GL10.GL_FLOAT, 0, m_VertexData); gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glColorPointer(4, GL10.GL_FLOAT, 0, m_ColorData); gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, (m_Slices+1)2(m_Stacks-1)+2); }`
除了添加了第 1 行和第 2 行,用于将普通数据与颜色和顶点信息一起发送到 OpenGL 管道之外,它与原始代码没有太大的不同。如果你有一个非常简单的模型,其中许多顶点都共享相同的法线,你可以转储法线数组并使用glNormal3f()来代替,在这个过程中节省一点内存和 CPU 开销。
让我们做最后一个调整。对于本例,确保分配行星时将堆栈和切片值设置回 10(从第三章的结尾使用的 100)。这样更容易看到一些灯光是如何工作的。现在你可以真正编译和运行它,如果你得到类似于图 4–5 的东西,放松一下,停下来喝杯清凉饮料。
图 4–5。平面照明
现在你回来了,我相信你已经发现了一些奇怪的东西。据说几何学是基于一条条三角形,那么为什么脸是那些奇怪的四边三角形呢?
当设置为平面着色时,OpenGL 仅从单个顶点(相应三角形的最后一个顶点)拾取照明提示。现在,不是从水平成对的三角形中画出条带,而是把它们想象成垂直成对的松散耦合,正如你在图 4–6 中看到的。
图 4–6。“堆叠”的三角形对
在条带 0 中,将使用顶点 0、1 和 2 绘制三角形 1,顶点 2 用于着色。三角形 2 将使用 2、1 和 3。泡沫,冲洗,并重复其余的地带。接下来,对于条带 1,将绘制具有顶点 4、0 和 5 的三角形 41。但是三角形 42 将使用顶点 5、0 和 2,与三角形 1 具有相同的顶点用于其着色。这就是为什么垂直对组合起来形成一个“弯曲的”四边形。
如今很少有理由使用平面阴影,所以在initLighting()中,将GL_FLAT替换为GL_SMOOTH,在glShadeModel()中,通过改变以下内容来改变灯光的位置:
float[] pos = {0.0f, 10.0f, -3.0f, 1.0f};
对此:
float[] pos = {0.0f, 5.0f, 0.0f, 1.0f};
这将显示更多被照亮的一面。现在你可能知道该怎么做了:编译、运行和比较。然后为了好玩,将球体的分辨率从 20 个切片和分段降低到 5 个。回到平面阴影,然后与平滑阴影进行比较。参见图 4–7。图 4–7 中最右边的图像特别有趣,因为阴影模型开始崩溃,显示出一些沿着脸部边缘的赝像。
图 4–7。 从左到右:平滑着色一个 20 叠 20 片的球体;只有 5 个堆栈和切片的球体上的平面阴影;平滑阴影
光和材质的乐趣
现在,既然我们有一个光滑的球体可以玩,我们可以开始修补其他的灯光模型和材质。但是首先做一个思维实验:假设你有一个绿色的球体,如前所示,但是你的漫射光是红色的。球体会是什么颜色?(暂停危险主题。)准备好了吗?在现实世界中会是什么呢?红绿?绿红色?淡紫色的粉红色?让我们试一试,找出答案。再次修改initLighting(),如清单 4–4 所示。请注意,灯光向量已被重命名为其特定的颜色,以使其更具可读性。
清单 4–4。 添加更多的灯光类型和材质
private void initLighting(GL10 gl) { float[] diffuse = {0.0f, 1.0f, 0.0f, 1.0f}; float[] pos = {0.0f, 5.0f, -3.0f, 1.0f}; float[] white = {1.0f, 1.0f, 1.0f, 1.0f}; float[] red={1.0f, 0.0f, 0.0f, 1.0f}; float[] green={0.0f,1.0f,0.0f,1.0f}; ` float[] blue={0.0f, 0.0f, 1.0f, 1.0f};
float[] cyan={0.0f, 1.0f, 1.0f, 1.0f};
float[] yellow={1.0f, 1.0f, 0.0f, 1.0f};
float[] magenta={1.0f, 0.0f, 1.0f, 1.0f};
float[] halfcyan={0.0f, 0.5f, 0.5f, 1.0f};
gl.glLightfv(SS_SUNLIGHT, GL10.GL_POSITION, makeFloatBuffer(pos)); gl.glLightfv(SS_SUNLIGHT, GL10.GL_DIFFUSE, makeFloatBuffer(green));
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_DIFFUSE, makeFloatBuffer(red)); //1
gl.glShadeModel(GL10.GL_SMOOTH); gl.glEnable(GL10.GL_LIGHTING); gl.glEnable(SS_SUNLIGHT); gl.glLoadIdentity(); //2 }`
如果你看到我们的老朋友,来自 M31 的超大质量黑洞,你做得很好。那么,为什么是黑色的呢?那很简单;还记得本章开始时关于颜色和反射率的讨论吗?只有当照射到红色物体上的光线含有红色成分时,红色物体才会看起来是红色的,而我们的绿光却没有。如果你在一个黑暗的房间里有一个红色的气球,用绿色的光照亮它,它看起来会是黑色的,因为绿色不会回到你身边。如果有人问你在一个黑暗的房间里拿着一个红色的气球在做什么,就吼一声“物理学!”然后用轻蔑的语气告诉他们,他们不会理解的。
因此,有了这样的理解,用绿色替换第一行中的红色漫反射材质。你应该得到什么?对,绿色球体再次被照亮。但是你可能会注意到一些非常有趣的事情。绿色现在看起来比添加材质之前亮了一点。在图 4–8 中左边的图像显示它没有指定任何材质,右边的图像显示它添加了绿色漫射材质。
图 4–8。 无绿色物质定义(左)和有它定义(右)
让我们再做一个实验。让我们使漫射光是一个更传统的白色。现在绿色该怎么办?红色?蓝色怎么样?因为白光有所有这些成分,所以彩色材质应该都显示得一样好。但是如果你再次看到黑球,你改变了材质的颜色,而不是灯光。
镜面照明
那么,镜面反射的东西呢?将下面一行添加到 lights 部分:
gl.glLightfv(SS_SUNLIGHT,GL10.GL_SPECULAR, makeFloatBuffer(red));
在“材质”部分,添加以下内容:
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_SPECULAR, makeFloatBuffer(red));
并将灯光的位置更改为如下所示:
float[] pos={10.0f,0.0f,3.0f,1.0f};
注意:glMaterial*的第一个值必须始终是GL_FRONT_AND_BACK。在普通的 OpenGL 中,你可以在一个面的两面使用不同的材质,但是在 OpenGL ES 中却不行。但是,您仍然必须使用 OpenGL ES 中的前值和后值,否则材质将无法正常工作。
将漫射材质重置回绿色。你应该会看到一些东西,看起来像一大堆黄红色的东西。简而言之,我们可以用另一个值来玩灯光。称为闪亮度,它指定物体表面有多亮,范围从 0 到 128。该值越高,反射越集中,因此看起来越亮。但是由于它默认为 0,它将镜面财富分布在整个星球上。它压倒了绿色,以至于当与红色混合时,它显示为黄色。因此,为了控制这种混乱,添加以下代码行:
gl.glMaterialf(GL10.GL_FRONT_AND_BACK,GL10.GL_SHININESS, 5);
我将很快解释这背后的数学原理,但是现在先看看值 5 会发生什么。接下来尝试 25,并将其与图 4–9 进行比较。光泽度值从 5 到 10 大致对应塑料;再大一点,我们就进入了严肃的金属领域。
图 4–9。 光泽度分别设置为 0、5.0 和 25.0
环境照明
是时候享受一下环境照明了。在initLighting()中增加以下一行:然后编译并运行:
gl.glLightfv(SS_SUNLIGHT, GL10.GL_AMBIENT, makeFloatBuffer(blue));
看起来像不像图 4–10 中左边的图像?你应该怎么做才能得到右边的图像?您需要添加下面一行:
gl.glMaterialfv(GL10.*GL_FRONT_AND_BACK*, GL10.*GL_AMBIENT*, *makeFloatBuffer*(blue));
图 4–10。仅蓝色环境光(左),环境光和环境光材质(右)
除了每个灯光的环境属性,还可以设置一个世界环境值。与所有灯光参数一样,基于灯光的值也是变量,因此它们作为距离、衰减等的函数而变化。世界值在整个 OpenGL ES 世界中是一个常数,可以通过在initLighting()例程中添加以下内容来设置:
float[] colorVector={r, g, b, a}; gl.glLightModelfv(GL10.*GL_LIGHT_MODEL_AMBIENT*, *makeFloatBuffer*(colorVector));
默认值是由红色=.2f、绿色=.2f 和蓝色=.2f 组成的暗灰色。这有助于确保无论发生什么情况,您的对象总是可见的。当我们这样做的时候,glLightModelfv()还有一个值,它是由GL_LIGHT_MODEL_TWO_SIDE的参数定义的。该参数实际上是一个布尔浮点数。如果是 0.0,只照亮一边;否则,两者都会。默认值为 0.0。如果出于任何原因,你想改变哪些面是正面,你可以用和glFrontFace()指定顺时针或逆时针排序的三角形代表正面。默认为 CCW。
后退一步
那么,这里到底发生了什么?实际上,相当多。有三种通用的着色模型用于实时计算机图形。OpenGL ES 1.1 使用了其中的两个,这两个我们都见过。第一种是平面模型,简单地用一个常量值给每个三角形着色。你已经在图 4–5 中看到了它的样子。在过去的好日子里,这是一个有效的选择,因为它比其他任何方式都要快得多。然而,当你口袋里的 iPhone 大致相当于手持 Cray-1 时,那些速度技巧真的是过去的事情了。平滑模型使用插值阴影,计算每个顶点的颜色,然后在面上进行插值。OpenGL 实际使用的是一种特殊形式的着色,叫做古劳着色。这是基于所有相邻面的法线生成顶点法线的地方。
第三种着色被称为 Phong ,因为 CPU 开销高,所以在 OpenGL 中不使用。它不是在面上插值颜色值,而是插值法线,为每个片段(即像素)生成一个法线。这有助于消除由高曲率定义的边缘上的一些人为因素,这些因素会产生非常尖锐的角度。Phong 可以减少这种效果,但是使用更多的三角形来定义你的对象也可以。
还有许多其他模型。20 世纪 70 年代,JPL/航海家动画公司的吉姆·布林创造了一种改进的 Phong 着色形式,现在称为 Blinn-Phong 模型。如果光源和观看者可以被视为在无限远处,那么计算强度可以更小。
Minnaert 模型倾向于给漫反射材质增加一点对比度。柳文欢-纳耶在漫反射模型中添加了一个“粗糙度”组件,以便更好地匹配现实。
发光材质
还有一个我们需要考虑的影响最终颜色的重要参数是GL_EMISSION。与漫反射、环境光和镜面反射不同,GL_EMISSION仅用于材质,并指定材质在质量上具有发射。一个发射物体有它自己的内部光源,比如太阳,这在太阳系模型中会派上用场。要查看实际效果,在initLighting()的其他材质代码中添加下面一行,并删除周围材质:
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_EMISSION, makeFloatBuffer(yellow));
因为黄色是最强烈的,你期望看到什么?大概就像图 4–11 中左边的图像。接下来将这些值减半,这样就有了:
float[] yellow={.5f, .5f, 0.0f, 1.0f};
现在你看到了什么?我敢打赌它看起来有点像图 4–11 中的右图。
图 4–11。一个发射材质设置为黄色全强度的物体(左);同样的场景,但是只有 50%的亮度(右)
从表面上看,发光材质可能看起来就像使用环境照明的结果。但与环境光不同,场景中只有一个对象会受到影响。附带的好处是,它们不会用尽额外的轻物体。然而,如果你的发射物体确实代表了任何种类的真实灯光,比如太阳,在里面放一个灯光物体肯定会给场景增加一层真实性。
关于材质的另一个注意事项:如果你的对象已经指定了颜色顶点,就像我们的立方体和球体一样,这些值可以用来代替设置材质。你必须使用gl.glEnable(GL10.GL_COLOR_MATERIAL);。这将把顶点颜色应用到阴影系统,而不是那些由glMaterial *调用指定的颜色。
衰减
当然,在现实世界中,物体离光源越远,光线越弱。OpenGL ES 也可以使用以下三个衰减因子中的一个或多个来模拟该因子:
- GL _ 常数 _ 衰减
- GL _ 线性 _ 衰减
- GL _ 二次衰减
所有这三个值组合在一起形成一个值,然后该值会计算到模型每个顶点的总照度中。使用 gLightf (GLenum light, GLenum pname, GLfloat param)设置它们,其中 light 是您的光源 ID,例如GL_LIGHT0, pname是前面列出的三个衰减参数之一,实际值使用param传递。
线性衰减可用于模拟由雾等因素引起的衰减。随着距离的增加,二次衰减模拟光的natural衰减,这呈指数变化。当灯光的距离增加一倍时,照明会减少到原来的四分之一。
我们暂且只看一个,GL_LINEAR_ATTENUATION。这三者背后的数学原理将在稍后揭晓。将下面一行添加到initLighting():
gl.glLightf(SS_SUNLIGHT, GL10.GL_LINEAR_ATTENUATION, .025f);
为了让事情在视觉上更加清晰,请确保关闭发光材质。你看到了什么?现在,在位置向量中将 x 轴上的距离从 10 增加到 50。图 4–12 显示了结果。
图 4–12。 光线的 x 距离为 10(左)和 50(右),衰减常数为 0.025。
聚光灯
标准灯光默认为各向同性模型;也就是说,它们就像一盏没有灯罩的台灯,向四面八方均匀地(而且是刺眼地)照射着。OpenGL 提供了三个额外的照明参数,可以将普通光转化为平行光:
- GL _ SPOT _ 方向
- GL _ SPOT _ 指数
- GL _ SPOT _ CUTOFF
因为它是平行光,所以由你使用GL_SPOT_DIRCTION矢量来瞄准它。默认为 0,0,-1,指向-z轴,如图图 4–13 所示。否则,如果您想要更改它,您可以使用类似下面的调用,将它沿着+x 轴移动:
GLfloat direction[]={1.0,0.0,0.0}; gl.glLightfv(GL10.GL_LIGHT0, GL10.GL_SPOT_DIRECTION, direction);
图 4–13。聚光灯对准默认方向
GL_SPOT_CUTOFF指定聚光灯光束从聚光灯圆锥体中心渐变到 0 强度的角度,自然是整个光束角直径的一半。对于 90 度的光束宽度,默认值为 45 度。该值越低,光束越窄。
第三个也是最后一个聚光灯参数GL_SPOT_EXPONENT,确定了光束强度的下降率,这也是另一种形式的衰减。OpenGL ES 将取光束的中心轴和任意顶点的中心轴所形成的角度的余弦。,并将其提升到GL_SPOT_EXPONENT的幂。因为其默认值为 0,所以灯光的强度在照明区域的所有部分都是相同的,直到达到截止值,然后降到零。
灯光参数进场
表 4–1 总结了本节涵盖的各种灯光参数。
阴影背后的数学原理
正如你所看到的,漫反射阴影模型给物体一个非常平滑的外观。它使用一种叫做兰伯特照明模型的东西。朗伯照明简单地说,一个特定的面越直接对准光源,它就越亮。天空中的太阳越高,你脚下的土地就越明亮。或者在更晦涩但精确的技术版本中,反射光随着入射光、 I 和面部法线、 N 之间的角度从 0 度增加到 1 度,基于 cos
从 90 度减少到 0 度。参见图 4–14。这里有一个快速思维实验:当
是 90 度时,它是从侧面过来的;cos(90)为 0,那么沿 N 的反射光自然也要为 0。当它垂直向下时,平行于 N,cos(0)将为 1,因此最大量将被反射回来。这可以更正式地表达如下:
Id= kdIIcos()
Id是漫反射的强度,KI是入射光线的强度, k d 表示与物体材质的粗糙度松散耦合的漫反射率。不严格地说是指在许多真实世界的材质中,实际的表面可能有些抛光,但仍然是半透明的,而下面的层执行散射。像这样的材质可能同时具有强漫射和镜面反射成分。此外,在现实生活中,每个色带可能都有自己的 k 值,因此红色、绿色和蓝色都有一个。**
图 4–14。对于完全漫射的表面,入射光束的反射强度将是该光束的垂直分量,或者是入射光束和表面法线之间角度的余弦。
镜面反射
如前所述,除了更普通的漫反射表面之外,镜面反射还为你的模型提供了闪亮的外观。很少有东西是完全平坦或完全闪亮的,大多数都介于两者之间。事实上,地球的海洋是很好的镜面反射体,在远距离拍摄的地球图像上,可以清楚地看到海洋中的太阳反射。
与所有方向都相等的漫反射不同,镜面反射高度依赖于观察者的角度。我们被告知入射角=反射角。这是真的足够完美的反射器。但是除了镜子,51 年的斯图贝克的鼻子,或者那个赛昂百夫长在把你轰进 15 万年前擦亮的前额,很少有东西是完美的反射器。因此,入射光线会有轻微的散射;参见图 4–15。
图 4–15。 对于镜面反射来说,入射光线是散射的,但只围绕其反射部分的中心。
镜面分量的等式可以表示如下:
I 镜面=W(q)*I光线*cosn
其中:
I 光线是入射光线的强度。
W ( q )是基于 I 光 的角度的表面反射程度。
n 是的闪亮因子(听着耳熟?).
是反射光线与射向眼睛的光线之间的角度。
这实际上是基于所谓的菲涅耳反射定律,这就是 W(q)值的来源。虽然 W(q)不直接用于 OpenGL ES 1.1,因为它随入射角变化,因此比镜面照明模型稍微复杂一些,但它可以用于 OpenGL ES 2.0 版本的着色器中。在这种情况下,它将特别有用,例如,在水面上做反射。取而代之的是一个基于材质设置的高光值的常数。
闪亮因子,也称为镜面指数,是我们之前用过的。然而,在现实生活中 n 可以远远高于最大值 128。
衰减
现在回到前面列出的三种衰减:常数、线性和二次衰减。总衰减计算如下,其中kc为常数, k l 为线性值, k q 为二次分量, d 代表光源与任意顶点的距离:
总结这一切
所以,现在你可以看到,有许多因素在起作用,仅仅是为我们场景中的任何模型的任何顶点生成颜色和颜色强度。其中包括以下内容:
- 距离衰减
- 漫射灯光和材质
- 镜面光和材质
- 聚光灯参数
- 环境光和材质
- 发光
- 材质的发射率
您可以将所有这些视为作用于整个颜色矢量或颜色的每个单独的 R、G 和 B 分量。
因此,为了说明一切,最终的顶点颜色将如下:
颜色 = 环境世界模型环境材质 + 发光材质+强度光线
其中:
换句话说,一旦我们考虑到衰减,漫射,镜面反射和聚光灯元素,颜色等于一些不受灯光控制的东西加上所有灯光的强度。
计算时,这些值分别作用于相关颜色的 R、G 和 B 分量。
那么,这一切是为了什么?
理解幕后发生的事情很方便的一个原因是它有助于使 OpenGL 和相关工具不那么神秘。就像你学外语一样,说克林贡语(如果你,亲爱的读者,是克林贡语,majQa ' nuq DAQ ' oH puch pa ' ' e '!),它不再是它曾经是神秘的;在咆哮是咆哮,咆哮是咆哮的地方,现在你可能会认为它们是一首关于好茶的可爱的诗。
另一个原因是,正如前面提到的,所有这些好的“高级”工具在 OpenGL ES 2.0 中都没有。大多数早期的着色算法都必须由你用一点点代码来实现,这些代码叫做着色器。幸运的是,关于最常见着色器的信息可以在互联网上获得,并且复制了以前的信息,相对简单。
更多有趣的东西
现在,有了所有这些光子的优点,是时候回去编码并引入不止一种光了。次要灯光可以不费吹灰之力就让场景的真实性产生惊人的巨大差异。
回到initLighting(),让它看起来像清单 4–5。这里我们再添加两盏灯,分别命名为SS_FILLLIGHT1和SS_FILLLIGHT2。将其定义添加到渲染器的类中:
public final static int *SS_SUNLIGHT1* = GL10.*GL_LIGHT1*; public final static int *SS_SUNLIGHT2* = GL10.*GL_LIGHT2*;
现在编译并运行。你看到图 4–16 中左边的图像了吗?如前所述,这是 Gouraud 着色模型崩溃的地方,暴露了三角形的边缘。解决方案是什么?此时,只需将每个切片和堆叠的数量从 20 个增加到 50 个,您将获得更加令人满意的图像,如图 4–16 右侧所示。
清单 4–5。 增加两个补光灯
`private void initLighting(GL10 gl) { float[] posMain={5.0f,4.0f,6.0f,1.0f}; float[] posFill1={-15.0f,15.0f,0.0f,1.0f}; float[] posFill2={-10.0f,-4.0f,1.0f,1.0f};
float[] white={1.0f,1.0f,1.0f,1.0f}; float[] red={1.0f,0.0f,0.0f,1.0f}; float[] dimred={.5f,0.0f,0.0f,1.0f};
float[] green={0.0f,1.0f,0.0f,0.0f}; float[] dimgreen={0.0f,.5f,0.0f,0.0f}; float[] blue={0.0f,0.0f,1.0f,1.0f}; float[] dimblue={0.0f,0.0f,.2f,1.0f};
float[] cyan={0.0f,1.0f,1.0f,1.0f}; float[] yellow={1.0f,1.0f,0.0f,1.0f}; float[] magenta={1.0f,0.0f,1.0f,1.0f}; float[] dimmagenta={.75f,0.0f,.25f,1.0f};
float[] dimcyan={0.0f,.5f,.5f,1.0f};
//Lights go here.
gl.glLightfv(SS_SUNLIGHT, GL10.GL_POSITION, makeFloatBuffer(posMain)); gl.glLightfv(SS_SUNLIGHT, GL10.GL_DIFFUSE, makeFloatBuffer(white)); gl.glLightfv(SS_SUNLIGHT, GL10.GL_SPECULAR, makeFloatBuffer(yellow));
gl.glLightfv(SS_FILLLIGHT1, GL10.GL_POSITION, makeFloatBuffer(posFill1)); gl.glLightfv(SS_FILLLIGHT1, GL10.GL_DIFFUSE, makeFloatBuffer(dimblue)); gl.glLightfv(SS_FILLLIGHT1, GL10.GL_SPECULAR, makeFloatBuffer(dimcyan));
gl.glLightfv(SS_FILLLIGHT2, GL10.GL_POSITION, makeFloatBuffer(posFill2)); gl.glLightfv(SS_FILLLIGHT2, GL10.GL_SPECULAR, makeFloatBuffer(dimmagenta)); gl.glLightfv(SS_FILLLIGHT2, GL10.GL_DIFFUSE, makeFloatBuffer(dimblue));
gl.glLightf(SS_SUNLIGHT, GL10.GL_QUADRATIC_ATTENUATION, .005f);
//Materials go here.
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_DIFFUSE, makeFloatBuffer(cyan)); gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_SPECULAR, makeFloatBuffer(white));
gl.glMaterialf(GL10.GL_FRONT_AND_BACK, GL10.GL_SHININESS, 25);
gl.glShadeModel(GL10.GL_SMOOTH); gl.glLightModelf(GL10.GL_LIGHT_MODEL_TWO_SIDE, 1.0f);
gl.glEnable(GL10.GL_LIGHTING); gl.glEnable(SS_SUNLIGHT); gl.glEnable(SS_FILLLIGHT1); gl.glEnable(SS_FILLLIGHT2);
gl.glLoadIdentity();
}`
图 4–16。 三灯,一主二补。左图是一个低分辨率的球体,而右图是高分辨率的。
在前面的示例中,涵盖了许多新的 API 调用,这些调用汇总在 Table 4–2 中。了解他们——他们是你的朋友,你会经常用到他们。
回到太阳系
现在我们有足够的工具回到太阳系项目。等等,这里有很多材质要讲。特别是 OpenGL 的一些其他方面,它们与照明或材质无关,但需要在太阳系模型变得更加复杂之前解决。
首先,我们需要向 renderer 类添加一些新的方法声明和实例变量。参见清单 4–6。
清单 4–6。 为太阳和地球做准备。
`public final static int X_VALUE = 0;
public final static int Y_VALUE = 1;
public final static int Z_VALUE = 2;
Planet m_Earth; Planet m_Sun;
float[] m_Eyeposition = {0.0f, 0.0f, 0.0f};`
接下来,需要生成第二个对象,在本例中是我们的太阳,并调整其大小和位置。当我们这样做的时候,改变地球的大小,使它比太阳小。因此,用清单 4–7 中的替换渲染器构造函数中的初始化代码。
清单 4–7。 添加第二个物体并初始化观察者的位置
` m_Eyeposition[X_VALUE] = 0.0f; //1 m_Eyeposition[Y_VALUE] = 0.0f; m_Eyeposition[Z_VALUE] = 5.0f;
m_Earth = new Planet(50, 50, .3f, 1.0f); //2 m_Earth.setPosition(0.0f, 0.0f, -2.0f); //3
m_Sun = new Planet(50, 50,1.0f, 1.0f); //4 m_Sun.setPosition(0.0f, 0.0f, 0.0f); //5`
事情是这样的:
- 我们的眼点,线 1,现在在 z 轴上有一个明确定义的+5 位置。
- 在第二行,地球的直径缩小到 0.3。
- 初始化地球的位置,从我们的角度看,在太阳的后面,在 z=-2,第 3 行。
- 现在,我们可以创建太阳,并将其放置在相对虚假的太阳系的中心,即 4 号线和 5 号线。
initLighting()需要看起来像在清单 4–8 中,清除了前面例子中的所有混乱。
清单 4–8。 太阳系模型的扩展照明
`private void initLighting(GL10 gl) { float[] sunPos={0.0f, 0.0f, 0.0f, 1.0f}; float[] posFill1={-15.0f, 15.0f, 0.0f, 1.0f}; float[] posFill2={-10.0f, -4.0f, 1.0f, 1.0f}; float[] white={1.0f, 1.0f, 1.0f, 1.0f}; float[] dimblue={0.0f, 0.0f, .2f, 1.0f}; float[] cyan={0.0f, 1.0f, 1.0f, 1.0f}; float[] yellow={1.0f, 1.0f, 0.0f, 1.0f}; float[] magenta={1.0f, 0.0f, 1.0f, 1.0f}; float[] dimmagenta={.75f, 0.0f, .25f, 1.0f}; float[] dimcyan={0.0f, .5f, .5f, 1.0f};
//Lights go here. gl.glLightfv(SS_SUNLIGHT, GL10.GL_POSITION, makeFloatBuffer(sunPos));
gl.glLightfv(SS_SUNLIGHT, GL10.GL_DIFFUSE, makeFloatBuffer(white));
gl.glLightfv(SS_SUNLIGHT, GL10.GL_SPECULAR, makeFloatBuffer(yellow));
gl.glLightfv(SS_FILLLIGHT1, GL10.GL_POSITION, makeFloatBuffer(posFill1)); gl.glLightfv(SS_FILLLIGHT1, GL10.GL_DIFFUSE, makeFloatBuffer(dimblue)); gl.glLightfv(SS_FILLLIGHT1, GL10.GL_SPECULAR, makeFloatBuffer(dimcyan));
gl.glLightfv(SS_FILLLIGHT2, GL10.GL_POSITION, makeFloatBuffer(posFill2)); gl.glLightfv(SS_FILLLIGHT2, GL10.GL_SPECULAR, makeFloatBuffer(dimmagenta)); gl.glLightfv(SS_FILLLIGHT2, GL10.GL_DIFFUSE, makeFloatBuffer(dimblue));
//Materials go here.
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_DIFFUSE, makeFloatBuffer(cyan)); gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_SPECULAR, makeFloatBuffer(white));
gl.glLightf(SS_SUNLIGHT, GL10.GL_QUADRATIC_ATTENUATION,.001f); gl.glMaterialf(GL10.GL_FRONT_AND_BACK, GL10.GL_SHININESS, 25); gl.glShadeModel(GL10.GL_SMOOTH); gl.glLightModelf(GL10.GL_LIGHT_MODEL_TWO_SIDE, 0.0f);
gl.glEnable(GL10.GL_LIGHTING); gl.glEnable(SS_SUNLIGHT); gl.glEnable(SS_FILLLIGHT1); gl.glEnable(SS_FILLLIGHT2); }`
自然地,顶层的 execute 方法必须彻底修改,同时添加一个小的实用函数,如清单 4–9 所示。
清单 4–9。新的渲染方式
`static float angle = 0.0f;
private void onDrawFrame(GL10 gl) { float paleYellow[]={1.0f, 1.0f, 0.3f, 1.0f}; //1 float white[]={1.0f, 1.0f, 1.0f, 1.0f}; float cyan[]={0.0f, 1.0f, 1.0f, 1.0f}; float black[]={0.0f, 0.0f, 0.0f, 0.0f}; //2
float orbitalIncrement= 1.25f; //3 float[] sunPos={0.0f, 0.0f, 0.0f, 1.0f};
gl.glEnable(GL10.GL_DEPTH_TEST);
gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT); gl.glClearColor(0.0f,0.0f,0.0f,1.0f);
gl.glPushMatrix(); //4
gl.glTranslatef(-m_Eyeposition[X_VALUE], -m_Eyeposition[Y_VALUE],- m_Eyeposition[Z_VALUE]); //5
gl.glLightfv(SS_SUNLIGHT, GL10.GL_POSITION, makeFloatBuffer(sunPos)); //6 gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_DIFFUSE, makeFloatBuffer(cyan)); gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_SPECULAR, makeFloatBuffer(white));
gl.glPushMatrix(); //7 angle+=orbitalIncrement; //8 gl.glRotatef(angle, 0.0f, 1.0f, 0.0f); //9 executePlanet(m_Earth, gl); //10 gl.glPopMatrix(); //11
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_EMISSION, makeFloatBuffer(paleYellow)); //12 gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_SPECULAR, makeFloatBuffer(black)); //13 executePlanet(m_Sun, gl); //14
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_EMISSION, makeFloatBuffer(black)); //15
gl.glPopMatrix(); //16 }
private void executePlanet(Planet m_Planet, GL10 gl) { float posX, posY, posZ; posX = m_Planet.m_Pos[0]; //17 posY = m_Planet.m_Pos[1]; posZ = m_Planet.m_Pos[2];
gl.glPushMatrix(); gl.glTranslatef(posX, posY, posZ); //18 m_Planet.draw(gl); //19 gl.glPopMatrix(); }`
事情是这样的:
- 第 1 行创建了一个较浅的黄色阴影。这只是给太阳涂上了更精确的颜色。
- 如果需要,我们需要一个黑色来“关闭”一些材质特性,如第 2 行所示。
- 在第 3 行中,需要轨道增量来使地球绕太阳运行。
- 第 4 行的
glPushMatrix()是一个新的 API 调用。当与glPopMatrix()结合时,它有助于将世界的一部分与另一部分的转换隔离开来。在这种情况下,第一个glPushMatrix实际上阻止了随后对glTranslate()的调用向自身添加新的翻译。你可以转储glPush/PopMatrix对,把onDrawFrame()中的glTranslate放到初始化代码中,只要它只被调用一次。 - 第 5 行的平移确保对象从我们的视点“移开”。请记住,OpenGL ES 世界中的一切都是围绕眼点进行的。我更喜欢有一个不依赖于观察者位置的公共原点,在这种情况下,它是太阳的位置,用视点的偏移量来表示。
- 第 6 行仅仅强调了太阳的位置在原点。
- 哦!7 号线另一个
glPushMatrix()。这确保了地球上的任何变化都不会影响太阳。 - 第 8 行和第 9 行使地球绕太阳运行。怎么会?在第 10 行中,调用了一个小的实用函数。它执行任何过渡,并在需要时将对象从原点移开。正如您所记得的,转换可以被认为是最后调用/第一次使用的。因此,
executePlanets()中的翻译实际上是首先执行的,然后是glRotation。请注意,这种方法将使地球以正圆轨道运行,然而在现实中,没有行星会有正圆轨道,所以将使用glTranslation。 - 第 11 行转储任何地球独有的转换。
- 第 12 行设置太阳的物质是发射性的。注意对
glMaterialfv的调用没有绑定到任何特定的对象。它们设置所有后续对象使用的当前材质,直到进行下一次调用。第 13 行关闭任何用于地球的镜面反射设置。 - 第 14 行再次调用我们的工具,这次是太阳。
- 发射材质属性被关闭,在第 15 行,接着是另一个
glPopMatrix()。请注意,每次使用 push 矩阵时,它都必须与 pop 配对使用。OpenGL ES 可以处理高达 16 层的堆栈。此外,由于 OpenGL 中使用了三种矩阵(模型视图、投影和纹理),请确保您正在推送/弹出正确的堆栈。您可以通过记住使用glMatrixMode()来确保这一点。 - 现在在
executePlanet()中,第 17 行获得行星的当前位置,因此第 18 行可以将行星平移到适当的位置。在这种情况下,它实际上从未改变,因为我们让glRotatef()处理轨道任务。否则,xyz 会随着时间不断变化。 - 最后在第 19 行调用星球自己的
drawing例程。
对于 Planet.java,将下面一行添加到实例变量中:
public float[] m_Pos = {0.0f, 0.0f, 0.0f};
在 execute 方法之后,添加清单 4–10 中的代码,定义新的方法。
**清单 4–10。**m _ Pos 的设定者
public void setPosition(float x, float y, float z) { m_Pos[0] = x; m_Pos[1] = y; m_Pos[2] = z; }
当你这么做的时候,让我们调低背景的灰色。应该是太空,太空不是灰色的。返回渲染器 onDrawFrame(),将对glClearColor的调用更改如下:
gl.glClearColor(0.0f,0.0f, 0.0f, 1.0f);
现在编译并运行。您应该会看到类似于图 4–17 的内容。
图 4–17。 中间发生了什么?
这里有点奇怪。跑步的时候,你应该看到地球从太阳的左侧后面出来,朝着我们的方向绕行到太阳前面穿过,然后移开,再次重复绕行。那么,为什么我们看不到中间图像中太阳前方的地球(图 4–17)?
在所有的图形中,无论是计算机还是其他,绘制的顺序起着很大的作用。如果你在画肖像,你先画背景。如果你正在生成一个小太阳系,那么,应该先画太阳(呃,也许不是…或者不总是)。
渲染顺序,或*深度排序,*以及如何确定什么对象遮挡其他对象一直是计算机图形学的一大部分。在添加太阳之前,渲染顺序是无关紧要的,因为只有一个对象。但是随着世界变得越来越复杂,你会发现有两种方法可以解决这个问题。
第一种叫做画家算法。这意味着简单地先画最远的物体。对于像一个球体绕另一个球体旋转这样简单的事情来说,这是非常容易的。但是,当你拥有像《魔兽世界》或《第二人生》这样非常复杂的 3D 沉浸式世界时,会发生什么呢?这些实际上会使用 painter 算法的一个变体,但是会预先计算一些信息来确定所有可能的遮挡顺序。该信息然后被用来形成一个二进制空间划分 (BSP)树。3D 世界中的任何地方都可以映射到树中的一个元素,然后可以遍历树以获取查看者位置的最佳顺序。这在执行上非常快,但是设置起来很复杂。幸运的是,对于我们简单的宇宙来说,这是一种过度杀戮。深度排序的第二种方法根本不是排序,而是实际上使用每个单独像素的 z 分量。屏幕上的一个像素有一个 x 和 y 值,但它也可以有一个 z 值,即使我面前的优派是一个平面 2D 表面。当一个像素准备在另一个像素上绘制时,z 值被比较,两者中较近的胜出。它被称为 z 缓冲,非常简单明了,但对于非常复杂的场景,它会占用额外的 CPU 时间和图形内存。我更喜欢后者,OpenGL 使得 z 缓冲非常容易实现。
在方法 OnSurfaceCreated 中,找到:
gl.glDepthMask(false);
并替换为
gl.glDepthMask(true);
如果它工作正常,你现在应该看到地球在前面时遮住太阳,或者在后面时被遮住。参见图 4–18。
图 4–18。 使用 z 缓冲器
乐队继续演奏
在 Android 下,GLSurfaceView 对象没有默认为“真正的”32 位颜色,即红色、绿色、蓝色和 alpha 各 8 位。相反,它选择了一种质量稍低的模式,称为“RGB565”,每像素仅使用 16 位,或者红色使用 5 位,绿色使用 6 位,蓝色使用 5 位。使用像这样的低分辨率颜色模式,您可能会在平滑着色的对象上看到“带状”伪像,如图 Figure 4–19 中左侧的图像所示。这仅仅是因为没有足够的颜色可用。但是,您可以指示 GLSurfaceView 使用更高分辨率的模式,从而生成图 4–19 中右侧的图像。在 activity 对象的 onCreate()处理程序中使用以下代码:
GLSurfaceView view = new GLSurfaceView(this); view.setEGLConfigChooser(8,8,8,8,16,0); //The new line view.setRenderer(new SolarSystemRenderer()); setContentView(view);
新行(view.setEGLConfigChooser(8,8,8,8,16,0)告诉视图每种颜色使用 8 位,此外还有 16 位用于深度或 z 缓冲,如上所述。最终值是模板的,将在第七章的中介绍。
图 4–19。 左边 16 位颜色,右边 32 位颜色。
自动使用 32 位颜色模式时要小心,因为一些较旧的设备不支持它们,可能会导致崩溃。建议您在使用默认模式之外的任何模式之前,先检查哪些模式可用。这可以通过创建一个自定义的“ColorChooser”对象来完成,该对象将枚举所有可能的模式。是的,这很痛苦,而且有很多代码。网站上将提供一个示例。
总结
本章介绍了场景照明和着色的各种方法,以及用于确定每个顶点颜色的数学算法。您还学习了漫射、镜面反射、发射和环境照明,以及与将灯光转化为聚光灯相关的各种参数。太阳系模型被更新以支持多个对象,并使用 z 缓冲来正确处理对象遮挡。