在上一篇介绍OPhone 3D开发的文章《OPhone 3D开发之解析渲染MS3D模型》中,我们向大家介绍了如何使用OPhone中的OpenGL ES API开发一个基本的3D应用程序,本文将在前文的基础上,通过编写一个3D封面效果演示,进一步介绍OPhone平台中的众多3D特性。程序最终的效果图如下:
构建封面渲染几何体
在上面的效果图中,大家可以看到每个封面,均由封面正面、倒影两部分组成。正面部分显示的是封面图片主体,倒影部分则是相对于正面做了一个垂直方向的反转,并且随着高度的下降透明度逐渐降低,最终与背景完全融合,从而营造出一种晶莹剔透的镜面倒影效果。
这里的每个封面几何体渲染对象,实际上就是一个2D的矩形面片,最简单的方式,当然就是根据4个顶点,构建两个三角形进行渲染。但这会造成什么问题呢?请看下图:
为了帮助大家更好的看清细节,下面是图2局部放大400%之后的截图:
从上面的图2和图3可以看出,如果仅仅是用4个顶点,2个三角形去简单渲染封面,最终效果会很差,边缘锯齿会很明显。在移动设备上,要实现抗锯齿,除了显卡硬件设备支持之外,我们可以用一些取巧的方式,以较小的代价来模拟实现抗锯齿的效果。下面就将介绍常用的一种方法:边缘半透明混合重绘。
这项技术的基本思想,就是在渲染完毕主体图片之后,对封面的边缘进行一次“描边”,使用Alpha过渡技术,通过半透明的像素将图片边缘像素与背景像素相融合,以达到模拟抗锯齿效果。为了启用纹理与顶点色的融合,需要我们通过调用glTexEnvf ( GL10.GL_TEXTURE_ENV, GL10.GL_TEXTURE_ENV_MODE,GL10.GL_ MODULATE),将纹理对象的纹理模式设置为GL_MODULATE。这样我们就可以得到如图4的效果:
可以看到,在多了一圈半透明的过渡边缘之后,整体锯齿感大大下降,整体效果可以见图1。为了达到这样的效果,我们需要在构建封面渲染几何体时,进行进一步的细分,把封面几何体分为主体部分和边缘部分。具体如图5所示:
新的几何体由16个顶点构成9个四边形(即18个三角形),其中,由顶点5,6,9,10构成的中心四边形作为封面主体,顶点色的透明度设置为完全不透明(即Alpha = 1.0);而其余的四边形作为过渡边缘进行二次描边操作,剩余顶点的顶点色透明度均设置为完全透明(即Alpha = 0.0)。这样,从中心区域到边缘,就会是一个平滑淡出的效果。
由于我们的每一个封面实际上是由两个组成,一个正面,一个倒影,因此,每一个封面实际上需要16 x 2 = 32个顶点,也就是前16个顶点表示封面的正面,后16个顶点表示封面倒影。然后我们再根据需要,对正面和倒影的顶点分别设置它们的位置、顶点色、纹理坐标等属性。创建全部顶点的相关代码如下:
- public void createCover() {
- //封面有16个顶点,这里还包括它自身 的倒影,所以一共要创建32个
- Vertex[] pVertices = new Vertex[ 16 * 2 ];
- // 初始化操作
- for ( int i = 0 ; i < pVertices.length; i++) {
- pVertices[i] = new Vertex();
- }
- // 设置封面初始化参数,宽度高度均为6.0f, 封面距离倒影的距离是0
- // 如果需要显示宽屏的图片,可以在这里修改图片比例
- float width = 6 .0f, height = 6 .0f, heightFromMirror = 0 .0f;
- float dim = 0 .5f; // as uv
- float mfBorderFraction = 0 .05f; //过渡边缘宽度系数,如果设置为0就表示没有边缘过渡
- float dimLess = 0 .5f - ( 0 .5f * mfBorderFraction);
- //设置顶点的法线,这里由于没有启用光照,因此并未特别设置
- Vector3f normal = new Vector3f( 0 .0f, 1 .0f, 0 .0f);
- //设置顶点的位置
- pVertices[ 0 ].p.set(-dim, dim, 0 );
- pVertices[ 1 ].p.set(-dimLess, dim, 0 );
- pVertices[ 2 ].p.set(dimLess, dim, 0 );
- pVertices[ 3 ].p.set(dim, dim, 0 );
- pVertices[ 4 ].p.set(-dim, dimLess, 0 );
- pVertices[ 5 ].p.set(-dimLess, dimLess, 0 );
- pVertices[ 6 ].p.set(dimLess, dimLess, 0 );
- pVertices[ 7 ].p.set(dim, -dimLess, 0 );
- pVertices[ 8 ].p.set(-dim, -dimLess, 0 );
- pVertices[ 9 ].p.set(-dimLess, -dimLess, 0 );
- pVertices[ 10 ].p.set(dimLess, -dimLess, 0 );
- pVertices[ 11 ].p.set(dim, -dimLess, 0 );
- pVertices[ 12 ].p.set(-dim, -dim, 0 );
- pVertices[ 13 ].p.set(-dimLess, -dim, 0 );
- pVertices[ 14 ].p.set(dimLess, -dim, 0 );
- pVertices[ 15 ].p.set(dim, -dim, 0 );
- //设置所有顶点的颜色和法线
- for ( int i = 0 ; i < 16 ; i++) {
- //设置法线
- pVertices[i].n.set(normal);
- //设置顶点色
- pVertices[i].c.set( 1 .0f, 1 .0f, 1 .0f, 0 .0f);
- //设置纹理坐标
- pVertices[i].t.x = pVertices[i].p.x + 0 .5f;
- pVertices[i].t.y = pVertices[i].p.y + 0 .5f;
- //设置顶点位置
- pVertices[i].p.x = pVertices[i].p.x * width;
- pVertices[i].p.y = pVertices[i].p.y * height;
- }
- //对于中心区域的4个顶点,设置为完全不透明,即alpha = 1.0
- pVertices[ 5 ].c.w = 1 .0f;
- pVertices[ 6 ].c.w = 1 .0f;
- pVertices[ 9 ].c.w = 1 .0f;
- pVertices[ 10 ].c.w = 1 .0f;
- //创建镜像倒影
- for ( int row = 0 ; row < 4 ; ++row) {
- //根据镜像的高度调整顶点色明暗
- float dark = 1 - ((pVertices[row * 4 ].p.y / height) + 0 .5f);
- dark -= 0 .5f;
- for ( int col = 0 ; col < 4 ; col++) {
- int offset = row * 4 + col;
- //将倒影封面的顶点垂直翻转
- pVertices[offset + 16 ].set(pVertices[offset]);
- pVertices[offset + 16 ].p.y = -pVertices[offset + 16 ].p.y;
- pVertices[offset + 16 ].p.y -= height + heightFromMirror;
- //设置倒影封面几何体的顶点颜色
- pVertices[offset + 16 ].c.x = dark;
- pVertices[offset + 16 ].c.y = dark;
- pVertices[offset + 16 ].c.z = dark;
- }
- }
public void createCover() { //封面有16个顶点,这里还包括它自身 的倒影,所以一共要创建32个 Vertex[] pVertices = new Vertex[16 * 2]; // 初始化操作 for (int i = 0; i < pVertices.length; i++) { pVertices[i] = new Vertex(); } // 设置封面初始化参数,宽度高度均为6.0f, 封面距离倒影的距离是0 // 如果需要显示宽屏的图片,可以在这里修改图片比例 float width = 6.0f, height = 6.0f, heightFromMirror = 0.0f; float dim = 0.5f; // as uv float mfBorderFraction = 0.05f;//过渡边缘宽度系数,如果设置为0就表示没有边缘过渡 float dimLess = 0.5f - (0.5f * mfBorderFraction); //设置顶点的法线,这里由于没有启用光照,因此并未特别设置 Vector3f normal = new Vector3f(0.0f, 1.0f, 0.0f); //设置顶点的位置 pVertices[0].p.set(-dim, dim, 0); pVertices[1].p.set(-dimLess, dim, 0); pVertices[2].p.set(dimLess, dim, 0); pVertices[3].p.set(dim, dim, 0); pVertices[4].p.set(-dim, dimLess, 0); pVertices[5].p.set(-dimLess, dimLess, 0); pVertices[6].p.set(dimLess, dimLess, 0); pVertices[7].p.set(dim, -dimLess, 0); pVertices[8].p.set(-dim, -dimLess, 0); pVertices[9].p.set(-dimLess, -dimLess, 0); pVertices[10].p.set(dimLess, -dimLess, 0); pVertices[11].p.set(dim, -dimLess, 0); pVertices[12].p.set(-dim, -dim, 0); pVertices[13].p.set(-dimLess, -dim, 0); pVertices[14].p.set(dimLess, -dim, 0); pVertices[15].p.set(dim, -dim, 0); //设置所有顶点的颜色和法线 for (int i = 0; i < 16; i++) { //设置法线 pVertices[i].n.set(normal); //设置顶点色 pVertices[i].c.set(1.0f, 1.0f, 1.0f, 0.0f); //设置纹理坐标 pVertices[i].t.x = pVertices[i].p.x + 0.5f; pVertices[i].t.y = pVertices[i].p.y + 0.5f; //设置顶点位置 pVertices[i].p.x = pVertices[i].p.x * width; pVertices[i].p.y = pVertices[i].p.y * height; } //对于中心区域的4个顶点,设置为完全不透明,即alpha = 1.0 pVertices[5].c.w = 1.0f; pVertices[6].c.w = 1.0f; pVertices[9].c.w = 1.0f; pVertices[10].c.w = 1.0f; //创建镜像倒影 for (int row = 0; row < 4; ++row) { //根据镜像的高度调整顶点色明暗 float dark = 1 - ((pVertices[row * 4].p.y / height) + 0.5f); dark -= 0.5f; for (int col = 0; col < 4; col++) { int offset = row * 4 + col; //将倒影封面的顶点垂直翻转 pVertices[offset + 16].set(pVertices[offset]); pVertices[offset + 16].p.y = -pVertices[offset + 16].p.y; pVertices[offset + 16].p.y -= height + heightFromMirror; //设置倒影封面几何体的顶点颜色 pVertices[offset + 16].c.x = dark; pVertices[offset + 16].c.y = dark; pVertices[offset + 16].c.z = dark; } }
渲染封面几何体
为了更好的向大家演示边缘过渡的概念,我们特意把过渡系数调大(即在CoverRenderable类中的createCover()函数中设置mfBorderFraction,这里设置为0.25),最终效果如图6所示:
大家可以很明显的看到每个封面都具有很宽的过渡边缘。由于封面渲染几何体被我们分成了两部分,一部分是完全不透明的中心区域,而另一部分是带透明信息的边缘过渡区域,因此在提交渲染时,我们需要渲染两次。第一次是禁用混合,只渲染不透明的部分;第二次是启用混合(GL_BLEND),并将混合方式设置为glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA),只渲染边缘过渡部分。图7和图8是分别独立渲染的截图:
这样,两次渲染叠加之后,就会最终得到如图6的效果。
在渲染过程中,由于第二次渲染启用了混合操作(GL_BLEND),因此要特别注意渲染的次序问题。对于需要混合的对象,必须要采取从后向前的渲染次序,也就是大家熟悉的画家算法,首先绘制离相机距离远的,最后再绘制离相机距离近的。如果次序错了,就会得到如图9所示的效果:
大家可以看到,由于绘制次序的错误,本来封面的边缘过渡部分,需要和后面的封面像素相融合,但这里由于距离相机近的首先绘制,导致边缘混合时发现更远的位置上并没有填充应有像素,因此只能和黑色的背景色相混合,导致结果错误。因此,在启用混合操作时,渲染的次序尤其重要。
对于同屏显示的众多封面,由于它们所最终渲染的对象实际上是一样的,不用的仅仅是各自的模型变换矩阵,因此只需要创建一份共享的封面几何体渲染对象的实例,在渲染不同位置的封面时,计算好相关的位置以及偏转角度,并设置好相关的封面纹理,最后将渲染数据统一提交即可。
输入事件响应
本例中为了支持用户通过按键或者触屏操作进行左右滑动翻页,重载了GLSurfaceView中的onTouchEvent()以及onKeyDown()。同时为了确保输入响应操作能放在渲染线程中执行,需要调用queueEvent(new Runnable())将左右翻页的操作附加到渲染线程任务队列中。对于触屏操作,我们设置了一个最小响应距离,只有当用户拖动距离超过这个最小距离时,才会触发相应翻页操作。相关代码如下:
- /**
- * 响应触屏事件
- */
- @Override
- public boolean onTouchEvent(MotionEvent e) {
- float x = e.getX();
- switch (e.getAction()) {
- case MotionEvent.ACTION_MOVE:
- float dx = x - mPreviousX;
- if (dx > MIN_DIS) {
- queueEvent( new Runnable() {
- // This method will be called on the rendering
- // thread:
- public void run() {
- mRenderer.slideLeft();
- }});
- } else if (dx < -MIN_DIS) {
- queueEvent( new Runnable() {
- // This method will be called on the rendering
- // thread:
- public void run() {
- mRenderer.slideRight();
- }});
- } else {
- //do nothing
- }
- }
- mPreviousX = x;
- return true ;
- }
- /**
- * 响应按键事件
- */
- @Override
- public boolean onKeyDown( int keyCode, KeyEvent event) {
- if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
- queueEvent( new Runnable() {
- // This method will be called on the rendering
- // thread:
- public void run() {
- mRenderer.slideLeft();
- }});
- return true ;
- } else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
- queueEvent( new Runnable() {
- // This method will be called on the rendering
- // thread:
- public void run() {
- mRenderer.slideRight();
- }});
- return true ;
- }
- return super .onKeyDown(keyCode, event);
/** * 响应触屏事件 */ @Override public boolean onTouchEvent(MotionEvent e) { float x = e.getX(); switch (e.getAction()) { case MotionEvent.ACTION_MOVE: float dx = x - mPreviousX; if(dx > MIN_DIS) { queueEvent(new Runnable() { // This method will be called on the rendering // thread: public void run() { mRenderer.slideLeft(); }}); } else if(dx < -MIN_DIS) { queueEvent(new Runnable() { // This method will be called on the rendering // thread: public void run() { mRenderer.slideRight(); }}); } else { //do nothing } } mPreviousX = x; return true; } /** * 响应按键事件 */ @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { queueEvent(new Runnable() { // This method will be called on the rendering // thread: public void run() { mRenderer.slideLeft(); }}); return true; } else if(keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { queueEvent(new Runnable() { // This method will be called on the rendering // thread: public void run() { mRenderer.slideRight(); }}); return true; } return super.onKeyDown(keyCode, event); }
总结
在本例中,我们通过开发一个3D封面效果演示程序,向大家进一步介绍了OPhone平台中强大的3D特性,包括纹理与顶点色的融合,以及Alpha混合等。借助于OPhone平台,开发者可以开发出更加炫酷的效果,让自己的程序具有更加强大的表现力。
作者介绍
薛永,专注于移动平台3D应用程序的开发,熟悉M3G,JSR 239,OpenGL ES(OPhone&iphone)等多种移动3D开发平台。目前正在自主开发全套3D引擎,包括PC端场景/模型/动画/UI编辑器,3ds max导出插件,面向Java、C++的客户端。同时在制作一款3D射击游戏,到时会面向OPhone、iphone等多个平台发布。