OPhone平台中,3D模块已经成为一项标准配置,而且随着硬件成本的降低,搭配硬件加速图形芯片的移动设备也越来越多地出现在人们的视野当中,手机上的3D再也不是“幻灯片”的代名词。享受着快捷无比的3G网络,在你心爱的OPhone手机上玩着真正的魔兽世界,这也许在不远的未来就会变成现实。本文将以解析渲染MS3D格式的3D模型为例子,介绍OPhone平台中使用OpenGL ES进行3D程序的开发。程序最终效果如图1所示:
图1 程序最终效果图
OPhone中的OpenGL ES简介
OpenGL ES是免授权费的、跨平台的、功能完善的2D和3D图形应用程序接口API,它针对多种嵌入式系统专门设计,由精心定义的桌面OpenGL子集组成,创造了软件与图形加速间灵活强大的底层交互接口。OPhone中目前提供基于OpenGL ES 1.X的应用程序接口,整体与Java ME中的JSR 239 OpenGL ES API类似,但相比之下更为强大和易用。
OPhone中提供的android.opengl.GLSurfaceView辅助类,进一步封装了OpenGL ES与底层视窗系统的交互,开发者可以很方便地进行创建OpenGL ES渲染窗口、重载按键触屏事件响应、设置渲染模式、配置EGL参数等操作。由于GLSurfaceView是独立于系统UI线程之外运行的,因此在系统UI线程挂起或者恢复时,需要显式调用GLSurfaceView中的onPause()或者onResume()来通知底层OpenGL ES模块进行相应处理。下面的代码简单展示了GLSurfaceView的使用方法:
- public class MyGLSurfaceView extends GLSurfaceView {
- private final float TOUCH_SCALE_FACTOR = 180.0f / 320;
- /**
- * 具体实现的渲染器
- */
- private OPhoneOglesDevRenderer mRenderer;
- /**
- * 记录上次触屏位置的坐标
- */
- private float mPreviousX, mPreviousY;
- public MyGLSurfaceView(Context context) {
- super(context);
- // 设置渲染器
- mRenderer = new OPhoneOglesDevRenderer(context);
- setRenderer(mRenderer);
- // 设置渲染模式为主动渲染
- setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
- }
- public void onPause() {
- super.onPause();
- }
- public void onResume() {
- super.onResume();
- }
- /**
- * 响应触屏事件
- */
- @Override
- public boolean onTouchEvent(MotionEvent e) {
- float x = e.getX();
- float y = e.getY();
- switch (e.getAction()) {
- case MotionEvent.ACTION_MOVE:
- float dx = x - mPreviousX;
- float dy = y - mPreviousY;
- mRenderer.mAngleX += dx * TOUCH_SCALE_FACTOR;
- mRenderer.mAngleY += dy * TOUCH_SCALE_FACTOR;
- requestRender();
- }
- mPreviousX = x;
- mPreviousY = y;
- return true;
- }
- }
复制代码
OpenGL ES开发简要框架
开发OpenGL ES程序,首要做的就是设置视口,设置投影矩阵,设置模型视图矩阵等。对于设置模型视图矩阵,我们通常会分别设置相机矩阵和模型矩阵。对于一些全局性的设置,我们通常只需要执行一次;而对于那些需要动态改变的属性,则应该在相应事件发生时或者逐帧进行动态更新。GLSurfaceView.Renderer接口提供了监视绘图表面创建、改变以及逐帧更新的方法,分别是:
- /**
- * 创建绘图表面时调用
- */
- @Override
- public void onSurfaceCreated(GL10 gl, EGLConfig config)
- /**
- * 当绘图表面尺寸发生改变时调用
- */
- @Override
- public void onSurfaceChanged(GL10 gl, int width, int height)
- /**
- * 逐帧渲染
- */
- @Override
- public void onDrawFrame(GL10 gl)
复制代码
通常,我们在onSurfaceCreated()中通过调用glHint()函数来设置渲染质量与速度的平衡,设置清屏颜色,着色模型,启用背面剪裁和深度测试,以及禁用光照和混合等全局性设置。相关代码如下:
- public void onSurfaceCreated(GL10 gl, EGLConfig config) {
- //全局性设置
- gl.glDisable(GL10.GL_DITHER);
-
- gl.glHint(GL10.GL_PERSPECTIVE_CORRECTION_HINT, GL10.GL_FASTEST);
- //设置清屏背景颜色
- gl.glClearColor(0.5f, 0.5f, 0.5f, 1);
- //设置着色模型为平滑着色
- gl.glShadeModel(GL10.GL_SMOOTH);
-
- //启用背面剪裁
- gl.glEnable(GL10.GL_CULL_FACE);
- gl.glCullFace(GL10.GL_BACK);
- //启用深度测试
- gl.glEnable(GL10.GL_DEPTH_TEST);
- //禁用光照和混合
- gl.glDisable(GL10.GL_LIGHTING);
- gl.glDisable(GL10.GL_BLEND);
- }
复制代码
在onSurfaceChanged中,我们会根据绘图表面尺寸的改变,来即时改变视口大小,以及重新设置投影矩阵。相关代码如下:
- public void onSurfaceChanged(GL10 gl, int width, int height) {
- //设置视口
- gl.glViewport(0, 0, width, height);
-
- //设置投影矩阵
- float ratio = (float) width / height;//屏幕宽高比
- gl.glMatrixMode(GL10.GL_PROJECTION);
- gl.glLoadIdentity();
- GLU.gluPerspective(gl, 45.0f, ratio, 1, 5000);
- //每次修改完GL_PROJECTION后,最好将当前矩阵模型设置回GL_MODELVIEW
- gl.glMatrixMode(GL10.GL_MODELVIEW);
- }
复制代码
在onDrawFrame中,需要编写的是每帧实际渲染的代码,包括清屏,设置模型视图矩阵,渲染模型,以及相应的update函数。相关代码如下:
- public void onDrawFrame(GL10 gl) {
- //一般的opengl程序,首先要做的就是清屏
- gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT);
-
- //紧接着设置模型视图矩阵
- setUpCamera(gl);
-
- //渲染物体
- drawModel(gl);
-
- //更新时间
- updateTime();
- }
复制代码
设置模型视图矩阵(即GL_MODELVIEW矩阵)时,我们通常分别设置相机和物体矩阵。在设置相机矩阵时,我们可以通过调用
GLU.gluLookAt (GL10 gl, float eyeX, float eyeY, float eyeZ, float centerX, float centerY, float centerZ, float upX, float upY, float upZ) 传入视点位置(eyeX, eyeY, eyeZ)、被观察体的中心位置(centerX, centerY, centerZ)以及相机向上方向的向量(upX, upY, upZ)。相关代码如下:
- /**
- * 设置相机矩阵
- * @param gl
- */
- private void setUpCamera(GL10 gl) {
- gl.glMatrixMode(GL10.GL_MODELVIEW);
- gl.glLoadIdentity();
- GLU.gluLookAt(gl, mfEyeX, mfEyeY, mfEyeZ, mfCenterX, mfCenterY, mfCenterZ, 0, 1, 0);
- }
复制代码
OpenGL ES中采用的是矩阵堆栈体系。对于模型视图矩阵,堆栈深度至少为16;对于投影矩阵或者纹理矩阵,则至少为2。由于OpenGL ES中的矩阵操作,都是针对当前栈顶的矩阵,因此很多时候需要配对使用glPushMatrix()和glPopMatrix()来进行保存和恢复矩阵现场。在本例中,渲染模型之前,我们首先使用glPushMatrix()来复制当前模型视图矩阵,并将其推入到栈顶,之后所有的矩阵操作均针对该矩阵。然后我们通过调用glRotate()函数,进行适当的旋转,在渲染模型完毕之后,通过调用glPopMatrix()将当前矩阵弹出,恢复之前的矩阵现场。相关代码如下:
- /**
- * 渲染模型
- * @param gl
- */
- private void drawModel(GL10 gl) {
- gl.glPushMatrix();
- {
- //首先对模型进行旋转
- gl.glRotatef(mfAngleX, 1, 0, 0);//绕X轴旋转
- gl.glRotatef(mfAngleY, 0, 1, 0);//绕Y轴旋转
- if(mModel.containsAnimation()) {
- //如果模型有动画,那么按时间就更新动画
- if (mMsPerFrame > 0) {
- mModel.animate(mMsPerFrame * 0.001f);//将毫秒数转化为秒, /1000
- }
- mModel.fillRenderBuffer();//更新顶点缓存
- }
- mModel.render(gl);//渲染模型
- mModel.renderJoints(gl);//渲染关节,骨骼
- }
- gl.glPopMatrix();
- }
复制代码
OpenGL ES中支持三种渲染图元:点(GL_POINTS)、线(GL_LINES)和三角形(GL_TRIANGLES)。在本例子中,模型实体采用三角形渲染(对应函数mModel.render(gl)),而对于有骨骼信息的模型,会使用点和线来渲染骨骼辅助信息(对应函数mModel.renderJoints(gl))。OpenGL ES抛弃了OpenGL中传统但低效的glBegin()、glEnd()的渲染方式,采用了更为高效的批量渲染模式,使用java.nio.Buffer对象来存储渲染数据,之后通过调用glVertexPointer()、glNormalPointer()、glColorPointer()以及glTextureCoordPointer()传入Buffer对象来分别设置顶点位置、法线、颜色和纹理坐标渲染数据。在设置渲染数据的同时,需要通过调用glEnableClientState()函数,分别传入GL_VERTEX_ARRAY、GL_NORMAL_ARRAY、GL_COLOR_ARRAY和GL_TEXTURE_COORD_ARRAY来通知底层引擎启用相应渲染属性数据。这四个渲染属性并非要全部设置,而是可以根据需要只是启用其中的某几个。在本例中,渲染模型实体时,仅启用了顶点位置数据和纹理坐标数据;在渲染点线的骨骼辅助信息时,则仅仅启用了顶点位置数据。对于那些没有被启用的渲染属性,必须要确保其当前处于为非活动状态(即调用glDisableClientState()),否则就可能会对渲染结果造成一定影响,或者白白加重底层管线运算负担。
另外需要注意的是OPhone中要传入gl*Pointer()函数的Buffer对象必须要为direct模式申请的,这样可以确保缓存对象放置在Native的堆中,以免受到Java端的垃圾回收机制的影响。对于FloatBuffer、ShortBuffer和IntBuffer等多字节的缓存对象,它们的字节顺序必须设置为nativeOrder,否则会极大降低程序执行效率。
在设置好各个渲染属性的数据之后,就要通过调用glDrawArrays()或者glDrawElements()来进行数据的最终提交渲染。前者表示传入的数据是最终要渲染的数据,可以直接渲染,而后者会根据传入的索引,由底层重组最终要真正渲染的数据。相比之下,后者可以节省更多的内存。下面的代码展示了以三角形来渲染模型实体,启用顶点位置数据和纹理坐标数据,未启用法线和颜色数据:
- /**
- * 渲染实体模型
- * @param gl
- */
- public void render(GL10 gl) {
- gl.glPushMatrix();
- {
- //设置默认颜色
- gl.glColor4f(1.0f, 0.5f, 0.5f, 1.0f);
-
- //启用客户端状态
- gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
-
- //遍历所有的MS3D Group,渲染每一个Group
- for (int i = 0; i < mpGroups.length; i++) {
- if (mpGroups[i].getTriangleCount() == 0) {
- //如果该Group包含的三角形个数为零,则直接跳过
- continue;
- }
- //得到相应纹理
- TextureInfo tex = mpTexInfo[i % mpTexInfo.length];
-
- if (tex != null) {
- //如果纹理不为空,则绑定相应纹理
- gl.glBindTexture(GL10.GL_TEXTURE_2D, tex.mTexID);
- //启用纹理贴图
- gl.glEnable(GL10.GL_TEXTURE_2D);
- //绑定纹理坐标数据
- gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
- gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0,
- mpBufTextureCoords[i]);
- } else {
- //如果纹理为空,禁用纹理贴图
- //禁用纹理客户端状态
- gl.glDisable(GL10.GL_TEXTURE_2D);
- gl.glDisableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
- }
- //绑定顶点数据
- gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mpBufVertices[i]);
- //提交渲染
- gl.glDrawArrays(GL10.GL_TRIANGLES, 0, mpGroups[i]
- .getTriangleCount() * 3);
- }
- //渲染完毕,重置客户端状态
- gl.glDisableClientState(GL10.GL_VERTEX_ARRAY);
- gl.glDisableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
- gl.glDisable(GL10.GL_TEXTURE_2D);
- }
- gl.glPopMatrix();
- }
复制代码
程序中渲染骨骼关节辅助信息的部分,就是以点和线的模型进行渲染,相关代码如下
- /**
- * 渲染骨骼帮助信息
- * @param gl
- */
- public void renderJoints(GL10 gl) {
- if(!containsJoint()) {
- return;
- }
- //为保证骨骼始终可见,暂时禁用深度测试
- gl.glDisable(GL10.GL_DEPTH_TEST);
- //设置点和线的宽度
- gl.glPointSize(4.0f);
- gl.glLineWidth(2.0f);
- //仅仅启用顶点数据
- gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
-
- //渲染骨骼连线
- gl.glColor4f(1.0f, 0.0f, 0.0f, 1.0f);//设置颜色
- gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mBufJointLinePosition);
- //提交渲染
- gl.glDrawArrays(GL10.GL_LINES, 0, mJointLineCount);
-
- //渲染关节点
- gl.glColor4f(1.0f, 1.0f, 0.0f, 1.0f);//设置颜色
- gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mBufJointPointPosition);
- //提交渲染
- gl.glDrawArrays(GL10.GL_POINTS, 0, mJointPointCount);
-
- //重置回默认状态
- gl.glDisableClientState(GL10.GL_VERTEX_ARRAY);
- gl.glPointSize(1.0f);
- gl.glLineWidth(1.0f);
- gl.glEnable(GL10.GL_DEPTH_TEST);
- }
复制代码
纹理操作
在前面的代码中,我们看到了启用、绑定纹理等操作。纹理映射是3D中非常重要的一块,如果没有纹理,整个3D世界就会只是一些单纯的色块。OPhone中目前支持2D纹理映射(贴图尺寸必须要为2的N次方),并支持2个以上的纹理贴图单元。由于纹理数据存储在OpenGL ES服务器端(可以理解为GPU端,即Graphics Process Unit,图形处理单元),因此需要我们从客户端(即外部的应用程序端)将像素数据传入,由底层将这些像素转换成更为高效的、对硬件更为友好的纹素格式。OpenGL ES中的每一个纹理都被当作一个纹理对象,它除了包括纹理像素数据之外,还包括该纹理的其他属性,比如名字、过滤模式、混合模式等。开发者需要首先向底层申请一个纹理名称,之后上传纹理像素数据,以及设置其他属性。下面的代码向我们展示了如何在OPhone中创建一个纹理对象:
- /**
- * 创建一个纹理对象
- * @param context - 应用程序环境
- * @param gl - opengl es对象
- * @param resID - R.java中的资源ID
- * @param wrap_s_mode - 纹理环绕S模式
- * @param wrap_t_mode - 纹理环绕T模式
- * @return 申请好的纹理ID
- */
- public static int getTexture(Context context, GL10 gl, int resID,
- int wrap_s_mode, int wrap_t_mode) {
- //申请一个纹理对象ID
- int[] textures = new int[1];
- gl.glGenTextures(1, textures, 0);
- //绑定这个申请来的ID为当前纹理操作对象
- int textureID = textures[0];
- gl.glBindTexture(GL10.GL_TEXTURE_2D, textureID);
- //设置当前纹理对象的过滤模式
- gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER,
- GL10.GL_NEAREST);
- 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,
- wrap_s_mode);
- gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_T,
- wrap_t_mode);
- //设置混合模式
- gl.glTexEnvf(GL10.GL_TEXTURE_ENV, GL10.GL_TEXTURE_ENV_MODE,
- GL10.GL_REPLACE);
-
- //开始载入纹理
- InputStream is = context.getResources().openRawResource(resID);
- Bitmap bitmap;
- try {
- bitmap = BitmapFactory.decodeStream(is);
- } finally {
- try {
- is.close();
- } catch (IOException e) {
- // Ignore.
- }
- }
-
- //绑定像素数据到纹理对象
- GLUtils.texImage2D(GL10.GL_TEXTURE_2D, 0, bitmap, 0);
- bitmap.recycle();
- return textureID;
- }
复制代码
创建好纹理对象之后,在使用时,需要首先通过调用gl.glEnable(GL10.GL_TEXTURE_2D)来通知底层开启纹理贴图操作,之后绑定相应的纹理ID到当前纹理贴图单元,同时通过调用glTexCoordPointer()来设置好相应的纹理坐标信息,最终提交渲染时,底层就会自动进行纹理映射操作。当纹理不再被使用时,可以通过调用glDeleteTextures()来将其删除。
输入事件响应
我们可以重载GLSurfaceView的onTouchEvent()方法,从而监测用户对屏幕的触摸事件。本例中,我们根据触摸位置的改变,来对模型进行绕Y轴和X轴的旋转。如果有需要,开发者还可以重载键盘按键onKeyDown()方法。值得注意的是,由于这些事件和渲染线程是分别独立的线程,因此有些操作如果需要确保在渲染线程内部执行的话,可以调用queueEvent (Runnable)来将该操作附加到渲染线程操作队列中。相关代码如下:
- /**
- * 响应触屏事件
- */
- @Override
- public boolean onTouchEvent(MotionEvent e) {
- float x = e.getX();
- float y = e.getY();
- switch (e.getAction()) {
- case MotionEvent.ACTION_MOVE:
- float dx = x - mPreviousX;
- float dy = y - mPreviousY;
- mRenderer.mfAngleY += dx * TOUCH_SCALE_FACTOR;
- mRenderer.mfAngleX += dy * TOUCH_SCALE_FACTOR;
- requestRender();
- }
- mPreviousX = x;
- mPreviousY = y;
- return true;
- }
复制代码
MS3D模型的解析和渲染
MilkShape3D是一款轻量级的3D建模软件,最早是为《半条命》游戏的模型制作而开发的,随着越来越多的特性的添加,其功能也逐渐强大,开发者可以从其官方网站(http://chumbalum.swissquake.ch/)获得评估版本。它自带的模型格式为MS3D格式,包括二进制版本和TXT版本,本例中我们解析的是二进制版。MS3D格式简单紧凑,支持骨骼动画,非常适合初学者学习和使用。MS3D格式由头信息(Header)、顶点数据(Vertices)、三角形数据(Triangles)、分组信息(Groups)、材质信息(Materials)、骨骼关节数据(Joints)以及动画播放信息等部分组成。载入时,根据模型格式白皮书,读取并解析相应数据即可。需要注意的是由于MS3D中默认数据字节序为Little-Endian,因此我们构建了一个专门的LittleEndianDataInputStream来确保不同平台下Java端数据的正确读取。由于篇幅关系,这里将不详细介绍模型载入模块,具体请参考附带源码,这里仅介绍下如何调用现有代码读取一个MS3D模型:
- /**
- * 载入模型
- * @param gl
- * @param idxModel - 模型资源索引
- * @param pIdxTex - 纹理数组
- */
- private void loadModel(GL10 gl, int idxModel, int[] pIdxTex) {
- try {
- TextureInfo[] pTexInfos = new TextureInfo[pIdxTex.length];
- mModel = new IMS3DModel();
-
- //打开模型二进制流
- InputStream is = mContext.getResources().openRawResource(idxModel);
-
- if(mModel.loadModel(is)) {
- //载入模型成功,开始载入纹理
- for(int i = 0; i < pTexInfos.length; i++) {
- pTexInfos[i] = new TextureInfo();
- //得到创建成功的纹理对象名称
- pTexInfos[i].mTexID = TextureFactory.getTexture(mContext, gl, pIdxTex[i]);
- }
- //赋予纹理
- mModel.setTexture(pTexInfos);
- } else {
- System.out.println("Load Model Failed. IdxModel:" + idxModel);
- }
-
- is.close();
- } catch(Exception ex) {
- ex.printStackTrace();
- }
- }
复制代码
在载入模型过程中,我们会计算模型初始绑定盒以及绑定球,以便当载入完成后将相机放置于合适位置从而完全观察到模型本身。下面的代码展示了一种较通用的处理方式,将模型放置在(0,0,0)位置,计算好相机位置以及视点朝向位置后,调用GLU.lookAt()函数来计算模型视图矩阵:
- mfEyeX = mModel.getSphereCenter().x;
- mfEyeY = mModel.getSphereCenter().y;
- mfEyeZ = mModel.getSphereCenter().z + mModel.getSphereRadius() * 2.8f;
- mfCenterX = mfCenterZ = 0;
- mfCenterY = mfEyeY;
- GLU.gluLookAt(gl, mfEyeX, mfEyeY, mfEyeZ, mfCenterX, mfCenterY, mfCenterZ, 0, 1, 0);
复制代码
对于每个MS3D模型,都包含一个顶点池(包括了模型所有的顶点),一个三角形池(包括了模型所有的三角形),若干个模型分组(Group),一个材质池(Material)以及骨骼关节池(Joints)等。一个模型可能会有多个分组,比如一个人物模型,头部可能会是一个单独的分组。每个分组需要单独渲染,渲染一个MS3D模型,实际上就是渲染这个模型的所有分组。由于OPhone中的OpenGL ES是采用java.nio.Buffer的形式进行渲染的,因此我们需要为每个分组构建对应的Buffer数据,包括顶点位置缓存、顶点纹理坐标缓存、顶点法线缓存以及顶点颜色缓存。一般来说,法线用于光照计算,顶点颜色用于同纹理的混合等特殊操作,本例中我们没有相关的需求,因此只是创建了顶点位置缓存和顶点纹理坐标缓存。对于静态模型,这两种缓存只需要创建一次即可;而对于包含动画信息的模型,随着每帧时间更新,顶点的位置可能会随时改变,因此顶点位置缓存也需要同时更新,而顶点纹理坐标数据,除非是启用了纹理动画,否则不需要实时更新。在填充顶点位置缓存时,根据每个模型分组(MS3DGroup)内的三角形索引,找到对应的三角形(MS3DTriangle),然后再根据三角形内顶点的索引,找到对应的顶点(MS3DVertex),从而得到顶点位置信息。如果模型包含骨骼动画信息,那么需要根据顶点相关联的骨骼索引和权重等信息进行蒙皮计算,得到当前时间的顶点位置,然后写入到顶点位置缓存中。相关代码如下:
- /**
- * 填充渲染缓存数据
- */
- public void fillRenderBuffer() {
- if(!mbDirtFlag) {
- //如果模型数据没有更新,那么就无需重新填充
- return;
- }
- Vector3f position = null;
- //遍历所有Group
- for (int i = 0; i < mpGroups.length; i++) {
- //获得该Group内所有的三角形索引
- int[] indexes = mpGroups[i].getTriangleIndicies();
- mpBufVertices[i].position(0);
- int vertexIndex = 0;
- //遍历每一个三角形
- for (int j = 0; j < indexes.length; j++) {
- //从三角形池内找到对应三角形
- MS3DTriangle triangle = mpTriangles[indexes[j]];
- //遍历三角形的每个顶点
- for (int k = 0; k < 3; k++) {
- //从顶点池中找到相应顶点
- MS3DVertex vertex = mpVertices[triangle
- .getVertexIndicies()[k]];
- //获得最新的位置
- //如果模型带骨骼,那么就是当前的变换后的位置
- //否则就是初始位置
- //具体的变换过程请参考animate(float timedelta)函数
- position = vertex.mvTransformedLocation;
- //填充顶点位置信息到缓存中
- mpBufVertices[i].put(position.x);
- mpBufVertices[i].put(position.y);
- mpBufVertices[i].put(position.z);
- }
- }
- mpBufVertices[i].position(0);
- }
- mbDirtFlag = false;
- }
复制代码
MS3D的骨骼系统
MS3D的一个顶点最多支持被4个骨骼所影响,这对于移动设备来讲已经够用了,实际上大部分顶点可能只被1个骨骼所影响。MS3DLib中骨骼相关的类有MS3DJoint和Joint,前者用于模型数据的读取,后者用于实时骨骼计算,模型载入时读取MS3DJoint完毕后会自动转换为Joint来更方便的进行计算。在Joint类中,存储着该骨骼关节的动画帧序列,包括每帧的位置偏移量和旋转量。当动画时间更新时,通过传入的时间查找到最接近的前后两帧,之后根据插值得到当前时间的位置偏移量和旋转量合成变换矩阵(Matrix)。由于骨骼系统的层级关系,因此如果该关节有父节点,那么则需要乘以父关节的矩阵,以得到该关节的最终矩阵。由于我们在载入模型关节数据时,已经确保了最上层的关节处于关节数组的前列,因此当后面的子关节更新时,它的父节点已经被更新过,所以可以直接相乘。当所有骨骼关节的最终矩阵更新完毕之后,需要更新模型的全部顶点,根据每个顶点所关联的骨骼索引和权重信息,计算出顶点当前时刻的位置信息,之后在fillRenderBuffer()函数中填入当前的顶点位置缓存以用于提交渲染。相关代码如下:
- /**
- * 根据时间来更新模型动画
- *
- * @param timedelta - 本次tick时间
- */
- public void animate(float timedelta) {
- //累加时间
- mCurrentTime += timedelta;
- if (mCurrentTime > mTotalTime) {
- mCurrentTime = 0.0f;
- }
- //首先要更新每个骨骼节点的当前位置信息
- for (int i = 0; i < mpJoints.length; i++) {
- Joint joint = mpJoints[i];
- //如果不包含动画信息那就无需更新
- if (joint.mNumTranslationKeyframes == 0
- && joint.mNumRotationKeyframes == 0) {
- joint.mMatGlobal.set(joint.mMatJointAbsolute);
- continue;
- }
- //开始进行插值计算
- //首先进行旋转插值
- Matrix4f matKeyframe = getJointRotation(i, mCurrentTime);
- //进行偏移的线性插值
- matKeyframe.setTranslation(getJointTranslation(i, mCurrentTime));
- //乘以节点本身的相对矩阵
- matKeyframe.mul(joint.mMatJointRelative, matKeyframe);
-
- //乘以父矩阵,得到最终矩阵
- if (joint.mParentId == -1) {
- joint.mMatGlobal.set(matKeyframe);
- } else {
- matKeyframe.mul(mpJoints[joint.mParentId].mMatGlobal,
- matKeyframe);
- joint.mMatGlobal.set(matKeyframe);
- }
- }
- //更新点线渲染的骨骼帮助信息
- updateJointsHelper();
- //开始更新每个顶点
- for (int i = 0, n = mpVertices.length; i < n; i++) {
- MS3DVertex vertex = mpVertices[i];
- if (vertex.getBoneID() == -1) {
- //如果该顶点不受骨骼影响,那么就无需计算
- vertex.mvTransformedLocation.set(vertex.getLocation());
- } else {
- //通过骨骼运算,得到顶点的当前位置
- transformVertex(vertex);
- }
- }
-
- mbDirtFlag = true;
- }
复制代码
在上面的插值运算中,位置偏移的插值和旋转的插值是分开处理的。对于以向量(x, y, z)表示的位移,这里我们使用的就是简单的线性插值方式,如果对插值质量要求更高、需要效果更平滑,可以采用Hermite样条插值等方式。对于关节的旋转,这里是采用四元数表示的,四元数最大的优点就是便于球面插值,当我们利用四元数进行完插值计算之后,需要把四元数转换为相应的旋转矩阵。相关代码如下:
- /**
- * 根据传入的时间,返回插值后的位置信息
- *
- * @param frames
- * 偏移量关键帧数组
- * @param time
- * 目标时间
- * @return 插值后的位置信息
- */
- private Vector3f lerpKeyframeLinear(Keyframe[] frames, float time) {
- int frameIndex = 0;
- int numFrames = frames.length;
- //这里可以使用二分查找进行优化
- while (frameIndex < numFrames && frames[frameIndex].mfTime < time) {
- ++frameIndex;
- }
-
- //首先处理边界情况
- Vector3f parameter = tmpVectorLerp;
- if (frameIndex == 0) {
- parameter.set(frames[0].mvParam.x, frames[0].mvParam.y,
- frames[0].mvParam.z);
- } else if (frameIndex == numFrames) {
- parameter.set(frames[numFrames - 1].mvParam.x,
- frames[numFrames - 1].mvParam.y,
- frames[numFrames - 1].mvParam.z);
- } else {
- int prevFrameIndex = frameIndex - 1;
- //得到临近两帧
- Keyframe right = frames[frameIndex];
- Keyframe left = frames[prevFrameIndex];
- //计算插值因子
- float timeDelta = right.mfTime - left.mfTime;
- float interpolator = (time - left.mfTime) / timeDelta;
- //进行简单的线性插值
- parameter.interpolate(left.mvParam, right.mvParam, interpolator);
- }
- return parameter;
- }
- /**
- * 根据传入的时间,计算插值后的旋转量
- *
- * @param frames
- * 旋转量关键帧数组
- * @param time
- * 目标时间
- * @return 插值后的旋转量数据
- */
- private Quat4f lerpKeyframeRotate(Keyframe[] frames, float time) {
- Quat4f quat = tmpQuatLerp;
- int frameIndex = 0;
- int numFrames = frames.length;
-
- //这里可以使用二分查找进行优化
- while (frameIndex < numFrames && frames[frameIndex].mfTime < time) {
- ++frameIndex;
- }
- //首先处理边界情况
- if (frameIndex == 0) {
- quat.set(frames[0].mvParam);
- } else if (frameIndex == numFrames) {
- quat.set(frames[numFrames - 1].mvParam);
- } else {
- int prevFrameIndex = frameIndex - 1;
- //找到最邻近的两帧
- Keyframe right = frames[frameIndex];
- Keyframe left = frames[prevFrameIndex];
- //计算好插值因子
- float timeDelta = right.mfTime - left.mfTime;
- float interpolator = (time - left.mfTime) / timeDelta;
- //进行四元数插值
- Quat4f quatRight = tmpQuatLerpRight;
- Quat4f quatLeft = tmpQuatLerpLeft;
- quatRight.set(right.mvParam);
- quatLeft.set(left.mvParam);
- quat.interpolate(quatLeft, quatRight, interpolator);
- }
- return quat;
- }
复制代码
有关矩阵、四元数以及向量等数学知识背景,请参考相关3D数学基础书籍,这里限于篇幅不做过多的介绍。
程序介绍
在本程序中,附带了若干个MS3D模型,有静态的,也有带骨骼动画的,如图2所示。
图2 内置MS3D模型列表
在选项中可以设置是否渲染骨骼节点辅助信息,以及是否自动播放动画,见图3。当进入模型渲染界面后,可以通过触屏拖拉以旋转模型。
本程序所有的源码都会发布在ophonesdn上,项目主页:http://www.ophonesdn.com/projectDetail/show/382
开发者联系方式(MSN&EMail): xueyong@live.com
图3 程序选项界面
更多截图
总结
本文通过对MS3D模型的解析和渲染,向大家介绍了OPhone平台下使用OpenGL ES进行3D开发的基本概念以及输入事件响应,同时也介绍了3D中的骨骼、动画等高级话题。这样就构成了一个小型的OPhone 3D程序开发框架,读者可以根据自己的需要对其进行进一步的完善。
作者介绍
薛永,专注于移动平台3D应用程序的开发,熟悉M3G,JSR 239,OpenGL ES(OPhone&iphone)等多种移动3D开发平台。目前正在自主开发全套3D引擎,包括PC端场景/模型/动画/UI编辑器,3ds max导出插件,面向Java、C++的客户端。同时在制作一款3D射击游戏,到时会面向OPhone、iphone等多个平台发布。
|