八、把这一切放在一起
一个人的一生,即使完全奉献给了天空,也不足以研究如此庞大的课题。
——罗马哲学家塞内加
好了,现在我们已经一路走到了第八章。此时,我们可以将从练习中学到的东西整合到一个更完整的太阳系模型中(尽管它仍然缺少彗星、杀手小行星、中微子和外海王星物体)。然后,我希望你会说,“哇!这有点酷!”
这一章的代码非常多,因为该模型需要大量新的例程和对现有项目的修改。和第七章中的一些清单一样,我不会展示整个代码文件,因为它们很长,为了避免重复,或者只是为了早点睡觉(天哪,现在是凌晨 2:45);因此,我们鼓励您从 Apress 站点获取完整的项目,以及任何必要的数据文件,以确保您拥有完整的功能示例。我总是说,完成一套。
一些新的技巧也将被抛出,比如如何整合标准的 Android 小工具和四元数的使用。请注意,尽管下面的许多代码都是基于前面的练习,但可能需要一些小的调整来将其集成到更大的包中,因此不幸的是,这不会是简单的剪切和粘贴情况。
重访太阳系
如果你想自己填写代码,我建议获取太阳系模型的第五章变体,而不是第七章变体,后者仅用于在 3D 对象上显示动态纹理,在这里不会以那种方式使用。
第一个练习是在节目中添加一些导航元素,这样你就可以在地球上移动视点。
但是首先,我们需要调整模型的大小,以使演示更加真实。就目前而言,地球看起来只有太阳的三分之一大,距离我们只有几千英里。考虑到这是北加利福尼亚的一个宜人的夏日,而且地球一点也不像烧焦的煤渣,我敢打赌这个模型是错误的。好吧,让我们来弥补吧。这将在你们太阳系控制器的initGeometry()
方法中完成。当我们这样做的时候,m_Eyeposition
的类型将被改变,以将其升级为一个为 3D 操作定制的稍微更加对象化的对象。新的例程在清单 8–1 中。当你在太阳表面的时候,确保添加一个纹理;否则,讨厌的事情可能会发生。
清单 8–1。 为太阳系调整天体大小
` private void initGeometry(GL10 gl)
{
// Let 1.0=1 million miles.
// The sun’s radius=.4.
// The earth’s radius=.04 (10x larger to make it easier to see).
m_Eyeposition[X_VALUE] = 0.0f;
m_Eyeposition[Y_VALUE] = 0.0f;
m_Eyeposition[Z_VALUE] = 93.25f;
m_Earth = new Planet(48, 48, .04f, 1.0f, gl, myAppcontext, true,
book.SolarSystem.R.drawable.earth);
m_Earth.setPosition(0.0f, 0.0f, 93.0f);
m_Sun = new Planet(48, 48, 0.4f, 1.0f, gl, myAppcontext, false, 0);
m_Sun.setPosition(0.0f, 0.0f, 0.0f);
}`
我们模型的比例设置为 1 单位= 100 万英里(1.7 米公里或 8.3 米弗隆,或 3.52e+9 肘)。太阳的半径为 400,000 英里,用这些单位表示就是 0.4 英里。这意味着地球的半径将会是 0.004,但是我已经把它增加了 10 倍,达到 0.04,使它更容易处理。因为地球的默认位置是沿着+z 轴,让我们把眼睛的位置放在地球的正后方,只有 25 万英里远,在“93.25”在太阳系物体的execute
方法中,去掉glRotatef()
,这样地球现在将保持固定。这让事情暂时变得简单多了。将视野从 50 度改为 30 度;另外,将setClipping
中的zFar
设置为 2000(处理未来对象)。您最终应该得到类似于 Figure 8–1 的东西。因为从我们的角度来看,太阳实际上是在地球的后面,所以我调高了SS_FILLLIGHT1
的镜面照明。
图 8–1。微型屏幕上的我们的家
“一切都好,代码男孩!”你一定在小声嘀咕。“但现在我们被困在太空了!”没错,这意味着下一步是添加导航元素。这意味着(提示戏剧性的音乐)我们将添加四元数。
这些四元数到底是什么东西?
1843 年 10 月 16 日,在都柏林,爱尔兰数学家威廉·哈密顿爵士正在皇家运河边散步,突然数学灵感闪现。他一直在研究如何对空间中的两点进行有意义的乘法和除法运算,突然在脑海中看到了四元数的公式:。I2=j2=k2=ijk= 1。印象深刻吧。
他是如此的兴奋,以至于他无法抗拒将它刻在他刚刚来到的 Brougham 桥的石雕上的诱惑(毫无疑问,它位于“Eamon loves Fiona,1839”或“Patrick O’Callahan rulz!”).这种见解直接衍生出看待物理学和几何学的全新方式。例如,电磁理论中的经典麦克斯韦方程完全是通过使用四元数来描述的。随着处理类似情况的新方法的出现,四元数被搁置一旁,直到 20 世纪后期,它们在 3D 计算机图形学、阿波罗飞船到月球的导航以及其他严重依赖空间旋转的领域中发挥了重要作用。由于其紧凑的性质,它们可以描述方向向量,从而比标准的 3×3 矩阵更有效地描述 3D 旋转。不仅如此,它们还提供了一种更好的方法,将一系列旋转串联起来。那么,这意味着什么呢?
在第二章中,我们介绍了使用矩阵的传统 3D 变换数学。如果你想绕 z 轴旋转一个对象 32°,你可以通过命令glRotatef(32,0,0,1)
指示 OpenGL ES 执行旋转。对于 x 轴和 y 轴也将执行类似的命令。但是,如果你想要一种时髦的旋转,就像飞机向左倾斜时那样,那该怎么办呢?在glRotatef()
格式中是如何描述的?使用更传统的方法,您将为三次旋转生成单独的矩阵,然后按照偏航(绕 y 轴旋转)、俯仰(绕 x 轴旋转)和绕 z 轴滚动的顺序将它们相乘。仅仅针对一个方向就需要大量的数学计算。但是,如果这是一个飞行模拟器,你的倾斜动作将不断更新新的滚动和航向,增量。这意味着你必须每次计算三个矩阵,计算自上一帧以来轨迹的增量,而不是某个起点的绝对值。
在计算机的早期,当浮点计算非常昂贵,并且由于性能原因经常调用快捷方式时,舍入误差是常见的,并且可能会随着时间的推移而增加,导致当前的矩阵“不合适”。然而,四元数被拯救了,因为它们有几个非常引人注目的性质。
首先,四元数可以表示物体在空间中的旋转,大致相当于glRotate()
的工作方式,但使用分数轴值。这不是一个直接的一对一的关系,因为你仍然需要做一些数学上的事情来转换四元数和姿态。
第二个也是更重要的性质来源于这样一个事实,即球面上的弧可以用两个四元数来描述,每个端点一个四元数。并且圆弧上它们之间的任何一点也可以用一个四元数来描述,只需使用球面几何插值一个端点到另一个端点的距离,如图 Figure 8–2 所示。也就是说,如果你正在通过一个 60°的圆弧,你可以沿着圆弧的三分之一找到一个中间四元数,比如说,从起点开始 20°。在下一帧中,如果你要跳到 20.1,你只需在你的当前四元数上增加一点点弧度,而不必经历每次生成三个矩阵并将它们相乘的繁琐过程。这个过程叫做 slerping,其中sler RP代表球面线性插补。因为轴/角度对不像使用矩阵时那样依赖于所有先前轴/角度对的累积和,而是依赖于瞬时值,所以前者不会导致误差累积。
图 8–2。 一个中间四元数;球面上的问题 1.5 可以由另外两个 Q1 和 Q2 插值得到
Slerp 用于在从一个点移动到另一个点时提供视点的“相机”的平滑动画。它可以是飞行模拟器、太空模拟器的一部分,也可以是赛车游戏中追逐车的视图。当然,它们也用于实际的飞行导航系统。
现在有了这些背景,我们将使用四元数来帮助移动地球。
在 3D 中移动物体
由于我们目前没有制作地球的动画,我们需要一种方法来移动它,这样我们就可以从各个角度研究它。考虑到这一点,由于地球是我们感兴趣的目标,我们将设置一种情况,在这种情况下,通过挤压和移动手势,视点将有效地悬停在地球上。
第一步是添加手势识别器,这是通过 Android 的onTouchEvent()
调用实现的。你需要同时支持挤压和拖动功能。捏是放大和缩小,而平移让你拖动你下面的星球,始终保持它的中心。更复杂的动作,如动量滑动,或“甩”,留给你来实现,不幸的是,这可能会有点乱。
代码的结构略有不同。传统上作为GLSurfaceView.Renderer
实现的核心模块现在是一个叫做SolarSystemView
的GLSurfaceView
子类。渲染器现在是新的SolarSystem
对象。前者主要作为收缩和拖动事件的事件接收器,而后者处理主更新循环,并作为任何太阳系类型对象的容器。
在新的SolarSystemView
中,我们将只需要捏和平移手势。您使用onTouchEvent()
来处理所有触摸事件,初始化一些值,并决定您是在执行挤压还是拖动功能。向视图控制器的onTouchEvent()
方法添加清单 8–2。
清单 8–2。 处理捏拖事件
` public boolean onTouchEvent(MotionEvent ev)
{
boolean retval = true;
switch (ev.getAction() & MotionEvent.ACTION_MASK)
{
case MotionEvent.ACTION_DOWN:
m_Gesture = DRAG; //1
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_POINTER_UP:
m_Gesture = NONE; //2
m_LastTouchPoint.x = ev.getX();
m_LastTouchPoint.y = ev.getY();
break;
case MotionEvent.ACTION_POINTER_DOWN:
m_OldDist = spacing(ev); //3
midPoint(m_MidPoint, ev);
m_Gesture = ZOOM;
m_LastTouchPoint.x = m_MidPoint.x;
m_LastTouchPoint.y = m_MidPoint.y;
m_CurrentTouchPoint.x=m_MidPoint.x;
m_CurrentTouchPoint.y=m_MidPoint.y;
break;
case MotionEvent.ACTION_MOVE:
if (m_Gesture == DRAG) //4
{
retval = handleDragGesture(ev);
}
else if (m_Gesture == ZOOM)
{
retval = handlePinchGesture(ev);
}
break;
}
return retval;
}`
这是细目分类:
- 第一部分处理触摸显示屏的手指,或多点触摸事件的第一个手指。将运动类型
m_Gesture
初始化为DRAG
。 - 第二部分处理运动何时完成。
- 第三部分介绍缩放功能。
m_MidPoint
用于确定放大到屏幕上的哪个点。这里不需要这样做,因为我们将只放大屏幕中央的地球,但这仍然是很好的参考代码。 - 最后,在第四部分中,调用正确的手势动作。
接下来我们需要添加两个处理程序,handleDragGesture()
和handlePinchGesture()
,如清单 8–3 所示。
清单 8–3。 手势识别器的两个处理程序
`final PointF m_CurrentTouchPoint = new PointF();
PointF m_MidPoint = new PointF();
PointF m_LastTouchPoint = new PointF();
static int m_GestureMode = 0;
static int DRAG_GESTURE = 1;
static int PINCH_GESTURE = 2;
public boolean handleDragGesture(MotionEvent ev)
{
m_LastTouchPoint.x = m_CurrentTouchPoint.x;
m_LastTouchPoint.y = m_CurrentTouchPoint.y;
m_CurrentTouchPoint.x = ev.getX();
m_CurrentTouchPoint.y = ev.getY();
m_GestureMode = DRAG_GESTURE;
m_DragFlag = 1;
return true;
}
public boolean handlePinchGesture(MotionEvent ev)
{
float minFOV = 5.0f;
float maxFOV = 100.0f;
float newDist = spacing(ev);
m_Scale = m_OldDist/newDist;
if (m_Scale > m_LastScale)
{
m_LastScale = m_Scale;
}
else if (m_Scale <= m_LastScale)
{
m_LastScale = m_Scale;
}
m_CurrentFOV = m_StartFOV * m_Scale;
m_LastTouchPoint = m_MidPoint;
m_GestureMode = PINCH_GESTURE;
if (m_CurrentFOV >= minFOV && m_CurrentFOV <= maxFOV)
{
mRenderer.setFieldOfView(m_CurrentFOV);
return true;
}
else
return false;
}`
这两个都很基本。handleDragGesture()
设置跟踪当前和先前的触摸点,在确定拖动操作的速度时使用。两者之间的差值越大,屏幕的动画应该越快。handlePinchGesture()
对缩放操作执行相同的操作。m_OldDist
和newDist
是两个夹指之间以前和新的距离。这种差异决定了视野的变化程度。压缩图形会放大,而展开图形会缩小到最大 100 度。
然后手势在onDrawFrame()
方法中被处理,如清单 8–4 所示。
清单 8–4。处理新的挤压和拖动状态
` 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);
if (m_GestureMode == PINCH_GESTURE && m_PinchFlag == 1) //1
{
setClipping(gl, origwidth, origheight);
m_PinchFlag = 0;
}
else if (m_GestureMode == DRAG_GESTURE && m_DragFlag == 1) //2
{
setHoverPosition(gl, 0, m_CurrentTouchPoint, m_LastTouchPoint, m_Earth);
m_DragFlag = 0;
}
execute(gl);
}`
在第一部分中,仅当通过m_PinchFlag
检测到并标记了新的手势时,才处理挤压,该手势在处理后复位。如果不这样做,缩放将在每次连续调用onDrawFrame()
时继续。每次使用m_FieldOfView
值通过setClipping()
更新视见体,因为该机制实际上决定了视图的放大倍数。第二部分同样适用于拖动手势。在这种情况下,setHoverPosition()
是用当前和以前的触摸点调用的。它还有一个通过m_DragFlag
的开关,关闭任何进一步的拖动处理,直到检测到新的事件。否则,即使你的手指没有移动,你的观点也会发生偏移。
如果您想立即看到缩放操作,请注释掉前面清单中的行setHoverPosition()
,然后编译并运行。
你应该能够放大和缩小地球模型,如图图 8–3 所示。
图 8–3。 使用捏手势放大和缩小
现在我们要做旋转支持,包括那些四元数的东西。这可能是迄今为止最复杂的练习。我们将需要一些辅助程序来瞄准你的视点,并在地球周围的“轨道”上移动它。所以,让我们从顶部开始,一步步往下。清单 8–5 是“悬停模式”的核心
清单 8–5。围绕地球设定新的悬停位置
public void setHoverPosition(GL10 gl, int nFlags, PointF location, PointF prevLocation, Planet m_Planet) { double dx;
`double dy;
Quaternion orientation = new Quaternion(0, 0, 0, 1.0);
Quaternion tempQ;
Vector3 offset = new Vector3(0.0f, 0.0f, 0.0f);
Vector3 objectLoc = new Vector3(0.0f, 0.0f, 0.0f);
Vector3 vpLoc = new Vector3(0.0f, 0.0f, 0.0f);
Vector3 offsetv = new Vector3(0.0f, 0.0f, 0.0f);
Vector3 temp = new Vector3(0.0f, 0.0f, 0.0f);
float reference = 300.0f;
float scale = 2.0f;
float matrix3[][] = new float[3][3];
boolean debug = false;
gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glLoadIdentity();
orientation = Miniglu.gluGetOrientation(); //1
vpLoc.x = m_Eyeposition[0]; //2
vpLoc.y = m_Eyeposition[1];
vpLoc.z = m_Eyeposition[2];
objectLoc.x = m_Planet.m_Pos[0]; //3
objectLoc.y = m_Planet.m_Pos[1];
objectLoc.z = m_Planet.m_Pos[2];
offset.x = (objectLoc.x - vpLoc.x); //4
offset.y = (objectLoc.y - vpLoc.y);
offset.z = (objectLoc.z - vpLoc.z);
offsetv.z = temp.Vector3Distance(objectLoc, vpLoc); //5
dx = (double) (location.x - prevLocation.x);
dy = (double) (location.y - prevLocation.y);
float multiplier;
multiplier = origwidth / reference;
gl.glMatrixMode(GL10.GL_MODELVIEW);
// Rotate around the X-axis.
float c, s; //6
float rad = (float) (scale * multiplier * dy / reference)/2.0;
s = (float) Math.sin(rad * .5);
c = (float) Math.cos(rad * .5);
temp.x = s;
temp.y = 0.0f;
temp.z = 0.0f;
Quaternion tempQ1 = new Quaternion(temp.x, temp.y, temp.z, c);
tempQ1 = tempQ1.mulThis(orientation);
// Rotate around the Y-axis.
rad = (float) (scale * multiplier * dx / reference); //7
s = (float) Math.sin(rad * .5);
c = (float) Math.cos(rad * .5);
temp.x = 0.0f;
temp.y = s;
temp.z = 0.0f;
Quaternion tempQ2 = new Quaternion(temp.x, temp.y, temp.z, c);
tempQ2 = tempQ2.mulThis(tempQ1);
orientation=tempQ2;
matrix3 = orientation.toMatrix(); //8
matrix3 = orientation.tranposeMatrix(matrix3); //9
offsetv = orientation.Matrix3MultiplyVector3(matrix3, offsetv);
m_Eyeposition[0] = (float)(objectLoc.x + offsetv.x); //10
m_Eyeposition[1] = (float)(objectLoc.y + offsetv.y);
m_Eyeposition[2] = (float)(objectLoc.z + offsetv.z);
lookAtTarget(gl, m_Planet); //11
}`
我打赌你想知道这里发生了什么?
-
首先,我们从稍后将创建的新辅助类中获取缓存的四元数。四元数是我们的视点在第 1 行的当前方向,我们需要它和视点在第 2 行太阳系物体的 xyz 位置。
-
第 3ff 行获取目标的位置。在这种情况下,目标仅仅是地球。有了这些,我们需要找到我们的视点从地球中心的偏移,然后计算这个距离,如第 4ff 行所示。
-
第 5 行获取了前一次和当前拖动的屏幕坐标,因此我们知道自上次以来我们移动了多少。
-
Lines 6ff create a fractional rotation in radians for each new position of the drag operation around the X-axis. This is then multiplied by the actual orientation quaternion (recovered in line 1) to ensure that the new orientation from each touch position is preserved. The 2.0 divisor scales back the vertical motions; otherwise, they’d be much to fast. This represents the cumulative rotations of the eye point. The three values of scale, multiplier, and reference are all arbitrary. Scale is fixed and was used for some fine-tuning to ensure things moved at just the right speed that ideally will match that of your finger. The multiplier is handy for orientation changes because it is a scaling factor that is based on the screen’s current width and a reference value that is also arbitrary.
另一个封装围绕 Y 轴旋转的四元数以非常相似的方式在第 7ff 行中生成。最后一次旋转时,该值将与前一个值相乘。第 8 行将它转换成传统的矩阵。
-
第 9f 行使用矩阵对偏移值的转置来到达空间中的新位置,并存储在
m_Eyeposition.
中,因为我们要从地球的局部坐标到世界坐标,我们进行转置,实际上是反转操作。 -
即使我们的视点被移动到了一个新的位置,我们仍然需要将它重新对准悬停目标,地球,就像第 11 行中通过
lookAtTarget()
所做的那样。
现在,我们需要创建一些前面提到的助手例程,它们将有助于把所有的事情都联系在一起。
在普通的 OpenGL 中,我提到过一个叫做 GLUT 的工具库。不幸的是,在撰写本文时,还没有完整的 Android 库,尽管有一些不完整的版本。我已经把它们放到了一个名为Miniglu.java
的文件中,可以从这个项目的网站上获得。
注意: Android 在android.opengl.GLU
有一个非常小但是官方的 GLU 程序套件,但是它没有我需要的所有东西。
清单 8–6 包含了gluLookAt(),
的 Miniglu 版本,这是一个非常有用的工具,正如它所说的:瞄准你的视角。你传递给它你的视点的位置,你想看的东西,和一个向上的向量来指定滚动的角度。自然,直线上升就等于没有滚动。但是你还是需要供应。
清单 8–6。 用gluLookAt
看东西
`static Quaternion m_Quaternion = new Quaternion(0, 0, 0, 1);
public static void gluLookAt(GL10 gl, float eyex, float eyey, float eyez,
float centerx, float centery, float centerz, float upx,
float upy, float upz)
{
Vector3 up = new Vector3(0.0f, 0.0f, 0.0f); //1
Vector3 from = new Vector3(0.0f, 0.0f, 0.0f);
Vector3 to = new Vector3(0.0f, 0.0f, 0.0f);
Vector3 lookat = new Vector3(0.0f, 0.0f, 0.0f);
Vector3 axis = new Vector3(0.0f, 0.0f, 0.0f);
float angle;
lookat.x = centerx; //2
lookat.y = centery;
lookat.z = centerz;
from.x = eyex;
from.y = eyey;
from.z = eyez;
to.x = lookat.x;
to.y = lookat.y;
to.z = lookat.z;
up.x = upx;
up.y = upy;
up.z = upz;
Vector3 temp = new Vector3(0, 0, 0); //3
temp = temp.Vector3Sub(to, from);
Vector3 n = temp.normalise(temp);
temp = temp.Vector3CrossProduct(n, up);
Vector3 v = temp.normalise(temp);
Vector3 u = temp.Vector3CrossProduct(v, n);
float[][] matrix;
matrix = temp.Matrix3MakeWithRows(v, u, temp.Vector3Negate(n));
m_Quaternion = m_Quaternion.QuaternionMakeWithMatrix3(matrix); //4
m_Quaternion.printThis(“GluLookat:”);
axis = m_Quaternion.QuaternionAxis();
angle = m_Quaternion.QuaternionAngle();
gl.glRotatef((float) angle * DEGREES_PER_RADIAN, (float) axis.x,
(float) axis.y, (float) axis.z); //5
}`
事情是这样的:
- 如前所述,我们需要获取点或向量来完整描述我们和目标在空间中的位置,如第 1ff 行所示。上向量是你的视点的局部向量,它通常只是一个指向 y 轴的单位向量。如果你想做银行卷,你可以修改这个。
Vector3
对象是与这个项目相关的小型数学库的一部分。然而,存在许多这样的库。 - 在第 2ff 行中,以离散值形式传递的项被映射到
Vector3
对象,然后这些对象可以用于矢量数学库。为什么不用矢量呢?官方的 GLUT 库不使用矢量对象,所以这符合现有的标准。 - 第 3ff 行生成三个新向量,其中两个使用叉积。这确保了一切都是标准化的,并且轴是方形的。
gluLookAt()
生成矩阵的一些例子。这里,用四元数来代替。在第 4 行中,四元数是由我们的新向量创建的,用于获取glRotatef()
喜欢使用的轴/角度参数,如第 5 行所示。注意,生成的四元数是通过一个全局缓存的,如果需要通过gluGetOrientation
()获取瞬时姿态,可以稍后获取。很笨拙,但很管用。在现实生活中,你可能不想这样做,因为它假设你的整个世界只有一个单一的观点。实际上,你可能想要不止一个——例如,如果你想要两个同时显示,从两个不同的有利位置显示你的对象。
最后,我们可以看看生成的图像。你现在应该能够随心所欲地旋转我们这个公平的小世界了(见图 8–4)。有时出现的小黄点是太阳。
图 8–4。 悬停模式让你随意旋转地球。
这就是今天练习的第一部分。还记得第七章里那些镜头光晕的东西吗?现在我们可以使用它们了。
添加一些光斑
从第七章的中,从镜头光晕练习中获取三个源文件,并将它们与插图一起添加到您的项目中。这些将是CreateTexture.java
助手库,Flare.java
用于每个反射,以及LensFlare.java
。这也需要对渲染器对象进行一些实质性的调整,主要是在执行例程中。
像镜头光晕效应这样的东西有各种各样的小问题需要解决。也就是说,如果耀斑的源物体,比如太阳,在地球后面,耀斑本身就会消失。此外,请注意,它不会立即消失,但实际上会淡出。在渲染光晕本身之前,需要添加几个新的工具例程。
首先确保在您的onSurfaceCreated()
处理程序中初始化 LensFlare 对象:
int resid; resid = book.SolarSystem.R.drawable.gimpsun3; m_FlareSource = CT.createTexture(gl, myAppcontext, true, resid); m_LensFlare.createFlares(gl, myAppcontext);
现在是时候将任何图像工具转储到它们自己的例程中了。它叫做CreateTexture.java
。这将有助于支持前面的呼叫。.png
文件可以是您想要的任何文件,它将替换当前的 3D 太阳模型。我们希望这样做,这样我们就可以绘制一个太阳的平面位图,在这个位置上,球面模型通常会像过去一样进行渲染。原因是我们可以精细地控制我们的恒星的外观,使它更接近于肉眼可能看到的样子。这个明显的黄色球,虽然在技术上更准确,但看起来并不正确,因为任何光学接收器都会添加各种各样的扭曲、反射,
和高光(例如,镜头眩光)。可以使用着色器来对眼睛的光学进行数学建模,但目前对于一个模糊的球状物体来说,这是一个很大的工作量。如果你愿意,你可以从 Apress 网站下载我自己的作品。或者只是复制一些适合自己口味的东西。图 8–5 是我正在使用的。足够有趣的是,这个图像愚弄了我自己的眼睛,足以让我的大脑认为我实际上正在看着一个太亮的东西,因为当我盯着它时,它会导致各种各样的眼睛疲劳。
这使用了一种叫做布告板的技术,这种技术采用了一种平面 2D 纹理,并使其对准观众,无论他们在哪里。它允许复杂和相当随机的有机物体(我想是被称为树的东西)在只使用简单纹理的情况下被轻易地描绘出来。随着视点的变化,广告牌对象会旋转以进行补偿。
图 8–5。 太阳图像用来给出看起来更真实的辉光
我称之为LensFlare.java
的镜头光晕管理器和单个 Flare.java 物体都需要修改。对于LensFlare.java
的执行方法,我添加了两个新参数。execute()
现在应该是这个样子:
public void execute(GL10 gl,CGSize size, CGPoint source, float scale, float alpha)
新的scale
参数是一个单一的值,它将增加或减少整个耀斑链的大小,当你放大或缩小场景时需要,而alpha
用于在太阳开始滑向地球后面时使整个耀斑变暗。这两个参数同样需要添加到单个 flare 对象的 execute 方法中,然后用于旋转传递给CreateTexture's renderTextureAt()
方法的大小和 alpha 参数,如下所示:
public void renderFlareAt(GL10 gl, int textureID, float x, float y, CGSize size, Context context, float scale, float alpha) { CreateTexture ct = new CreateTexture(); ct.renderTextureAt(gl, x, y, 0f, size, textureID, m_Size*scale, m_Red*alpha, m_Green*alpha, m_Blue*alpha, m_Alpha); }
下一个清单,清单 8–7,包含了另外两个 Miniglu 调用。首先是gluGetScreenLocation
(),它返回 3D 对象在屏幕上的 2D 坐标。它只不过是gluProject
()的前端,它将 3D 点映射或投影到它的视口。尽管这些可能是“固定的”程序,但看看它们是如何工作的仍然是有启发性的。它们在这里被用来获得太阳的位置,以放置 2D 比尔登上艺术品。后来,它们可以用来放置天空中的其他 2D 项目,如星座名称。
清单 8–7。gluProject()``gluGetScreenCoords()
`public static boolean gluProject(float objx, float objy, float objz,
float[] modelMatrix, float[] projMatrix, int[] viewport,float[] win)
{
float[] in = new float[4];
float[] out = new float[4];
in[0] = objx; //1
in[1] = objy;
in[2] = objz;
in[3] = 1.0f;
gluMultMatrixVector3f (modelMatrix, in, out); //2
gluMultMatrixVector3f (projMatrix, out, in);
if (in[3] == 0.0f)
in[3] = 1.0f;
in[0] /= in[3];
in[1] /= in[3];
in[2] /= in[3];
/* Map x, y and z to range 0-1 */
in[0] = in[0] * 0.5f + 0.5f; //3
in[1] = in[1] * 0.5f + 0.5f;
in[2] = in[2] * 0.5f + 0.5f;
/* Map x,y to viewport
*/
win[0] = in[0] * viewport[2] + viewport[0];
win[1] = in[1] * viewport[3] + viewport[1];
win[2] = in[3];
return (true);
}
public static void gluGetScreenLocation(GL10 gl, float xa, float ya, float za,
float screenRadius, boolean render, float[] screenLoc)
{
float[] mvmatrix = new float[16];
float[] projmatrix = new float[16];
int[] viewport = new int[4];
float[] xyz = new float[3];
GL11 gl11 = (GL11) gl;
gl11.glGetIntegerv(GL11.GL_VIEWPORT, viewport, 0); // 4
gl11.glGetFloatv(GL11.GL_MODELVIEW_MATRIX, mvmatrix, 0);
gl11.glGetFloatv(GL11.GL_PROJECTION_MATRIX, projmatrix, 0);
gluProject(xa, ya, za, mvmatrix, projmatrix, viewport,xyz);
xyz[1]=viewport[3]-xyz[1]; //5
screenLoc[0] = xyz[0];
screenLoc[1] = xyz[1];
screenLoc[2] = xyz[2];
}`
让我们更仔细地检查一下代码:
- 第 1ff 行将对象坐标映射到一个数组,该数组将乘以
modelMatrix
(作为参数之一提供)。 - 在第 2ff 行,乘法是通过我添加的另一个 GLUT helper 例程来完成的,因为写要比跟踪快。首先是模型视图矩阵,然后是投影矩阵在我们对象的 xyz 坐标上操作。(记住,列表中的第一个转换是最后执行的。)注意,对 gluMultMatrixVector3f()的第一个调用传递“in”数组,然后是“out”,而第二个调用以相反的顺序传递两个数组。这里没有什么巧妙之处——第二个实例颠倒了两者的使用,只是为了回收现有的数组。
- 在第 3ff 行中,前面计算的结果值被归一化,然后映射到屏幕的尺寸上,得到最终的值。
- 我们可能永远不会直接调用
gluProject()
;相反,调用者是gluGetScreenLocation()
,它仅仅获取第 4ff 行中所需的矩阵,将它们传递给gluProject()
,并检索屏幕坐标。因为 OpenGL ES 会反转 y 轴,所以我们需要在第 5 行取消反转。
SolarSystem renderer
中的execute()
例程必须进行一些修改,以管理镜头光晕的调用和放置,同时随着增强的executePlanet()
增加了一些新参数,以实际识别光晕应该位于屏幕上的什么位置。清单 8–8 中提供了两者。
清单 8–8。 用镜头光晕支持执行
`public void execute(GL10 gl)
{
float[] paleYellow = { 1.0f, 1.0f, 0.3f, 1.0f };
float[] white = { 1.0f, 1.0f, 1.0f, 1.0f };
float[] black = { 0.0f, 0.0f, 0.0f, 0.0f };
float[] sunPos = { 0.0f, 0.0f, 0.0f, 1.0f };
float sunWidth=0.0f;
float sunScreenLoc[]=new float[4]; //xyz and radius
float earthScreenLoc[]=new float[4]; //xyz and radius
gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glShadeModel(GL10.GL_SMOOTH);
gl.glEnable(GL10.GL_LIGHTING);
gl.glEnable(GL10.GL_BLEND);
gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA);
gl.glPushMatrix();
gl.glTranslatef(-m_Eyeposition[X_VALUE], -m_Eyeposition[Y_VALUE], //1
-m_Eyeposition[Z_VALUE]);
gl.glLightfv(SS_SUNLIGHT, GL10.GL_POSITION, makeFloatBuffer(sunPos));
gl.glEnable(SS_SUNLIGHT);
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_EMISSION,
makeFloatBuffer(paleYellow));
executePlanet(m_Sun, gl, false,sunScreenLoc); //2
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_EMISSION,makeFloatBuffer(black));
gl.glPopMatrix();
if ((m_LensFlare != null) && (sunScreenLoc[Z_INDEX] > 0.0f)) //3
{
CGPoint centerRelative = new CGPoint();
CGSize windowSize = new CGSize();
float sunsBodyWidth=44.0f; //About the width of the sun’s body
// within the glare in the bitmap, in pixels.
float cx,cy;
float aspectRatio;
float scale=0f;
DisplayMetrics display = myAppcontext.getResources().getDisplayMetrics();
windowSize.width = display.widthPixels;
windowSize.height = display.heightPixels;
cx=windowSize.width/2.0f;
cy=windowSize.height/2.0f;
aspectRatio=cx/cy; //4
centerRelative.x = sunScreenLoc[X_INDEX]-cx;
centerRelative.y =(cy-sunScreenLoc[Y_INDEX])/aspectRatio;
scale=CT.renderTextureAt(gl, centerRelative.x, centerRelative.y, 0f, windowSize,
m_FlareSource,sunScreenLoc[RADIUS_INDEX], 1.0f,1.0f, 1.0f, 1.0f); //5
sunWidth=scalewindowSize.widthsunsBodyWidth/256.0f; //6
}
gl.glEnable(SS_FILLLIGHT2);
gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glPushMatrix();
gl.glTranslatef(-m_Eyeposition[X_VALUE], -m_Eyeposition[Y_VALUE], //7
-m_Eyeposition[Z_VALUE]);
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_DIFFUSE,
makeFloatBuffer(white));
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_SPECULAR,
makeFloatBuffer(white));
executePlanet(m_Earth, gl, true,earthScreenLoc); //8
gl.glPopMatrix();
if ((m_LensFlare != null) && (sunScreenLoc[Z_INDEX] > 0)) //9
{
float scale = 1.0f;
float delX = origwidth / 2.0f - sunScreenLoc[X_INDEX];
float delY = origheight / 2.0f - sunScreenLoc[Y_INDEX];
float grazeDist = earthScreenLoc[RADIUS_INDEX] + sunWidth;
float percentVisible = 1.0f;
float vanishDist = earthScreenLoc[RADIUS_INDEX] - sunWidth;
float distanceBetweenBodies = (float) Math.sqrt(delX * delX + delY * delY);
if ((distanceBetweenBodies > vanishDist)&& (distanceBetweenBodies < grazeDist)) //10
{
percentVisible=(float) ((distanceBetweenBodies - vanishDist) /sunWidth);
if (percentVisible > 1.0) //11
percentVisible = 1.0f;
else if (percentVisible < 0.3)
percentVisible = .5f;
}
else if (distanceBetweenBodies > grazeDist)
{
percentVisible = 1.0f;
}
else
{
percentVisible = 0.0f;
}
scale = STANDARD_FOV / m_FieldOfView; //12
CGPoint source = new CGPoint();
source.x = sunScreenLoc[X_INDEX];
source.y = sunScreenLoc[Y_INDEX];
CGSize winsize = new CGSize();
winsize.width = origwidth;
winsize.height = origheight;
if (percentVisible > 0.0)
{
m_LensFlare.execute(gl, winsize, source, scale, percentVisible);
}
}
}`
好了,现在开始粉笔对话:
-
您会注意到发出了两个相同的
glTranslatef()
调用。第 1 行中的第一个为第 2 行的结果做准备。但是当我们的自定义太阳图像在第 5 行中呈现时,我们需要将它从堆栈中弹出。当地球被绘制到屏幕上时,需要在第 7 行再次调用它。 -
在第二行,看起来我们正在渲染太阳。但也不尽然。这是为了在主屏幕上提取出太阳实际将要到达的位置。第三个参数
render
,如果为假,将使例程只返回屏幕位置和预期半径,而不是实际绘制太阳。 -
第 3 行决定我们是否应该绘制新的太阳和镜头眩光对象,如果太阳基于它的 z 坐标可能是可见的。如果 z 为负,它在我们后面,所以我们可以一起跳过它。
-
第 4 行的
aspectRatio
处理非方形视口,这意味着几乎所有的非方形视口。然后,我们根据屏幕的中心计算太阳的预期广告牌图像的位置。 -
新的
renderToTextureAt()
调用现在将太阳的广告牌放在屏幕上,如第 5 行的m_FlareSource
所示。sunScreenLoc{RADIUS_INDEX]
是从executePlanet()
获取的值之一,对应于实际 3D 图像的大小。scale
的返回值暗示了最终位图的大小,占屏幕的百分比。这在第 6 行中用于计算太阳位图中“热点”的实际宽度,因为太阳身体的中心图像自然会远远小于位图的尺寸。 -
在第 7 行,我们再次执行翻译,因为前一个在弹出矩阵时丢失了。接下来是第 8 行,它渲染地球,但是在本例中,传递了一个 true 的渲染标志。然而,它仍然获得屏幕位置信息,在这种情况下,仅仅是为了获得图像的尺寸,以便我们知道何时开始消除镜头眩光。
-
然后我们从第 9ff 行开始,来到实际渲染光晕的地方。这里的大部分代码主要处理一个基本效应:当太阳走到地球后面时会发生什么?自然,耀斑会消失,但它不会立即出现或消失,因为太阳的直径有限。因此,
grazeDist
和vanishDist
等数值告诉我们,当太阳第一次与地球相交时,开始变暗过程,当它最终被完全覆盖时,耀斑完全消失。使用地球屏幕的 x 和 y 值以及太阳的 x 和 y 值,指定一个渐变函数变得很容易。 -
Any value that falls between the
vanishDist
andgrazeDist
values specifies what percentage of dimming should be done, as in line 10, while lines 11ff actually calculate the value. Notice the line:else if(percentVisible<0.3) percentVisible=0.5f
额外学分:这是做什么的,为什么?
-
Lines 12ff calculate the size of the flare and its corresponding elements. As you zoom in with a decreasing field of view—that is, a higher-power lens—the sun’s image will increase and the flare should as well.
这个练习的最后一点是看一下
executePlanet()
,如清单 8–9 所示。
清单 8–9。 ExecutePlanet()
修改后得到屏幕坐标
`public void executePlanet(Planet planet, GL10 gl, Boolean render,float[] screenLoc)
{
Vector3 planetPos = new Vector3(0, 0, 0);
float temp;
float distance;
float screenRadius;
gl.glPushMatrix();
planetPos.x = planet.m_Pos[0];
planetPos.y = planet.m_Pos[1];
planetPos.z = planet.m_Pos[2];
gl.glTranslatef((float) planetPos.x, (float) planetPos.y,(float) planetPos.z);
if (render)
{
planet.draw(gl); //1
}
Vector3 eyePosition = new Vector3(0, 0, 0);
eyePosition.x = m_Eyeposition[X_VALUE];
eyePosition.y = m_Eyeposition[Y_VALUE];
eyePosition.z = m_Eyeposition[Z_VALUE];
distance = (float) planetPos.Vector3Distance(eyePosition, planetPos);
float fieldWidthRadians = (m_FieldOfView /DEGREES_PER_RADIAN) / 2.0f;
temp = (float) ((0.5f * origwidth) / Math.tan(fieldWidthRadians));
screenRadius = temp * getRadius(planet) / distance;
if(screenLoc!=null) //2
{
Miniglu.gluGetScreenLocation(gl, (float) planetPos.x, (float) -planetPos.y,
(float) planetPos.z, (float) screenRadius, render,screenLoc);
}
screenLoc[RADIUS_INDEX]=screenRadius;
gl.glPopMatrix();
angle += .5f;
}`
在最后一位,当且仅当渲染标志为真时,第 1 行正常绘制行星。否则,它只是获取屏幕位置和尺寸,如第 2 行所示,这样我们就可以自己绘制了。
应该可以了。我确信你能够编译时没有错误或警告,因为你就是这么好。因为你就是那么优秀,你可能会得到图 8–6 中的图片作为奖励。和我一样,随意使用环境光和镜面光。效果可能不是很逼真,但看起来很不错。
**图 8–6。**看,马!镜头眩光!
*#### 看星星
下一个练习所需的大部分新代码主要用于加载和管理所有新数据。因为这是一本关于 OpenGL 的书,而不是 XML 或数据结构或如何有效地输入书中的代码,所以我将省去那些你可能已经知道的更乏味的东西。
当然,没有一些漂亮的恒星作为背景,任何太阳系模型都是不完整的。到目前为止,所有的例子都足够小,可以在文本中完整地打印出来,但是现在,当我们在背景中添加一个简单的 star field 时,情况会稍有变化。不同之处很大程度上在于你需要从 Apress 网站获取的数据库,因为它将包含 500 多颗 4.0 星等的恒星,以及一个包含星座轮廓和一些更突出的星座名称的额外数据库。
除了 OpenGL ES 用于创建实体模型的三角形面之外,如果您的 Android 设备支持多像素点表示,您还可以指定将模型的每个顶点渲染为给定大小和大小的点图像。此时,Android 商业模式丑陋的一面开始显露出来。
谷歌对 Android 的做法很简单:试图将它打造成世界上最卓越的移动操作系统。为了做到这一点,谷歌让它免费,并允许制造商随心所欲地修改它。结果,可怕的分裂迅速潜入。从表面上看,消费者不应该担心这一点,因为他们有大量的手机可供选择。但是从开发人员的角度来看,这使得编写在成百上千台设备上运行的软件成为一场噩梦,因为每台设备都有自己的小毛病。从长远来看,这确实会影响消费者,因为开发者可能会选择不支持特定的设备家族,或者如果他们支持,他们可能会遭受发布延迟和成本增加,以确保他们的最新产品能够在所有设备上工作。这种差异在图形支持方面表现得最为明显。
图形处理单元有许多不同的制造商。GC860 的制造商 Vivante 向 Marvell 供应芯片;AMD 把自己的 GPU 送给东芝,PowerVR 卖给苹果和三星。更糟糕的是,每个特定型号的 GPU 都可能比同一制造商的前几代产品拥有更多功能。这意味着您可能不得不通过省略最新设备可能支持的酷功能来编写最低公分母,或者如果您真的需要一个特定的功能来跨所有平台工作,您可能不得不推出自己的功能。或者,作为第三种方法,您可能只需要针对特定的设备进行编码,而将其他设备排除在外。在很大程度上,苹果已经成功地用他们的 iOS 设备在这些水域中航行,而微软(它对旧的 Windows Mobile 手机使用“Android”方法)现在通过确保他们的 Windows Mobile 7 许可证持有者遵守非常严格的规范来避免分裂。因此,选择目标机器至关重要。
现在回到星星上。那么,这有什么特别的呢?很简单。并不是所有的设备都支持 OpenGL 的大于一个像素的渲染。或者那些支持的可能不支持圆形抗锯齿点。前者几乎可以工作,但只适用于上一代低分辨率屏幕,比如 75 DPI 左右。但现在有了更新的高分辨率显示器(如苹果的视网膜显示器),单个像素小到几乎看不见,这使得显示由像素集合组成的星星变得势在必行。这就是这个练习的发展情况,你很快就会看到。但首先,敬明星们。
**注意:**设备的价格或制造商似乎与其支持的 3D 功能没有多大关系。比如第一代摩托罗拉 Xoom 可以做胖点,但是只能做方点。Kindle Fire 做的是宽线,但只有单像素点,而廉价的无名设备既做粗线又做点。
这第一点的恒星数据库是从我的遥远的太阳数据编译成苹果的 plist XML 文件格式。然后,为了便于演示,对其进行了一点调整,使解析变得更加容易。同样的方法也用于星座数据。加载时,它被绘制成非常像以前的对象,比如球体,但是没有指定GL_TRANGLE_STRIPS
,而是使用了GL_POINTS
。清单 8–10 显示了恒星的execute()
方法。
清单 8–10。 渲染群星
public void execute(GL10 gl) { int
len`;
float[] pointSize = new float[2];
GL11 gl11 = (GL11) gl;
gl.glDisable(GL10.GL_LIGHTING); ` //1
gl.glDisable(GL10.GL_TEXTURE_2D);
gl.glDisable(GL10.GL_DEPTH_TEST);
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_COLOR_ARRAY); //2
gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glBlendFunc(GL10.GL_ONE, GL10.GL_ONE_MINUS_DST_ALPHA);
gl.glEnable(GL10.GL_BLEND);
gl.glColorPointer(4, GL10.GL_FLOAT, 0, m_ColorData);
gl.glVertexPointer(3,GL10.GL_FLOAT, 0, m_VertexData);
gl.glEnable(GL10.GL_POINT_SMOOTH); //3
gl.glPointSize(5.0f);
gl.glDrawArrays(GL10.GL_POINTS,0,totalElems/4); ` //4
gl.glDisableClientState(GL10.GL_VERTEX_ARRAY);
gl.glDisableClientState(GL10.GL_COLOR_ARRAY);
gl.glEnable(GL10.GL_DEPTH_TEST);
gl.glEnable(GL10.GL_LIGHTING);
}`
接下来的故事是:
-
第 1 行对于渲染任何自发光的对象或者你只想一直可见的对象是必不可少的。在这种情况下,恒星属于前一类,而星座名称和轮廓等各种标识符属于后一类。关灯可以确保无论发生什么都能看到它们。(在之前的练习中,太阳被渲染成一个发射物体,但仍然被照亮,只是为了在表面上获得一个看起来非常好的轻微渐变。)
-
Colors in line 2 are being used to specify the intensity of a star’s magnitude. A more sophisticated system would encode both the star’s real color and luminosity, but here we’re just doing the simple stuff.
**注:**恒星的星等是其视亮度;数值越大,恒星越暗。天空中仅次于太阳的最亮的星星当然是天狼星,目视星等为-1.46。肉眼可见的最暗的恒星大约是 6.5 星等。双筒望远镜的最高星等约为 10,而哈勃太空望远镜的最高星等为 31.5。每个整数的实际亮度相差约 2.5 倍,因此,一颗 3 等星的亮度约为 4 等星的 2.5 倍。
-
硬边星形看起来并不有趣,所以第 3f 行打开了点的反走样,并确保这些点足够大以至于可见。
-
第 4 行像往常一样绘制数组,但是使用了
GL_POINT
渲染风格,而不是用于实体的GL_TRIANGLES
。
让我们回到第 3 行。还记得关于不同设备和 GPU 是否具有不同功能的讨论吗?这里有一个这样的例子。在这个练习的开发中,使用的硬件是摩托罗拉 Droid 的第一代产品。事实证明,它不支持多像素点,所以每颗星星在一个非常高分辨率的屏幕上只是一个像素。解决方案是使用“点精灵”,一种为每个绘制的点分配小位图的方法。OpenGL ES 也可以支持这一点,但是,如前所述,只在某些设备上支持。唉。或者如官方 OpenGL 文档所述:
仅保证支持尺寸 1;其他的就看实现了。
如果支持较大的点,无需任何进一步修改,它们将被绘制为正方形。这里是你要开启GL_POINT_SMOOTHING
的地方。如果实现,它将尝试创建圆角。然而,点平滑所允许的大小取决于实现。你可以查一下
通过下面的调用:
float[] pointSize = new float[2]; gl11.glGetFloatv(GL10.GL_SMOOTH_POINT_SIZE_RANGE, makeFloatBuffer(pointSize));
如果点平滑不可用,pointSize
将显示 0.0f,0.0f。然而,平滑的点不一定是好看的点。要获得更好的点,请打开混合。那么系统将消除图像的锯齿。然而,这“取决于实施”唉。
图 8–7 显示了三种可能性之间的差异。
图 8–7。 从左到右,一个 8 像素宽的不平滑点的特写,带平滑,带平滑和混合
如果你想获得尽可能多的观众,最终你将不得不处理你自己的点渲染。
看线条
当然,除了恒星和行星,天空中还有更多。有星座。和前面的星星一样,你必须从一个网站获取星座数据库。这包含了 17 个不同星座的数据。该数据包括形成星座轮廓的普通名称和线数据。在这种情况下,设置实际上与前面描述的星形相同,但我们没有绘制点阵列,而是绘制了线阵列:
gl.glDrawArrays(GL10.*GL_LINE_STRIP*,0,numVertices);
与点一样(取决于实现),线也可以画得比单个像素宽。下面的调用将达到目的:
gl.glLineWidth(lineWidth);
然而,大于 1 的线宽,现在全部一致,取决于实施。
您应该能够通过调用以下代码来检查可用的线宽:
int[] pointSize = new int[2]; gl11.glGetIntegerv(GL10.*GL_ALIASED_LINE_WIDTH_RANGE*, *makeIntBuffer*(pointSize));
宽度大于 1 的线取决于实现,尽管看起来更多的设备允许宽线而不是宽像素。
线条的 OpenGL ES 实现中的一个问题是,与桌面版本相比,不支持抗锯齿线条(平滑)。这意味着使用标准技术绘制的任何线条在较旧、分辨率较低的显示器上看起来都非常不舒服。但是随着更高的 dpi 变得可用,抗锯齿就不那么必要了。但是如果你还是要考虑的话,常用的一个技巧就是把线条画成真的很细的多边形,使用纹理贴图,可以抗锯齿,可以给图片添加虚线之类的东西。繁琐?没错。作品?相当好。OpenGL ES 宇宙中的另一种方法是使用多纹理抗锯齿。但是,这要看执行情况!
MSAA 创建了两个 OpenGL 渲染表面。一个是我们看到的,另一个是我们看到的两倍大。这两者的混合使整个屏幕中的所有图像变得平滑,看起来非常好,尽管这是以性能和内存使用为代价的。图 8–8 显示了两者的对比。
图 8–8。 左无 MSAA;右侧启用
看到文字
对于 OpenGL 的所有功能,文本支持不在其中。这是一个长期存在的问题,真正正确处理文本的唯一方法是使用带有文本的纹理。毕竟,这就是任何文本的开始:只是一堆小纹理。
如果你有很小的文本需求,最简单的方法是预渲染文本块,然后像导入其他纹理一样导入它们。如果您有很多文本,您可以在需要每个字符串时动态生成它们。如果你想使用各种各样的字体,这是一个不错的方法。总的来说,最好的方法是使用称为纹理贴图集(也称为精灵表)的东西。
当与文本渲染结合使用时,纹理贴图集将获取与您想要的字体相关联的所有字符,并将它们存储在一个位图中,如图 Figure 8–9 所示。为了绘制文本,我们使用之前在镜头光晕中使用的技术来渲染 2D 位图。
图 8–9。 时代新罗马,纹理图册版
取纹理图谱,使用分数纹理映射(见第五章的图 5-6 ),字母可以动态组合成任何需要的字符串。由于 OpenGL 本身不支持任何类型的字体处理,我们要么推出自己的字体管理器,要么求助于第三方。幸运的是,由于这个问题如此普遍,许多善良的灵魂创造了工具和库,并使它们免费可用。图 8–9 是用一个非常好的基于 PC 的工具 CBFG 生成的,可以从[www.codehead.co.uk/cbfg/](http://www.codehead.co.uk/cbfg/)
下载。
这包括该工具以及嵌入式 C++ 和 Java 阅读器,在本例中使用了后者。要创建和初始化字体使用,请使用以下代码:
m_TexFont = new TexFont(context, gl); m_TexFont.LoadFont("TimesNewRoman.bff", gl);
清单 8–11 展示了如何使用它。摘录来自示例代码中的Outline.java
。
清单 8–11。 将文本写入 OpenGL 视图
` public void renderConstName(GL10 gl, String name, int x, int y, float r, float g,
float b)
{
gl.glDisable(GL10.GL_LIGHTING);
gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA);
gl.glEnable(GL10.GL_BLEND);
if(name!=null)
{
m_TexFont.SetPolyColor(r, g, b);
m_TexFont.PrintAt(gl, name.toUpperCase(), x, y);
}
} `
现在我们有了线、点和文本,我们应该看到什么?啊,问题是我们会看到什么*(图 8–10)。毕竟,这将取决于实施。*
*
图 8-10。 左边看不见的单像素恒星;正确的做事方法
查看按钮
当然,任何没有与之交互手段的应用通常都被称为演示程序。但是这里我们的小演示实际上将获得一个简单的用户界面和 HUD 图形。
说到给你的 Android 应用添加 UI 元素,我有一些非常好的消息:不会找到“取决于实现”这句话。添加简单的控制元素非常容易。当然,你通常不会使用 OpenGL 显示作为一个应用的背景,UI 元素通常仍然应该被隔离在它们自己的空间中;这完全取决于你的目标。考虑到这一点,可以添加一个简单的 UI 面板,如SolarSystemActivity
中的清单 8–12 所示,看起来应该类似于图 8–11。
清单 8–12。 向天空添加 UI
`public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN);
mGLSurfaceView = new SolarSystemView(this);
setContentView(mGLSurfaceView);
mGLSurfaceView.requestFocus();
mGLSurfaceView.setFocusableInTouchMode(true);
ll = new LinearLayout(this);
ll.setOrientation(VERTICAL_ORIENTATION);
b_name = new Button(this);
b_name.setText(“names”);
b_name.setBackgroundDrawable(getResources().getDrawable( book.SolarSystem.R.drawable.bluebuttonbig));
ll.addView(b_name);
b_line = new Button(this);
b_line.setText(“lines”);
b_line.setBackgroundDrawable(getResources().getDrawable (book.SolarSystem.R.drawable.greenbuttonbig));
ll.addView(b_line, 1);
b_lens_flare = new Button(this);
b_lens_flare.setText(“lens flare”);
b_lens_flare.setBackgroundDrawable(getResources().getDrawable (book.SolarSystem.R.drawable.redbuttonbig));
ll.addView(b_lens_flare);
this.addContentView(ll, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));
b_name.setOnClickListener(new Button.OnClickListener() {
@Override
public void onClick(View v) {
if (name_flag == false)
name_flag = true;
else if (name_flag == true)
name_flag = false;
Log.d(TAG, “b_name clicked”);
}
});
b_line.setOnClickListener(new Button.OnClickListener() {
@Override
public void onClick(View v) {
if (line_flag == false)
line_flag = true;
else if (line_flag == true)
line_flag = false;
Log.d(TAG, “b_line clicked”);
}
});
b_lens_flare.setOnClickListener(new Button.OnClickListener() {
@Override
public void onClick(View v) {
if (lens_flare_flag == false)
lens_flare_flag = true;
else if (lens_flare_flag == true)
lens_flare_flag = false;
Log.d(TAG, “b_lensflare clicked”);
}
});`
图 8–11。 将 UI 组件放在 OpenGL 屏幕上
如果你希望有很多 UI 元素,你可能会考虑用 OpenGL 来创建它们。如果您计划支持多个平台,这尤其有用,因为 OpenGL 跳过了所有特定于平台的工具包,尽管这使得最初的工作有些乏味。游戏是最能从这种方法中受益的应用,特别是因为它们通常有高度定制的用户界面。一个编写良好的纯 OpenGL 应用从一个平台移植到另一个平台可能只需要几天,而不是几个月。iPhone 上的《遥远的太阳》使用了两者的混合,主要是看两者如何互补。我在图 8–12 中的小时间设置部件使用了所有的 OpenGL,而其他的都使用了标准的 UI 组件。
图 8–12*.
遥远的太阳,左侧带有自定义日期轮,同时带有标准 UIKit 工具栏*
总结
在这一章中,我们采用了前几章中学到的许多技巧,并根据实现的不同,将它们组合成一个更完整、更吸引人(不那么蹩脚)的太阳系模型。一个单一行星的太阳系并不像现在这样令人印象深刻。所以,亲爱的读者,我把它留给你,添加月亮,添加一些其他的行星,并让地球围绕太阳旋转。
我们添加了第七章中的镜头光晕,以及星星和星座轮廓的点和线对象,将文本插入到 OpenGL 环境中,并在同一个屏幕上混合了 OpenGL 视图和标准 Android 控件。
我还指出了图形子系统是一个在不同设备之间变化最大的子系统,它会引起很多痛苦、烦恼、咬牙切齿和撕裂衣服。在下一章,我们将研究优化技巧,然后是 OpenGL ES 2.0,以及如何应用它来增强我们的地球模型。**
九、性能和材质
一盎司的表现抵得上几磅的承诺。
—西部
我动作太快了,以至于昨晚我在酒店房间里关掉了灯,在房间天黑之前就上床睡觉了。
—穆罕默德·阿里
在处理 3D 世界时,性能几乎总是一个问题,因为即使是简单的场景也需要密集的数学运算。如果你想渲染的只是一个动画旋转的三角形,上面有可爱的机器人,那么不要担心,但是如果你想展示宇宙,那么你会一直关注性能。
到目前为止,练习以一种相当清晰(我希望如此)但不一定有效的方式呈现。不幸的是,高效的代码很少是最清晰易懂的。现在,我们将开始研究稍微复杂一些的东西,看看如何将它集成到您的应用中。
在行业中,这些技巧被称为最佳实践。有些可能是显而易见的,但有些则不然。
顶点缓冲对象
性能增强的两个主要方面是最大限度地减少与图形硬件之间的数据传输,以及最大限度地减少数据本身。顶点缓冲对象(VBOs)是前一个过程的一部分。当你生成了你的几何图形,并愉快地将它发送出去显示时,通常的过程是告诉系统在哪里可以找到每个需要的数据块,允许使用哪些数据(顶点、法线、颜色和纹理坐标),然后绘制它。每次调用glDrawArrays()
或glDrawElements()
时,所有的数据都必须打包并发送到图形处理单元(GPU)。每次数据传输都需要有限的时间,显然,如果一些数据可以缓存在 GPU 上,性能会得到提高。vbo 是在 GPU 上分配常用数据的一种方式,然后可以调用这些数据进行显示,而不必每次都重新提交数据。
创建和使用 VBOs 的过程对你来说应该很熟悉,因为它模仿了用于纹理的过程。首先生成一个“名称”,然后为数据分配空间,然后加载数据,然后在需要使用这些数据时使用glBindBuffer()
。我将同时介绍的另一个实践是交错数据,如图 9–1 所示,在提交给 VBO 之前,我将首先做这件事。这可能会也可能不会有太大的区别,但如果驱动程序和 GPU 针对数据局部性进行了优化,交错阵列可能会有所帮助。
图 9–1。数据排序。VBO 的例子使用了上面的格式,而下面的例子说明了数据交错。
在我自己的测试中,我发现差异可以忽略不计。但是,仍然要在你的项目中记住交错;为它设计不会有什么坏处,因为未来的硬件可能会更好地利用它。参考列表 9–1 来看看行星的几何形状是如何交错的。
清单 9–1。 创建交错数组
`** private void** createInterleavedData()
{
int i;
int j;
float[] interleavedArray;
int size;
size=NUM_XYZ_ELS+NUM_NXYZ_ELS+NUM_RGBA_ELS+NUM_ST_ELS;
interleavedArray=new float[size*m_NumVertices];
for(i=0;i<m_NumVertices;i++)
{
j=i*size;
//Vertex data
interleavedArray[j+0]=m_VertexData.get();
interleavedArray[j+1]=m_VertexData.get();
interleavedArray[j+2]=m_VertexData.get();
//Normal data
interleavedArray[j+3]=m_NormalData.get();
interleavedArray[j+4]=m_NormalData.get();
interleavedArray[j+5]=m_NormalData.get();
//cColor data
interleavedArray[j+6]=m_ColorData.get();
interleavedArray[j+7]=m_ColorData.get();
interleavedArray[j+8]=m_ColorData.get();
interleavedArray[j+9]=m_ColorData.get();
//Texture coordinates
interleavedArray[j+10]=m_TextureData.get();
interleavedArray[j+11]=m_TextureData.get();
}
m_InterleavedData=makeFloatBuffer(interleavedArray);
m_VertexData.position(0);
m_NormalData.position(0);
m_ColorData.position(0);
m_TextureData.position(0);
}`
这些都是不言自明的,但是请注意最后四行。它们会重置FloatBuffer
对象的内部计数器,如果您想在其他地方使用任何单独的数据数组,就需要这样做。
清单 9–2 展示了我如何从行星数据中创建 VBO。由于大多数行星通常都是相同的形状,圆形,所以可以在 CPU 上缓存一个球体模型,并将其用于任何行星或卫星,除了 Demos 或 Hyperion 或 Nix 或 Miranda…或者任何看起来更像发霉土豆的小卫星。听到了吗,火卫一?我在和你说话!
清单 9–2。 为行星模型创建一个 VBO
`** public void** createVBO(GL10 gl)
{
int size=NUM_XYZ_ELS+NUM_NXYZ_ELS+NUM_RGBA_ELS+NUM_ST_ELS;
createInterleavedData();
GLES11.glGenBuffers(1,m_VertexVBO,0); //1
GLES11.glBindBuffer(GL11.GL_ARRAY_BUFFER, m_VertexVBO[0]); //2
GLES11.glBufferData(GLES11.GL_ARRAY_BUFFER,sizeFLOAT_SIZEm_NumVertices, //3
m_InterleavedData,GLES11.GL_STATIC_DRAW);
}`
简单,嗯?注意 VBOs 直到 OpenGL ES 1.1 才出现,这就是为什么需要GLES11
修改器的原因。
- 第 1 行生成了缓冲区的名称。因为我们只处理一个数据集,所以我们只需要一个。
- 接下来,我们将它绑定到第 2 行,使其成为当前的 VBO。要解除绑定,可以绑定一个 0。
- 来自交错阵列的数据现在可以发送到第 3 行中的 GPU。第一个参数是数据的时间,可以是
GL_ARRAY_BUFFER
也可以是GL_ELEMENT_ARRAY_BUFFER
。前者用于传递顶点数据(包括颜色和法线信息),后者用于传递索引连通性数组。但是由于我们使用的是三角形条带,所以不需要索引数据。
那么,我们如何使用 VBOs 呢?非常容易。看一下清单 9–3。
清单 9–3。 使用 VBOs 渲染星球
`** public void** draw(GL10 gl)
{
int startingOffset;
int i;
int maxDuplicates=10; //1
boolean useVBO=true; //2
int stride=(NUM_XYZ_ELS+NUM_NXYZ_ELS+*NUM_RGBA_ELS *//3
+NUM_ST_ELS)*FLOAT_SIZE;
GLES11.glEnable(GLES11.GL_CULL_FACE);
GLES11.glCullFace(GLES11.GL_BACK);
GLES11. glDisable ( GLES11.GL_BLEND);
GLES11.glDisable(GLES11.GL_TEXTURE_2D);
** if**(useVBO) //4
{
GLES11.glBindBuffer(GL11.GL_ARRAY_BUFFER, m_VertexVBO[0]); //5
GLES11.glEnableClientState(GL10.GL_VERTEX_ARRAY); //6
GLES11.glVertexPointer(NUM_XYZ_ELS,GL11.GL_FLOAT,stride,0);
GLES11.glEnableClientState(GL11.GL_NORMAL_ARRAY);
GLES11.glNormalPointer(GL11.GL_FLOAT,stride,NUM_XYZ_ELS*FLOAT_SIZE);
GLES11.glEnableClientState(GL11.GL_TEXTURE_COORD_ARRAY);
GLES11.glTexCoordPointer(2,GL11.GL_FLOAT,stride,
(NUM_XYZ_ELS+NUM_NXYZ_ELS+NUM_RGBA_ELS)*FLOAT_SIZE);
GLES11.glEnable(GL11.GL_TEXTURE_2D);
GLES11.glBindTexture(GL11.GL_TEXTURE_2D, m_TextureIDs[0]);
}
else
{
GLES11.glBindBuffer(GL11.GL_ARRAY_BUFFER,0); //7
m_InterleavedData.position(0);
GLES11.glEnableClientState(GL10.GL_VERTEX_ARRAY);
GLES11.glVertexPointer(NUM_XYZ_ELS,GL11.GL_FLOAT,stride,m_InterleavedData);
m_InterleavedData.position(NUM_XYZ_ELS);
GLES11.glEnableClientState(GL11.GL_NORMAL_ARRAY);
GLES11.glNormalPointer(GL11.GL_FLOAT,stride,m_InterleavedData);
m_InterleavedData.position(NUM_XYZ_ELS+NUM_NXYZ_ELS+NUM_RGBA_ELS);
GLES11.glEnableClientState(GL11.GL_TEXTURE_COORD_ARRAY);
GLES11.glTexCoordPointer(2,GL11.GL_FLOAT,stride,m_InterleavedData);
GLES11.glEnable(GL11.GL_TEXTURE_2D);
GLES11.glBindTexture(GL11.GL_TEXTURE_2D, m_TextureIDs[0]);
}
for(i=0;i<maxDuplicates;i++) //8
{
GLES11.glTranslatef(0.0f,0.2f,0.0f);
GLES11.glDrawArrays(GL11.GL_TRIANGLE_STRIP, 0,
(m_Slices+1)2(m_Stacks-1)+2);
}
GLES11.glDisable(GL11.GL_BLEND);
GLES11.glDisable(GL11.GL_TEXTURE_2D);
GLES11.glDisableClientState(GL11.GL_TEXTURE_COORD_ARRAY);
GLES11.glBindBuffer(GL11.GL_ARRAY_BUFFER,0);
m_VerticesPerUpdate=maxDuplicates*m_NumVertices;
}`
渲染 VBOs 非常简单,只有一个简单的“嗯?”在这个过程中。我已经决定在所有的调用前面加上前缀GLES11
,只是为了好看。不是所有的套路都需要。
- 第 1 行和第 2 行让您配置用于测试目的的例程。
maxDuplicates
是星球被渲染的次数。useVBO
可以关闭 VBO 处理,仅使用交错数据进行性能比较。 - 请记住,第 3 行中的跨距值表示任意数组中从顶点到顶点的字节数。这对于交叉存取数据来说是必不可少的,例如,相同的数组可以用于顶点位置和颜色。跨距只是告诉系统在找到下一个顶点之前要跳过多少字节。
- 第 4 行将打开实际的 VBO 设置代码。第 5 行以与绑定纹理相同的方式绑定它,使它成为当前使用的对象,直到另一个对象被绑定或者这个对象与
glBindBuffer(GL_ARRAY_BUFFER, 0);
解除绑定。 - 线 6ff 启用各种数据缓冲器,如之前的
draw()
方法所做的那样。一个主要的区别可以从各种glEnable*Pointer()
调用的最后一个参数中看出。在正常情况下,你传递一个指针或引用给它们。然而,当使用 VBOs 时,指向数据块的各种“指针”是从第一个元素的偏移,它总是从零“地址”开始,而不是从应用自己的地址空间中的一开始。这意味着在我们的例子中,顶点指针从地址 0 开始,而法线就在顶点之后,颜色是跟随法线的纹理坐标。这些值从数据开始以字节表示。 - 第 7 行突出显示了另一部分。这里我们只使用交叉存取的数据,并以更传统的方式将其传递给指针例程。这允许您查看从交叉存取数据中获得了什么性能增强(如果有的话)。
- 第 8 行之后的部分通过
maxDuplicates
调用循环到glDrawArrays()
。值为 10 时效果很好。
当谈到优化代码时,我是一个需要确信某个特定的技巧会起作用的人。否则,我可能会花很多时间做一些事情来增加 0.23%的帧速率。一个游戏程序员可能会觉得这是一种荣誉,但我觉得它通过将注意力转移到可能不会有太大影响的东西上,从我的用户那里偷走了可选的新功能或错误修复。因此,我开发了一个简单的测试程序。
该程序简单地绘制了十个行星地球并旋转它们,如图 Figure 9–2 所示。球体由 100 个堆栈和 100 个切片组成,每个球体有 20,200 个顶点。我使用同一个实例,,所以我只需要在程序启动时加载一次 GPU。如果没有 VBOs,相同的模型将被加载十次。
图 9–2。 巨型计算机生成的母猪虫。或者十个地球互相堆叠在一起。
现在,您可以向您的onDrawFrame()
处理程序添加一个帧速率计算器,看看会发生什么。我自己的设置使用的是第一代摩托罗拉 Xoom,下面是一些基本的测试,这些测试应该让我对 Xoom/Java 平台与其他类似价格范围的平台相比表现如何有一个大致的了解。
**注意:**我自己的设置使用的是第一代摩托罗拉 Xoom,它支持 NVIDIA Tegra 2 GPU。截至本文撰写时,苹果的所有 iOS 设备都使用 Imagination Technologies 制造的 PowerVR 系列芯片;三星的 Galaxy Tab 和黑莓的 Playbook 也是如此。去你的 GPU 制造商的开发者部门可能是值得的。Imagination Tech 和 NVIDIA 都有优秀的 notes、SDK 和 demos,可以充分利用各自的硬件。
基线配置打开了纹理,三个灯,具有深度缓冲的 32 位颜色,在 5 个单位之外的视点上混合,并且视场设置为 30 度。结果相当令人惊讶,如表 9–1 所示。
在这种情况下,最大的 CPU 消耗是灯光,因为只要关掉三盏灯中的两盏灯,帧速率就会翻倍。在第十章中,你会看到如何管理引擎盖下的照明,这会更有意义。关闭深度缓冲和纹理对从 32 位降到 16 位颜色几乎没有影响。
另一个令人惊讶的结果是交错数据格式似乎实际上降低了性能!选择为每种数据类型使用单独的离散缓冲区,“老方法”稍微加快了速度。移开视点减少了要处理的像素数量,但几乎没有增加帧速率。这表明 GPU 并没有受到像素的限制,主要的性能问题是实际的顶点计算。很有可能 Java 肯定会在这方面发挥很大的作用。作为语言相关问题的部分解决方案,Android 还提供了一个本地开发包(NDK)。
NDK 旨在让开发人员将他们的性能关键代码放在 Java 层之下,放入 C 或 C++ 中,使用 JNI 在两个世界之间来回移动。(性能关键型可能包括图像处理或大型系统建模。)OpenGL 将针对您使用的任何级别进行优化,因此在纯 OpenGL 比较中,您可能会看到很少的改进。除此之外,即使在网上快速搜索,也会发现许多开发人员创建了比较这两种环境的测试,几乎所有测试都显示,对于数学密集型任务,性能显著提高了 30 倍或更多。但是当然,由于驱动程序、操作系统、GPU 或编译器问题,您的收益可能会有所不同。
配料
尽可能多地批处理依赖于相同状态的操作,因为改变系统状态(通过使用glEnable()
和glDisable()
调用)代价很高。OpenGL 不会在内部检查某个特定的特性是否已经处于您想要的状态。在本书的练习中,我会比我可能需要的更频繁地设置状态,以确保行为是容易预测的。但是对于商业的、性能密集型的应用,在发布版本中尽量去掉多余的调用。
此外,尽可能批处理您的绘图调用。
纹理
一些纹理优化技巧已经被提出,比如小中见大贴图。其他的只是简单的常识:纹理占用大量的内存。使它们尽可能小,并在需要时重复使用。此外,在加载之前设置任何图像参数,因为它们是一个提示,告诉 OpenGL 如何在发送到硬件之前优化信息。
首先绘制不透明的纹理,避免使用半透明的 OpenGL ES 屏幕。
雪碧床单
当介绍在 OpenGL 环境中显示文本时,在第八章中简要提到了精灵表(或纹理图谱)。Figure 9–3 展示了一个 sprite 工作表在屏幕上显示文本时的样子。
**图 9–3。**24 点铜板用雪碧片
这张特殊的图片是使用一个名为 LabelAtlasCreator 的免费工具为 Mac 制作的,而不是在第八章中使用的 CBFG 工具。除了图像文件之外,它还会生成一个 Apple 的 plist 格式的方便的 XML 文件,其中包含所有易于转换为纹理空间的放置细节。
但是不要停留在字体上,因为精灵表也可以在你有一个志同道合的图像家庭的任何时候使用。从一种纹理切换到另一种纹理会导致大量不必要的开销,而 sprite sheet 充当了一种纹理批处理的形式,节省了大量的 GPU 时间。查看 OS X 兼容的 Zwoptex 工具或 TexturePacker,它用于通用图像。
纹理上传
将纹理复制到 GPU 可能非常昂贵,因为它们必须由芯片重新格式化才能使用。对于较大的纹理,这可能会导致显示不连贯。因此,确保从一开始就用glTexImage2D
加载它们。一些 GPU 制造商,如苹果产品所用芯片的制造商 Imagination Technologies,有自己的专有图像格式,针对自己的硬件进行了微调。当然,在日益碎片化的 Android 市场,你将不得不在运行时嗅出你的用户有什么芯片,并在那时处理任何特殊需求。
mipmap
除了 2D 未缩放的图像,一定要使用小中见大贴图。是的,它们确实使用了更多的内存,但是当你的对象在远处时,更小的小贴图可以节省很多周期。建议您使用GL_LINEAR_MIPMAP_NEAREST
,因为它比GL_LINEAR_MIPMAP_LINEAR
快(参考表 5-3 ),尽管图像保真度稍低。
颜色变少
其他建议可能包括较低分辨率的颜色格式。许多图像在 16 位和 32 位下看起来几乎一样好,特别是如果没有 alpha 蒙版的话。Android 的默认格式是一直流行的 RGB565,这意味着它有 5 位红色,6 位绿色,5 位蓝色。(绿色得到了加强,因为我们的眼睛对它最敏感。)其他 16 位格式包括 RGBA5551 或 RBGA4444。在遥远的太阳上,我的灰度星座作品只有 8 位,减少了 75%的内存使用。如果我想让它与特定的主题相匹配,我会让 OpenGL 来完成这项工作。通过适当的工具和仔细的调整,一些 16 位纹理几乎与 32 位纹理无法区分。
Figure 9–4 展示了 TexturePacker 创建的四种格式,从左到右从高到低排列。图 1 显示了我们一直使用的真彩色纹理,有时称为 RGBA8888。图片 2 使用默认的 RGB565 格式,考虑到其他因素,看起来还是很不错的。图 9–4 中的图像 3 使用 RGBA5551,分配一个 1 位 alpha 通道(注意绿色的额外位与之前的纹理相比有多大区别),图像 4 是最低质量的,使用 RGBA4444。TexturePacker 还支持第五章中引用的 PVRTC 文件类型。
**注意:**PowerVR 芯片的制造商 Imagination Technologies Ltd .提供了一个替代(免费)工具。它的纹理模式与 TexturePacker 相同,但不像 TexturePacker 那样创建 sprite 表。请注意,它使用 X11 作为 UI,其外观看起来像 Windows NT。前往[www.imgtec.com](http://www.imgtec.com)
并在开发者部分下寻找 PowerVR Insider 工具。寻找 PVRTexTool。
压缩后效果最好的图像是那些调色板严重依赖光谱的一个或两个部分的图像。你的颜色越多样,就越难减少人为因素。Hedly 的图像比地球的纹理图更好,因为前者主要是灰色和绿色,而后者是由绿色、棕色、灰色(极地)和蓝色组成的。
图 9–4。 纹理 1,32 位;纹理 2,RGB565 纹理 3,RGBA555 纹理 4,RGBA:4444
其他要记住的提示
以下是一些需要记住的有用提示:
-
尽管多采样抗锯齿在平滑图像方面非常有用,但它意味着性能部门的突然死亡,将帧速率降低 30%或更多。所以,你一定很需要用它。
-
避免使用
GL_ALPHA_TEST
。这是从来没有涵盖,但它也可以杀死性能一样多的 MSAA。 -
当转到后台时,确保停止动画并删除任何容易重新创建的资源。
-
任何从系统返回信息的调用(主要是
glGet*
系列调用)都会查询系统的状态,包括容易被忽略的glGetError()
。其中许多命令会强制执行任何以前的命令,然后才能检索状态信息。 -
使用尽可能少的灯光,通过关闭补光灯和环境光,只使用一个灯光(太阳)。
不从 CPU 访问帧缓冲区。应该避免像
glReadPixels()
这样的调用,因为它们会迫使帧缓冲区刷新所有排队的命令。
上述提示仅代表最基本的推荐做法。真正的图形大师们在他们的实用工具中有许多神秘的技巧,简单的谷歌搜索就可能揭示出来。
总结
本章描述了让你的 OpenGL 应用真正运行的基本技巧和最佳实践。VBOs 将通过在 GPU 上保留常用的几何图形来降低总线的饱和度。将状态改变和glGet*
调用减少到最少也能显著提高渲染速度。
在第十章中,你会学到一些关于 OpenGL ES 2.0 和那些最近风靡孩子们的神秘着色器的东西。
十、OpenGL ES 2、着色器以及其它
她的天使的脸,像天上的大眼睛一样明亮,在阴暗的地方照出了阳光。
-埃德蒙史宾塞
Android 设备上有两个不同版本的 OpenGL ES 图形库。这本书主要讨论了更高层次的版本,即 OpenGL ES 1,有时也称为 1.1 或 1。 x 。第二个版本是一个相当容易混淆的名字 OpenGL ES 2。第一个是两个中最容易的一个;它附带了各种各样的助手库,为你做大量的 3D 数学和所有的照明、着色和阴影。版本 2 避开了所有这些细节,有时被称为“可编程功能”版本,而不是其他的“固定功能”设计。这通常被真正的像素操纵者嘲笑,他们更喜欢控制他们的图像,通常保留给沉浸式 3D 游戏环境,其中每个小的视觉脚注都被强调。为此,OpenGL ES 2 发布了。
第 1 版相对来说更接近于桌面版的 OpenGL,使得移植应用,尤其是老式的应用,比让獾啃掉你的脸要轻松一些。之所以省略这些内容,是为了在资源有限的设备上保持较小的占用空间,并确保尽可能好的性能。
第二版完全摒弃了兼容性,专注于主要针对娱乐软件的面向性能的特性。被遗漏的东西有glRotate(), glTranslate()
,矩阵堆栈操作,等等。但是我们得到的回报是一些令人愉快的小东西,比如通过使用着色器的可编程流水线。幸运的是,Android 自带矩阵和向量库(android.opengl.Matrix
),这应该会使任何代码迁移变得更容易一些。
这个主题太大了,无法在一章中涵盖(它通常被归入整本书),但接下来的概述应该会让您对着色器及其使用有一个良好的感觉,以及它们是否是您想要在某个时候解决的问题。
阴影管线
如果你对 OpenGL 或 Direct3D 稍有了解,仅仅提到术语着色器可能会让你不寒而栗。它们似乎是神秘的护身符,属于最神秘的图形圣职圈。
并非如此。
版本 1 的“固定功能”管道是指顶点和片段的照明和着色。例如,您最多可以拥有八个灯光,每个灯光都有不同的属性。灯光照亮表面,每个表面都有自己的属性,称为材质。结合这两者,我们得到了一个相当好的,但有限制的,通用的照明模型。但是如果你想做一些不同的事情呢?如果你想让一个表面根据它的相对照度渐变到透明,那该怎么办呢?如果你想精确地模拟阴影,比如说,土星的光环,投射在云层上,或者日出前你看到的苍白的冷光,那该怎么办?鉴于固定函数模型的局限性,所有这些都几乎是不可能的,尤其是最后一个模型,因为一旦你开始考虑大气中的水分、反向散射等等的影响,照明方程就非常复杂。好吧,一个可编程的管道,让你不需要使用任何技巧,如纹理组合器,就可以模拟那些精确的方程,这正是版本 2 给我们的。
阴暗的三角形
我将从安装 Eclipse 和 Android SDK 时应该已经安装的 Android 示例项目开始。您应该在诸如samples/android-10
的目录中找到它们。寻找巨大的 ApiDemo。编译时,它会给你一个冗长的菜单,演示从 NFC 到通知的一切。向下滚动到图形部分,并导航到 OpenGL ES/OpenGL ES2.0 演示。这显示了一个简单的旋转和纹理三角形,如图 Figure 10–1 所示。
**图 10–1。**Android SDK 的着色器实例
那么,这是怎么做到的呢?
ES 2 的流水线架构允许你在几何处理中有两个不同的访问点,如图 Figure 10–2 所示。第一个将每个顶点以及各种属性(例如,xyz 坐标、颜色、不透明度)信息交给您控制。这被称为顶点着色器。此时,由您来决定这个顶点应该是什么样子,以及它应该如何与所提供的属性一起呈现。完成后,顶点被送回硬件,用你计算的数据栅格化,并作为 2D 位传递给你的片段(或像素)着色器。在这里,您可以根据需要组合纹理,进行任何最终处理,并将其传递回系统,最终在帧缓冲区中进行渲染。
如果这听起来像是对场景中以 60 fps 的速度咆哮的每个对象的每个片段做了大量的工作,那么你是对的。但从根本上说,着色器是实际加载并运行在图形硬件本身上的小程序,因此速度非常快。
**图 10–2。**OpenGL ES 2 架构概述
着色器结构
顶点和片段着色器在结构上是相似的,看起来有点像一个小的 C 程序。在 C 语言中,入口点总是被称为main()
,而语法也非常类似 C 语言。
着色器语言称为 GLSL(不要与它的 Direct3d 对应物 HLSL 混淆),包含一组丰富的内置函数,属于三个主要类别之一:
- 面向图形处理的数学运算,如矩阵、向量、三角函数、导数和逻辑函数
- 纹理采样
- 小型辅助工具,如模、比较和赋值器
值在以下类型的着色器之间来回传递:
- 制服,是调用程序传过来的值。这些可能包括变换矩阵或投影矩阵。它们在顶点和片段着色器中都可用,并且必须在每个地方声明为相同的类型。
- 可变变量(是的,是个听起来很哑的名字),是在顶点着色器中定义的变量,传递给片段着色器。
变量既可以定义为通常的数字原语,也可以定义为基于向量和矩阵的面向图形的类型,如 Table 10–1 所示。
除此之外,您还可以提供修饰符来定义基于 int 和基于 float 的类型的精度。这些可以是 highp (24 位)、mediump (16 位)或 lowp (10 位),默认为 highp。所有的变换都必须在 highp 中完成,而颜色只需要在 mediump 中完成。(不过,我不明白为什么 bools 没有精度限定符。)
任何基本类型都可以声明为常量变量,比如const float x=1.0
。
结构也是允许的,看起来就像它们的 C 对应物。
限制
由于着色器驻留在 GPU 上,它们自然有许多限制,从而限制了它们的复杂性。它们可能受到“指令计数”、允许的统一数量(通常为 128)、临时变量数量以及循环嵌套深度的限制。不幸的是,在 OpenGL ES 上,没有真正的方法从硬件中获取这些限制,所以你只能知道它们的存在,并使你的着色器尽可能小。
顶点着色器和片段着色器之间也存在差异。例如,highp 支持在片段着色器上是可选的,而在顶点着色器上是强制的。呸。
回到旋转的三角形
所以,现在让我们跳回三角形的例子,分解一个基本的 OpenGL ES 2 程序是如何构造的。正如您将看到的,生成着色器的过程与生成大多数其他应用没有什么不同。您已经有了基本的编译、链接和加载序列。清单 10–1 展示了这个过程的第一部分,编译。
**注意:**清单 10–1 中的代码来自 ApiDemo 包中名为GLES20TriangleRenderer.java
的 Android 示例。
清单 10–1。编译一个着色器
` private int createProgram(String vertexSource, String fragmentSource) { //1
int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource); //2
if (vertexShader == 0) {
return 0;
}
int pixelShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource);
if (pixelShader == 0) {
return 0;
}
int program = GLES20.*glCreateProgram()**; *//3
if (program != 0) {
GLES20.glAttachShader(program, vertexShader); //4
GLES20.glAttachShader(program, pixelShader);
GLES20.glLinkProgram(program); //5
int[] linkStatus = new int[1];
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0); //6
if (linkStatus[0] != GLES20.GL_TRUE) {
Log.e(TAG, "Could not link program: ");
Log.e(TAG, GLES20.glGetProgramInfoLog(program));
GLES20.glDeleteProgram(program);
program = 0;
}
}
return program; //7
}
private int loadShader(int shaderType, String source) {
int shader = GLES20.glCreateShader(shaderType); //8
if (shader != 0) {
GLES20.glShaderSource(shader, source); //9
GLES20.glCompileShader(shader); //10
int[] compiled = new int[1];
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0);//11
if (compiled[0] == 0) {
Log.e(TAG, "Could not compile shader " + shaderType + “:”);
Log.e(TAG, GLES20.glGetShaderInfoLog(shader));
GLES20.glDeleteShader(shader);
shader = 0;
}
}
return shader; //12
}`
在前面的例子中,createProgram()
是从您的onSurfaceCreated()
方法中调用的,这里完成了给定应用的大部分初始化工作。那么,让我们来追溯一下到底发生了什么:
- 在
createProgram()
的参数列表中,如第 1 行所示,来自两个着色器的实际可执行代码的字符串被传递。您可以这样做,或者将它们作为文本文件读入。清单 10–2 有两个着色器,稍后会讨论。 - 行 2ff 为两个程序调用
loadShader()
并返回各自的句柄。 - 第 3 行创建了一个空的程序对象。这托管两个着色器并执行兼容性检查。
- 第 4f 行将两个着色器都附加到程序。
- 链接出现在第 5 行。建议检查任何可能的错误,如下所示。不仅仅是一个错误代码,GLSL 还会返回非常好的消息,比如:
ERROR: 0:19: Use of undeclared identifier 'normalX'
- 如果一切正常,我们可以在第 7 行返回程序。
- 第 8ff 行中定义的
loadShader()
,执行实际的编译。它首先获取原始源文本,并使用glCreateShader()
创建指定类型的着色器(顶点或片段)。这将返回一个空对象,然后通过第 9 行的glShaderSource()
将其绑定到源文本。 - 第 10 行编译实际的着色器,第 11ff 行像以前一样进行错误检查,而第 12 行返回经过验证和编译的对象。
如果你想进一步检查你的着色器代码,你可以用glValidateProgram()
来“验证”它。验证是 OpenGL 实现者返回关于代码任何方面的信息的一种方式,比如推荐的改进。您将主要在开发过程中使用它。
着色器现在可以使用了。当在它们和您的调用代码之间来回传递值时,您可以随时指定使用哪一个。这个稍后会讲到。现在,让我们仔细看看这两个演示着色器。这个例子的作者选择将着色器文本定义为一个大的静态字符串。其他人选择从文件中读取它们。但是在这种情况下,我将它们从原来的字符串重新格式化,使它们更具可读性。清单 10–2 涵盖了着色器对的前半部分。
清单 10–2。 顶点着色器
uniform mat4 uMVPMatrix; //1 attribute vec4 aPosition; //2 attribute vec2 aTextureCoord; varying vec2 vTextureCoord //3 void main //4 { gl_Position = uMVPMatrix * aPosition; //5 vTextureCoord = aTextureCoord; //6 }
现在仔细看看:
- 第 1 行定义了从调用程序传入的 4x4 模型/视图/透视矩阵 uniform 。如果你想在一个着色器或者更高的层次上执行实际的变换,这确实是一个风格的问题。并且注意,制服必须在着色器中实际使用,而不仅仅是声明;否则,如果您的调用程序试图引用它,将会失败。
- 第 2f 行声明了我们也在调用代码中指定的属性。请记住,属性是直接映射到每个顶点的数据数组,仅在顶点着色器中可用。在这种情况下,它们是顶点(被称为术语位置)及其对应的纹理坐标。对每个顶点调用一次着色器。
- 第 3 行声明了纹理坐标的一个变量。
- 与好的 ol’ C 一样,入口点是 a
main()
,如第 4 行所示。 - 在第 5 行,位置(顶点)乘以矩阵。您可以在着色器中或调用软件中完成此操作,这是更传统的方法。
- 最后,第 6 行只是将纹理坐标复制到其变化的副本,这样它就可以被片段着色器拾取。
现在真正的魔术发生在片段着色器中,如清单 10–3 所示。
清单 10–3。 片段着色器
` precision mediump float; //1
varying vec2 vTextureCoord; //2
uniform sampler2D sTexture; //3
void main()
{
gl_FragColor = texture2D(sTexture, vTextureCoord); //4
}`
- 如前所述,您可以通过第 1 行指定着色器的精度。
- 第 2 行声明了变量
vTextureCoord
。所有变量必须在两个着色器中声明;否则,它将生成一个错误。此外,片段着色器中的变量是只读的,而它们在顶点着色器中是可读/写的。 - 第 3 行声明了一个
sampler2D
对象。采样器是内置的制服,用于将纹理信息传递到片段着色器。其他采样器包括sampler1D
和sampler3D
。 - 第 4 行的
gl_FragColor
是一个内置变量,用于将片段的最终颜色传回系统进行显示。在这种情况下,我们只是传回由vTextureCoord
定义的特定点的纹理颜色。如果你想做更有趣的事,你可以在这里做。例如,您可以去掉蓝色和绿色部分,只留下红色层来显示,添加运动模糊或演示大气反向散射。
在我们可以使用着色器之前,我们需要在创建程序后立即获得着色器内制服和属性的“位置”或句柄,如前所述。这些用于将任何数据从调用方法传递给 GPU。清单 10–4 显示了onSurfaceCreated()
中用于旋转三角形的过程。
清单 10–4。 获取制服和属性的句柄
` public void onSurfaceCreated(GL10 glUnused, EGLConfig config)
{
mProgram = createProgram(mVertexShader, mFragmentShader); //1
if (mProgram == 0) {
return;
}
maPositionHandle = GLES20.glGetAttribLocation(mProgram, “aPosition”); //2
if (maPositionHandle == -1) {
throw new RuntimeException(“Could not get attrib location for aPosition”);
}
maTextureHandle = GLES20.glGetAttribLocation(mProgram, “aTextureCoord”); //3
if (maTextureHandle == -1) {
throw new RuntimeException(“Could not get attrib location for
aTextureCoord”);
}
muMVPMatrixHandle = GLES20.glGetUniformLocation(mProgram, “uMVPMatrix”); //4
if (muMVPMatrixHandle == -1) {
throw new RuntimeException(“Could not get attrib location for uMVPMatrix”);
}
}`
第 1 行、第 2 行和第 3 行使用着色器内部的实际名称获得属性的句柄。第 4 行获得矩阵的统一句柄。所有四个句柄都被保存起来,供调用程序的主处理循环使用。传入的GL10
接口被忽略,代替了GLES20
类的静态方法。
**注意:**您可以获取 OpenGL 定义的对象位置,也可以在链接前自行设置。后一种方法使您可以确保在代码中的整个着色器系列中,类似的统一或属性都利用相同的句柄。
现在剩下的唯一一件事就是执行一个着色器程序,并将任何数据传递给它。清单 10–5 展示了三角形的整个onDrawFrame()
方法来演示这一点。
清单 10–5。 调用和使用着色器
` public void onDrawFrame(GL10 glUnused) //1
{
GLES20.glClearColor(0.0f, 0.0f, 1.0f, 1.0f); //2
GLES20.glClear( GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glUseProgram(mProgram); //3
GLES20.glActiveTexture(GLES20.GL_TEXTURE0); //4
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureID); //5
mTriangleVertices.position(TRIANGLE_VERTICES_DATA_POS_OFFSET); //6
GLES20.glVertexAttribPointer(maPositionHandle, 3, GLES20.GL_FLOAT, false, //7
TRIANGLE_VERTICES_DATA_STRIDE_BYTES, mTriangleVertices);
GLES20.glEnableVertexAttribArray(maPositionHandle); //8
mTriangleVertices.position(TRIANGLE_VERTICES_DATA_UV_OFFSET); //9
GLES20.glVertexAttribPointer(maTextureHandle, 2, GLES20.GL_FLOAT, false,//10
TRIANGLE_VERTICES_DATA_STRIDE_BYTES, mTriangleVertices);
GLES20.glEnableVertexAttribArray(maTextureHandle); //11
long time = SystemClock.uptimeMillis() % 4000L;
float angle = 0.090f * ((int) time);
Matrix.setRotateM(mMMatrix, 0, angle, 0, 0, 1.0f); //12
Matrix.multiplyMM(mMVPMatrix, 0, mVMatrix, 0, mMMatrix, 0);
Matrix.multiplyMM(mMVPMatrix, 0, mProjMatrix, 0, mMVPMatrix, 0);
GLES20.glUniformMatrix4fv(muMVPMatrixHandle, 1, false, mMVPMatrix, 0); //13
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3); //14
}`
现在,让我们来分解一下:
onDrawFrame()
的参数是 GL10 对象。但是由于这是一个 OpenGL ES2 应用,GL10 句柄代替静态函数被忽略。- 第 2f 行只是清除屏幕的标准内容。
- 第 3 行是有趣的开始。打开你当时可能想要的任何着色器。你需要多少就有多少,在它们之间自由跳跃。
- 我们要传递给片段着色器的纹理在第 4 行指定,并由
sampler2D
对象拾取。此代码代表 GPU 上使用的实际纹理单元。 - 第 5 行将本地纹理句柄绑定到这个单元。
- 第 6 行通过将三角形顶点数组对象的内部位置索引设置为实际顶点 xyz 值的起始点,来准备实际的三角形顶点数组对象。
- 现在我们终于可以把一些东西交给着色器了,如第 7 行所示。在这种情况下,顶点数据通过
glVertexAtttribPointer()
被发送到着色器,它接受位置属性的句柄maPosition
;数据的类型,stride
;和指向所述数据的指针。第 8 行允许使用数组。 - 第 9、10 和 11 行对纹理坐标做了同样的处理。
- 第 12ff 行使用 Android 自己的矩阵库(
android.opengl.Matrix
)执行旋转和投影,因为 OpenGL ES 2 没有glRotate
/glTranslate
/glScale
函数。否则,你需要写你自己的数学库。 - 我们现在可以将之前矩阵操作的结果传递给顶点着色器,使用
glUniformMatrix4fv
()
和我们之前获得的矩阵句柄。 - 现在在最后一行,我们称我们的老朋友为
glDrawArrays()
。
所以,你有它。一个“简单的”基于着色器的程序。还不算太糟,是吧?现在我们可以重温我们蹩脚的太阳系模型,并展示如何使用着色器来使它不那么蹩脚。
夜晚的地球
你熟悉用于地球表面的日光图像(图 10–3,左),但你可能也见过类似的地球夜晚图像(图 10–3,右)。如果我们可以在地球的黑暗面显示夜晚纹理,而不仅仅是常规纹理贴图的黑暗版本,那将会是一件好事。
图 10–3。白天的地球对夜晚的地球
在 OpenGL 1.1 下,如果不是不可能完成的话,这将是非常棘手的。算法应该相对简单:渲染地球两次,一次用白天的图像,一次用夜晚的图像。然后根据光照改变日光侧地球纹理的日光侧 alpha 通道。当照度达到 0 时,它是完全透明的,夜晚部分显示通过。然而,在 OpenGL ES 2 下,您可以非常容易地对着色器进行编码,以几乎完全匹配算法。(你也可以渲染地球一次,同时提供两种纹理。这项技术将在下一个练习中介绍)。
该程序的结构类似于前面的任何一个程序,有一个“活动”文件、一个渲染器,在本例中还有一个Planet
对象。
第一个例子很好,你可能会想,“但是我们实际上如何命令 OpenGL 使用 2。xstuff vs . 1 . x?”清单 10–6 给出了答案。
首先我们需要检测设备是否真的支持 OpenGL ES 2。新的肯定会,但旧的可能不会。iPhone 直到 iOS 3.0 才得到它。这是通过检索配置信息包的getSystemService()
方法完成的。如果通过了,只需简单地调用GLSurfaceView()
.setEGLContextClientVersion(2)
就可以了。
**清单 10–6。**调用 OpenGL ES 2
` private boolean detectOpenGLES20()
{
ActivityManager am =
(ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
ConfigurationInfo info = am.getDeviceConfigurationInfo();
return (info.reqGlEsVersion >= 0x20000);
}
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
GLSurfaceView view = new GLSurfaceView(this);
if (detectOpenGLES20())
{
view.setEGLContextClientVersion(2);
view.setEGLConfigChooser(8,8,8,8,16,1);
view.setRenderer(new SolarSystemRendererES2(this));
setContentView(view);
}
}`
接下来,地球需要以通常的方式生成。这个实例将使用 50 个切片和 50 个堆栈,同时获取两个纹理,如清单 10–7 所示。
清单 10–7。 初始化地球
` private void initGeometry(GL10 glUnused)
{
String extensions = GLES20.glGetString(GL10.GL_EXTENSIONS);
m_DayTexture=createTexture(glUnused, m_Context,
book.SolarSystem.R.drawable.earth_light);
m_NightTexture=createTexture(glUnused, m_Context,
book.SolarSystem.R.drawable.earth_night);
m_Earth = new Planet(50, 50, 1.0f, 1.0f, glUnused, myAppcontext,true,-1);
}`
onSurfaceCreated()
方法在调用initGeometry()
的同时加载并初始化两组着色器,如清单 10–8 所示。
清单 10–8。 加载着色器
` public void onSurfaceCreated(GL10 glUnused, EGLConfig config)
{
int program;
m_DaysideProgram=createProgram(m_DaySideVertexShader,m_DaySideFragmentShader);
m_NightsideProgram=createProgram
(m_NightSideVertexShader,m_NightSideFragmentShader);
initGeometry(glUnused);
Matrix.setLookAtM(m_WorldMatrix, 0, 0, 5f, -2, 0f, 0f, 0f, 0.0f, 1.0f, 0.0f);
}`
与前面的例子没有太大的不同,但是它有更多的制服和属性需要处理。下面,为法线提供了一个新的属性,这样我们可以处理光照。在本例中,我们将特定的标识符绑定到每个属性,以便我们可以确保两组着色器使用相同的值。它有时会让事情变得简单一点。而这个必须在链接前完成。
GLES20\. glBindAttribLocation(*program, ATTRIB_VERTEX, "aPosition"); GLES20\. glBindAttribLocation(*program, ATTRIB_NORMAL, "aNormal"); GLES20\. glBindAttribLocation(*program, ATTRIB_TEXTURE0_COORDS, "aTextureCoord");
除了用于模型/视图/投影矩阵的制服之外,还有两个新制服需要处理。与前面的例子不同,我们仍然必须在链接后获取位置,所以不能保证它们的位置在程序的其他实例中是在相同的位置,除非程序有相同的变量集。在这里,我将所有统一的句柄缓存到一个数组中,该数组应该适用于两组着色器。新制服是为普通矩阵和光位置准备的。(对于非常简单的模型,你可以在顶点着色器中硬编码灯光的位置。)
m_UniformIDs[*UNIFORM_MVP_MATRIX*]=GLES20.*glGetUniformLocation*(program, "uMVPMatrix") ; m_UniformIDs[*UNIFORM_NORMAL_MATRIX*]=GLES20.*glGetUniformLocation*(program, "uNormalMatrix"); m_UniformIDs[*UNIFORM_LIGHT_POSITION*]=GLES20.*glGetUniformLocation*(program, "uLightPosition");;
因此,添加新制服的流程如下:
- 在着色器中声明(即
uniform vec3 lightPosition;
)。 - 使用
glGetUniformLocation()
获取其“位置”。它只返回该会话的唯一 ID,然后在设置或从着色器获取数据时使用该 ID。(或使用glBindAttribLocation
指定具体的位置值。) - 使用众多
glUniform*()
调用中的一个来动态设置值。
自然,球体生成器也需要做一些修改。利用上一章的交错数据示例,新的draw()
方法看起来类似于清单 10–9。
清单 10–9。?? 中兼容 OpenGL ES 2 的绘制方法Planet.java
` public void draw(GL10 gl,int vertexLocation,int normalLocation, //1
int colorLocation, int textureLocation,int textureID)
{
//Overrides any default texture that may have been supplied at creation time.
if(textureID>=0) //2
{
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureID);
}
else if(m_Texture0>=0)
{
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, m_Texture0);
}
GLES20.glEnable(GLES20.GL_CULL_FACE);
GLES20.glCullFace(GLES20.GL_BACK);
m_InterleavedData.position(0); //3
GLES20.glVertexAttribPointer(vertexLocation, 3, GLES20.GL_FLOAT,
false,m_Stride, m_InterleavedData);
GLES20.glEnableVertexAttribArray(vertexLocation);
m_InterleavedData.position(NUM_XYZ_ELS);
if(normalLocation>=0)
{
GLES20.glVertexAttribPointer(normalLocation, 3, GLES20.GL_FLOAT,
false,m_Stride, m_InterleavedData);
GLES20.glEnableVertexAttribArray(normalLocation);
}
m_InterleavedData.position(NUM_XYZ_ELS+NUM_NXYZ_ELS);
if(colorLocation>=0)
{
GLES20.glVertexAttribPointer(colorLocation, 4, GLES20.GL_FLOAT,
false,m_Stride, m_InterleavedData);
GLES20.glEnableVertexAttribArray(colorLocation);
}
m_InterleavedData.position(NUM_XYZ_ELS+NUM_NXYZ_ELS+NUM_RGBA_ELS);
GLES20.glVertexAttribPointer(textureLocation, 2, GLES20.GL_FLOAT,
false,m_Stride, m_InterleavedData);
GLES20.glEnableVertexAttribArray(textureLocation);
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, (m_Slices+1)2(m_Stacks-1)+2);
}`
与其他例程一样,我们忽略传递给它的GL10
对象,而是使用GLES20
静态调用。
- 在第 1 行,注意所有的附加参数。这些允许各种属性的句柄或位置在这里传递和使用。
- 第 2ff 行允许我们使用在对象创建时定义的纹理,或者在运行时交换另一个纹理。
- 第 3ff 行按照前面演示的标准方式设置属性指针。为特定类型的数据设置每个指针后,交叉索引将前进到下一个数据块的开始处。
接下来我们可以看看实际的着色器,特别是清单 10–10 中的顶点。与前面的一样,为了可读性,这些着色器已被重新格式化。请注意,对于这个示例和下一个示例,白天和夜晚的顶点着色器是相同的。
清单 10–10。 白天和黑夜两边的顶点着色器
` attribute vec4 aPosition;
attribute vec3 aNormal; //1
attribute vec2 aTextureCoord;
varying vec2 vTextureCoord;
varying lowp vec4 colorVarying;
uniform vec3 uLightPosition; //2
uniform mat4 uMVPMatrix;
uniform mat3 uNormalMatrix; //3
void main()
{
vTextureCoord = aTextureCoord;
vec3 normalDirection = normalize(uNormalMatrix * aNormal);//4
float nDotVP = max(0.0, dot(normalDirection, normalize(uLightPosition)));
vec4 diffuseColor = vec4(1.0, 1.0, 1.0, 1.0);
colorVarying = diffuseColor * nDotVP;
gl_Position = uMVPMatrix * aPosition; //5
}`
这里我们有三个新的参数要担心,更不用说照明了。
-
线 1 是这个顶点的法线属性,当然是照明解决方案所需要的。
-
第 2 行通过制服提供灯光的位置。
-
第 3 行支持法线矩阵。为什么法线应该像顶点一样,却有一个单独的矩阵?在大多数情况下,它们是正常的,但是法线在某些情况下会分解,例如当仅在一个方向上不均匀地缩放几何体时。因此,要将其与这些情况隔离开来,需要一个单独的矩阵。
-
Lines 4ff do the lighting calculations. First the normal is normalized (I always get a kick out of saying that) and when multiplied by the normal’s matrix produces the normalized normal direction. Normally.
之后,我们取法线方向和归一化光线位置的点积。它给出了给定顶点的光强。
之后,漫射颜色被定义。它被设置为全 1,因为阳光被定义为白色。(提示,设为红色真的看起来很酷。)漫射颜色乘以强度,然后将最终结果传递给片段着色器。
-
第 5 行通过将原始顶点乘以模型/视图/投影矩阵来处理顶点的最终位置。
gl_Position
是一个专用的内置变量,不需要声明。
两侧的碎片着色器是不同的,因为黑暗面处理照明的方式不同于日光面。清单 10–11 是白天的片段着色器。
清单 10–11。 地球日光面的片段着色器
varying lowp vec4 colorVarying; precision mediump float; varying vec2 vTextureCoord; uniform sampler2D sTexture; void main() { gl_FragColor = texture2D(sTexture, vTextureCoord)*colorVarying; }
这看起来应该和三角形的着色器一样,除了添加了colorVarying
。这里,从sTexture
得到的输出乘以最终结果的颜色。
然而,夜间的事情会更有趣一些,如清单 10–12 所示。
清单 10–12。 地球黑夜面的碎片着色器
varying lowp vec4 colorVarying; precision mediump float; varying vec2 vTextureCoord; uniform sampler2D sTexture; void main() { vec4 newColor; newColor=1.0-colorVarying; gl_FragColor = texture2D(sTexture, vTextureCoord)*newColor; }
你会注意到参数与另一个着色器相同,但是我们得到了几行额外的代码来计算夜晚的颜色。因为我们可以根据光照从一个纹理到另一个纹理进行混合,所以夜晚的颜色应该是 1.0 日光色。GLSL 漂亮的向量库使得这样的数学运算变得非常简单。
清单 10–13 显示了完成所有操作的onDrawFrame()
。
清单 10–13。 把所有的东西放在一起
` public void onDrawFrame(GL10 glUnused)
{
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
GLES20.glClear( GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);
m_Angle+=0.20;
Matrix.setRotateM(m_MMatrix, 0,m_Angle, 0, 1.0f, 0.0f); //1
Matrix.multiplyMM(m_MVMatrix, 0, m_WorldMatrix, 0, m_MMatrix, 0);
Matrix.multiplyMM(m_MVPMatrix, 0, m_ProjMatrix, 0, m_MVMatrix, 0);
m_NormalMatrix[0]=m_MVMatrix[0]; //2
m_NormalMatrix[1]=m_MVMatrix[1];
m_NormalMatrix[2]=m_MVMatrix[2];
m_NormalMatrix[3]=m_MVMatrix[4];
m_NormalMatrix[4]=m_MVMatrix[5];
m_NormalMatrix[5]=m_MVMatrix[6];
m_NormalMatrix[6]=m_MVMatrix[8];
m_NormalMatrix[7]=m_MVMatrix[9];
m_NormalMatrix[8]=m_MVMatrix[10];
GLES20.glUseProgram(m_NightsideProgram); //3
checkGlError(“glUseProgram:nightside”);
GLES20.glUniformMatrix4fv(m_UniformIDs[UNIFORM_MVP_MATRIX], 1, false,
m_MVPMatrix, 0);
GLES20.glUniformMatrix3fv(m_UniformIDs[UNIFORM_NORMAL_MATRIX], 1, false,
m_NormalMatrix,0);
GLES20.glUniform3fv(m_UniformIDs[UNIFORM_LIGHT_POSITION], 1, m_LightPosition,0);
m_Earth.setBlendMode(m_Earth.PLANET_BLEND_MODE_FADE); //4
m_Earth.draw(glUnused,ATTRIB_VERTEX,ATTRIB_NORMAL,-1,
ATTRIB_TEXTURE0_COORDS,m_NightTexture);
checkGlError(“glDrawArrays”);
GLES20.glUseProgram(m_DaysideProgram); //5
checkGlError(“glUseProgram:dayside”);
GLES20.glUniformMatrix4fv(m_UniformIDs[UNIFORM_MVP_MATRIX], 1, false,
m_MVPMatrix, 0);
GLES20.glUniformMatrix3fv(m_UniformIDs[UNIFORM_NORMAL_MATRIX], 1, false,
m_NormalMatrix,0);
GLES20.glUniform3fv(m_UniformIDs[UNIFORM_LIGHT_POSITION], 1, m_LightPosition,0);
m_Earth.draw(glUnused,ATTRIB_VERTEX,ATTRIB_NORMAL,-1,
ATTRIB_TEXTURE0_COORDS,m_DayTexture);
checkGlError(“glDrawArrays”);
}`
事情是这样的:
- 第 1ff 行执行预期的旋转,首先在 Y 轴上,乘以世界矩阵,然后乘以投影矩阵。
- 第 2ff 行有点欺骗。还记得我之前说过需要一个正规矩阵吗?在简化的情况下,我们可以只使用模型视图矩阵,或者至少是它的一部分。由于法线矩阵只有 9x9(避开了平移分量),我们将它从更大的 4x4 模型视图矩阵的旋转部分中切掉。
- 现在程序的夜间部分被切换进来,如第 3 行所示。之后,制服被填充。
- 第 4 行设置了一个类似于 OpenGL ES 1.1 的混合模式。在这种情况下,我们推动系统实际上认识到阿尔法是用来管理半透明。阿尔法值越低,这个碎片越透明。就在那之后,黑暗面被画了出来。
- 第 5 行现在将我们切换到了日光程序,并且做了许多相同的事情。
图 10–4 应该是结果。你现在可以看到做非常微妙的效果是多么容易,例如满月的光照或太阳在海洋中的反射。
图 10–4。 一点一点照亮黑暗
带来云彩
所以,看起来肯定是缺少了什么。是啊。那些云一样的东西。嗯,我们很幸运,因为着色器也可以很容易地管理这一点。在可下载的项目文件中,我添加了整个地球的云图,如图 Figure 10–5 所示。大块的陆地有点难以看清,但在右下角是澳大利亚,而在左半部你应该能分辨出南美。所以,我们的工作是将它覆盖在彩色风景地图上,去掉所有的暗点。
图 10–5。全地球云图案
我们不仅要添加云到我们的模型中,我们还将看到如何使用着色器处理多重纹理,例如,如何告诉一个着色器使用多个纹理?还记得第六章中关于纹理单元的那一课吗?它们现在真的很方便,因为那是纹理存储的地方,为片段着色器拾取它们做好了准备。通常,对于单一纹理,系统默认不需要额外的设置,除了对glBindTexture()
的正常调用。但是,如果您想要使用多个,需要进行一些设置。步骤如下:
- 在你的主程序中加载新的纹理。
- 添加第二个
uniform sampler2D
到你的片段着色器来支持第二个纹理,并通过glGetUniformLocation()
拾取它。 - 告诉系统哪个纹理单元使用哪个采样器。
- 在主循环中激活所需的纹理并将其绑定到指定的 tu。
现在来看几个细节:你已经知道如何加载纹理;当然,这是显而易见的。因此,对于步骤 2,您将需要向片段着色器添加如下内容,与前两个练习中使用的相同:
uniform sampler2D sCloudTexture;
并且到createProgram()
:
`m_UniformIDs[UNIFORM_SAMPLER0] = GLES20.glGetUniformLocation(program, "sTexture);
m_UniformIDs[UNIFORM_SAMPLER1] = GLES20.glGetUniformLocation(program, “sCloudTexture”);`
第三步添加到onSurfaceCreated()
。glUniform1i()
调用将片段着色器中统一的位置作为第一个参数,将实际的纹理单元 ID 作为第二个参数。所以在这种情况下,sampler0
被绑定到纹理单元 0,而sampler1
去纹理单元 1。由于单个纹理总是默认为 TU0 以及第一个采样器,设置代码并不是普遍需要的。
` GLES20.glUseProgram(m_DaysideProgram);
GLES20.glUniform1i(m_UniformIDs[UNIFORM_SAMPLER0],0);
GLES20.glUniform1i(m_UniformIDs[UNIFORM_SAMPLER1],1);
GLES20.glUseProgram(m_NightsideProgram);
GLES20.glUniform1i(m_UniformIDs[UNIFORM_SAMPLER0],0);
GLES20.glUniform1i(m_UniformIDs[UNIFORM_SAMPLER1],1);`
当在onDrawFrame()
中运行主循环时,在步骤 4 中,你可以执行以下操作来打开两个纹理:
` GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,m_NightTexture);
GLES20.glActiveTexture(GLES20.GL_TEXTURE1);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,m_CloudTexture);
GLES20.glUseProgram(m_NightsideProgram);`
glActiveTexture()
指定使用什么 TU,然后调用绑定纹理。之后,该程序可以用于预期的效果。
“cloud-lovin”片段现在应该看起来类似于清单 10–14 来执行实际的混合。
清单 10–14。 将第二个纹理和云彩混合在一起
` varying lowp vec4 colorVarying;
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D sTexture;
uniform sampler2D sCloudTexture; //1
void main()
{
vec4 cloudColor;
vec4 surfaceColor;
cloudColor=texture2D(sCloudTexture, vTextureCoord ); //2
surfaceColor=texture2D(sTexture, vTextureCoord );
if(cloudColor[0]>0.2) //3
{
cloudColor[3]=1.0;
gl_FragColor=(cloudColor1.3+surfaceColor.4)*colorVarying;
}
else
gl_FragColor = texture2D(sTexture, vTextureCoord)*colorVarying;
}`
下面是正在发生的事情:
-
第 1 行仅仅声明了新的云纹理。
-
在第 2 行中,我们从 cloud sampler 对象中获取云的颜色。
-
The new color is filtered and merged with the earth’s surface image in lines 3ff. Since the clouds are neutral in color, all we need to do is to analyze one color component, red in this case. If it is brighter than a given value, then blend it with the earth’s surface texture. The numbers used are quite arbitrary and can be tweaked based on your taste. Naturally, much of the finer detail will have to be cut out to ensure the colored landmasses show through.
cloudColor
用 1.3 的乘数稍微增加了一点,而下面的表面只使用了 0.4 的乘数,以便更加强调云彩,同时仍然使它们相对不透明。低于阈值 0.2,只需发回表面着色。
由于云是灰度对象,我只需要选取一种颜色进行测试,因为正常的 RGB 值是相同的。所以,我选择处理所有比 0.2 亮的纹理元素。然后,我确保 alpha 通道为 1.0,并将所有三个组件组合在一起。
理想情况下,您会看到类似图 10–6 的内容。这就是我所说的行星!
图 10–6。 给地球增加云层
但是镜面反射呢?
就像任何其他闪亮的东西一样(地球在蓝色部分是闪亮的),你可能会看到太阳在水中的反射。嗯,你是对的。图 10–7 显示了地球的真实图像,正中间是太阳的反射。让我们在自己的地球上试试吧。
图 10–7。 从太空中看到的地球,它反射着太阳
自然,我们将不得不编写自己的镜面反射着色器,或者,在这种情况下,将它添加到现有的日光着色器中。
清单 10–15 是针对日光顶点着色器的。我们只做一面,但是满月可能会对夜晚有类似的影响。在这里,我预先计算了镜面反射信息和正常的漫反射颜色,但是这两者是分开的,直到碎片着色器,因为不是地球的所有部分都是反射的,所以陆地不应该得到镜面反射处理。
清单 10–15。 用于镜面反射的日光顶点着色器
` attribute vec4 aPosition;
attribute vec3 aNormal;
attribute vec2 aTextureCoord;
varying vec2 vTextureCoord;
varying lowp vec4 colorVarying;
varying lowp vec4 specularColorVarying; //1
uniform vec3 uLightPosition;
uniform vec3 uEyePosition;
uniform mat4 uMVPMatrix;
uniform mat3 uNormalMatrix;
void main()
{
float shininess=25.0;
float balance=.75;
float specular=0.0;
vTextureCoord = aTextureCoord;
vec3 normalDirection = normalize(uNormalMatrix * aNormal);
vec3 lightDirection = normalize(uLightPosition);
vec3 eyeNormal = normalize(uEyePosition);
vec4 diffuseColor = vec4(1.0, 1.0, 1.0, 1.0);
float nDotVP = max(0.0, dot(normalDirection, lightDirection));
//2
float nDotVPReflection = dot(reflect(-
lightDirection,normalDirection),eyeNormal);
specular = pow(max(0.0,nDotVPReflection),shininess)balance; //3
specularColorVarying=vec4(specular,specular,specular,0.0); //4
colorVarying = diffuseColor * nDotVP1.3;
gl_Position = uMVPMatrix * aPosition;
}`
- 第 1 行声明了一个可变变量,将镜面照明交给片段着色器。
- 我们现在需要得到光线反射和法线的点积,以法线方式乘以法线矩阵。二号线。注意
reflect()
方法的使用,这是着色器语言的另一个优点。reflect()
根据负光方向和局部法线生成反射向量。然后点缀着eyeNormal
。 - 在第 3 行,前面的点积被用来产生实际的镜面反射分量。你还会看到我们的老朋友 shininess,就像 OpenGS ES 的 1 版一样,数值越高,反射越窄,越“热”。
- 因为我们可以认为太阳的颜色只是白色,所以第 4 行中的镜面反射颜色可以将其所有组件设置为相同的值。
现在片段着色器可以用来进一步细化,如清单 10–16 所示。
清单 10–16。 处理镜面反射的碎片着色器
` varying lowp vec4 colorVarying;
varying lowp vec4 specularColorVarying; //1
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D sTexture;
uniform sampler2D sCloudTexture;
void main()
{
vec4 finalSpecular=vec4(0,0,0,1);
vec4 cloudColor;
vec4 surfaceColor;
float halfBlue;
cloudColor=texture2D(sCloudTexture, vTextureCoord );
surfaceColor=texture2D(sTexture, vTextureCoord );
halfBlue=0.5*surfaceColor[2]; //2
if(halfBlue>1.0) //3
halfBlue=1.0;
if((surfaceColor[0]<halfBlue) && (surfaceColor[1]<halfBlue)) //4
finalSpecular=specularColorVarying;
if(cloudColor[0]>0.2)
{
cloudColor[3]=1.0;
gl_FragColor=(cloudColor1.3+surfaceColor.4)*colorVarying;
}
else
gl_FragColor=(surfaceColor+finalSpecular)*colorVarying; //5
}`
这里的主要任务是确定哪些碎片代表海,哪些不代表海。这很简单。蓝色的东西是水(强大的水湿的东西!)而不是的一切都不是。
- 在第 1 行,我们选择了
specularColorVarying
变量。 - 在第 2 行中,我们选取蓝色分量并将其一分为二,将其夹在第 3 行中,因为实际上没有颜色可以超过最大强度。
- 第 4 行进行过滤。如果红色和绿色的成分都不到蓝色的一半,那么我们可以在水面上绘制镜面反射,而不是像乍得那样的地方。
- 在第一次与
colorVarying
相乘后,镜面反射部分现在被添加到最后一行的碎片颜色中,因为这将与其他所有东西一起调制它。
图 10–8 显示了没有云的结果,图 10–9 显示了有云的结果。
图 10–8。 右边地球/水界面的特写
图 10–9。 完工的地球,至少目前来看是
这只是一个简单的例子,使用着色器来增强你渲染的场景的真实感。例如,当涉及到空间主题时,您可能会在一个行星周围生成一个朦胧的大气或 3D 体积纹理来模拟星系或星云。要是我再有十章就好了…
如果你想继续复制整个第八章的项目,用镜头光晕、小部件等等来获得额外的学分,请随意。
总结
在这最后一章中,你学习了一些关于 OpenGL ES 2 的知识,ES 的可编程管道版本,看到了着色器如何和在哪里适合它,并使用它们给地球添加一些额外的细节。然而,对于额外的学分,请参见关于将模拟器的其余部分移植到版本 2。
在这本书里,你已经学习了基本的 3D 理论,包括数学和整体原理。我认为它给了你对这个主题的基本感觉或理解,即使知道这本书可能会大很多倍,考虑到我们几乎没有接触过 3D 图形。
Khronos Group 是官方 OpenGL 的守护者,已经出版了几本关于这个主题的书籍。根据封面的颜色,它们被亲切地称为红皮书(官方编程指南)、蓝皮书(教程和参考)、黄皮书(着色语言)、绿皮书(Mac 上的 Open GL)和略带紫色的书籍(OpenGL ES 2)。还有许多其他第三方书籍,它们比我所能找到的要深入得多。同样,网上有许多致力于 OpenGL 教程的网站;到目前为止是最好的之一,在撰写本文时有近 50 个不同的教程。NVidia 有一系列优秀的大师级书籍可供免费下载,名为 GPU Gems。这些包括从渲染水焦散到起伏的草地。它们确实值得一看。
当你阅读其他作者的作品时,不管是从其他书上还是在网上,只要记住这本书是给你太阳、地球和星星的那本书。没有几个人能这么说。