安卓 OpenGL ES 高级教程(二)
五、纹理
一个人的真正价值不在于他自己,而在于他身上的色彩和质地。
—阿尔贝特·施韦泽
人们的生活会变得相当乏味,没有质感。去除那些有趣的小缺点和怪癖,会给我们的日常生活增添一点光彩,不管它们是奇怪的但却是迷人的小习惯还是意想不到的才能。想象一下,一个高中看门人碰巧是一个优秀的舞厅舞者,一个著名的喜剧演员每天必须只穿新的白色袜子,一个非常成功的游戏工程师害怕手写信件——所有这些都可以让我们微笑,并在一天中增加一点点奇迹。在创造人造世界时也是如此。计算机可以产生的视觉完美可能很漂亮,但如果你想给你的场景创造一种真实感,那就感觉不太对了。这就是纹理产生的原因。
质感让完美成为真实。《美国传统词典》这样描述它:“某物独特的物理组成或结构,尤其是其各部分的大小、形状和排列。”很有诗意,是吧?
在 3D 图形的世界里,纹理在创建引人注目的图像时和灯光一样重要,而且现在可以不费吹灰之力地加入进来。图形芯片行业的大部分工作都植根于以比前一代硬件更高的速度渲染越来越精细的纹理。
因为 OpenGL ES 中的纹理是一个很大的主题,这一章将局限于基础知识,更高级的主题和技术留待下一章。记住这一点,让我们开始吧。
纹理化的语言
假设你想在你正在开发的游戏中创建一个飞机跑道。你会怎么做?很简单,拿几个黑色三角形,把它们拉长。砰!现在你有你的着陆跑道了!别急,伙计。画在长条中心的线条呢?一堆小白脸怎么样?这可能行得通。但是不要忘记最后那些黄色的人字形。嗯,添加一些额外的面孔,并把它们涂成黄色。别忘了数字。通向停机坪的曲线怎么样?很快你可能会有数百个三角形,但这仍然无助于解决油渍、修理、刹车痕或交通事故。现在事情开始变得复杂了。获取所有的细节可能需要成千上万张脸。与此同时,你的伙伴亚瑟也在创作一部漫画。你在比较笔记,告诉他多边形的数量,你甚至还没有谈到路杀。亚瑟说他只需要几个三角形和一个图像。你看,他使用纹理地图,使用纹理地图可以创建一个非常详细的表面,如飞机跑道,砖墙,装甲,云,吱吱作响的风化木门,一个遥远星球上的坑坑洼洼的地形,或一辆 56 年别克生锈的外表。
在计算机图形学的早期,纹理,或者说纹理映射,消耗了两种最珍贵的资源:CPU 周期和内存。它很少被使用,各种各样的小技巧都被用来节省这两种资源。与 20 年前相比,现在的内存几乎是免费的,现代芯片似乎有无限的速度,使用纹理不再是一个需要整夜熬夜和挣扎的决定。
关于纹理的一切(大部分)
纹理有两大类型:程序和图像。程序纹理是基于某种算法动态生成的。木材、大理石、沥青、石头等等都有“方程式”。几乎任何种类的材质都可以简化成一种算法,从而绘制到一个物体上,如图 Figure 5–1 所示。
图 5–1。 左边的圣杯是抛光的黄金,而右边使用程序纹理看起来像金矿石,而圆锥体看起来像大理石。
程序纹理非常强大,因为它们可以产生无限多种可缩放的图案,这些图案可以被放大以显示越来越多的细节,如图 5–2 所示。否则,这将需要大量的静态图像。
图 5–2。 图 5–1 中右边的高脚杯特写。请注意需要非常大的图像才能完成的细节。
用于之前图像的 3D 渲染应用 Strata Design 3D-SE 支持程序纹理和基于图像的纹理。图 5–3 显示了用于指定图 5–2 中描述的金矿纹理参数的对话框。
图 5–3。 图 5–2 中用于产生金矿纹理的所有可能设置
程序纹理,以及在较小程度上的图像纹理,可以按照从随机到结构化的复杂程度进行分类。随机或随机纹理可以被认为是“看起来像噪音”,就像沙子、灰尘、砾石、纸张中的颗粒等细粒度材质。接近随机的可能是火焰、草地或湖面。另一方面,结构化纹理具有广泛的可识别特征和图案。砖墙、柳条篮、格子花呢或一群壁虎将被构建。
图像纹理
如前所述,图像纹理就是这样。它们可以作为表面或材质纹理,如红木、钢板或散落在地上的树叶。如果做得好,这些可以无缝平铺,覆盖比原始图像更大的表面。因为他们来自现实生活,他们不需要复杂的程序性软件。Figure 5–4 展示了圣杯的场景,但这一次使用了木质纹理,圣杯使用桃花心木,圆锥使用桤木,而立方体仍然是金色的。
图 5–4。 使用真实世界的图像纹理
除了使用图像纹理作为材质,它们本身也可以在你的 3D 世界中作为图片使用。Galaxy Tab 的渲染图像可以将纹理放到屏幕上。一个 3D 城市可以使用真实的照片作为建筑物的窗户、广告牌或客厅里的家庭照片。
OpenGL ES 和纹理
当 OpenGL ES 渲染一个物体时,比如《??》第四章中的迷你太阳系,它会绘制每个三角形,然后根据组成每个面的三个顶点对其进行光照和着色。之后,它愉快地走向下一个。纹理只不过是一个图像。正如您在前面了解到的,它可以动态生成以处理上下文相关的细节(比如云模式),也可以是.jpg、.png或其他任何东西。当然,它是由像素组成的,但是当作为纹理操作时,它们被称为纹理元素。你可以把一个 OpenGL ES 纹理想象成一堆小的彩色“面”(纹理元素),每一个都有相同的大小,并且缝合在一张纸上,比如说,一面上有 256 个这样的“面”。每个面的大小都一样,可以拉伸或挤压,以便在任何大小或形状的表面上工作。它们没有浪费存储 xyz 值的内存的角几何,可以有多种可爱的颜色,并且非常划算。当然,它们的用途非常广泛。
像你的几何体一样,纹理也有自己的坐标空间。几何图形使用可靠的笛卡尔坐标表示其许多部分的位置,称为 x,y 和 z ,纹理使用 s 和 t 。将纹理应用到某个几何对象的过程称为 UV 映射。( s 和 t 仅用于 OpenGL 世界,其他使用 u 和 v 。去想想。)
那么,这是如何应用的呢?假设你有一块正方形的桌布,你必须把它拼成一张长方形的桌子。你需要沿着一边把它固定住,然后沿着另一边用力拉,直到它刚好盖住桌子。您可以只连接四个角,但如果您真的希望它“适合”,您可以沿着边缘甚至在中间连接其他部分。这就是一个纹理如何适应一个表面的一点点。
纹理坐标空间归一化;也就是说, s 和 t 的范围都是从 0 到 1。它们是无单位的实体,被抽象为不依赖于源或目的地的维度。因此,要进行纹理处理的面的顶点 s 和 t 值将在 0.0 到 1.0 之间,如图图 5–5 所示。
图 5–5。 纹理坐标从 0 到 1.0,不管纹理是什么。
在最简单的例子中,我们可以将一个矩形纹理应用到一个矩形面上,然后完成它,如图 5–5 所示。但是如果你只想要纹理的一部分呢?你可以提供一个只有你想要的位的.png,如果你想拥有这个东西的许多变体,这就不太方便了。然而,还有另一种方法。仅仅改变目的面的 s 和 t 坐标。假设你想要的只是复活节岛雕像的左上角,我称之为海德利。你需要做的只是改变目的地的坐标,而那些坐标是基于你想要的图像部分的比例,如图 Figure 5–6 所示。也就是说,因为您希望图像被裁剪到沿 S 轴的一半,所以 s 坐标将不再从 0 到 1,而是从 0 到. 5。然后 t 坐标将从 0.5 变为 1.0。如果你想要左下角,你可以使用与 s 坐标相同的 0 到 0.5 的范围。
还要注意,纹理坐标系是独立于分辨率的。也就是说,边长为 512 的图像的中心将是(. 5,. 5),就像边长为 128 的图像的中心一样。
图 5–6。 通过改变纹理坐标裁剪掉一部分纹理
纹理不限于直线物体。通过仔细选择目标面上的第个坐标,你可以做出一些更加丰富多彩的形状,如图图 5–7 所示。
图 5–7。 将图像映射成不寻常的形状
如果您在目的地的顶点上保持图像坐标不变,图像的角将跟随目的地的角,如图 Figure 5–8 所示。
图 5–8。 扭曲的图像可以在 2D 表面产生 3D 效果。
纹理也可以平铺以便复制描绘壁纸、砖墙、沙滩等的图案,如图图 5–9 所示。请注意,坐标实际上超过了 1.0 的上限。所做的只是开始纹理重复,例如,0.6 的 s 等于 1.6 的 s ,2.6,等等。
图 5–9。 平铺图像对于重复的图案非常有用,例如用于壁纸或砖墙的图案。
除了在图 5–9 中显示的平铺模型,纹理平铺也可以被“镜像”或夹紧。两者都是处理 0 到 1.0 范围之外的 s 和 t 的机制。
镜像平铺类似重复,但只是翻转交替图像的列/行,如图图 5–10a 所示。钳制图像意味着纹理元素的最后一行或最后一列重复,如图 Figure 5–1bb 所示。在我的示例图像中,钳制看起来一塌糊涂,但是当图像具有中性边框时,钳制很有用。在这种情况下,如果 s 或 v 超出其正常界限,您可以防止任何图像在一个或两个轴上重复。
图 5–10。 左边(a)显示的是一个镜像-重复,只针对 S 轴,而右边纹理(b)被钳制。
**注意:**图 5–10 中右侧图像右边缘的问题表明,设计用于夹紧的纹理应该有一个 1 像素宽的边界,以匹配它们所绑定的对象的颜色——除非你认为它真的很酷,那么当然,这几乎胜过一切。
正如你现在所知道的,OpenGL ES 不做四边形——也就是有四个边的面(相对于它的桌面兄弟)。所以,我们必须用两个三角形来制作它们,给我们一些结构,比如我们在第三章实验过的三角形带和扇形。将纹理应用到这个“假”四边形是一件简单的事情。一个三角形的纹理坐标为(0,0)、(1,0)和(0,1),而另一个三角形的坐标为(1,0)、(1,1)和(0,1)。如果你研究一下图 5–11,会更有意义。
图 5–11。 在两个面上放置纹理
最后,让我们看看一个纹理是如何被拉伸到一大堆人脸上的,如图 Figure 5–12 所示,然后我们可以做一些有趣的事情,看看这是不是真的。
图 5–12。 将一个纹理拉伸到多个面上
图像格式
OpenGL ES 支持很多不同的图像格式,我说的不是.png vs. .jpg,我指的是内存中的形式和布局。标准是 32 位,为红色、绿色、蓝色和 alpha 各分配 8 位内存。被称为 RGBA,它是大多数练习使用的标准。它也是“最漂亮的”,因为它提供了超过 1600 万种颜色和透明度。但是,您通常可以使用 16 位甚至 8 位图像。这样做,你可以节省大量的内存和曲柄速度相当多,仔细选择图像。参见表 5–1 了解一些更流行的格式。
另外,各种格式的要求是,一般来说,OpenGL 只能使用边长为 2 的幂的纹理图像。一些系统可以绕过这一点,比如有一定限制的 iOS,但目前,只要坚持标准就行了。
所以,所有这些东西都解决了,是时候开始编码了。
回到充满弹性的正方形
让我们后退一步,再次创建通用的弹性正方形,这是我们在第一章中第一次做的。我们将对它应用一个纹理,然后操纵它来展示前面详述的一些技巧,比如重复、动画和扭曲。
随意回收第一个项目。
接下来是纹理的实际创建。这将读入 Android 支持的任何类型的图像文件,并将其转换为 OpenGL 可以使用的格式。
在我们开始之前,还需要一个步骤。Android 需要被通知将被用作纹理的图像文件。这可以通过将图像文件hedly.png添加到/res/drawable文件夹来完成。如果 drawable 文件夹不存在,您可以现在创建它并将其添加到项目中。我们将在整数数组中存储纹理信息。在Square.java中增加以下一行:
private int[] textures = new int[1];
添加以下导入内容:
import android.graphics.*;celar import android.opengl.*;
接下来将列表 5–1 添加到Square.java来创建纹理。
清单 5–1。 创建 OpenGL 纹理
public int createTexture(GL10 gl, Context contextRegf, int resource) { Bitmap image = BitmapFactory.decodeResource(contextRegf.getResources(), resource); // 1 gl.glGenTextures(1, textures, 0); // 2 gl.glBindTexture(GL10.*GL_TEXTURE_2D*, textures[0]); // 3 ` GLUtils.texImage2D(GL10.GL_TEXTURE_2D, 0, image, 0); // 4
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_LINEAR); // 5a
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR); // 5b
image.recycle(); //6 return resource; }`
让我们来分解一下:
- 第 1 行加载 Android 位图,让加载器处理 Android 可以读取的任何图像格式。表 5–2 列出了支持的格式。
- 第 2 行得到一个未使用的纹理“名称”,它实际上只是一个数字。这确保了您使用的每个纹理都有一个唯一的标识符。如果您想重用标识符,那么您应该调用
glDeleteTextures()。 - 之后,纹理被绑定到下一行当前的 2D 纹理,用新的纹理替换之前的纹理。在 OpenGL ES 中,这些纹理目标只有一个,所以它必须总是
GL_TEXTURE_2D,而成熟的 OpenGL 有几个。所有可用参数参见表 5–3。绑定也会使该纹理处于活动状态,因为一次只有一个纹理处于活动状态。这也指导 OpenGL 在哪里放置任何新的图像数据。参见表 5–2。 - 在这里的第 4 行,
GLUtils使用了绑定 OpenGL ES 和 Android APIs 的 Android 工具类。这个工具为我们在第 1 行创建的位图指定了 2D 纹理图像。图像(纹理)是基于创建的位图以其原始格式在内部创建的。 - 最后,第 5a 和 5b 行设置了 Android 上需要的一些参数。没有它们,纹理有一个默认的“过滤器”值,这是不必要的。最小和最大过滤器告诉系统如何在某些情况下处理纹理,在这些情况下,纹理必须缩小或放大以适应给定的多边形。表 5–3 显示了 OpenGL ES 中可用的参数类型。
- 为了成为好邻居,第 6 行告诉 Android 显式回收位图,因为位图会占用大量内存。
在我们调用createTexture方法之前,我们需要获取图像的上下文和资源 ID(headly.png)。要获得上下文,请修改BouncySquareActivity.java中的onCreate()方法,如下所示:
view.setRenderer(new SquareRenderer(true));
致以下内容:
view.setRenderer(new SquareRenderer(true, this.getApplicationContext()));
这也需要将SquareRenderer.java中的构造器定义更改为以下内容:
public SquareRenderer(boolean useTranslucentBackground, Context context) { mTranslucentBackground = useTranslucentBackground; this.context = context; //1 this.mSquare = new Square(); }
您需要添加以下导入:
import android.content.Context;
并添加一个实例变量来支持新的上下文。稍后,当加载图像并将其转换为 OpenGL 兼容纹理时,会用到上下文。
现在,为了获得资源 ID,在SquareRenderer.java的onSurfaceCreated()方法中添加以下内容:
int resid = book.BouncySquare.R.drawable.*hedly*; //1 mSquare.createTexture(gl, this.context, resid); //2
- 第 1 行获取我们添加到 drawable 文件夹中的图像资源(
hedly.png)。当然,你可以使用任何你想要的图像。 - 在第 2 行,我们使用了
Square类的对象,并用正确的上下文和图像的资源 ID 调用了createTexture()方法。
然后将以下内容添加到Square.java中的接口实例变量中:
public FloatBuffer mTextureBuffer; float[] textureCoords = { 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f };
这定义了纹理坐标。现在在 square 的构造函数中创建类似于我们在第一章中创建的vertBuffer的textureBuffer。
` ByteBuffer tbb = ByteBuffer.allocateDirect(textureCoords.length * 4);
tbb.order(ByteOrder.nativeOrder());
mTextureBuffer = tbb.asFloatBuffer();
mTextureBuffer.put(textureCoords);
mTextureBuffer.position(0);`
我的图片是太平洋复活节岛上一个神秘的巨大石头头像的照片。为了便于测试,请使用 32 位 RGBA 的 2 的幂(POT)图像。
**注意:**默认情况下,OpenGL 要求图像数据中的每一行纹理元素都在一个 4 字节的边界上对齐。我们的 RGBA 纹理坚持这一点;对于其他格式,考虑使用调用 glPixelStorei(GL _ PACK _ ALIGNMENT,x),其中 x 可以是 1、2、4 或 8 个字节用于对齐。使用 1 来涵盖所有情况。
请注意,纹理通常有大小限制,这取决于实际使用的图形硬件。您可以通过调用以下代码来确定特定平台可以使用多大的纹理,其中maxSize是一个在运行时进行补偿的整数:
gl.glGetIntegerv(GL10.GL_MAX_TEXTURE_SIZE,maxSize);
最后,需要修改draw()例程,如清单 5–2 所示。大部分你已经见过了。我已经将渲染器模块中的glEnableClientState()调用迁移到这里,使方形对象更加包容。
清单 5–2。 用纹理渲染几何体
`public void draw(GL10 gl) { gl.glVertexPointer(2, GL10.GL_FLOAT, 0, mFVertexBuffer); gl.glEnableClientState(GL10.GL_VERTEX_ARRAY); gl.glColorPointer(4, GL10.GL_UNSIGNED_BYTE, 0, mColorBuffer); gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
gl.glEnable(GL10.GL_TEXTURE_2D); //1 gl.glEnable(GL10.GL_BLEND); //2
gl.glBlendFunc(GL10.GL_ONE, GL10.GL_SRC_COLOR); //3 gl.glBindTexture(GL10.GL_TEXTURE_2D, textures[0]); //4
gl.glTexCoordPointer(2, GL10.GL_FLOAT,0, mTextureBuffer); //5 gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY); //6
gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, 4); //7
gl.glDisableClientState(GL10.GL_COLOR_ARRAY); gl.glDisableClientState(GL10.GL_VERTEX_ARRAY); gl.glDisableClientState(GL10.GL_TEXTURE_COORD_ARRAY); //8 }`
这是怎么回事?
- 在第 1 行中,
GL_TEXTURE_2D目标被启用。桌面 OpenGL 支持 1D 和 3D 纹理,但不支持 es。 - 在第 2 行中,可以启用混合。混合是指颜色和目标颜色根据第 3 行中打开的公式进行混合。
- 混合功能决定如何将源和目标像素/片段混合在一起。最常见的形式是源覆盖目标,但其他形式可以创建一些有趣的效果。由于这是一个很大的话题,所以后面会讲到。
- 第 4 行确保我们想要的纹理是当前的。
- 第 5 行是纹理坐标被传递给硬件的地方。
- 正如您必须告诉客户端处理颜色和顶点一样,您需要对第 6 行中的纹理坐标做同样的事情。
- 第 7 行你会认识,但这一次除了绘制颜色和几何图形,它现在从当前纹理(
texture_2d)获取信息,将四个纹理坐标匹配到由vertices[]数组指定的四个角(纹理对象的每个顶点都需要分配一个纹理坐标),并使用第 3 行中指定的值混合它。 - 最后,禁用纹理的客户端状态,就像禁用颜色和顶点一样。
如果一切正常,您应该会看到类似于图 5–13 中左图的东西。注意纹理是如何从顶点获取颜色的?注释掉该行glColorPointer(4, GL10.GL_UNSIGNED_BYTE, 0, mColorBuffer),现在您应该会看到图 5–13 中的右图。如果您没有看到任何图像,请仔细检查您的文件,确保它的大小确实是 2 的幂,例如 128x128 或 256x256。
图 5–13。 对有弹性的正方形应用纹理。左边使用顶点颜色;右派没有。
你说什么?纹理颠倒了?这很可能是一个问题,取决于各自的操作系统如何对待位图。OpenGL 希望左下角作为原点,而一些图像格式或驱动程序选择左上角作为原点。有两种主要方法可以解决这个问题:您可以选择更改代码,或者您可以提供一个预先裁剪的图像。因为这是一个非常简单的项目,我选择用图像编辑器翻转图形。
所以现在我们可以复制本章第一部分中的一些例子。第一种方法是只挑选一部分纹理来显示。将textureCoords更改如下:
`float[] textureCoords =
{ 0.0f, 0.0f, 0.5f, 0.0f, 0.0f, 0.5f, 0.5f, 0.5f };`
你拿到图 5–14 了吗?
图 5–14。 使用 s 和 t 坐标裁剪图像
纹理坐标到真实几何坐标的映射看起来像图 5–15。如果你还不太清楚,花几分钟去理解这里发生了什么。简单地说,数组中的纹理坐标与几何坐标是一对一的映射。
图 5–15。 纹理坐标与几何坐标一一对应。
现在改变纹理坐标如下。你能猜到会发生什么吗?(图 5–16):
float[] textureCoords = { 0.0f, 2.0f, 2.0f, 2.0f, 0.0f, 0.0f, 2.0f, 0.0f };
图 5–16。 当需要做壁纸等重复图案时,重复图像很方便。
现在让我们通过改变顶点的几何形状来扭曲纹理,为了让事情看起来更清楚,恢复原始的纹理坐标来关闭重复:
float vertices[] = { -1.0f, -0.7f, 1.0f, -0.30f, -1.0f, 0.70f, 1.0f, 0.30f, };
这应该会夹住正方形的右侧,并带走纹理,如图图 5–17 所示。
图 5–17。 捏紧多边形的右边
有了这些知识,如果你动态改变纹理坐标会发生什么?将下面的代码添加到draw()—任何地方都应该可以工作。
*` textureCoords[0]+=texIncrease;
textureCoords[2]+=texIncrease;
textureCoords[4]+=texIncrease;
textureCoords[6]+=texIncrease;
textureCoords[1]+=texIncrease;
textureCoords[3]+=texIncrease;
textureCoords[5]+=texIncrease;
textureCoords[7]+=texIncrease;`
并使textureCoords和texIncrease都成为实例变量。
这将逐帧增加一点纹理坐标。敬畏地奔跑和站立。这是一个非常简单的获得动画纹理的技巧。3D 世界中的字幕可能会用到这个。您可以创建一个纹理,就像一个卡通人物正在做某事的电影胶片,并更改 s 和 t 的值,像一本小动画书一样从一帧跳到另一帧。另一种是创建基于纹理的字体。由于 OpenGL 没有原生字体支持,这取决于我们,世界上长期受苦的工程师,自己添加它。唉。这可以通过将所需字体的字符放置在单个马赛克纹理上,然后通过仔细使用纹理坐标来选择它们来实现。
mipmap
小中见大贴图是一种为给定纹理指定多个细节级别的方法。这可以在两个方面有所帮助:当纹理对象到视点的距离变化时,它可以平滑纹理对象的外观;当纹理对象很远时,它可以节省资源的使用。
例如,在遥远的太阳中,我可能会为木星使用 1024x512 的纹理。但是,如果木星离我们很远,只有几个像素宽,那将是对内存和 CPU 的浪费。这就是 mipmapping 发挥作用的地方。那么,什么是 mipmap?
源自拉丁语短语“multum in parvo”(字面意思:小中有大),一个 mipmap 是一系列细节层次不同的纹理。你的根图像的边长可能是 128,但是当它是一个纹理贴图的一部分时,它的边长可能也是 64,32,16,8,4,2 和 1 像素,如图图 5–18 所示。
图 5–18。 海德利,mipmapped 版
回到本章最初的练习,有纹理的弹性正方形,你将通过测试进行 mipmapping。
首先,创建一系列纹理,从 1x1 到 256x256,使每个纹理的大小是前一个的两倍,同时给它们涂上不同的颜色。这些颜色使您能够很容易地分辨出一个图像何时变成另一个图像。将它们添加到您的项目中的/res/drawable/下,然后在onSurfaceCreated()的SquareRendered.java中,用清单 5–3 替换掉对createTexture()的单个调用。注意,最后使用的是最后一个参数,即细节层次索引。如前所述,如果您只有一个图像,则使用 0 作为默认值。任何大于 0 的都将是 mipmap 图像族的剩余部分。所以,第一张是海德利的,其余的我用的是彩色方块,这样当它们弹出和弹出时,很容易看到不同的图像。请注意,如果您像这样手动生成 mipmaps,您需要为每个级别指定图像,并且它们必须是正确的尺寸,因此您不能仅仅为了节省几行代码而跳过 1、2 和 4 像素图像。否则,什么都不会显示。并确保原始图像的边长为 256,以便从 1 到 256 的图像之间有一个完整的链。
为了方便打开或关闭 mipmapping,我在Square.java中添加了以下实例变量。
public boolean m_UseMipmapping = true;
将清单 5–3 添加到SquareRenderer.java中的onSurfaceCreated()方法。
清单 5–3。 设置自定义预过滤 Mipmap
` int resid = book.BouncySquare.R.drawable.hedly256; mSquare.createTexture(gl, this.context, resid, true);
if (mSquare.m_UseMipmapping) { resid = book.BouncySquare.R.drawable.mipmap128; mSquare.createTexture(gl, this.context, resid, false); resid = book.BouncySquare.R.drawable.mipmap64; mSquare.createTexture(gl, this.context, resid, false); resid = book.BouncySquare.R.drawable.mipmap32; mSquare.createTexture(gl, this.context, resid, false); resid = book.BouncySquare.R.drawable.mipmap16; mSquare.createTexture(gl, this.context, resid, false); resid = book.BouncySquare.R.drawable.mipmap8; mSquare.createTexture(gl, this.context, resid, false); resid = book.BouncySquare.R.drawable.mipmap4; mSquare.createTexture(gl, this.context, resid, false); resid = book.BouncySquare.R.drawable.mipmap2; mSquare.createTexture(gl, this.context, resid, false); resid = book.BouncySquare.R.drawable.mipmap1; mSquare.createTexture(gl, this.context, resid, false); }`
在此之后,Square.java中的createTexture()需要替换为清单 5–4 中的内容。
清单 5–4。 生成 Mipmap 链
private int[] textures = new int[1]; static int *level* = 0; ` public int createTexture(GL10 gl, Context contextRegf, int resource, boolean imageID)
{
Bitmap tempImage = BitmapFactory.decodeResource(
contextRegf.getResources(), resource); // 1
if (imageID == true) {
gl.glGenTextures(1, textures, 0);
gl.glBindTexture(GL10.GL_TEXTURE_2D, textures[0]);
}
GLUtils.texImage2D(GL10.GL_TEXTURE_2D, level, tempImage, 0); // 4 level++;
if (m_UseMipmapping == true) { gl.glTexParameterx(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_LINEAR_MIPMAP_NEAREST); gl.glTexParameterx(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR_MIPMAP_NEAREST); } else { gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_LINEAR); gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR); }
tempImage.recycle();// 6 return resource; }`
自然也需要一些改变。将以下实例变量添加到SquareRenderer.java:
` float z = 0.0f;
boolean flipped=false;
float delz_value=.040f;
float delz = 0.0f;
float furthestz=-20.0f;
static float rotAngle=0.0f;`
现在,使用清单 5–5 代替当前的onDrawFrame()。这将导致 z 值来回振荡,以便您可以观察到小中见大贴图的运行。
清单 5–5。 随 z 值变化的 onDrawFrame()
` public void onDrawFrame(GL10 gl) {
gl.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT);
gl.glMatrixMode(GL11.GL_MODELVIEW); gl.glLoadIdentity();
if(z<furthestz)
{ if(!flipped) { delz=delz_value; flipped=true; } else { flipped=false; } } else if(z > -.01f) { if(!flipped) { delz=-delz_value; flipped=true; } else { flipped=false; } } z=z+delz; gl.glTranslatef(0.0f, (float) (Math.sin(mTransY) / 2.0f), z); gl.glRotatef(rotAngle, 0, 0, 1.0f); rotAngle+=.5f;
mSquare.draw(gl);
mTransY += .15f; }`
最后,确保调用glColorPonter()从 square 的draw()方法中移除。
如果编译和运行正常,您应该会看到类似于 Figure 5–19 的内容,当 OpenGL ES 为给定的距离选择最佳颜色时,不同的颜色会出现和消失。
图 5–19。 根据到眼点的距离,每个 mipmap 级别的不同图像会弹出和弹出。
让 OpenGL 为您生成小中见大贴图是可能的,如下一节所示。
过滤
用作纹理的图像在投影到屏幕上时,根据其内容和最终大小,可能会出现各种人为效果。非常详细的图像可能会出现令人讨厌的闪烁效果。然而,可以通过一个叫做过滤的过程来动态修改图像以最小化这些影响。
假设你有一个 128x128 的纹理,但是纹理面的边长是 500 像素。你应该看到什么?显然,图像的原始像素,现在称为纹理像素,将比任何屏幕像素大得多。这是一个被称为放大的过程。相反,你可能会遇到纹理元素比一个像素小得多的情况,这被称为缩小。过滤是用于确定如何将像素的颜色与底层纹理元素相关联的过程。表 5–4 和 5–5 显示了这种情况的可能变化。
有三种主要的过滤方法:
- 点采样(在 OpenGL lingo 中调用):像素的颜色是基于离像素中心最近的纹理元素。这是最简单、最快的,自然产生最不令人满意的图像。
- 双线性采样,也称为线性像素的着色基于距离像素中心最近的 2×2 纹理元素阵列的加权平均值。这可以大大平滑图像。
- 三线性采样:这需要小中见大贴图,将两个最接近的小中见大贴图级别用于屏幕上的最终渲染,对每个级别执行双线性选择,然后对两个单独的值进行加权平均。
您可以通过再次查看第一个练习来了解这一点。在您的 mipmapping 实验中,将以下行添加到createTexture()的最末尾,同时删除创建所有先前 mipmap 图像的初始化行(当然,图像#0 除外):
gl.glHint(GL11.GL_GENERATE_MIPMAP,GL10.GL_NICEST); gl.glTexParameterf(GL10.GL_TEXTURE_2D,GL11.GL_GENERATE_MIPMAP,GL10.GL_TRUE);
第二个调用,在前面已经提到过,将自动从你唯一的图像中创建一个 mipmap,并交给渲染器。并且确保createTexture()只被调用一次,因为不需要为不同层次的细节使用我们自己的定制图像。对glHint()的第一次调用告诉系统使用它所拥有的任何算法来生成看起来最好的图像。还可以选择GL_FASTEST和GL_DONT_CARE。后者将选择它认为最适合这种情况的方法。
在 Figure 5–20 中,左边的图像显示了关闭过滤功能的 Hedly 的特写,而右边的图像显示了过滤功能的开启。
图 5–20。 左侧已经全部过滤关闭。右侧打开了双线性过滤。
OpenGL 扩展
尽管 OpenGL 是一个标准,但它在设计时就考虑到了可扩展性,允许各种硬件制造商使用extension strings将他们自己的特殊调料添加到 3D 汤里。在 OpenGL 中,开发人员可以轮询可能的扩展,然后使用它们,如果它们存在的话。要了解这一点,请使用下面一行代码:
String extentionList=gl.glGetString(GL10.*GL_EXTENSIONS*);
这将返回一个空格分隔的列表,列出 Android 中用于 OpenGL ES 的各种额外选项,如下所示(来自 Android 2.3):
GL_OES_byte_coordinates GL_OES_fixed_point GL_OES_single_precision GL_OES_read_format GL_OES_compressed_paletted_texture GL_OES_draw_texture GL_OES_matrix_get GL_OES_query_matrix GL_OES_EGL_image GL_OES_compressed_ETC1_RGB8_texture GL_ARB_texture_compression GL_ARB_texture_non_power_of_two GL_ANDROID_user_clip_plane GL_ANDROID_vertex_buffer_object GL_ANDROID_generate_mipmap
这有助于找到一部手机可能比另一部手机拥有的定制功能。一种可能是使用一种称为 PVRTC 的特殊图像压缩格式,这种格式只为使用 PowerVR 类图形芯片的设备定制。PVRTC 与 PowerVR 硬件紧密结合,可以改善渲染和加载时间。三星 Galaxy S、摩托罗拉的 Droid X、黑莓 playbook 以及本文撰写时的所有 iOS 设备都可以利用 PVRTC。非 PowerVR 设备,如使用 Ardreno 或 Tegra 内核的设备,也可能有自己的特殊格式。
如果字符串GL_IMG_texture_compression_pvrtc出现在之前的扩展列表中,您可以判断您的设备是否支持 PVRTC。其他 GPU 可能有类似的格式,所以如果你想走自定义路线,我们鼓励你查看开发者论坛和 SDK。
最后,更多太阳系的善良
现在我们可以回到上一章的太阳系模型,给地球添加一个纹理,让它看起来更像地球。对SolarSystemActivity.java进行类似于我们之前对BouncySquareActivity.java所做的修改,将 setter 修改为:
view.setRenderer(new SolarSystemRenderer(this.getApplicationContext());
还要修改SolarSystemRenderer.java中的构造函数来处理传递的上下文。
import android.content.Context; public Context myContext; public SolarSystemRenderer(Context context) { this.myContext = context; }
我们需要将上下文存储在一个公共变量中,因为我们将在创建图像纹理时将它传递给init()函数。接下来,检查Planet.java,并将init()替换为清单 5–6;变化已被突出显示。对于地球的纹理,有很多例子。只要在谷歌上搜索一下。或者你可能想在[maps.jpl.nasa.gov/](http://maps.jpl.nasa.gov/)先检查一下 NASA。
清单 5–6。 修改了球体生成器,增加了纹理支持
private void init(int stacks,int slices, float radius, float squash, GL10 gl, Context context, boolean imageId, int resourceId) // 1 { float[] vertexData; `float[] normalData;
float[] colorData;
float[] textData=null;
float colorIncrement=0f;
float blue=0f; float red=1.0f;
int vIndex=0; //vertex index int cIndex=0; //color index int nIndex=0; //normal index int tIndex=0; //texture index
if(imageId == true) createTexture(gl, context, resourceId); //2 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_Slices*2+2) * m_Stacks)];
//Normal pointers for lighting
normalData = new float[3*((m_Slices2+2) * m_Stacks)]; if(imageId == true) //3 textData = new float [2 * ((m_Slices2+2) * (m_Stacks))];
int phiIdx, thetaIdx;
//Latitude
for(phiIdx=0; phiIdx < m_Stacks; phiIdx++) { //Starts at -1.57 and goes up to +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] = 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);
//Normal pointers for lighting
normalData[nIndex+0] = (float)(cosPhi0 * cosTheta); normalData[nIndex+2] = cosPhi0 * sinTheta; normalData[nIndex+1] = sinPhi0;
//Get x-y-z for the first vertex of stack N. normalData[nIndex+3] = cosPhi1 * cosTheta; normalData[nIndex+5] = cosPhi1 * sinTheta; normalData[nIndex+4] = sinPhi1;
if(textData != null) //4 {
float texX = (float)thetaIdx *
(1.0f/(float)(m_Slices-1));
textData [tIndex + 0] = texX;
textData [tIndex + 1] = (float)(phiIdx+0) *
(1.0f/(float)(m_Stacks));
textData [tIndex + 2] = texX;
textData [tIndex + 3] = (float)(phiIdx+1) *
(1.0f/(float)(m_Stacks));
}
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;
cIndex+=24; vIndex+=23; nIndex+=2*3;
if(textData!=null) //5 tIndex+= 2*2;
blue+=colorIncrement; red-=colorIncrement;
//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];
normalData[nIndex+0] = normalData[nIndex+3] = normalData[nIndex-3]; normalData[nIndex+1] = normalData[nIndex+4] = normalData[nIndex-2]; normalData[nIndex+2] = normalData[nIndex+5] = normalData[nIndex-1];
if(textData!= null) //6
{
textData [tIndex + 0] = textData [tIndex + 2] =
textData [tIndex -2];
textData [tIndex + 1] = textData [tIndex + 3] =
textData [tIndex -1]; }
}
}
m_Pos[0]= 0.0f;
m_Pos[1]= 0.0f;
m_Pos[2]= 0.0f;
m_VertexData = makeFloatBuffer(vertexData); m_NormalData = makeFloatBuffer(normalData); m_ColorData = makeFloatBuffer(colorData);
if(textData!= null) m_TextureData = makeFloatBuffer(textData); }`
所以,事情是这样的:
- 图像的 GL 对象、上下文、图像 ID 和资源 ID 被添加到第 1 行的参数列表的末尾。
- 在第 2 行,创建了纹理。
- 在第 3ff 行中,分配了纹理的坐标数组。
- 接下来,计算第 4ff 行的纹理坐标。由于球体有 x 个切片和 y 个堆栈,坐标空间仅从 0 到 1,我们需要将每个值增加 s 的增量
1/m_slices和 t 的增量1/m_stacks。注意,这覆盖了两对坐标,一对在另一对之上,匹配三角形条带的布局,也产生了堆叠的坐标对。 - 在第 5 行,推进坐标数组以保存下一组值。
- 最后,一些松散的线程被绑在一起,准备进入第 6 行的下一个堆栈。
确保将以下内容添加到实例数据中:
FloatBuffer m_TextureData;
将第一个例子中的createTexture()方法复制到Planet.java,并根据需要进行修改。如果您愿意,可以随意移除 mipmap 支持,但是保留它没有坏处,因为它对于本练习来说并不重要。确保glTexParameterf()将GL10.GL_LINEAR作为参数。
对于一个地球纹理,请注意,这将环绕整个球体模型,所以不是任何图像都可以,因此它应该类似于图 5–21。
图 5–21。 纹理通常填充整个帧,边到边。行星使用墨卡托投影(圆柱形地图)。
一旦你找到一个合适的.png,将它添加到你的项目中/res/drawable-nodpi/下,并在分配时将它交给行星对象。因为你不需要太阳的纹理,你可以传递一个 0 作为资源 ID。所以我们可以把false设定为imageId太阳的行星物体,而把true设定为地球。接下来,我们修改行星的构造函数,使其看起来像清单 5–7 中的。
清单 5–7。 为 Planet.java 增加几个新参数
public Planet(int stacks, int slices, float radius, float squash, GL10 gl, Context context, boolean imageId, int resourceId) { this.m_Stacks = stacks; this.m_Slices = slices; this.m_Radius = radius; this.m_Squash = squash; init(m_Stacks,m_Slices,radius,squash, gl, context, imageId, resourceId); }
自然地,initGeometry()需要被修改以支持额外的参数,如清单 5–8 中的所示。
清单 5–8。initGeometry()将新参数传递给 Planet.java
`private void initGeometry(GL10 gl) { int resid; m_Eyeposition[X_VALUE] = 0.0f; m_Eyeposition[Y_VALUE] = 0.0f; m_Eyeposition[Z_VALUE] = 10.0f;
resid = com.SolarSystem.R.drawable.earth_light;
m_Earth = new Planet(50, 50, .3f, 1.0f, gl, myContext, true, resid);
m_Earth.setPosition(0.0f, 0.0f, -2.0f); m_Sun = new Planet(50, 50, 1.0f, 1.0f, gl, myContext, false, 0);
m_Sun.setPosition(0.0f, 0.0f, 0.0f);
}`
当然,我们需要更新Planet.java中的draw()方法,如清单 5–9 所示。
清单 5–9。 准备处理新的纹理
` public void draw(GL10 gl) { gl.glMatrixMode(GL10.GL_MODELVIEW); gl.glEnable(GL10.GL_CULL_FACE); gl.glCullFace(GL10.GL_BACK);
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY); gl.glEnableClientState(GL10.GL_NORMAL_ARRAY); gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
if(m_TextureData != null) { gl.glEnable(GL10.GL_TEXTURE_2D); //1 gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY); gl.glBindTexture(GL10.GL_TEXTURE_2D, textures[0]); gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0, m_TextureData); }
gl.glMatrixMode(GL10.GL_MODELVIEW); gl.glVertexPointer(3, GL10.GL_FLOAT, 0, m_VertexData); gl.glNormalPointer(GL10.GL_FLOAT, 0, m_NormalData); gl.glColorPointer(4, GL10.GL_FLOAT, 0, m_ColorData); gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, (m_Slices+1)2(m_Stacks-1)+2);
gl.glDisable(GL10.GL_BLEND); gl.glDisable(GL10.GL_TEXTURE_2D); gl.glDisableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
}`
在第 1ff 行中,您将识别出带有正方形的示例中的相同调用。首先启用纹理支持,然后调用一个glBindTexture()来确保当前纹理可用,然后提醒系统期待一个纹理坐标数组,然后将数据传递给它。
编译并运行,理想情况下,您会看到类似于 Figure 5–22 的内容。
**注:**在 Android 环境中遇到仿真器 bug 并不少见(比如在准备这个例子的时候)。如果你看到了一些你没有预料到的东西,并且它违反了任何逻辑解释,那么试着在硬件上运行代码,看看会发生什么。
图 5–22。 太阳和地球
总结
这一章是对纹理及其用途的基本介绍。涵盖了以下主题:基本纹理理论,如何表达纹理坐标,提高保真度的小中见大贴图,以及如何过滤纹理以使其平滑。太阳系模型已经更新,所以现在地球看起来真的像使用纹理贴图的地球。table 5–7 总结了涵盖的所有新 API 调用。在下一章,我们将继续纹理,使用 Android 的多重纹理单元,以及混合技术。*
六、会混合吗?
是的!它混合了!
—汤姆·迪克森,Blendtec 搅拌机公司的老板
2006 年,汤姆·迪克森在 YouTube 上发布了一个愚蠢的视频,展示了他公司的搅拌机是如何通过将一些弹珠混合成粉末来搅拌的。从那时起,他频繁的视频被观看了超过 1 亿次,并展示了从 Tiki 火炬和激光笔到贾斯汀比伯娃娃和新摄像机的所有东西。汤姆的这种混合与我们的混合没有任何关系,除非虐待狂和无情地粉碎几个 Android 触摸板和手机也算在内。毕竟,它们是 OpenGL ES 设备:有自己的混合形式的设备,尽管不那么具有破坏性。(是的,这是一种延伸。)
混合在 OpenGL ES 应用中起着重要的作用。这是一个用来创造半透明物体的过程,这些物体可以用于像窗户这样简单的东西,也可以用于像池塘这样复杂的东西。其他用途包括添加大气,如雾或烟,平滑锯齿线,以及模拟各种复杂的灯光效果。OpenGL ES 2.0 有一个复杂的机制,它使用称为着色器的小模块来做专门的混合效果等。但在着色器之前,有混合功能,这是不那么多才多艺,但更容易使用。
在这一章中,你将学习混合功能的基础,以及如何将它们应用于颜色和 alpha 混合。之后,你将使用一种不同的混合方式,包括多种纹理,用于更复杂的效果,比如阴影。最后,我会想出如何在太阳系项目中应用这些效果。
阿尔法混合
你一定注意到了“RGBA”的彩色四胞胎如前所述, A 部分是 alpha 通道,它通常用于指定图像中的透明度。在用于纹理的位图中,alpha 层形成各种各样的 8 位图像,它可以在一个部分半透明,在另一个部分透明,在第三个部分完全不透明。如果一个对象没有使用纹理,而是通过其顶点,照明或整体全局着色来指定其颜色,alpha 将使整个对象或场景具有半透明属性。值 1.0 表示对象或像素完全不透明,而值 0 表示完全不可见。
要使 alpha 与任何混合模型一起工作,您需要同时处理源图像和目标图像。因为这个主题最好通过例子来理解,所以我们现在从第一个开始。
抓住你的第一章练习,然后用清单 6–1 代替原来的方法。这里首先使用的是纯色方块,而不是纹理方块,因为这是一个更简单的例子。
清单 6–1。 新改进的onDrawFrame()方法
` public void onDrawFrame(GL10 gl) { gl.glClearColor(0.0f,0.0f,0.0f,1.0f); //1 gl.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT);
gl.glMatrixMode(GL11.GL_MODELVIEW); gl.glEnableClientState(GL11.GL_VERTEX_ARRAY);
//SQUARE 1
gl.glLoadIdentity(); gl.glTranslatef(0.0f,(float)Math.sin(mTransY), -3.0f); //2 gl.glColor4f(0.0f, 0.0f, 1.0f, 1.0f); mSquare.draw(gl);
//SQUARE 2
gl.glLoadIdentity(); //3 gl.glTranslatef( (float)(Math.sin(mTransY)/2.0f),0.0f, -2.9f); gl.glColor4f(1.0f, 0.0f, 0.0f, 1.0f); mSquare.draw(gl);
mTransY += .075f; }`
和以前一样,让我们仔细看看代码:
- 在第 1ff 行中,缓冲区被清除为黑色,以便稍后更容易看到任何混合。
- 在 2ff 行中,我们可以画一个上下移动 3 个单位的正方形,同时给它一个蓝色。因为没有逐顶点着色,所以对
glColor4f()的调用会将整个正方形设置为蓝色。但是,请注意 1.0 的最后一个组件。那是阿尔法,它将很快被处理。紧随gl.glColor4f()之后的是实际绘制正方形的调用。 - 第 3ff 行寻址第二个方块,将它涂成红色并左右移动。将它移动 2.9 个单位而不是 3.0 个单位可以确保红色方块在蓝色方块的前面。
如果一切正常,你应该得到类似于 Figure 6–1 的东西。
图 6–1。 蓝色方块上下起伏;红色的走左边和右边。
看起来没什么,但这将是接下来几个实验的框架。第一个将打开默认的混合功能。
和许多其他 OpenGL 特性一样,通过调用gl.glEnable(GL10.GL_BLEND)打开混合。在第一次调用mSquare.draw()之前的任何地方添加。重新编译,你会看到什么?什么都没有,或者至少什么都没有改变。它看起来仍然像图 6–1。那是因为混合不仅仅是说“混合,你!”我们还必须指定一个混合函数 ,它描述了源颜色(通过其片段或像素表示)如何与目标颜色混合。当然,默认情况下,当深度提示关闭时,源片段总是替换目的片段。事实上,只有当z-缓冲关闭时,才能确保正确的混合。
混合功能
要改变默认的混合,我们必须求助于使用glBlendFunc(),它有两个参数。第一个说明如何处理源,第二个说明目的地。为了描述接下来发生的事情,请注意,最终发生的事情只是将每个 RGBA 源组件与每个目标组件相加、相减或进行其他操作。也就是说,源的红色通道与目的的红色通道混合,源的绿色与目的的绿色混合,依此类推。这通常是这样表示的:把源 RGBA 值 Rs、Gs、Bs、和称为,把目的值 Rd、Gd、Bd、和 Ad 。但是我们还需要源和目的地的混合因子,表示为 Sr,Sg,Sb,Sa ,和 *Dr,Dg,Db,*和 Da 。(没看起来那么复杂,真的)。这是最终合成颜色的公式:
*(R,G,B) = ((Rs* Sr) + (Rd* Dr),(Gs* Sg) + (Gd*Dg),(Bs* Sb) + (Bd*Db))*
换句话说,将源颜色乘以其混合因子,并将其添加到乘以其混合因子的目标颜色。
最常见的混合形式之一是在已经绘制好的东西(即目的地)上覆盖一个半透明的面。像以前一样,这可以是模拟的窗玻璃,飞行模拟器的平视显示器,或者其他图形,当与现有的图像混合时可能会看起来更好。(后者在遥远的太阳中被大量用于许多元素,如星座名称、轮廓等。)根据目的的不同,您可能希望覆盖图接近不透明,使用接近 1.0 的 alpha,或者非常模糊,使用接近 0.0 的 alpha。
在这个基本的混合任务中,源的颜色首先乘以 alpha 值,即它的混合因子。因此,如果源红色的最大值为 1.0,alpha 为 0.75,那么结果就是 1.0 乘以 0.75。这同样适用于绿色和蓝色。另一方面,目标颜色乘以 1.0 减去源的 alpha 。为什么呢?这有效地产生了永远不会超过最大值 1.0 的复合颜色;否则,可能会发生各种颜色失真。或者这样想象:源的 alpha 值是允许源填充的颜色“宽度”1.0 的比例。剩余空间变成 1.0 减去源的 alpha。alpha 越大,可以使用的源颜色的比例就越大,保留给目标颜色的比例就越小。因此,alpha 越接近 1.0,复制到帧缓冲区的源颜色就越多,从而替换目标颜色。
**注意:**在这些例子中,使用了标准化的颜色值,因为它们比使用无符号字节(表示从 0 到 255 的颜色)更容易理解这个过程。
现在我们可以在下一个例子中检验这一点。要设置前面描述的混合函数,可以使用下面的调用:
gl.glBlendFunc(GL10)。*GL_SRC_ALPHA*gl10。*GL_ONE_MINUS_SRC_ALPHA*;
GL_SRC_ALPHA和GL_ONE_MINUS_SRC_ALPHA是之前描述的混合因子。请记住,第一个参数是源的混合,即当前正在编写的对象。将该行放在启用混合的位置之后。红色,编译并运行。你看到图 6–2 了吗?
图 6–2。 红色方块的 alpha 值为 0.5,蓝色方块的 alpha 值为 1.0。
发生了什么事?蓝色的 alpha 值为 1.0,因此每个蓝色片段会完全替换背景中的任何内容。那么 alpha 为 0.5 的红色表示 50%的红色被写入目标。黑色区域将是暗红色,但只有glColor4f()中给出的指定值 1.0 的 50%。目前为止,一切顺利。现在在蓝色之上,50%的红色值与 50%的蓝色值混合:
混合颜色=颜色源*源的 Alpha+(1.0-源的 Alpha)*目标的颜色。或者根据上一个示例中的值查看每个组件:
红色=1.0*0.5+(1.0-0.5)*0.0
绿色=0.0*0.5+(1.0-0.5)*0.0
蓝色=0.0*0.5+(1.0-0.5)*1.0
因此,片段像素的最终颜色应该是 0.5、0.0、0.5 或洋红色。现在红色和由此产生的洋红色有点偏暗。如果你想让它变得更亮,你会怎么做?如果有一种混合各种颜色全部强度的方法就好了。你会使用 1.0 的 alpha 值吗?没有。为什么呢?好吧,以蓝色为目标,源 alpha 为 1.0,前面的蓝色通道等式就是 0.0*1.0+(1.0-1.0)*1.0。这等于 0,而红色是 1.0,或者是纯色。你想要的是在黑色背景上书写时有最亮的红色,蓝色也是一样。为此,你可以使用一个混合函数,以最大强度写出两种颜色,比如GL_ONE。这意味着:
gl.glBlendFunc(GL10.GL_ONE, GL10.GL_ONE);
回到使用源三元组红色= 1、绿色=0、蓝色=0 和目的地红色= 0、绿色=0、蓝色= 1(alpha 默认为 1.0)的等式,计算如下:
红色=11+01
绿色=0*(1+(0-0)*1
蓝色=0*1+(1-0)*1
这就产生了一种颜色,其中红色=1,绿色=0,蓝色=1。而我的朋友,是洋红色的(见图 6–3)。
图 6–3。混合红色和蓝色的全部强度
现在是时候进行另一种实验了。以上例中的代码为例,将两个 alphas 设置为 0.5,并将混合函数重置为传统的透明度值:
gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA);
运行修改后的代码后,请注意组合的颜色,并注意在-4.0 处的另一个正方形是蓝色的,也是第一个被渲染的,第二个是红色的。现在颠倒颜色的顺序,运行。怎么了?您应该得到类似于图 6–4 的东西。
图 6–4。 左边先画蓝色,右边先画红色。
交叉点颜色略有不同。这显示了 OpenGL 中一个令人困惑的问题:与大多数 3D 框架一样,渲染时根据面和颜色的顺序,混合会略有不同。在这种情况下,弄清楚发生了什么其实很简单。在图 6–4 的左图中,蓝色方块首先以 0.5 的 alpha 绘制。因此,即使蓝色三元组被定义为 0,0,1,当写入帧缓冲区时,alpha 值也会将其降低到 0,0,. 5。现在添加具有相似属性的红色方块。自然,红色将以与蓝色相同的方式写入帧缓冲区的黑色部分,因此最终值将是. 5,0,0。但是注意当红色写在蓝色上面时会发生什么。由于蓝色已经是其强度的一半,混合函数将进一步将其削减到. 25,这是混合函数的目标部分的结果,*(1.0-源 alpha)蓝色+目标,或(1.0-.5).5+0,或. 25。最后的颜色然后是 *.5,0,. 25。*蓝色的强度越低,它对复合色的贡献就越小,红色占主导地位。现在在图 6–4 的右图中,顺序颠倒了,所以蓝色占主导,最终颜色为. 25,0,. 5。
Table 6–1 包含了所有允许的 OpenGL ES 混合因子,尽管源和目标并不都支持。正如你所看到的,有足够的空间来修补,没有固定的规则来创造最好的效果。这将高度依赖于你的个人品味和需求。尽管尝试不同的价值观很有趣。确保用暗淡的灰色填充背景,因为一些组合在黑色背景上书写时只会产生黑色。
这里最后一个可能在一些混合操作中非常方便的方法是glColorMask()。此功能允许您阻止一个或多个颜色通道被写入目标。要查看实际效果,请将红色方块的颜色修改为 1,1,0,1;将两个混合功能设置回GL_ONE;又注释掉了一行gl。glBlendEquation(GL10.GL_FUNC_SUBTRACT);。运行时,您应该会看到类似于图 6–5 中左边的图像。红色方块现在是黄色的,当与蓝色混合时,在交叉点产生白色。现在添加下面一行:
gl.glColorMask(true, false, true, true);
前面的行在被拉到帧缓冲器时屏蔽或关闭绿色通道。运行时,您应该会在图 6–5 中看到右图,该图与图 6–3 非常相似。事实上,逻辑上它们是相同的。
图 6–5。 左边不使用glColorMask,所以所有颜色都在起作用,而右边屏蔽掉绿色通道。
多色混合
现在,我们可以花几分钟来看看当用每个顶点的单独颜色定义正方形时,混合函数的效果。将清单 6–2 中的添加到正方形的构造函数中。第一组颜色定义黄色、品红色和青色。标准红绿蓝的互补色在第二组中指定。
清单 6–2。 两个正方形的顶点颜色
` float squareColorsYMCA[] = { 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f }; float squareColorsRGBA[] = {
1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f };`
将第一个颜色数组分配给第一个方块(到目前为止一直是蓝色的),将第二个颜色数组分配给之前的红色方块。我在SquareRenderer.java中这样做,并通过 square 的构造函数传递颜色数组。当然,现在我们需要两个方块,每种颜色一个,而不是只有一个。不要忘记启用颜色数组的使用。
你现在应该很熟悉了,知道该怎么做了。另外,请注意,数组现在被规范化为一组浮点数,而不是以前使用的无符号字节,所以您必须调整对glColorPointer()的调用。解决方案由学生自己决定(我一直想这么说)。禁用混合后,您应该会看到图 6–6 中最左边的图像,当使用传统的透明功能启用时,结果应该是图 6–6 中中间的图像。什么事?不是吗?你说它看起来仍然像第一个图形?为什么会这样?
回头看看颜色数组。请注意每行的最后一个值 alpha 是如何达到最大值的。请记住,在这种混合模式下,任何目标值都要乘以(1.0-源 alpha),或者更确切地说,是 0.0,这样源颜色就占主导地位,如前面的示例所示。看到一些真正的透明度的一个解决方案是使用下面的:
gl.glBlendFunc(GL10.GL_ONE, GL10.GL_ONE);
这是可行的,因为它完全抛弃了阿尔法通道。如果您希望 alpha 具有“标准”函数,只需将 1.0 值更改为其他值,如. 5,并将混合函数更改为以下值:
gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA);
而结果就是图 6–6 中最右边的图像。
图 6–6。无混合、GL_ONE混合和阿尔法混合,分别为
纹理混合
现在,战战兢兢地,我们可以接近纹理的混合了。最初,这看起来很像前面描述的阿尔法混合,但是通过使用多重纹理可以做各种有趣的事情。
首先,让我们重新编写前面的代码来同时支持两个纹理,并进行顶点混合。你必须修改第五章例子中的Square.draw()和createImage()。正方形也需要支持纹理坐标,并且正方形的每个实例都需要自己独特的纹理。图 6–7 中最右边的图像是您禁用混合后应该得到的图像。如果你激活上一个练习中的颜色,并使用本章前面的GL_ONE功能启用混合,就可以生成中间的那个。
那么,正确的图像是如何产生的呢?
使用单一位图并着色是节省内存的常见做法。如果你在 OpenGL 层做一些 UI 组件,考虑使用一个单一的图像,并使用这些技术着色。你可能会问为什么它是纯红的,而不仅仅是浅红色,允许一些颜色的变化。这里所发生的是顶点的颜色被每个片段的颜色相乘。对于红色,我使用了 1.0,0.0,0.0 的 RGB 三元组。因此,当每个片段在通道乘法中计算时,绿色和蓝色通道将乘以 0,因此它们被完全过滤掉,只留下红色通道。如果想要让一些其他颜色透过,可以指定顶点偏向更中性的色调,所需的色调颜色比其他颜色稍高,如 1.0、0.7、0.7。
图 6–7。在左边,只显示纹理。在中间,它们与颜色混合,在右边的是纯红。
你也可以很容易地给纹理添加透明度,图 6–8。为了实现这一点,我将在这里引入一个小的简化因子。你可以通过简单的使用glColor4f()用一种单一的颜色给纹理化的表面着色,并且完全消除了创建顶点颜色数组的需要。所以,对于第二个方块,最近的一个,用glColor4f(1, 1, 1, .75)给它着色,并确保重置第一个方块的颜色;否则会随着第二个变暗。此外,确保混合已打开,并且混合功能使用了SRC_ALPHA/ONE_MINUS_SRC_ALPHA组合。
图 6–8。 左边的图像 alpha 为 0.5,而右边的为 0.75。
多重纹理
现在我们已经讨论了颜色混合和纹理颜色混合模式,但是把两个纹理混合在一起做第三个怎么样呢?这样的技术被称为多重纹理。多重纹理可用于在执行某些数学运算时将一个纹理叠加到另一个纹理上。更复杂的应用包括简单的图像处理。但是让我们先去摘低垂的果实。
多重纹理需要使用纹理组合器和纹理单元。纹理组合器让你可以组合和操作绑定到硬件纹理单元的纹理,这些纹理单元是图形芯片的特定部分,将图像包裹在对象周围。如果您希望大量使用合并器,您可能希望通过gl来验证支持的总数。glGetIntegerv(GL10.GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS, numberTextureUnits),其中numberTextureUnits被定义为一个整数。
为了建立一个管道来处理多重纹理,我们需要告诉 OpenGL 使用什么纹理以及如何将它们混合在一起。这个过程与之前处理 alpha 和颜色混合操作时定义混合函数没有太大的不同(至少在理论上是这样)。它确实大量使用了glTexEnvf()调用,这是 OpenGL 的另一个超负荷方法。(如果你不相信我,可以在 OpenGL 网站上查看它的官方参考页面。)这将设置纹理环境,定义多重纹理处理的每个阶段。
图 6–9 显示了组合器链。每个组合器引用第一个组合器的前一个纹理片段(P0或Pn)或输入片段。然后,它从一个“源”纹理(图中的S0)中取出一个片段,将其与P0组合,如果需要的话,将它交给下一个组合器C1,循环重复。
图 6–9。 纹理合并器链
解决这个问题的最好方法和其他任何问题一样:查阅代码。在下面的示例中,两个纹理一起加载,绑定到各自的纹理单元,并合并成一个输出纹理。我们尝试了几种不同的方法来组合两幅图像,并对每幅图像的结果进行了深入的展示和检查。
首先,我们重访我们的老朋友。我们回到了只有一个纹理,上升和下降。颜色支持也被关闭。因此,您应该有类似于清单 6–3 的东西。确保你还在加载第二个纹理。
清单 6–3。 Square.draw()改版,修改为多文支持
`public void draw(GL10 gl)
{
gl.glEnable(GL10.GL_TEXTURE_2D);
gl.glBindTexture(GL10.GL_TEXTURE_2D,mTexture0);
gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY); gl.glFrontFace(GL11.GL_CW);
gl.glVertexPointer(2, GL11.GL_FLOAT, 0, mFVertexBuffer);
gl.glColorPointer(4, GL11.GL_FLOAT, 0, mColorBuffer);
gl.glClientActiveTexture(GL10.GL_TEXTURE0); //1 gl.glTexCoordPointer(2, GL10.GL_FLOAT,0,mTextureCoords);
gl.glClientActiveTexture(GL10.GL_TEXTURE1); //2 gl.glTexCoordPointer(2, GL10.GL_FLOAT,0,mTextureCoords);
multiTexture(gl,mTexture0,mTexture1); //3
gl.glDrawElements(GL11.GL_TRIANGLES, 6, GL11.GL_UNSIGNED_BYTE, mIndexBuffer); gl.glFrontFace(GL11.GL_CCW); }`
这里有一个新的调用,如第 1 行和第 2 行所示。是glClientActiveTexture(),设置操作什么纹理单元。这是在客户端,而不是硬件方面的事情,并指示哪个纹理单元将接收纹理坐标数组。不要把这个和glActiveTexture()混淆,后者用在清单 6–4 中,它实际上打开了一个特定的纹理单元。第 3 行调用配置纹理单元的方法。
这是一个非常简单的默认情况。精彩的东西在后面。
清单 6–4。 设置纹理合并器
` public void multiTexture(GL10 gl, int tex0, int tex1) { float combineParameter= GL10.GL_MODULATE; //1
// Set up the First Texture. gl.glActiveTexture(GL10.GL_TEXTURE0); //2 gl.glBindTexture(GL10.GL_TEXTURE_2D, tex0); //3
// Set up the Second Texture. gl.glActiveTexture(GL10.GL_TEXTURE1); gl.glBindTexture(GL10.GL_TEXTURE_2D, tex1);
// Set the texture environment mode for this texture to combine. gl.glTexEnvf(GL10.GL_TEXTURE_ENV, GL10.GL_TEXTURE_ENV_MODE, combineParameter); //4 }`
-
第 1 行指定了组合器应该做什么。表 6–1 列出了所有可用的可能值。
-
glActiveTexture()在第 2 行激活一个特定的硬件纹理单元。 -
第 3 行不应该是个谜,因为你以前见过。在这个例子中,第一纹理被绑定到特定的硬件纹理单元。下面两行对第二个纹理做了同样的处理。
-
现在告诉系统如何处理最后一行的纹理。在该表中,P 是 previous,S 是 source,下标 a 是 alpha,c 是 color,仅在必须单独考虑 color 和 alpha 时使用。
现在编译并运行。您的显示表面上应该类似于 Figure 6–10 的结果。
图 6–10。 左边的海德利是“先前的”纹理,而杰克森·波拉克的画是“源”使用GL_MODULATE时,结果在右边。
现在是时候玩其他组合器设置了。尝试用GL_ADD替换清单 63 中的combineParameter中的GL_MODULATE。然后通过GL_BLEND和GL_DECAL跟随这个。结果如图 6–11 所示。另外,注意叠加纹理的白色部分是不透明的。因为白色对于所有三种颜色都是 1.0,所以它将总是产生 1.0 的颜色,以便遮挡下面的任何东西。对于非白色的阴影,你应该可以看到一点海德利纹理穿透。中间图像中的GL_BLEND,图 6–11 不太明显。为什么青色代替了红色?很简单。说红色值是 1.0,它的最高。考虑GL_BLEND的等式:
output =Pn(1—Sn)+Sn×C
对于红色,第一部分将是零,因为红色的值 1 被等式中的 1 减去,天哪,第二部分也将是零,假设使用默认的黑色环境颜色。考虑绿色通道。假设背景图像的绿色值为 0.5,这是“先前”的颜色,同时保持 splat 颜色(源)为纯红色(因此 splat 中没有蓝色或绿色)。现在等式的第一部分变成了. 5*(1.0-0.0),或. 5。也就是说,前一个纹理 Hedly 中绿色的. 5 值与源纹理中的“1 减绿色”相乘。由于源的红色斑点中的绿色和蓝色通道都是 0.0,这意味着没有任何红色的绿色和蓝色的组合会产生青色阴影,因为青色是红色的反转。如果你仔细观察图 6–11 中的中间图像,你可以分辨出一块突出的碎片。这同样适用于洋红色和黄色斑点。在图 6–11 的最右侧图像中,使用了GL_DECAL,它可以起到许多与塑料模型贴花相同的作用,即应用标志或符号来遮挡其后面的任何东西。因此,对于贴花,通常纹理的实际图像部分的 alpha 通道将设置为 1.0,而不是所需图像的任何部分的 alpha 通道将设置为 0.0。通常背景是黑色的,在你的画图程序中,你可以让它根据亮度或者图像中非零颜色的部分生成一个 alpha 通道。在 splat 的情况下,因为背景是白色的,我必须先反转颜色,使其变成黑色,生成遮罩,并将其与正常的正片图像合并。一些略小于 1 的 alpha 是为绿色通道生成的,因此,您可以看到一小部分 Hedly 显示出来。
图 6-11。使用左边的*GL_ADD,GL_BLEND为中心,右边的GL_DECAL*
最后一个任务是制作第二个纹理的动画。你将需要创建一个textureCoordinates、mTextureCoods0和mTextureCoords1的副本,每个纹理一个,因为我们不能再共享它们。接下来公开用于在构造函数中生成 Java 字节缓冲区的“原始”坐标。这样,我们可以在Square.draw()方法中修改它们。然后将以下内容添加到draw()中,仅更新贴花纹理的坐标:
` for (i=0;i<8;i++) { mTextureCoordsAnimated[i]+=.01; }
mTextureCoords1.position(0); mTextureCoords1.put(mTextureCoordsAnimated);`
调用mTextureCoords1.position()来重置缓冲区的内部指针。否则,下面对put()的调用将在下次通过时追加数据并溢出缓冲区。
像这样的效果可以用来在卡通般的环境中或行星周围的云层中制作雨或雪的动画。后者会很酷,如果你有两个额外的纹理,一个用于上层云,一个用于下层云,以不同的速度移动。
如前所述,环境参数GL_COMBINE需要一系列额外的设置才能工作,因为它让您可以在更精确的水平上操作合并器方程。如果你只想使用GL_COMBINE,它默认为GL_MODULATE,所以你看不出两者有什么不同。使用Arg0和Arg1代表输入源,它们是纹理组合器。它们是通过使用类似下面的行来设置的,其中GL_SOURCE0_RGB是在表 6–3 中引用的参数 0 或Arg0:
gl.glTexEnvf(GL10.GL_TEXTURE_ENV, GL10.GL_SOURCE0_RGB, GL10.GL_TEXTURE);
同样,你可以用GL_SOURCE1_RGB来代表Arg1:
gl.glTexEnvf(GL10.GL_TEXTURE_ENV, GL10.GL_SOURCE1_RGB, GL10.GL_TEXTURE);
用凹凸贴图
你可以用纹理做很多非常复杂的事情;凹凸贴图只是其中之一。因此,接下来是对“凸起”到底是什么以及为什么任何人都应该关注映射它们的讨论。
正如前面所指出的,计算机图形学中的大部分挑战是在幕后使用巧妙的黑客技术来制作看起来复杂的视觉效果。凹凸贴图只是其中的一个技巧,在 OpenGL ES 1.1 中,它可以用纹理合并器来实现。
就像纹理是在简单的面上增加复杂性的“技巧”一样,凹凸贴图是一种给纹理添加第三维的技术。它用于生成物体整体表面的粗糙度,当被照亮时,会产生一些令人惊讶的真实高光。它可能用于模拟湖面、网球表面或行星表面的波浪。
一个物体表面的粗糙度是通过它处理光线和阴影的方式来感知的。例如,考虑满月和凸月,如图图 6–12 所示。当太阳在月亮正前方时,月亮是圆的,因此,月亮表面只不过是深浅不一的灰色。看不到任何影子。和你背对太阳看地面没太大区别。在你头部阴影的周围,表面看起来是平的。现在,如果把光源移到事物的侧面,突然各种细节都蹦了出来。图 6–12 中的右图显示了一个凸月,太阳朝向左侧,即月亮的东翼。这是一个完全不同的故事,不是吗?
图 6–12。 相对较少的细节显示在左边,而用斜向照明,则更多的细节显示在右边。
理解高光和阴影是如何一起工作的,对于训练优秀的艺术家和插图画家来说是绝对重要的。
添加真实的表面位移来复制整个月球表面可能需要数千兆字节的数据,从内存和 CPU 的角度来看,这对于当前一代的小型手持设备来说是不可能的。因此进入了相当优雅的凹凸贴图到中心阶段。
你可能还记得在第四章中,你必须给球体模型添加一组“面法线”。法线仅仅是垂直于面的向量,显示面指向的方向。任何光源的法线角度在很大程度上决定了人脸的明暗程度。脸部朝向光线越直接,光线就越亮。那么,如果你有一种紧凑的方法来编码法线,而不是基于逐面,因为一个模型可能有相对较少的面,而是基于,比如说,一个像素一个像素?如果您可以将编码的法线数组与真实的图像纹理相结合,并根据入射光的方向以某种方式处理它,使图像中的像素变亮或变暗,会怎么样?
这让我们回到了纹理合成。在表 6–3 中,注意最后两种组合器类型:GL_DOT3_RGB和GL_DOT3_RGBA。现在,回到你高中的几何课上。还记得两个向量的点积吗?点积和叉积都是那些的东西,你用抱怨“老师,对吗??为什么我需要知道这个?”好吧,现在你会得到你的答案。
点积是基于另外两个向量的角度的向量的长度。还不明白吗?考虑图 6–13 中左侧的图表。点积是指向灯光的法向量的“量”,该值用于直接照亮面部。在图 6–13 的右图中,脸部与太阳方向成直角,因此没有被照亮。
图 6–13。 左边,脸被照亮;右边就不是这样了。
记住这一点,凹凸贴图使用的“欺骗”如下。使用你想要使用的真实纹理,并添加一个特殊的第二个辅助纹理。第二个纹理编码普通信息,而不是 RGB 颜色。因此,它没有使用每个都是 4 个字节的浮点数,而是使用 1 个字节的值作为法向量的 xyz 值,这样可以方便地放入一个 4 个字节的像素中。由于向量通常不需要非常精确,8 位分辨率就可以了,而且非常节省内存。因此,这些法线以一种直接映射到您想要突出显示的垂直特征的方式生成。
因为法线可以有正值也可以有负值(背向太阳时为负值),所以 xyz 值在 0 到 1 的范围内居中。也就是说,-127 到+127 必须映射到 0 到 1 之间的任何位置。因此,“红色”分量通常是矢量的 x 部分,计算如下:
*red* = (*x* +1) /2.0
当然,绿色和蓝色位的情况类似。
现在看看在表 6–3 的GL_DOT3_RGB条目中表示的公式。这将 RGB 三元组作为向量,并返回其长度。n 是法向量,L 是光向量,所以长度求解如下:
长度】= 4×4(rn——5)×(rl
因此,如果面沿着 x 轴直接朝向灯光,法线的红色将是 1.0,灯光的红色或 x 值也将是 1.0。绿色和蓝色位是 0 的编码形式 0.5。将它代入前面的等式会是这样的:
长度= 4×4(1*——5)×(1*】5)+(【5】**
**长度 = 4×(.25+0+0) =1.0
这正是我们所期待的。如果法线在 z 方向上指向上并远离表面,用蓝色字节编码,答案应该是 0,因为法线主要指向远离纹理的 X 和 Y 平面。在图 6–14 中左边的图像显示了我们地球地图的一部分,而右边的图像显示了它对应的法线贴图。
图 6–14。 左边是我们的形象;右边是匹配的法线贴图。
为什么法线贴图主要是紫色的?指向远离地球表面的垂直矢量被编码为红色=.5,绿色=.5,蓝色=1.0。(记住. 5 其实是 0。)
当纹理合并器设置为 DOT3 模式时,它使用法线和光照向量来确定每个纹理元素的强度。然后,该值用于调制真实图像纹理的颜色。
现在是时候回收之前的多纹理项目了。这一次,第二个纹理需要由可从 Apress 站点获得的凹凸贴图组成。接下来,设置合并器来处理法线贴图和任何从过去的练习中剩余的动画。
在这个例子中加载法线贴图,然后添加新的例程,multiTextureBumpMap(),如清单 6–5 中的所示。
清单 6–5。 为凹凸贴图设置组合器
`static float lightAngle=0.0f; public void multiTextureBumpMap(GL10 gl, int mainTexture, int normalTexture) { float x,y,z;
lightAngle+=.3f; //1
if(lightAngle>180) lightAngle=0;
// Set up the light vector. x = (float) Math.sin(lightAngle * (3.14159 / 180.0f)); //2 y = 0.0f; z = (float) Math.cos(lightAngle * (3.14159 / 180.0f));
// Half shifting to have a value between 0.0f and 1.0f. x = x * 0.5f + 0.5f; //3 y = y * 0.5f + 0.5f; z = z * 0.5f + 0.5f;
gl.glColor4f(x, y, z, 1.0f); //4
//The color and normal map are combined. gl.glActiveTexture(GL10.GL_TEXTURE0); //5 gl.glBindTexture(GL10.GL_TEXTURE_2D, mainTexture);
gl.glTexEnvf(GL10.GL_TEXTURE_ENV, GL10.GL_TEXTURE_ENV_MODE, GL11.GL_COMBINE); //6
gl.glTexEnvf(GL10.GL_TEXTURE_ENV, GL11.GL_COMBINE_RGB, GL11.GL_DOT3_RGB); //7
gl.glTexEnvf(GL10.GL_TEXTURE_ENV, GL11.GL_SRC0_RGB, GL11.GL_TEXTURE); //8
gl.glTexEnvf(GL10.GL_TEXTURE_ENV, GL11.GL_SRC1_RGB, GL11.GL_PREVIOUS); //9 // Set up the Second Texture, and combine it with the result of the Dot3
combination.
gl.glActiveTexture(GL10.GL_TEXTURE1); //10 gl.glBindTexture(GL10.GL_TEXTURE_2D, normalTexture);
gl.glTexEnvf(GL10.GL_TEXTURE_ENV, GL10.GL_TEXTURE_ENV_MODE, GL10.GL_MODULATE); //11 }`
前述操作使用两个级进行。第一个将凹凸或法线贴图与原色混合,这是使用glColor4f()调用建立的。第二个使用我们的老朋友GL_MODULATE将结果与彩色图像结合起来。
所以让我们一点一点地检查一下:
- 在第 1 行中,我们定义了
lightAngle,它将围绕纹理在 0 到 180 度之间循环,以显示高光在不同光照条件下的外观。 - 计算第 2 行中灯光矢量的 xyz 值。
- 在第 3 行,xyz 组件需要被缩放以匹配凹凸贴图的组件。
- 现在使用第 4 行中的光线矢量组件给片段上色。
- 先设置并绑定凹凸贴图,就是 5f 行的 tex0 。
- 第 6 行的
GL_COMBINE告诉系统预期一个组合类型跟随其后。 - 在第 7 行,我们指定我们将使用
GL_DOT3_RGB操作只组合 RGB 值(GL_DOT3_RGBA包括 alpha,但在这里并不需要)。 - 这里我们设置了“阶段 0”,这是两个阶段中的第一个。第 8 行指定了第一位数据的来源。这表示使用当前纹理单元(
GL_TEXTURE0)的纹理作为第 5 行分配的凹凸贴图的来源。 - 然后我们必须告诉它与之前的颜色混合——在这个例子中,是通过第 4 行的
glColor()设置的。对于阶段 0,GL_PREVIOUS与GL_PRIMARY_COLOR相同,因为没有之前的纹理可以使用。 - 现在在第 10 行和下面的行中设置阶段 1。参数
tex1是彩色图像。 - 现在我们要做的就是把图像和凹凸贴图结合起来,这就是第 11 行所做的。
我的源纹理被选中,这样你可以很容易地看到结果。启动时,光线应该从左向右移动,照亮陆地的边缘,如图 6–15 所示。
图 6–15。 分别在早上、中午和晚上到达北美
看起来很酷,是吧?但是我们能把它应用到一个旋转的球体上吗?试一试,循环利用上一章末尾的太阳系模型。为了使凹凸贴图的细节更容易被看到,太阳被去掉了,代替了地球的一个更大的图像。因此,我们将加载凹凸贴图,将地球移动到场景的中心,调整照明,并添加组合器支持。
在分配主图像的位置下方,添加以下内容:
if(imageId == true) { m_BumpmapID = createTexture(gl, context, imageId, resourceId); }
加上这个:
int m_BumpmapID;
现在确保用位于太阳系控制器对象中的init()中的新参数调用这个函数。
使用清单 6–6 作为新的draw()方法,放入Planet.java并从bumpmappingController的executePlanet()例程中调用。这主要是为纹理合并器做准备,并调用清单 6–6 中的multiTextureBumpMap。
清单 6–6。 修改后执行为凹凸贴图
`public void draw(GL10 gl) { gl.glMatrixMode(GL10.GL_MODELVIEW); gl.glEnable(GL10.GL_CULL_FACE); gl.glCullFace(GL10.GL_BACK); gl.glEnable(GL10.GL_LIGHTING);
gl.glFrontFace(GL10.GL_CW); gl.glEnable(GL10.GL_TEXTURE_2D);
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glVertexPointer(3, GL10.GL_FLOAT, 0, m_VertexData);
gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY); gl.glClientActiveTexture(GL10.GL_TEXTURE0); gl.glBindTexture(GL10.GL_TEXTURE_2D, textures[0]);
gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0, m_textureData);
gl.glClientActiveTexture(GL10.GL_TEXTURE1); gl.glTexCoordPointer(2, GL10.GL_FLOAT,0,m_textureData);
gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glEnableClientState(GL10.GL_NORMAL_ARRAY); gl.glNormalPointer(GL10.GL_FLOAT, 0, m_NormalData);
gl.glColorPointer(4, GL10.GL_UNSIGNED_BYTE, 0, m_ColorData); multiTextureBumpMap(gl, m_BumpmapID, textures[0]); gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, (m_Slices+1)2(m_Stacks-1)+2);
}`
方法multiTextureBumpMap()与前一个相同,除了光矢量计算可以删除(直到第 4 行),所以只需复制到你的行星物体上。
现在转到你在太阳系控制器中初始化灯光的地方,注释掉创建高光材质的调用。凹凸贴图和镜面反射相处得不太好。
清单 6–7 是新的执行例程;控制器也是如此。这使得太阳转储,将地球移动到事物的中心,并将主光线置于左侧。
清单 6–7。 用这个代替旧的execute()套路
`private void execute(GL10 gl) { float posFill1[]={-8.0f, 0.0f, 7.0f, 1.0f}; float cyan[]={0.0f, 1.0f, 1.0f, 1.0f}; float orbitalIncrement=0.5f; float sunPos[]={0.0f, 0.0f, 0.0f, 1.0f};
gl.glLightfv(SS_FILLLIGHT1, GL10.GL_POSITION, makeFloatBuffer(posFill1));
gl.glEnable(GL10.GL_DEPTH_TEST);
gl.glClearColor(0.0f, 0.25f, 0.35f, 1.0f);
gl.glClear(GL10.GL_COLOR_BUFFER_BIT); gl.glPushMatrix();
gl.glTranslatef(-m_Eyeposition[X_VALUE],-m_Eyeposition[Y_VALUE],- m_Eyeposition[Z_VALUE]); gl.glLightfv(SS_SUNLIGHT, GL10.GL_POSITION, makeFloatBuffer(sunPos));
gl.glEnable(SS_FILLLIGHT1); gl.glEnable(SS_FILLLIGHT2);
gl.glPushMatrix();
angle+=orbitalIncrement; gl.glRotatef(angle, 0.0f, 1.0f, 0.0f); executePlanet(m_Earth, gl); gl.glPopMatrix(); gl.glPopMatrix(); }`
如果你现在看到类似图 6–16 的东西,你可以正式地拍拍自己的背。
图 6–16。颠簸的大地
好,现在做个实验。移动灯光的位置,使它从右边而不是左边进来。图 6–17 是意想不到的结果。这是怎么回事?现在这些山看起来像山谷。
图 6–17。嗯?
现在的情况是,我们正在走向一个以前没有合并者去过的地方。通过使用我们自己的照明,由光矢量提供的模拟照明的效果被去除了。我们的灯在左边,它只是碰巧看起来很好,主要是靠运气。如果你的场景的照明是相对静态的,这里的凹凸贴图就可以了。它不喜欢多光源。事实上,通过灯光向量指定的伪照明效果会被忽略,而不是“真实”光源。此外,如果关闭这些光源,灯光向量会完全忽略对象上的任何着色。在这种情况下,你会看到整个星球变亮和变暗,因为这是纹理本身发生的事情,因为它只是一个 2D 表面。如果它的一部分被点亮,所有的都被点亮。那么,一个 GL 呆子该怎么办呢?着色器我的朋友。着色器。这就是 OpenGL ES 2.0 和 Android 扩展的用武之地。
总结
在这一章中,你学习了 OpenGL ES 1 提供的混合功能。混合有自己独特的语言,通过混合函数和组合器来表达。你已经学习了半透明,包括如何和何时应用它。还包括一些巧妙的技巧,通过混合和纹理来制作动画和凹凸贴图。在下一章,我将开始应用其中的一些技巧,并展示其他可以创造更有趣的 3D 世界的技巧。**
七、精心制作的杂集
如果我们知道我们在做什么,那就不叫研究了,对吗?
—阿尔伯特·爱因斯坦
当开始这一章,我试图找到一个合适的报价关于杂集。不幸的是,我所能找到的都是一些杂七杂八的引言。但是阿尔伯特·爱因斯坦写的那本书是一个真正的瑰宝,几乎可以应用,因为亲爱的读者,你正在进行研究——研究如何制作更丰富、更有趣的软件。
在像这样的书中,有时很难对某个特定的主题进行清晰的分类,当它们可能不值得拥有自己的一章时,我们不得不将许多东西放入一章中。因此,在这里我将涵盖一些经典的演示和渲染技巧,无论它们是否可以应用于太阳系项目或,所以在结束时你会惊呼“所以,这就是他们如何做到这一点!”
帧缓冲对象
通常称为 FBOs,您可以将帧缓冲区对象视为简单的渲染表面。到目前为止,您已经使用了一个,并且可能不知道它;你的场景通过GLSurfaceView对象渲染到的 EGL 环境是一个 FBO。你可能不知道的是,你可以同时拥有多个屏幕。像以前一样,我们将从旧标准开始,我们的彩虹色果冻弹跳板,然后看看它能从那里去哪里。
赫德利缓冲对象
这时候你知道该怎么做了:从第五章的中找到练习,用原来的 2D 弹跳纹理方块(图 5-13 ),并以此作为参考。由于大多数代码最终都会更改,我建议从头开始创建一个新项目。活动文件将是标准的默认文件。我们需要为 FBO 支持创建一个单独的对象;把这个叫做FBOController.java。它将涵盖 FBO 的初始化和执行。它应该看起来像清单 7–1,减去几个实用函数,您应该在别处有这些函数以节省空间。这些都在描述中注明了。
清单 7–1。 帧缓冲对象控制器
`public class FBOController { public Context context; int[] m_FBO1 = new int[3]; int[] m_FBOTexture = new int[1]; public String TAG = "FBO Controller"; int[] originalFBO = new int[1]; int[] depthBuffer = new int[1]; int m_ImageTexture; static float m_TransY = 0.0f; static float m_RotX = 0.0f; static float m_RotZ = 0.0f; static float m_Z = -1.5f; int[] m_DefaultFBO = new int[1]; int m_Counter=0; boolean m_FullScreen = false;
public int init(GL10 gl, Context contextRegf,int resource, int width, int height) { GL11ExtensionPack gl11ep = (GL11ExtensionPack) gl; //1
//Cache the original FBO, and restore it later.
gl11ep.glGetIntegerv(GL11ExtensionPack.GL_FRAMEBUFFER_BINDING_OES, //2 makeIntBuffer(originalFBO));
gl11ep.glGenRenderbuffersOES(1, makeIntBuffer(depthBuffer)); //3 gl11ep.glBindRenderbufferOES(GL11ExtensionPack.GL_RENDERBUFFER_OES, depthBuffer[0]);
gl11ep.glRenderbufferStorageOES(GL11ExtensionPack.GL_RENDERBUFFER_OES, GL11ExtensionPack.GL_DEPTH_COMPONENT16, width, height);
//Make the texture to render to. gl.glGenTextures(1, m_FBOTexture, 0); //4
gl.glBindTexture(GL10.GL_TEXTURE_2D, m_FBOTexture[0]);
gl.glTexImage2D(GL10.GL_TEXTURE_2D, 0, GL10.GL_RGB, width, height, 0, GL10.GL_RGB, GL10.GL_UNSIGNED_SHORT_5_6_5,null);
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_LINEAR); gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR); gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_S, GL10.GL_CLAMP_TO_EDGE); gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_T, GL10.GL_CLAMP_TO_EDGE);
//Now create the actual FBO.
gl11ep.glGenFramebuffersOES(1, m_FBO1,0); //5
gl11ep.glBindFramebufferOES(GL11ExtensionPack.GL_FRAMEBUFFER_OES, m_FBO1[0]);
// Attach the texture to the FBO. //6 gl11ep.glFramebufferTexture2DOES(GL11ExtensionPack.GL_FRAMEBUFFER_OES, GL11ExtensionPack.GL_COLOR_ATTACHMENT0_OES, GL10.GL_TEXTURE_2D, m_FBOTexture[0], 0);
// Attach the depth buffer we created earlier to our FBO. //7 gl11ep.glFramebufferRenderbufferOES (GL11ExtensionPack.GL_FRAMEBUFFER_OES, GL11ExtensionPack.GL_DEPTH_ATTACHMENT_OES, GL11ExtensionPack.GL_RENDERBUFFER_OES, depthBuffer[0]);
// Check that our FBO creation was successful.
gl11ep.glCheckFramebufferStatusOES (GL11ExtensionPack.GL_FRAMEBUFFER_OES);
int uStatus = gl11ep.glCheckFramebufferStatusOES(GL11ExtensionPack.GL_FRAMEBUFFER_OES);
if(uStatus != GL11ExtensionPack.GL_FRAMEBUFFER_COMPLETE_OES) return 0;
gl11ep.glBindFramebufferOES(GL11ExtensionPack.GL_FRAMEBUFFER_OES, //8
originalFBO[0]); m_ImageTexture = createTexture(gl,contextRegf,resource); //9
return 1; }
public int getFBOName() return m_FBO1[0];
public int getTextureName() return m_FBOTexture[0];
public void draw(GL10 gl) { GL11ExtensionPack gl11 = (GL11ExtensionPack) gl;
float squareVertices[] = //10 { -0.5f, -0.5f, 0.0f, 0.5f, -0.5f, 0.0f, -0.5f, 0.5f, 0.0f, 0.5f, 0.5f, 0.0f };
float fboVertices[] = { -0.5f, -0.75f, 0.0f, 0.5f, -0.75f, 0.0f, -0.5f, 0.75f, 0.0f, 0.5f, 0.75f, 0.0f };
float textureCoords1[] =
0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f };
if((m_Counter%250)==0) //11
{
if(m_FullScreen)
m_FullScreen=false;
else
m_FullScreen=true;
} gl.glDisable(GL10.GL_CULL_FACE);
gl.glEnable(GL10.GL_DEPTH_TEST);
if(m_DefaultFBO[0] == 0) //12 { gl11.glGetIntegerv(GL11ExtensionPack.GL_FRAMEBUFFER_BINDING_OES, makeIntBuffer(m_DefaultFBO)); }
gl.glDisableClientState(GL10.GL_COLOR_ARRAY | GL10.GL_DEPTH_BUFFER_BIT);
gl.glEnable(GL10.GL_TEXTURE_2D);
//Draw to the off-screen FBO first.
if(!m_FullScreen) //13 gl11.glBindFramebufferOES(GL11ExtensionPack.GL_FRAMEBUFFER_OES, m_FBO1[0]);
gl.glClearColor(0.0f, 0.0f, 1.0f, 1.0f); gl.glClear(GL10.GL_COLOR_BUFFER_BIT|GL10.GL_DEPTH_BUFFER_BIT);
gl.glPushMatrix();
gl.glMatrixMode(GL10.GL_MODELVIEW); gl.glLoadIdentity();
gl.glTranslatef(0.0f, (float)(Math.sin(m_TransY)/2.0f),m_Z);
gl.glRotatef(m_RotZ, 0.0f, 0.0f, 1.0f);
gl.glBindTexture(GL10.GL_TEXTURE_2D,m_ImageTexture); //14
gl.glTexCoordPointer(2, GL10.GL_FLOAT,0, makeFloatBuffer(textureCoords1)); gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
gl.glVertexPointer(3, GL10.GL_FLOAT, 0, makeFloatBuffer(squareVertices)); gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, 4);
gl.glPopMatrix();
//Now draw the offscreen frame buffer into another framebuffer.
if(!m_FullScreen) //15
{
gl.glPushMatrix(); gl11.glBindFramebufferOES(GL11ExtensionPack.GL_FRAMEBUFFER_OES,
m_DefaultFBO[0]);
gl.glMatrixMode(GL10.GL_MODELVIEW); gl.glLoadIdentity();
gl.glTranslatef(0.0f, (float)(Math.sin(m_TransY)/2.0f), m_Z); gl.glRotatef(m_RotX, 1.0f, 0.0f, 0.0f);
gl.glBindTexture(GL10.GL_TEXTURE_2D, m_FBOTexture[0]);
gl.glClearColor(1.0f, 0.0f, 0.0f, 1.0f); gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT);
gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0, makeFloatBuffer(textureCoords1)); gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
gl.glVertexPointer(3, GL10.GL_FLOAT, 0, makeFloatBuffer(fboVertices)); gl.glEnableClientState(GL10.GL_VERTEX_ARRAY); gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, 4);
gl.glPopMatrix(); }
m_TransY += 0.025f; m_RotX+=1.0f; m_RotZ+=1.0f; m_Counter++; }
//createTexture(), makeFloatBuffer() and makeIntBuffer() removed for clarity. //16
}`
您应该认识到这里的模式,因为创建 fbo 与许多其他 OpenGL 对象非常相似。您生成一个“名称”,绑定它,然后创建和修改对象。在这种情况下,有大量的创造和修改在进行。所以,让我们来分解一下:
- 在第一行,我们得到了一个叫做
GL11ExtensionPack的东西。扩展包是官方认可的一组额外的 API 调用,对于要被批准的特定版本的 OpenGL ES 不是必需的。这些可以由 GPU 供应商自行添加,但它们仍然必须遵循各种额外功能的规范。这方面的一个例子就是——哒哒!—帧缓冲对象!最初 fbo 是 OpenGL ES 2.0 的一部分,但是它们太有用了,所以决定向 1.1 用户开放。所有的 API 调用和定义都有后缀OES。由于 fbo 是普通 2.0 规范的一部分,这些调用不需要 OES。 - 第 2 行获得当前 FBO,这很可能是正常屏幕。它被缓存起来,以便以后可以恢复。
- 由于我们正在创建自己的私人 FBO,我们需要自己处理所有的设置,包括创建和添加一个深度缓冲区到我们的目标。第 3 行和接下来的一行生成一个新的缓冲区名称,绑定它,然后分配存储。
- 此时,在第 4ff 行中,我们需要分配一个纹理图像,并将其链接到我们的帧缓冲区。这是伪装我们的 FBO 所需要的接口,这样它看起来就像 OpenGL 中的其他纹理一样。在这里,我们也可以为边缘条件设置一些正常的纹理设置,并使用双线性过滤。
- 到目前为止,我们仅仅创建了深度缓冲和图像接口。在第 5f 行中,我们实际上创建了帧缓冲对象,并将前面的位附加到它上面。
- 第 6 行首先附加纹理。注意
GL_COLOR_ATTACHMENT0_OES的用法。纹理位实际上保存了颜色信息,所以它被称为颜色附件。 - 在第 7 行,我们使用
GL_DEPTH_ATTACHMENT_OES对深度缓冲区做了同样的操作。记住,在 OpenGL ES 中,我们只有三种类型的缓冲附件:深度、颜色和模板。后者做一些事情,比如在屏幕的某一部分阻止渲染,这将在本章后面介绍。OpenGL 成人版增加了第四种,GL_DEPTH_STENCIL_ATTACHMENT。 - 第 8 行恢复了之前的 FBO,第 9 行生成了我们的复活节岛朋友海德利的实际纹理,用于弹跳广场。
下一步是移动到 draw 方法,我们将看到 fbo 如何根据需要换入换出。
- 在第 10ff 行中,您将立即识别出标准的正方形数据,并添加了 FBO 的顶点。
- 需要第 11ff 行来允许我们在全屏纹理的 FBO 和正常的原始屏幕之间进行切换。
- 接下来,我们在第 12f 行再次缓存主屏幕的 FBO,就像在 create 方法中一样。
- 第 13 行是我们实际上告诉 OpenGL 使用我们的新 FBO 的地方。接下来是管理转换的标准代码,等等,这应该会让您感觉像在家里一样。
- 在第 14 行,我们绑定 Hedly 图像,然后设置顶点和纹理坐标,接下来是
glDrawArray()。 - 现在好戏开始了。在第 15ff 行中,FBO 是现在可以绑定到主屏幕的“新”纹理。首先,原始屏幕的 FBO 被绑定,接着是另一组转换调用,以及另一个
glClear()。为了让事情更明显,主屏幕被清除为红色,而 FBO 的背景被清除为蓝色。
所以,这仅仅是创造了一个 FBO。您将看到这是一段相当简洁的代码,使用了 OpenGL ES 1 和 2 中的内置函数。是的,它看起来确实有点过于复杂,但是很容易用一个辅助函数包装起来。
但是我们还没有完全完成,因为我们现在必须重新配置驱动程序来使用两个 fbo。重新配置过程的第一部分是看你的设备是否真的支持帧缓冲对象。为此,我们可以回到第五章中关于使用扩展枚举器的讨论。在这种情况下,下面的代码将会工作,这要感谢 OpenGL ES 工作组对这类事情的标准化。
private boolean checkIfContextSupportsExtension(GL10 gl, String extension) { String extensions = " " + gl.glGetString(GL10.*GL_EXTENSIONS*) + " "; return extensions.indexOf(" " + extension + " ") >= 0; }
现在对您的初始化代码所在的位置进行如下调用,例如onSurfaceChanged():
` m_FBOSupported=checkIfContextSupportsExtension(gl,"GL_OES_framebuffer_object");
if(m_FBOSupported) { int resid = book.BouncyCube1.R.drawable.hedly;
m_FBOController = new FBOController(); m_FBOController.init(gl, this.context, resid, width, height); }`
你应该能够运行它,看到它所有的华而不实的荣耀。如果你打算长时间盯着它,你的医生的许可可能是必要的。在 Figure 7–1 中最左边的图像是新的辅助 FBO 成为主要渲染表面的地方,而另一个表面现在嵌套在其中。
随意尝试用不同的图像和颜色做第三个或第四个 FBO。
图 7–1。在左边,只有海德利在纺纱。海德里和他的窗户现在都在中间逆时针旋转。在右边,框架首尾相连地旋转着。
太阳缓冲物体
你可以用缓冲对象做很多有趣又诡异的事情,相当于拥有了 3D 超能力。例如,你可以在电视机的小模型上模拟一些动画。您可以在地面上的水坑或汽车后视镜的倒影中显示同一数据的多个视图。更好的是,在我们的太阳系模拟器中放一个动画太阳场景的 OpenGL 帧。不是特别逼真,但是挺酷的。
这次我会把这个问题留给学生,但是我用了第五章的期末项目作为开始。你也可以从网站上下载。
我希望你能得到类似于图 7–2 的东西,其中海德利在太阳上上下跳动。
图 7–2。使用一个屏幕外的 FBO 在另一个屏幕上制作纹理动画
镜头眩光
我们都看到了。每当相机对准太阳时,这些幽灵般的、发光的薄纱光就会在电视场景周围飞舞,或者侵入图像。这是因为太阳光在相机的光学系统中愉快地来回反射,产生了大量的二次图像。这些既可以被视为一个明亮的广霾和许多较小的文物。图 7–3(左)用 1971 年阿波罗 14 号登月任务中的一张图片说明了这一点。耀斑遮住了登月舱的大部分。就连 iPhone 也有类似的问题,正如图 7–3 中右图所示。即使在月球上使用的哈苏相机是世界上最好的,我们也不能打败镜头光晕。不幸的是,它已经成为计算机图形中最常见的陈词滥调之一,被用作大喊“嘿!这不是假的电脑图像,因为它有镜头光晕!”然而,透镜耀斑确实有其用途,特别是在空间模拟领域,因为假图像经常看着假太阳。在这种情况下,无论是有意识的还是下意识的,你都会期待一些视觉上的暗示,那就是你在看非常非常非常亮的东西。这也有助于赋予图像额外的深度感。耀斑是在离用户很近的光学系统中产生的,而目标却在十亿英里之外。
图 7–3。左边是阿波罗 14 号在月球上的照片,右边是摩托罗拉 Xoom 的照片。
根据特定的光学系统和它们不同的内部涂层,耀斑可以采取许多不同的形式,但它们通常最终只是一堆不同大小和颜色的幽灵多边形。在下一个练习中,我们将创建一个简单的镜头光晕项目,演示如何在 3D 环境中使用 2D 图像。因为设置有很多代码,所以我在这里只强调关键的部分。您需要前往[www.apress.com](http://www.apress.com)获取完整的项目。
从几何学上来说,镜头光晕通常非常简单,因为它们是对称的。它们表现出两个主要特征:所有的镜头光晕都需要非常亮的光源,并且它们会沿着穿过屏幕中心的对角线,如图 Figure 7–4 所示。
图 7–4。镜头光晕是由相机镜头内明亮光源的内反射引起的。
既然耀斑图像是 2D,我们如何将它们放入 3D 空间?回到最初的例子,有弹性的正方形也是 2D 物体。但是显示它依赖于一些默认的对象映射到屏幕的方式。这里我们可以更具体一点。
还记得我在第三章中提到的透视和正投影吗?前者是我们感知物体维度的方式;当需要精确的大小和形状时,使用后者,消除透视给场景带来的失真。所以,当你画 2D 物体时,你通常会希望确保它们的视觉尺寸不会被你的世界的其他部分的 3D 所影响。
当涉及到生成镜头光晕时,你将需要一个不同形状的小集合来代表实际镜头的一些机制。六边形或五边形图像是用于改变入射光强度的虹膜图像;参见图 7–5。由于使用了各种涂层来保护镜片或过滤掉不需要的波长,它们也会呈现出不同的色彩。
图 7–5。六叶虹膜(戴夫·费希尔拍摄)
生成火炬集需要以下步骤:
- 导入各种图像。
- 检测源对象在屏幕上的位置。
- 创建一个穿过屏幕中心的虚拟向量,以容纳每件艺术品。
- 添加十几个或更多的图像,随机大小,颜色和透明度,分散在矢量的上下。
- 支持触摸拖动,在所有不同位置进行测试。
我从标准模板开始,添加了对触摸和拖动视觉效果的支持。您会注意到不再有 3D 太阳对象。现在它是一个在用户手指当前位置渲染的闪光 2D 纹理,如清单 7–2 所示。
清单 7–2。 顶级 onDrawFrame()
` public void onDrawFrame(GL10 gl) { CGPoint centerRelative = new CGPoint(); CGPoint windowDefault = new CGPoint(); CGSize windowSize = new CGSize(); float cx,cy; float aspectRatio
gl.glClearColor(0.0f, 0.0f, 0.0f, 1.0f); gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
DisplayMetrics display = context.getResources().getDisplayMetrics(); //1 windowSize.width = display.widthPixels; windowSize.height = display.heightPixels;
cx=windowSize.width/2.0f; cy=windowSize.height/2.0f;
aspectRatio=cx/cy;
centerRelative.x = m_PointerLocation.x-cx; centerRelative.y =(cy-m_PointerLocation.y)/aspectRatio;
CT.renderTextureAt(gl, centerRelative.x, centerRelative.y, windowSize, //2 m_FlareSource, 3.0f, 1.0f, 1.0f, 1.0f, 1.0f);
m_LensFlare.execute(gl, windowSize, m_PointerLocation); //3 }`
这里有三行需要注意:
- 第 1ff 行得到屏幕的中心,并用指针(你的手指)创建跟踪耀斑源(太阳)所需的信息。
- 在第 2 行中,渲染了耀斑的源对象,通常是太阳。
- 第 3 行调用绘制实际镜头光晕的助手例程。
清单 7–3 中的下一位在屏幕上绘制了一个 2D 纹理。您会发现这非常方便,并且会经常使用它在屏幕上显示文本或类似 HUD 的图形。简而言之,这将绘制一个矩形对象,就像弹性正方形一样。为了使它成为 2D,它在设置投影矩阵时使用了一个名为glOrthof()的新调用。
清单 7–3。 渲染出 2D 纹理
`public void renderTextureAt(GL10 gl, float postionX, float postionY, //1 CGSize windowsSize, int textureId, float size, float r, float g, float b, float a) { float scaledX, scaledY; float zoomBias = .1f;
float scaledSize;
float squareVertices[] = { -1.0f, -1.0f, 0.0f, 1.0f, -1.0f, 0.0f, -1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f };
float textureCoords[] = { 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f };
float aspectRatio = windowsSize.height / windowsSize.width;
scaledX = (float) (2.0f * postionX / windowsSize.width); // 2 scaledY = (float) (2.0f * postionY / windowsSize.height);
gl.glDisable(GL10.GL_DEPTH_TEST); // 3 gl.glDisable(GL10.GL_LIGHTING);
gl.glMatrixMode(GL10.GL_PROJECTION); // 4 gl.glPushMatrix(); gl.glLoadIdentity();
gl.glOrthof(-1.0f, 1.0f, -1.0f * aspectRatio, 1.0f * aspectRatio, -1.0f, 1.0f); // 5
gl.glMatrixMode(GL10.GL_MODELVIEW); // 6 gl.glLoadIdentity();
gl.glTranslatef(scaledX, scaledY, 0); // 7
scaledSize = zoomBias * size; // 8 gl.glScalef(scaledSize, scaledSize, 1); // 9
gl.glVertexPointer(3, GL10.GL_FLOAT, 0, makeFloatBuffer(squareVertices)); gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnable(GL10.GL_TEXTURE_2D); // 10 gl.glEnable(GL10.GL_BLEND); gl.glBlendFunc(GL10.GL_ONE, GL10.GL_ONE_MINUS_SRC_COLOR); gl.glBindTexture(GL10.GL_TEXTURE_2D, textureId); // 11 gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0, makeFloatBuffer(textureCoords)); gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
gl.glColor4f(r, g, b, a);
gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, 4);
gl.glMatrixMode(GL10.GL_PROJECTION); // 12 gl.glPopMatrix();
gl.glMatrixMode(GL10.GL_MODELVIEW); gl.glPopMatrix(); gl.glEnable(GL10.GL_DEPTH_TEST); gl.glEnable(GL10.GL_LIGHTING); gl.glDisable(GL10.GL_BLEND); }`
所以,事情是这样的:
- 在第 1 行中,位置是相对于纹理中心的纹理原点(以像素为单位),稍后会将其转换为归一化值。大小是相对的,需要把玩才能找到最合适的。最后的参数是颜色和 alpha。如果您不想要任何颜色,请为所有值传递 1.0。沿着这条线,你会认出我们的老朋友,顶点和纹理坐标。
- 第 2 行根据帧的宽度和高度将像素位置转换为相对值。这些值被缩放 2 倍,因为我们的视口将是 2 个单位宽和 2 个单位高,在每个方向上从-1 到 1。这些是最终传递给
glTranslatef()的值。 - 接下来,为了安全起见,第 3 行关闭了任意深度测试,同时关闭了照明,因为光晕必须与场景中的实际照明分开计算。
- 既然我们要使用正投影,让我们将
GL_PROJECTION矩阵重置为第 4ff 行中的单位(默认)。请记住,任何时候你想接触一个特定的矩阵,你需要提前指定哪一个。glPushMatrix()让我们修补投影矩阵,而不会弄乱事件链中的任何先前的东西。 - 第 5 行是这个例程的核心。
glOrthof()是一个新的调用,设置了正投影矩阵。实际上,它指定了一个盒子。在这种情况下,框的宽度和深度都从-1 到 1,而高度使用长宽比稍微放大一点,以补偿它是一个非正方形的显示。这就是为什么scaledX和scaledY的值被乘以 2。 - 接下来,在第 6f 行将 modelview 矩阵设置为其标识,然后在第 7 行调用
glTranslatef()。 - 第 8 行决定了如何根据我们场景的视野来缩放闪光集合,随后是第 9 行执行实际的缩放。这是相对的,取决于您想要处理的放大范围。现在,缩放还没有实现,所以它保持不变。
zoomBias影响所有元素,这使得一次性缩放所有元素变得很容易。 - 第 10ff 行使用最常见的选项设置混合功能。这使得每个反射以一种非常可信的方式混合,特别是当它们开始在中心堆积时。
- 现在在第 11ff 行,纹理被着色,装订,最后被绘制。
- 再次强调,做一个好邻居,弹出矩阵,这样它们就不会影响其他任何东西。重置一堆其他垃圾。
注意,这是一个非常低效的例程。通常情况下,您会以一种避免所有高开销的状态更改的方式来批处理绘制操作。(类似的性能问题将在第九章中讨论。)
我为单独的光斑创建了一个Flare.java,并创建了一个LensFlare父对象来设置矢量,包含每个单独的图像,并在准备好的时候放置它们。在这一点上,清单 7–4 中LensFlare.java的主循环几乎不需要解释。它只计算耀斑向量的起点,然后枚举整个耀斑数组以执行每个实体。
清单 7–4。 对整个镜头执行循环光晕效果来自LensFlare.java
`public void execute(GL10 gl,CGSize size, CGPoint source) { int i; float cx,cy; float aspectRatio;
cx=(float) (size.width/2.0f); cy=(float) (size.height/2.0f);
aspectRatio=cx/cy;
startingOffsetFromCenterX = cx-source.x; startingOffsetFromCenterY = (source.y-cy)/aspectRatio;
offsetFromCenterX = startingOffsetFromCenterX; offsetFromCenterY = startingOffsetFromCenterY;
deltaX = (float) (2.0f * startingOffsetFromCenterX); deltaY = (float) (2.0f * startingOffsetFromCenterY);
for (i = 23; i >= 0; i--) { offsetFromCenterX -= deltaX * myFlares[i].getVectorPosition(); offsetFromCenterY -= deltaY * myFlares[i].getVectorPosition();
myFlares[i].renderFlareAt(gl, m_Flares[i], offsetFromCenterX, offsetFromCenterY, size, this.context); } counter++; }`
最后,每个单独的耀斑图像必须在初始化时加载并添加到NSArray中。下面是几行代码:
` resid = book.lensflare.R.drawable.hexagonblur; m_Flares[0] = myFlares[0].init(gl, context, resid, .5f, .05f-ff, 1.0f, .73f, .30f, .4f);
resid = book.lensflare.R.drawable.glow; m_Flares[1] = myFlares[1].init(gl, context, resid, 0.5f, .05f-ff, 1.0f, .73f, .50f, .4f);`
这个演示有 24 个这样的对象。图 7–6 显示了结果。
图 7–6。简单的镜头光晕
不幸的是,在镜头眩光业务中有一个大问题。如果你的光源跟在别的东西后面会怎么样?如果它是一个规则的已知实体,比如场景中心的一个圆球,就很容易识别出来。但如果是随机地点的随机物体,就变得困难多了。那么如果光源只是部分被遮挡会怎么样呢?只有当整个物体被隐藏时,反射才会变暗和闪烁。解决方案暂时留给你。
反射表面
另一个很快变得有点俗套的视觉效果,尽管仍然很酷,是部分或整个场景下面的镜面。例如,Mac-heads 看到每次他们看着 Dock 时,快乐的小图标上下跳着他们的吉格舞,实际上是在说“看这里!看这里!”下面你会看到一个微弱的小倒影。很多第三方 app 也是一样,当然是以苹果自己的设计和例子为首。参见图 7–7。
图 7–7。远处太阳的倒影。(没错,就是无偿插。)
谷歌刚刚开始涉足其市场,展示了一些书籍和电影图片,下面还有一些倒影。这将引入下一个主题,它是关于模板和反射的,因为这两者经常被联系在一起。在这种情况下,我们将创建一个反射表面,在我们的对象下面的一个( stage ),做一个对象的镜像,并使用模板沿着舞台的边缘剪切反射。
除了“颜色”缓冲区(即图像缓冲区)和深度缓冲区,OpenGL 还有一个叫做模板缓冲区的东西。
模板格式可以是 8 位或 1 位,通常是后者。
在 Android 中添加一个模板是一件轻而易举的事情,它将我们带回活动文件的onCreate()方法,在这里GLSurfaceView被初始化。OpenGL 表面的默认格式是RGB565,有一个 16 位深度缓冲区,没有模板缓冲区。后者可以通过下面的代码调用setEGLConfigChooser()来解决,最后一个参数指定一个 1 位模板。
glsurface view view =newglsurface view(this);
view.setEGLConfigChooser(8,8,8,8,16,1); view.setRenderer(new CubeRenderer(true));
从本质上讲,你可以像对其他任何东西一样对模板缓冲区进行渲染,但是在这种情况下,任何像素及其值都被用来决定如何将未来的图像渲染到屏幕上。最常见的情况是,任何后来绘制到模具区域的图像都将正常呈现,而模具区域之外的任何图像都不会呈现。当然,这些行为是可以修改的,这符合 OpenGL 的理念,即让一切都比绝大多数工程师使用的更加灵活,更不用说理解了。尽管如此,它有时还是非常方便的。我们将暂时使用简单的函数(清单 7–5)。
清单 7–5。 模板像普通屏幕对象一样生成
` public void renderToStencil(GL10 gl) { gl.glEnable(GL10.GL_STENCIL_TEST); //1 gl.glStencilFunc(GL10.GL_ALWAYS,1, 0xFFFFFFFF); //2 gl.glStencilOp(GL10.GL_REPLACE, GL10.GL_REPLACE, GL10.GL_REPLACE); //3
renderStage(gl); //4
gl.glStencilFunc(GL10.GL_EQUAL, 1, 0xFFFFFFFF); //5 gl.glStencilOp(GL10.GL_KEEP, GL10.GL_KEEP,GL10.GL_KEEP); //6 }`
所以,你建立你的模具如下:
- 按照第 1 行中的操作启用模板。
- 在第 2 行中,我们指定了每当有东西写入模板缓冲区时使用的比较函数。因为我们每次都清除它,所以它将全是零。函数
GL_ALWAYS表示每次写入都将通过模板测试,这是我们在构造模板本身时想要的。值 1 被称为参考值,用于执行额外的测试以微调行为,但这超出了本文的范围。最终值是位平面要访问的掩码。既然我们不关心这个,那就把它们都打开吧。 - 第 3 行指定模板测试成功或失败时要做什么。第一个参数与模板测试失败有关,第二个参数与模板通过但深度测试失败有关,第三个参数与两者都成功有关。由于我们生活在 3D 空间中,将模板测试与深度测试结合在一起可以认识到,可能存在一个测试否决另一个测试的情况。模板缓冲区使用中的一些微妙之处会变得非常复杂。在这种情况下,将三个都设置为
GL_REPLACE。表 7–1 显示了所有其他允许值。 - 第 4 行调用我们的渲染函数,就像你通常调用的那样。在这种情况下,它同时写入模板缓冲区和其中一个颜色通道,因此我们可以在新的闪亮舞台或平台上看到一些闪光。同时,在模板缓冲区中,背景将保持为零,而图像将产生大于 0 的模板像素,因此它允许图像数据稍后写入其中。
- 第 5 行和第 6 行现在为正常使用准备缓冲区。第 5 行说,如果当前被寻址的模板像素的值是 1,保持它不变,如第 6 行所示。否则,传递片段以进行处理,就好像模板缓冲区不在那里一样(尽管如果深度测试失败,它仍可能被忽略)。因此,对于任何为 0 的模板像素,测试都将失败,传入的片段将被锁定。
正如你所看到的,模板缓冲是一个非常强大的工具,有很多微妙之处。但是任何更奢侈的使用都是为尚未命名的未来书籍保留的。
现在是使用renderStage()方法的时候了,如清单 7–6 所示。
清单 7–6。 只渲染反光区域到模版缓冲区
public void renderStage(GL10 gl) { float[] flatSquareVertices = { -1.0f, 0.0f, -1.0f, 1.0f, 0.0f, -1.0f, ` -1.0f, 0.0f, 1.0f,
1.0f, 0.0f, 1.0f
};
FloatBuffer vertexBuffer;
float[] colors= { 1.0f, 0.0f, 0.0f, 0.5f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.5f, 0.0f, 0.0f, 0.5f };
FloatBuffer colorBuffer;
gl.glFrontFace(GL10.GL_CW); gl.glPushMatrix(); gl.glTranslatef(0.0f,-2.0f,mOriginZ); gl.glScalef(2.5f,1.5f,2.0f);
gl.glVertexPointer(3, GL11.GL_FLOAT, 0,makeFloatBuffer(flatSquareVertices)); gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glColorPointer(4, GL11.GL_FLOAT, 0,makeFloatBuffer(colors));
gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
gl.glDepthMask(false); //1 gl.glColorMask(true,false,false, true); //2 gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP,0, 4); //3 gl.glColorMask(true,true,true,true); //4 gl.glDepthMask(true); //5
gl.glPopMatrix(); }`
- 在第 1 行中,禁止写入深度缓冲区,第 2 行禁止绿色和蓝色通道,因此只使用红色通道。这就是反射区域获得红色小亮点的原因。
- 现在我们可以将图像绘制到第 3 行的模板缓冲区。
- 第 4 行和第 5 行重置了掩码。
此时,必须再次修改onDrawFrame()例程。如果你能让你的窥视者睁开眼睛,看看清单 7–7 中残酷而不加掩饰的真相。很抱歉重复了这么多前面代码的,但这比说“……在关于松鼠投石机的那一点之后加上某某行……”要容易得多
清单 7–7。 三国志onDrawFrame()法
`public void onDrawFrame(GL10 gl) { gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT | //1 GL10.GL_STENCIL_BUFFER_BIT); gl.glClearColor(0.0f,0.0f,0.0f,1.0f);
renderToStencil(gl); //2
gl.glEnable(GL10.GL_CULL_FACE); gl.glCullFace(GL10.GL_BACK);
gl.glMatrixMode(GL10.GL_MODELVIEW); gl.glLoadIdentity();
gl.glPushMatrix();
gl.glEnable(GL10.GL_STENCIL_TEST); //3 gl.glDisable(GL10.GL_DEPTH_TEST);
//Flip the image.
gl.glTranslatef(0.0f,((float)(Math.sin(-mTransY)/2.0f)-2.5f),mOriginZ); //4 gl.glRotatef(mAngle, 0.0f, 1.0f, 0.0f);
gl.glScalef(1.0f, -1.0f, 1.0f); //5 gl.glFrontFace(GL10.GL_CW);
gl.glEnable(GL10.GL_BLEND); //6 gl.glBlendFunc(GL10.GL_ONE, GL10.GL_ONE_MINUS_SRC_COLOR);
mCube.draw(gl); //7
gl.glDisable(GL10.GL_BLEND);
gl.glPopMatrix();
gl.glEnable(GL10.GL_DEPTH_TEST); gl.glDisable(GL10.GL_STENCIL_TEST);
//Now the main image.
gl.glPushMatrix();
gl.glScalef(1.0f, 1.0f, 1.0f); //8 gl.glFrontFace(GL10.GL_CCW);
gl.glTranslatef(0.0f,(float)(1.5f*(Math.sin(mTransY)/2.0f)+2.0f),mOriginZ);
gl.glRotatef(mAngle, 0.0f, 1.0f, 0.0f);
mCube.draw(gl);
gl.glPopMatrix();
mTransY+=.075f; mAngle+=.4f; }`
这是细目分类:
- 在第 1 行中,
GL_STENCIL_BUFFER_BIT被加到glClear()中,这意味着它必须在每一帧中被重建,如清单 7–6 的第 2 行所示。这实际上会创建一个模版区域,戳出一个洞,我们下一步要画出来。 - 启用第 3 行中的模板测试。
- 在这里,第 4 行和第 5 行绘制了反射。先向下平移一点,减去 1.5,保证在实物下面。然后就是简单的将 y 轴“缩放”到-1.0,这样就有了上下翻转的效果。在这一点上,你需要改变正面为顺时针方向;否则,您将只能看到背面。
- 正如我们所期望的,我们希望使下面的图像半透明,而不是全亮度。在第 6f 行中,blend 被启用并使用第六章中
GL_ONE和GL_ONE_MINUS_SRC_COLOR的最常见混合功能。 - 在第 7 行我们可以画出我们的对象,在这个例子中是立方体。
- 因为触摸了 scale 以反转图像,所以在第 8 行中 scale 被重置为默认值。翻译已经用几个其他的小值进行了修改。这就把它向上移动了一点,为倒置的立方体留出了额外的空间。
现在测试:图 7–8 是你应该看到的。
图 7–8。使用模板创造倒影
阴影降临
阴影投射在 OpenGL 中一直是一种黑色艺术,在某种程度上仍然如此。然而,随着更快的 CPU 和 GPU,许多以前是研究生论文主题的商业技巧终于可以走出理论,进入现实世界部署的温暖光芒中。阴影投射的严格解决方案仍然是好莱坞采用的非实时渲染的领域,但在有限的条件下,基本阴影可用于全运动渲染。由于各种硬件制造商在其 GPU 中添加了阴影和照明支持,我们的 3D 世界看起来比以往任何时候都更加丰富,因为计算机图形中很少有元素能够比精心管理的阴影更具真实感。(问问任何一个好莱坞电影的灯光导演。)并且不要忘记通过使用 OpenGL ES 2 中的着色器来支持每像素,这可以让程序员微妙地为炸毁一切 3 中每个幽灵城堡的每个角落着色。
有很多方法可以投射阴影,或者至少是看起来像阴影的东西。也许最简单的是有一个预渲染的阴影斑点:一个看起来像地面上的阴影的位图,由你的对象投射。它便宜、快速,但极其有限。另一个极端是成熟的渲染任何你能看到的东西的软件,它把 GPU 当午餐吃掉。在这两者之间,你会发现阴影贴图、阴影体积、和投影阴影。
阴影贴图
曾经,阴影投射最流行的形式之一是通过在游戏中经常使用的阴影贴图。虽然设置起来有点麻烦,更不用说描述了,但是理论非常简单…考虑到。
阴影贴图需要场景的两张快照。一个是从光线的角度,另一个是从相机的角度。根据定义,当从灯光渲染时,图像将看到被自身照亮的一切。颜色信息被忽略,但深度信息被保留,所以我们最终得到了一个可见片段及其相对距离的地图。现在从相机的角度拍一张照片。通过比较这两幅图像,我们可以发现相机看到了光线看不到的部分。这些位在阴影中。
当然,实际上要比这复杂一点。
阴影卷
阴影体积用于决定场景的哪一部分被照亮,哪一部分没有被照亮,这是通过巧妙利用模板缓冲区的某些属性来实现的。这项技术如此强大的原因在于,它允许阴影投射到任意几何形状上,而不是投影阴影(稍后讨论),后者只适用于阴影投射到平面上的简化情况。
当使用阴影体积技术渲染场景时,模板缓冲区将处于这样一种状态,其中被着色的结果图像的任何部分将具有大于零的相应模板像素,而被照亮的任何部分将具有零。参见图 7–9。
图 7–9。 阴影体积显示模板缓冲区中的相应值:0 表示任何被照亮的部分,> 0 表示阴影区域
这分三个阶段完成。第一步是仅使用环境光渲染图像,以便场景的阴影部分仍然可见。接下来是只写入模板缓冲区的过程,最后一个阶段写入全光照的正常图像。然而,只有非阴影像素可以写入照亮的区域,而它们被阻止写入阴影部分,只留下原始的环境像素可见。
回到之前在反射练习中使用的神秘的glStencilOp()函数,我们现在可以利用那些奇怪的GL_INCR和GL_DECR操作。GL_INCR可将模板像素的计数增加 1,而GL_DECR将计数减少 1,两种操作都在特定条件下触发。
术语阴影体积来自下面的例子:想象一下这是一个多雾的夜晚。你用一盏明亮的灯,比如你汽车的前灯,照亮薄雾。现在在光束里做些皮影戏。你仍然会看到一部分光束绕过你画得很差的爱荷华州的阴影,飘向远方。我们对那部分不感兴趣。我们想要的是光束变暗的部分,也就是你双手投下的阴影。那是阴影体积。
在你的 OpenGL 场景中,假设你有一个光源和几个遮光器。这些影子投射到身后的任何东西上,不管是球体、圆锥体还是伍德罗·威尔逊的半身像。当你从侧面看时,你会看到有阴影的物体和被照亮的物体。现在从任何一个片段画一个向量,不管有没有被照亮,到你的相机。如果片段被照亮,根据定义,向量必须穿过偶数个阴影体积的墙:一个当它进入阴影体积时,一个当它出来时(当然,忽略场景边缘上的向量可能不需要穿过任何阴影区域的特殊情况)。对于其中一个体积内的碎片,向量必须穿过奇数个壁;让它显得奇怪的那堵额外的墙来自于它自身的居住空间。
现在回到模板。生成的阴影体积看起来像任何其他几何体,但只绘制到模板上,使它们不可见,因为颜色缓冲区都已关闭。使用深度缓冲区,这样体积的壁将只在模板中渲染*,如果它比真实的几何体更接近*。这个技巧可以让阴影追踪任意物体的轮廓,而不必对球体或复活节岛雕像进行复杂繁琐的相交平面计算。它只是使用深度缓冲区来进行逐个像素的测试,以确定阴影结束的位置。因此,当体积被渲染到模板上时,阴影的每个“圆锥体”的每一侧都会以不同的方式影响模板。面向我们的一边将模板缓冲区中的值增加 1,而另一边将它减少。因此,在体积的另一侧被照亮的任何区域将匹配模板遮罩的一部分,其中所有像素都被设置为零,因为向量必须通过相同数量的进入和出去的面。任何处于阴影中的部分都有一个对应的模板值 1。
这就是为什么本练习从未选择阴影体积的原因。
水滴影
斑点阴影完全是一个骗局。它只是假设没有真正的直射光源,所以物体的阴影只不过是下面的一个斑点,如图 Figure 7–10 所示。如你所见,如果我们的遮光器(投射阴影的东西)是一个巨大的食人玉米煎饼,这就不太好了。
图 7–10。放置在所有物体下的斑点阴影纹理
投影阴影
投影阴影是动态阴影算法中“最容易”实现的,但这也意味着它们有许多限制——即投影阴影在大平面上投射阴影时效果最佳,如图 Figure 7–11 所示。此外,阴影不能投射在任意对象上。与其他方法一样,基本过程是从光线的角度和相机的角度拍摄快照。灯光的视图在平面上被压扁,涂上合适的阴影颜色(也就是深色),然后遮光器被渲染在顶部。
图 7–11。投影在平面上的阴影,然后被“再投影”出来戳到观众的眼睛
阴影面积通过使用向量和平面的交点计算,向量和平面从光源出发,经过每个顶点。平面上的每个点形成一个“新的”对象,然后可以像平面上的其他任何东西一样进行变换。清单 7–11 展示了这是如何编码的。
让我们再次从基本的 bouncy cube 演示开始(尽管它的大部分内容将因阴影代码而改变,但它仍将作为一个工作模板),但我们将交换不同的控制器代码。清单 7–8 涵盖了您需要首先添加的一些初始化参数。
清单 7–8。 添加到渲染器的初始化素材
` float mSpinX=-1.0f; float mSpinY=0.0f; float mSpinZ=0.0f;
float mWorldY=-1.0f; float mWorldZ=-20.0f;
float mWorldRotationX=35.0f; float mWorldRotationY=0.0f; float mLightRadius=2.5f;`
这些只是设置场景的灯光,视角和动画。
清单 7–9 涵盖了onDrawFrame()方法。
清单 7–9。??onDrawFrame()投射阴影的方法
`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.glEnable(GL10.GL_DEPTH_TEST);
updateLightPosition(gl); //1
gl.glMatrixMode(GL10.GL_MODELVIEW); gl.glLoadIdentity();
gl.glTranslatef(0.0f,mWorldY,mWorldZ); //2 gl.glRotatef(mWorldRotationX, 1.0f, 0.0f, 0.0f); gl.glRotatef(mWorldRotationY, 0.0f, 1.0f, 0.0f);
renderStage(gl); //3
gl.glDisable(GL10.GL_DEPTH_TEST); //4
calculateShadowMatrix(); //5 drawShadow(gl,true); //6
gl.glShadeModel(GL10.GL_SMOOTH);
gl.glTranslatef(0.0f,(float)(Math.sin(mTransY)/2.0)+mMinY, 0.0f); //7
gl.glRotatef( mSpinZ, 0.0f, 0.0f, 1.0f ); gl.glRotatef( mSpinY, 0.0f, 1.0f, 0.0f ); gl.glRotatef( mSpinX, 1.0f, 0.0f, 0.0f );
gl.glEnable( GL10.GL_DEPTH_TEST); //8 gl.glFrontFace(GL10.GL_CCW);
mCube.draw(gl); //9
gl.glDisable(GL10.GL_BLEND);
mFrameNumber++;
mSpinX+=.4f; //10 mSpinY+=.6f; mSpinZ+=.9f;
mTransY+=.075f; }`
这是怎么回事?
- 第 1 行将导致光围绕立方体旋转,动态地改变阴影。
- 2ff 线瞄准您的眼点。
- 我们在第三行中循环使用上一个练习中的阶段。
- 我们需要在实际绘制阴影时禁用深度测试(第 4 行);否则,将会有各种 z 争用,产生很酷但无用的闪烁。
- 第 5 行调用例程来生成阴影的矩阵(稍后详述),接下来是第 6 行,它实际上使用巧妙命名的方法
drawShadow()来绘制阴影。 - 第 7ff 行定位和旋转遮光器,我们的立方体。
- 第 8f 行安全地再次打开深度测试,之后我们可以安全地在第 9 行绘制立方体。
- 在最后一位,第 10ff 行,立方体的位置和姿态为下一轮更新。
在进入下一步之前,请查看下面来自renderStage()的描述舞台几何图形的代码片段:
float[] flatSquareVertices = { -1.0f, -0.01f, -1.0f, 1.0f, -0.01f, -1.0f, -1.0f, -0.01f, 1.0f, 1.0f, -0.01f, 1.0f };
注意微小的负 y 值。这是一个快速解决问题的方法,称为 z-fighting ,其中来自共面对象的像素可能会也可能不会共享相同的深度值。结果是两张脸在一瞬间闪烁;A 面是最前面的,接下来,B 面的像素,现在认为自己是最前面的。*(注意硬件显示的可能和模拟器不同。这也是总是在硬件上测试的另一个原因。)*如果你在几乎任何实时 3D 软件中足够努力地寻找,你很可能会看到一些 z 在背景中战斗。参见图 7–12。
图 7–12。站台和影子之间的 Z 战斗
现在我们开始真正有趣的东西,实际上计算和绘制阴影。清单 7–10 展示了矩阵是如何生成的,而清单 7–11 绘制了被挤压的阴影。
**清单 7–10。**计算影子矩阵
` public void calculateShadowMatrix() { float[] shadowMat_local = { mLightPosY, 0.0f, 0.0f, 0.0f, -mLightPosX, 0.0f, -mLightPosZ, -1.0f, 0.0f, 0.0f, mLightPosY, 0.0f, 0.0f, 0.0f, 0.0f, mLightPosY };
for (int i=0;i<16;i++) { mShadowMat[i] = shadowMat_local[i]; } }`
这实际上是更一般化矩阵的简化版本,如下所示:
dotp是光线矢量与平面法线的点积,l 是光线的位置,p 是平面(我代码中的“舞台”)。由于我们的平台在 x/z 平面上,平面方程看起来像 p=[0,1,0,0],否则 p[0]=p[2]=p[3]=0。这意味着矩阵中的大多数项都被归零。一旦生成了矩阵,将其乘以现有的 modelview 矩阵,就可以将这些点与其他所有东西一起映射到您的本地空间。明白了吗?我也不知道,但这似乎很有效。
清单 7–11 为阴影执行所有需要的变换,并通过遮光器本身渲染立方体。
清单 7–11。??drawShadow()套路
`public void drawShadow(GL10 gl,boolean wireframe) { FloatBuffer vertexBuffer;
gl.glPushMatrix();
gl.glEnable(GL10.GL_DEPTH_TEST);
gl.glRotatef(mWorldRotationX, 1.0f, 0.0f, 0.0f); //1
gl.glRotatef(mWorldRotationY, 0.0f, 1.0f, 0.0f); gl.glMultMatrixf(makeFloatBuffer(mShadowMat)); //2
//Place the shadows.
gl.glTranslatef(0.0f,(float)(Math.sin(mTransY)/2.0)+mMinY, 0.0f); //3
gl.glRotatef((float)mSpinZ,0.0f,0.0f,1.0f); gl.glRotatef((float)mSpinY,0.0f,1.0f,0.0f); gl.glRotatef((float)mSpinX,1.0f,0.0f,0.0f);
//Draw them.
if(mFrameNumber>150) //4 mCube.drawShadow(gl,true); else mCube.drawShadow(gl,false);
gl.glDisable(GL10.GL_BLEND);
gl.glPopMatrix(); }`
- 首先将所有东西旋转到世界空间,就像我们之前在第一行做的那样。
- 第 2 行将阴影矩阵与当前模型视图矩阵相乘。
- 线条 3ff 对阴影执行与实际立方体相同的变换和旋转。
- 在第 4ff 行,立方体渲染了自己的阴影。这两个调用将导致阴影在实线和线框之间翻转,如图 Figure 7–13 所示。
清单 7–12 涵盖了Cube.java中的drawShadow()方法。
清单 7–12。 画影子
`public void drawShadow(GL10 gl,boolean wireframe) { gl.glDisableClientState(GL10.GL_COLOR_ARRAY); //1 gl.glDisableClientState(GL10.GL_NORMAL_ARRAY);
gl.glEnable(GL10.GL_BLEND); //2 gl.glBlendFunc(GL10.GL_ZERO,GL10.GL_ONE_MINUS_SRC_ALPHA);
gl.glColor4f(0.0f,0.0f,0.0f,0.3f);
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glVertexPointer(3, GL10.GL_FLOAT, 0,makeFloatBuffer(mVertices)); //3 if(wireframe)
{
gl.glLineWidth(3.0f); //4
gl.glDrawElements(GL10.GL_LINES, 6 * 3,GL10.GL_UNSIGNED_BYTE, mTfan1);
gl.glDrawElements(GL10.GL_LINES, 6 * 3,GL10.GL_UNSIGNED_BYTE, mTfan2);
}
else
{
gl.glDrawElements(GL10.GL_TRIANGLE_FAN,63,GL10.GL_UNSIGNED_BYTE,mTfan1);
gl.glDrawElements(GL10.GL_TRIANGLE_FAN,63,GL10.GL_UNSIGNED_BYTE,mTfan2);
}
}`
这将绘制一个线框阴影,以显示它是如何组成的,或者更传统的实体模型。
- 我们首先关闭第一行中的颜色和法线数组,因为这里不需要它们。
- 混合在 2ff 行中被激活,所以 0.3 的 alpha 值将使阴影不再是纯黑色。
- 在这里的第三行中,立方体自己的顶点被重用,所以没有必要为阴影使用特殊的几何图形。这意味着,即使是最复杂的模型,你也可以得到非常精确的表示。
- 第 4 行显示线框代码,该行设置为 3 像素宽,使用
GL_LINES类型而不是GL_TRIANGLE_FAN调用glDrawElements()。
现在是时候更新灯光的位置了,如清单 7–13 中的所示。
清单 7–13。 更新灯光的位置
`private void updateLightPosition(GL10 gl) { mLightAngle +=1.0f; //in degrees
mLightPosX = (float) (mLightRadius * Math.cos(mLightAngle/57.29f)); mLightPosY = mLightHight; mLightPosZ = (float) (mLightRadius * Math.sin(mLightAngle/57.29f));
mLightPos[1] = mLightPosY;
mLightPos[0]=mLightPosX; mLightPos[2]=mLightPosZ;
gl.glLightfv(GL10.GL_LIGHT0, GL10.GL_POSITION, makeFloatBuffer(mLightPos)); }`
这将在每次刷新时将灯光的位置更新一度。y 值是固定的,所以光线沿着它在 x/z 平面上的小轨道行进。
编译完成后,您是否看到类似于 Figure 7–13 的内容?
图 7–13。左侧和中间图像有实影;右边是线框。
又有什么能阻止你拥有多盏灯呢?参见图 7–14,两盏灯并排。
图 7–14。多灯魔方
在所有这些图像中,背景都是黑色的。改变背景的颜色并运行。图 7–15 中发生了什么?
图 7–15。惊喜!阴影不会被裁剪到平台上。
这里发生的事情是,我们在平台上裁剪阴影时作弊了。由于背景是黑色的,在平台上渲染的那部分阴影是不可见的。但是现在随着背景变亮,可以看到完整的阴影。如果你首先需要一个浅色背景会怎样?简单——只需用模板夹住平台。
总结
在这一章中,我们讲述了一些额外的技巧来增加 OpenGL ES 场景的真实感。首先是帧缓冲对象,它允许你绘制多个 OpenGL 帧并将它们合并在一起。接下来是可以给户外场景增加视觉戏剧的镜头光晕,随后是苹果在包括 CoverFlow 在内的许多 UI 设计中大量使用的反射。我们以使用阴影投影在背景上投射阴影的许多方法中的一种来结束。接下来,这些技巧中的一些将被应用到我们的小太阳系项目中。