十一、创建三维转轮菜单
除了第 5 章、介绍 3D 自定义视图我们解释了如何使用 OpenGL ES 构建自定义视图之外,本书的所有其他示例都使用了Canvas
类中可用的 2D 绘制方法。在最后两章中,我们已经看到了如何构建稍微复杂一点的自定义视图,但是它们都没有使用任何 3D 渲染技术。因此,在本章中,我们将展示如何构建和定制一个完整的定制三维视图,以及如何与之交互。
更详细地,我们将在本章中介绍以下内容:
- 向三维自定义视图添加交互
- 添加一个
GestureDetector
来管理复杂的手势 - 使用
scroller
管理滚动和投掷手势 - 将文本渲染成纹理并在 OpenGL ES 上绘制
- 通过编程生成几何图形
创建交互式三维自定义视图
在第 5 章、介绍 3D 自定义视图,我们看到了如何使用 OpenGL ES 创建一个非常简单的旋转立方体。从这个例子开始,通过添加一种对用户交互做出反应的方式,我们可以创建一个更加复杂和交互式的定制视图的基础。
添加交互
让我们从使用Example25-GLDrawing
的代码开始。正如我们在前面的例子中已经看到的,处理用户交互非常简单。我们不需要做任何与以前不同的事情,只需覆盖我们类中的onTouchEvent()
方法扩展GLSurfaceView
,并对我们将收到的不同运动事件做出适当的反应。例如,如果我们在收到MotionEvent.ACTION_DOWN
时没有返回true
,我们将不会收到任何进一步的事件,因为我们基本上是在说我们没有处理该事件。
有了示例的源代码后,让我们添加一个跟踪拖动事件的onTouchEvent()
的简单实现:
private float dragX;
private float dragY;
@Override
public boolean onTouchEvent(MotionEvent event) {
switch(event.getAction()) {
case MotionEvent.ACTION_DOWN:
dragX = event.getX();
dragY = event.getY();
getParent().requestDisallowInterceptTouchEvent(true);
return true;
case MotionEvent.ACTION_UP:
getParent().requestDisallowInterceptTouchEvent(false);
return true;
case MotionEvent.ACTION_MOVE:
float newX = event.getX();
float newY = event.getY();
angleTarget -= (dragX - newX) / 3.f;
dragX = newX;
dragY = newY;
return true;
default:
return false;
}
}
我们将使用拖动量来改变立方体的旋转角度,正如我们将在下面的代码片段中看到的。此外,在本章的后面,我们将看到如何使用scroller
类来制作这个动画,但是,目前,让我们使用一个固定的时间步长机制:
private float angle = 0.f;
private float angleTarget = 0.f;
private float angleFr = 0.f;
private void animateLogic() {
long currentTime = SystemClock.elapsedRealtime();
accTime += currentTime - timeStart;
timeStart = currentTime;
while (accTime > TIME_THRESHOLD) {
angle += (angleTarget - angle) / 4.f;
accTime -= TIME_THRESHOLD;
}
float factor = ((float) accTime) / TIME_THRESHOLD;
float nextAngle = angle + (angleTarget - angle) / 4.f;
angleFr = angle * (1.f - factor) + nextAngle * factor;
}
它使用与我们在前面例子中所做的相同的原理,每TIME_THRESHOLD
毫秒执行一次逻辑。立方角值将在当前状态和下一个状态之间进行插值,具体取决于执行下一个逻辑刻度的剩余时间。该插值将存储在angleFr
变量中。
我们也对onSurfaceChanged
做了一些修改,使用透视投影模式,而不是使用Matrix.frustrumM
。后者定义了六个剪裁平面:近、远、上、下、左和右。然而,使用Matrix.perspective
允许我们根据相机视场角和两个剪裁平面来定义投影矩阵:近和远。在某些情况下可能更容易,但最终,这两种方法实现了相同的目标:
@Override
public void onSurfaceChanged(GL10 unused, int width, int height) {
GLES20.glViewport(0, 0, width, height);
float ratio = (float) width / height;
Matrix.perspectiveM(mProjectionMatrix, 0, 90, ratio, 0.1f, 7.f);
}
最后,我们必须对onDrawFrame()
方法进行一些更改:
@Override
public void onDrawFrame(GL10 unused) {
animateLogic();
GLES20.glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);
Matrix.setLookAtM(mViewMatrix, 0,
0, 0, -3,
0f, 0f, 0f,
0f, 1.0f, 0.0f);
Matrix.multiplyMM(mMVPMatrix, 0, mProjectionMatrix, 0, mViewMatrix, 0);
Matrix.rotateM(mMVPMatrix, 0, angleFr, 0.f, 1.f, 0.f);
Matrix.rotateM(mMVPMatrix, 0, 5.f, 1.f, 0.f, 0.f);
GLES20.glUseProgram(shaderProgram);
int positionHandle = GLES20.glGetAttribLocation(shaderProgram, "vPosition");
GLES20.glVertexAttribPointer(positionHandle, 3,
GLES20.GL_FLOAT, false,
0, vertexBuffer);
int texCoordHandle = GLES20.glGetAttribLocation(shaderProgram, "aTex");
GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false,
0, texBuffer);
int mMVPMatrixHandle = GLES20.glGetUniformLocation(shaderProgram,
"uMVPMatrix");
GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mMVPMatrix, 0);
int texHandle = GLES20.glGetUniformLocation(shaderProgram, "sTex");
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
GLES20.glUniform1i(texHandle, 0);
GLES20.glEnable(GLES20.GL_DEPTH_TEST);
GLES20.glEnableVertexAttribArray(texHandle);
GLES20.glEnableVertexAttribArray(positionHandle);
GLES20.glDrawElements(
GLES20.GL_TRIANGLES, index.length,
GLES20.GL_UNSIGNED_SHORT, indexBuffer);
GLES20.glDisableVertexAttribArray(positionHandle);
GLES20.glDisableVertexAttribArray(texHandle);
GLES20.glDisable(GLES20.GL_DEPTH_TEST);
}
基本上,我们要做的改变是调用animateLogic()
方法来执行任何未决的逻辑勾号,并使用内插的angleFr
变量作为旋转角度。如果我们运行这个例子,我们将得到和在Example25
中一样的立方体,但是,在这种情况下,我们可以通过在屏幕上水平拖动来控制动画。我们还必须记住,在从GLSurfaceView
扩展我们的类时,没有必要调用invalidate
或postInvalidate
,除非特别指明,否则屏幕将不断被重绘。
改进交互和动画
我们一直在使用固定的时间步长机制来管理动画,但让我们看看使用安卓提供的scroller
类来处理动画,而不是自己处理所有的动画,会给我们带来什么好处。
首先,让我们创建一个GestureDetector
实例来处理触摸事件:
private GestureDetectorCompat gestureDetector =
new GestureDetectorCompat(context, new MenuGestureListener());
我们正在使用支持库中的GestureDetectorCompat
来保证在旧版本的安卓上有相同的行为。
正如我们在第三章、处理事件中所述,通过引入GestureDetector
我们可以大大简化我们的onTouchEvent()
,因为所有的逻辑都将由MenuGestureListener
回调处理,而不是在onTouchEvent()
上:
@Override
public boolean onTouchEvent(MotionEvent event) {
return gestureDetector.onTouchEvent(event);
}
gestureDetector
需要一个OnGestureListener
的实现,但是如果我们只想实现一些方法,而不用担心接口暴露的其他方法,我们可以从GestureDetector.SimpleOnGestureListener
扩展,只覆盖我们需要的方法。对于在OnGestureListener
接口中暴露的所有方法,GestureDetector.SimpleOnGestureListener
类都有一个空的虚拟实现。
SimpleOnGestureListener
还实现了其他接口,让我们作为软件工程师的生活变得更加轻松,但更多信息请参考安卓文档。
然后让我们创建自己的内部类MenuGestureListener
,从GestureDetector.SimpleOnGestureListener
开始扩展:
class MenuGestureListener extends
GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDown(MotionEvent e) {
scroller.forceFinished(true);
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float
distanceX,
float distanceY) {
scroller.computeScrollOffset();
int lastX = scroller.getCurrX();
scroller.forceFinished(true);
scroller.startScroll(lastX, 0, -(int) (distanceX + 0.5f), 0);
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float
velocityX, float velocityY) {
scroller.computeScrollOffset();
int lastX = scroller.getCurrX();
scroller.forceFinished(true);
scroller.fling(lastX,
0,
(int) (velocityX/4.f),
0,
-360*100,
360*100,
0,
0);
return true;
}
}
正如我们之前提到的,即使是OnGestureListener
实现,我们也必须在onDown()
方法上返回true
。否则,只要有滚动或抛出事件,我们的OnGestureListener
实现中的onScroll()
或onFling()
方法就不会被调用。
无论如何,我们在onDown()
方法上还有一些工作要做:我们必须停止任何正在运行的动画,这样自定义视图对用户来说会感觉更被动。
我们实现了另外两种方法:onScroll()
和onFling()
。他们都在管理不同的手势,这些手势直接映射到不同的滚动方式。当我们在屏幕上拖动时,会调用onScroll()
方法,因为我们实际上是在滚动。另一方面,当我们做一个投掷手势时;也就是说,当用户非常快速地从屏幕上拖动和抬起手指时,我们需要考虑其他参数,例如动画的速度和摩擦力。当手势完成时,动画仍将运行一段时间,根据定义的摩擦力减速直至停止。在这种情况下,来自我们的听众的onFling()
方法将会以投掷事件的水平和垂直速度被调用,留下摩擦由我们来处理。
在这两个事件中,我们将使用scroller
类来简化计算。我们可以自己做,但是,虽然实现onScroll()
逻辑会非常简单,但是正确地实现onFling()
动画将需要一些计算和复杂性,我们可以通过使用scroller
类来想当然。
在onScroll()
实现上,我们只是从当前位置和拖动的距离调用scroller
的startScroll
方法。要获得当前位置,我们必须先调用scroller.computeScrollOffset
。如果我们不称之为,当前值将始终为零。一旦我们调用了这个方法,我们就可以通过使用getCurrX
方法来检索scroller
的当前值。
因为在我们的监听器中,我们将距离作为一个浮点来获取,startScroll
只接受整数值,我们将对distanceX
值进行舍入,只需添加 0.5,然后将其转换为整数值。
同样,在onFling()
实现中,我们将调用scroller
的fling
方法。我们将获得当前位置,正如我们在onScroll()
实现中所描述的,我们将调整速度,因为从动画旋转立方体的角度来看,它太高了。我们已经将最大值和最小值设置为立方体的 100 整圈,因为在正常情况下,我们不想限制旋转。
现在,通过使用scroller
,我们可以去掉animateLogic()
方法和所有相关的变量,因为我们将不再需要它们。无论是手势、滚动还是投掷,动画都将在后台执行,我们可以直接从scroller
实例中直接查询当前动画值。
我们对onDraw()
方法所要做的唯一改变是调用scroller.computeScrollOffset
方法来获得更新的值,而不是使用angleFr
变量,从scroller
中获取值:
Matrix.rotateM(mMVPMatrix, 0, scroller.getCurrX(), 0.f, 1.f, 0.f);
添加可操作的回调
让我们把它转换成一个可操作的菜单。我们可以将一个动作映射到立方体的每个面。当我们水平或在y
轴上旋转立方体时,我们可以将一个动作映射到四个可用面中的每一个。
为了增加清晰度,因为目前旋转可能会在一个面的中间结束,让我们添加一个小功能:每当动画结束,让我们捕捉到最近的面,所以我们将总是有一个完全对齐的立方体的前面,当没有动画运行。
实现捕捉相当简单。我们必须检查动画是否已经完成,在这种情况下,检查哪张脸正对着摄像机。我们可以通过简单地将当前旋转角度除以90
来实现;360
度被四个面分割为90
度各一张。为了看我们是否比下一张脸更靠近那张脸,我们必须得到旋转角度的分数部分。如果我们以90
为模计算角度,我们会得到一个介于 0 和 89 之间的数字。如果这个结果小于从一个面切换到另一个面所需的度数的一半,我们将处于正确的面。然而,在相反的情况下,如果结果大于45
,或者小于-45
,我们将不得不分别旋转到下一个或上一个面。让我们在onDraw()
方法中写下这个小逻辑,就在调用scroller.computeScrollOffset
之后:
if (scroller.isFinished()) {
int lastX = scroller.getCurrX();
int modulo = lastX % 90;
int snapX = (lastX / 90) * 90;
if (modulo >= 45) snapX += 90;
if (modulo <- 45) snapX -= 90;
if (lastX != snapX) {
scroller.startScroll(lastX, 0, snapX - lastX, 0);
}
}
为了计算捕捉角度,我们用90
进行整数除法,并将结果乘以90
。由于是整数除法,所以会去掉小数部分,计算出那张脸的绝对角度值。编写该代码的另一种方式如下:
int face = lastX / 90;
int snapX = face * 90;
然后,根据取模结果,我们将90
相加或相减90
以有效地转到下一张或上一张脸。
现在,让我们添加代码来管理用户点击。首先,让我们创建一个侦听器的接口,将事件的处理委托给该侦听器:
interface OnMenuClickedListener {
void menuClicked(int option);
}
另外,让我们给我们的类添加一个OnMenuClickedListener
变量和一个 setter 方法:
private OnMenuClickedListener listener;
public void setOnMenuClickedListener(OnMenuClickedListener listener) {
this.listener = listener;
}
现在,我们可以在MenuGestureListener
上实现onSingleTapUp
方法:
@Override
public boolean onSingleTapUp(MotionEvent e) {
scroller.computeScrollOffset();
int angle = scroller.getCurrX();
int face = (angle / 90) % 4;
if (face < 0) face += 4;
if (listener != null) listener.menuClicked(face);
return true;
}
让我们也在activity_main
布局文件中为我们的自定义视图添加一个id
,这样我们就可以从代码中获得GLDrawer
视图:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="@dimen/activity_vertical_margin"
tools:context="com.packt.rrafols.draw.MainActivity">
<com.packt.rrafols.draw.GLDrawer
android:id="@+id/gldrawer"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</LinearLayout>
最后,修改MainActivity
类以创建一个OnMenuClickedListener
,并将其设置为GLDrawer
视图:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
GLDrawer glDrawer = (GLDrawer) findViewById(R.id.gldrawer);
glDrawer.setOnMenuClickedListener(new
GLDrawer.OnMenuClickedListener() {
@Override
public void menuClicked(int option) {
Log.i("Example36-Menu3D", "option clicked " + option);
}
});
}
如果我们运行这个例子,我们将看到MainActivity
是如何记录我们正在点击立方体的哪个面的:
com.packt.rrafols.draw I/Example36-Menu3D: option clicked 3
com.packt.rrafols.draw I/Example36-Menu3D: option clicked 2
。
我们还将看到捕捉是如何工作的。玩它,看看它是如何捕捉到当前的脸,下一张脸,或者上一张脸,如果我们向后滚动。
自定义
我们仍然按照我们在Example25
中留下的方式渲染立方体。让我们更改它,以不同的纯色绘制每个立方体面。我们可以为每个顶点定义不同的颜色,但是由于顶点在面之间共享,它们的颜色将被插值。
我们必须复制一些顶点,这样我们就可以为每个单独的面使用不同的独特颜色:
private float quadCoords[] = {
-1.f, -1.f, -1.0f, // 0
-1.f, 1.f, -1.0f, // 1
1.f, 1.f, -1.0f, // 2
1.f, -1.f, -1.0f, // 3
-1.f, -1.f, 1.0f, // 4
-1.f, 1.f, 1.0f, // 5
1.f, 1.f, 1.0f, // 6
1.f, -1.f, 1.0f, // 7
-1.f, -1.f, -1.0f, // 8 - 0
-1.f, -1.f, 1.0f, // 9 - 4
1.f, -1.f, 1.0f, // 10 - 7
1.f, -1.f, -1.0f, // 11 - 3
-1.f, 1.f, -1.0f, // 12 - 1
-1.f, 1.f, 1.0f, // 13 - 5
1.f, 1.f, 1.0f, // 14 - 6
1.f, 1.f, -1.0f, // 15 - 2
-1.f, -1.f, -1.0f, // 16 - 0
-1.f, -1.f, 1.0f, // 17 - 4
-1.f, 1.f, 1.0f, // 18 - 5
-1.f, 1.f, -1.0f, // 19 - 1
1.f, -1.f, -1.0f, // 20 - 3
1.f, -1.f, 1.0f, // 21 - 7
1.f, 1.f, 1.0f, // 22 - 6
1.f, 1.f, -1.0f // 23 - 2
};
private short[] index = {
0, 1, 2, // front
0, 2, 3, // front
4, 5, 6, // back
4, 6, 7, // back
8, 9,10, // top
8,11,10, // top
12,13,14, // bottom
12,15,14, // bottom
16,17,18, // left
16,19,18, // left
20,21,22, // right
20,23,22 // right
};
我们也更新了索引,以映射到新面孔。在复制的顶点上,我们用新索引和旧索引添加了注释。
现在,我们可以定义一些颜色:
float colors[] = {
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f,
0.0f, 0.0f, 0.0f, 1.0f,
0.0f, 0.0f, 0.0f, 1.0f,
0.0f, 0.0f, 0.0f, 1.0f,
0.0f, 0.0f, 0.0f, 1.0f,
1.0f, 1.0f, 1.0f, 1.0f,
1.0f, 1.0f, 1.0f, 1.0f,
1.0f, 1.0f, 1.0f, 1.0f,
1.0f, 1.0f, 1.0f, 1.0f,
1.0f, 1.0f, 0.0f, 1.0f,
1.0f, 1.0f, 0.0f, 1.0f,
1.0f, 1.0f, 0.0f, 1.0f,
1.0f, 1.0f, 0.0f, 1.0f,
1.0f, 0.0f, 1.0f, 1.0f,
1.0f, 0.0f, 1.0f, 1.0f,
1.0f, 0.0f, 1.0f, 1.0f,
1.0f, 0.0f, 1.0f, 1.0f
};
让我们也改变initBuffer
方法上的纹理初始化来创建颜色Buffer
,就像我们在Example24-GLDrawing
中做的那样:
ByteBuffer cbb = ByteBuffer.allocateDirect(colors.length * (Float.SIZE / 8));
cbb.order(ByteOrder.nativeOrder());
colorBuffer = cbb.asFloatBuffer();
colorBuffer.put(colors);
colorBuffer.position(0);
更新像素和顶点Shader
s:
private final String vertexShaderCode =
"uniform mat4 uMVPMatrix;" +
"attribute vec4 vPosition;" +
"attribute vec4 aColor;" +
"varying vec4 vColor;" +
"void main() {" +
" gl_Position = uMVPMatrix * vPosition;" +
" vColor = aColor;" +
"}";
private final String fragmentShaderCode =
"precision mediump float;" +
"varying vec4 vColor;" +
"void main() {" +
" gl_FragColor = vColor;" +
"}";
为了使它更具可配置性,让我们在GLDrawer
上创建一个公共的setColors()
方法来更改颜色:
public void setColors(int[] faceColors) {
glRenderer.setColors(faceColors);
}
Renderer
上的实现如下:
private void setColors(int[] faceColors) {
colors = new float[4 * 4 * faceColors.length];
int wOffset = 0;
for (int faceColor : faceColors) {
float[] color = hexToRGBA(faceColor);
for(int j = 0; j < 4; j++) {
colors[wOffset++] = color[0];
colors[wOffset++] = color[1];
colors[wOffset++] = color[2];
colors[wOffset++] = color[3];
}
}
ByteBuffer cbb = ByteBuffer.allocateDirect(colors.length *
(Float.SIZE /8));
cbb.order(ByteOrder.nativeOrder());
colorBuffer = cbb.asFloatBuffer();
colorBuffer.put(colors);
colorBuffer.position(0);
}
为了简单起见,我们将把颜色作为整数传递,而不是浮点数组,因此我们可以使用十六进制编码的颜色。要将整数颜色转换为浮点数组,我们可以使用一个简单的助手方法:
private float[] hexToRGBA(int color) {
float[] out = new float[4];
int a = (color >> 24) & 0xff;
int r = (color >> 16) & 0xff;
int g = (color >> 8) & 0xff;
int b = (color ) & 0xff;
out[0] = ((float) r) / 255.f;
out[1] = ((float) g) / 255.f;
out[2] = ((float) b) / 255.f;
out[3] = ((float) a) / 255.f;
return out;
}
为了更新示例,让我们使用刚刚添加的方法设置一些颜色:
glDrawer.setColors(new int[] {
0xff4a90e2,
0xff161616,
0xff594236,
0xffff5964,
0xff8aea92,
0xffffe74c
});
如果我们运行该示例,我们将获得类似以下截图的内容:
在 GitHub 存储库中的Example36-Menu3D
文件夹中检查这个例子的完整源代码。
在基本实现之外
我们有一个非常基本和可操作的 3D 菜单,但是,为了在生产应用中使用它,我们必须添加一些更多的细节。例如,我们现在可以根据我们选择的立方体的面来选择不同的菜单选项,但是除非我们正在做一个非常简单的颜色选择器,否则我们将完全盲目地选择一个选项,因为我们不知道哪个面具体做什么。
解决这个问题的一种方法是根据选择的是哪张脸来渲染一些文本,但是在 OpenGL ES 上,我们不能简单地调用drawText
来渲染一些文本,就像我们使用Canvas
时所做的那样。同样,在这个例子中,只有四个可选面或选项;让我们做一些改变,这样我们就可以有更多的可选选项。
呈现文本
正如我们刚刚提到的,要渲染文本,我们不能只调用一个drawText
方法,它会在我们的小 3D 场景中以 3D 方式渲染一些文本。实际上,我们将使用drawText
,但只是为了在背景Bitmap
上渲染它,该背景将用作我们将渲染的附加平面的纹理。
为此,我们必须定义该平面的几何形状:
private float planeCoords[] = {
-1.f, -1.f, -1.4f,
-1.f, 1.f, -1.4f,
1.f, 1.f, -1.4f,
1.f, -1.f, -1.4f,
};
private short[] planeIndex = {
0, 1, 2,
0, 2, 3
};
private float texCoords[] = {
1.f, 1.f,
1.f, 0.f,
0.f, 0.f,
0.f, 1.f
};
由于立方体正面在 z 坐标-1.f,这个平面将在-1.4f,所以它前面有 0.4f,否则它可能会被立方体遮挡。
我们必须再次添加顶点和片段Shader
,用纹理渲染。虽然我们不会替换我们代码中的当前Shader
,但我们将不得不接受两套Shader
:
private final String vertexShaderCodeText =
"uniform mat4 uMVPMatrix;" +
"attribute vec4 vPosition;" +
"attribute vec2 aTex;" +
"varying vec2 vTex;" +
"void main() {" +
" gl_Position = uMVPMatrix * vPosition;" +
" vTex = aTex;" +
"}";
private final String fragmentShaderCodeText =
"precision mediump float;" +
"uniform sampler2D sTex;" +
"varying vec2 vTex;" +
"void main() {" +
" gl_FragColor = texture2D(sTex, vTex);" +
"}";
让我们也更新initBuffers
方法来初始化两组Buffers
:
private void initBuffers() {
ByteBuffer vbb = ByteBuffer.allocateDirect(quadCoords.length
* (Float.SIZE / 8));
vbb.order(ByteOrder.nativeOrder());
vertexBuffer = vbb.asFloatBuffer();
vertexBuffer.put(quadCoords);
vertexBuffer.position(0);
ByteBuffer ibb = ByteBuffer.allocateDirect(index.length
* (Short.SIZE / 8));
ibb.order(ByteOrder.nativeOrder());
indexBuffer = ibb.asShortBuffer();
indexBuffer.put(index);
indexBuffer.position(0);
ByteBuffer cbb = ByteBuffer.allocateDirect(colors.length
* (Float.SIZE / 8));
cbb.order(ByteOrder.nativeOrder());
colorBuffer = cbb.asFloatBuffer();
colorBuffer.put(colors);
colorBuffer.position(0);
vbb = ByteBuffer.allocateDirect(planeCoords.length
* (Float.SIZE / 8));
vbb.order(ByteOrder.nativeOrder());
vertexTextBuffer = vbb.asFloatBuffer();
vertexTextBuffer.put(planeCoords);
vertexTextBuffer.position(0);
ibb = ByteBuffer.allocateDirect(planeIndex.length
* (Short.SIZE / 8));
ibb.order(ByteOrder.nativeOrder());
indexTextBuffer = ibb.asShortBuffer();
indexTextBuffer.put(planeIndex);
indexTextBuffer.position(0);
ByteBuffer tbb = ByteBuffer.allocateDirect(texCoords.length
* (Float.SIZE / 8));
tbb.order(ByteOrder.nativeOrder());
texBuffer = tbb.asFloatBuffer();
texBuffer.put(texCoords);
texBuffer.position(0);
}
正如我们所看到的,这个方法分配了两组缓冲区:一组用于立方体,另一组用于我们用来绘制文本的平面。我们必须对顶点和片段Shaders
进行类似的处理,我们必须加载并链接两组Shaders
:
private void initShaders() {
int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode);
int fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER,
fragmentShaderCode);
shaderProgram = GLES20.glCreateProgram();
GLES20.glAttachShader(shaderProgram, vertexShader);
GLES20.glAttachShader(shaderProgram, fragmentShader);
GLES20.glLinkProgram(shaderProgram);
vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCodeText);
fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER,
fragmentShaderCodeText);
shaderTextProgram = GLES20.glCreateProgram();
GLES20.glAttachShader(shaderTextProgram, vertexShader);
GLES20.glAttachShader(shaderTextProgram, fragmentShader);
GLES20.glLinkProgram(shaderTextProgram);
}
我们将用于在纹理中绘制文本的着色器附加到另一个Shader
程序,我们将存储在shaderTextProgram
变量中。根据我们想要渲染的内容,我们现在可以从shaderProgram
或shaderTextProgram
切换。
现在,让我们创建一个方法,返回一个文本居中的位图:
private Bitmap createBitmapFromText(String text) {
Bitmap out = Bitmap.createBitmap(512, 512,
Bitmap.Config.ARGB_8888);
out.eraseColor(0x00000000);
Paint textPaint = new Paint();
textPaint.setAntiAlias(true);
textPaint.setColor(0xffffffff);
textPaint.setTextSize(60);
textPaint.setStrokeWidth(2.f);
textPaint.setStyle(Paint.Style.FILL);
Rect textBoundaries = new Rect();
textPaint.getTextBounds(text, 0, text.length(), textBoundaries);
Canvas canvas = new Canvas(out);
for (int i = 0; i < 2; i++) {
canvas.drawText(text,
(canvas.getWidth() - textBoundaries.width()) / 2.f,
(canvas.getHeight() - textBoundaries.height()) / 2.f +
textBoundaries.height(), textPaint);
textPaint.setColor(0xff000000);
textPaint.setStyle(Paint.Style.STROKE);
}
return out;
}
此方法通过512
创建一个512
的Bitmap
,每个颜色分量有八位,四个分量:alpha,或透明度,红色,绿色和蓝色。然后,它用文本的颜色和大小创建一个Paint
对象,获取文本边界以便将其放在Bitmap
的中心,并在我们可以从Bitmap
获取的Canvas
对象上绘制两次文本。文本被绘制两次,因为它首先用纯白色绘制文本,然后,当我们将Paint
对象样式更改为STROKE
时,它使用黑色绘制轮廓。
我们在前面的例子中加载纹理的代码是从本地资源加载的。当它转换成未缩放的Bitmap
时,我们可以重用大部分代码来加载我们生成的Bitmap
。让我们恢复我们已经有的loadTexture()
方法,但是让我们改变它使用一个助手方法上传一个Bitmap
到一个Texture
:
private int loadTexture(int resId) {
final int[] textureIds = new int[1];
GLES20.glGenTextures(1, textureIds, 0);
if (textureIds[0] == 0) return -1;
// do not scale the bitmap depending on screen density
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inScaled = false;
final Bitmap textureBitmap =
BitmapFactory.decodeResource(getResources(),
resId, options);
attachBitmapToTexture(textureIds[0], textureBitmap);
return textureIds[0];
}
助手方法的实现如下:
private void attachBitmapToTexture(int textureId, Bitmap textureBitmap) {
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,
GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,
GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, textureBitmap, 0);
}
我们只需要创建一个将所有东西放在一起的方法:即从文本生成一个Bitmap
,生成一个textureIds
,上传Bitmap
作为纹理,并回收Bitmap
:
private int generateTextureFromText(String text) {
final int[] textureIds = new int[1];
GLES20.glGenTextures(1, textureIds, 0);
Bitmap textureBitmap = createBitmapFromText(text);
attachBitmapToTexture(textureIds[0], textureBitmap);
textureBitmap.recycle();
return textureIds[0];
}
使用这种方法,我们现在可以为立方体的每个面生成不同的纹理:
@Override
public void onSurfaceCreated(GL10 unused, EGLConfig config) {
initBuffers();
initShaders();
textureId = new int[4];
for (int i = 0; i < textureId.length; i++) {
textureId[i] = generateTextureFromText("Option " + (i + 1));
}
}
我们现在可以在onDraw()
方法的底部添加一些额外的代码来渲染立方体每个面前面的平面:
GLES20.glUseProgram(shaderTextProgram);
positionHandle = GLES20.glGetAttribLocation(shaderTextProgram, "vPosition");
GLES20.glVertexAttribPointer(positionHandle, 3,
GLES20.GL_FLOAT, false,
0, vertexTextBuffer);
int texCoordHandle = GLES20.glGetAttribLocation(shaderTextProgram, "aTex");
GLES20.glVertexAttribPointer(texCoordHandle, 2,
GLES20.GL_FLOAT, false,
0, texBuffer);
int texHandle = GLES20.glGetUniformLocation(shaderTextProgram, "sTex");
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glEnable(GLES20.GL_BLEND);
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA);
for (int i = 0; i < 4; i++) {
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId[i]);
GLES20.glUniform1i(texHandle, 0);
mMVPMatrixHandle = GLES20.glGetUniformLocation(shaderTextProgram,
"uMVPMatrix");
GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mMVPMatrix,
0);
GLES20.glEnableVertexAttribArray(texHandle);
GLES20.glEnableVertexAttribArray(positionHandle);
GLES20.glDrawElements(
GLES20.GL_TRIANGLES, planeIndex.length,
GLES20.GL_UNSIGNED_SHORT, indexTextBuffer);
GLES20.glDisableVertexAttribArray(positionHandle);
GLES20.glDisableVertexAttribArray(texHandle);
Matrix.rotateM(mMVPMatrix, 0, -90.f, 0.f, 1.f, 0.f);
}
GLES20.glDisable(GLES20.GL_BLEND);
GLES20.glDisable(GLES20.GL_DEPTH_TEST);
如我们所见,我们正在将positionHandle
更改为平面几何图形,启用纹理顶点阵列,此外,我们正在启用混合模式。由于文本纹理将是透明的,除了文本,我们需要启用混合或其他,OpenGL ES 将把透明像素渲染为黑色。
为了绘制不同的平面,立方体的每个水平面一个平面,我们做了一个小循环,在这个循环中,我们绑定了不同的纹理,并在每次迭代中旋转 90 度。
如果我们运行这个示例,我们将看到类似于下面截图的内容:
多个面
现在,我们已经添加了在立方体的表面上呈现一些文本的能力,我们可以知道当单击一个选项时我们正在选择什么,但是我们仍然局限于四个不同的选项。目前,我们已经将几何图形硬编码在几个数组的代码中。如果我们想使选项的数量、数量或面动态化,我们必须以编程方式生成几何和面索引。
对我们来说幸运的是,我们的出发点是在一个三维圆中有几个选择,所以我们只需要生成一个有几个面的空心圆柱体,正好和我们想要的选项数量一样多。
让我们给GLDrawer
自定义视图类添加一个方法,允许我们设置我们将拥有的选项和面的数量:
public void setNumOptions(int options) {
double halfAngle = Math.PI / options;
float[] coords = new float[options * 3 * 4];
int offset = 0;
for (int i = 0; i < options; i++) {
float angle = (float) (i * 2.f * Math.PI / options
- Math.PI / 2.f - halfAngle);
float nextAngle = (float) ((i + 1) * 2.f * Math.PI / options
- Math.PI / 2.f - halfAngle);
float x0 = (float) Math.cos(angle) * 1.2f;
float x1 = (float) Math.cos(nextAngle) * 1.2f;
float z0 = (float) Math.sin(angle) * 1.2f;
float z1 = (float) Math.sin(nextAngle) * 1.2f;
coords[offset++] = x0;
coords[offset++] = -1.f;
coords[offset++] = z0;
coords[offset++] = x1;
coords[offset++] = -1.f;
coords[offset++] = z1;
coords[offset++] = x0;
coords[offset++] = 1.f;
coords[offset++] = z0;
coords[offset++] = x1;
coords[offset++] = 1.f;
coords[offset++] = z1;
}
short[] index = new short[options * 6];
for (int i = 0; i < options; i++) {
index[i * 6 + 0] = (short) (i * 4 + 0);
index[i * 6 + 1] = (short) (i * 4 + 1);
index[i * 6 + 2] = (short) (i * 4 + 3);
index[i * 6 + 3] = (short) (i * 4 + 0);
index[i * 6 + 4] = (short) (i * 4 + 2);
index[i * 6 + 5] = (short) (i * 4 + 3);
}
glRenderer.setCoordinates(options, coords, index);
}
要以圆柱体的形式生成不同的面,就像将一个圆的360
度,或者弧度的两倍PI
除以我们想要的面的数量一样简单。这里,我们将2.f*Math.PI
除以选项数,然后乘以循环迭代器。通过计算该角度的正弦和余弦,我们可以得到两个坐标,通常是 2D 投影中的x
和y
,但在我们的具体情况下,我们会将其映射到x
和z
,因为我们将y
坐标设置为-1.f
作为顶部垂直边缘,1.f
作为底部垂直边缘。我们也在计算下一个x
和z
坐标,所以我们可以在这些点之间创建一个面四边形。
我们为每个面生成四个点,并将它们作为索引数组中的两个三角形进行索引。这与我们以前生成颜色的方式完全匹配,因为我们为每个面生成四个颜色值,现在我们也为每个面生成四个顶点,每个面都将有一个唯一的纯色。
在方法的最后,我们调用GLRenderer
的setCoordinates()
方法,但是实现起来非常简单:
private void setCoordinates(int options, float[] coords, short[] index) {
this.quadCoords = coords;
this.index = index;
this.options = options;
}
这将在不接触其他任何东西的情况下工作,只要我们在表面创建之前调用它。当我们谈论它的时候,我们必须更新onSurfaceCreated()
方法来使用我们设置的选项数量,而不是我们之前在代码中硬编码的默认四个:
@Override
public void onSurfaceCreated(GL10 unused, EGLConfig config) {
initBuffers();
initShaders();
textureId = new int[options];
for (int i = 0; i < textureId.length; i++) {
textureId[i] = generateTextureFromText("Option " + (i + 1));
}
faceAngle = 360.f / options;
}
我们还计算了从一个面切换到另一个面时需要旋转的量。在我们前面的例子中很容易,因为有四个面,360
度除以 4 等于 90。现在,计算仍然很简单,但是我们必须用我们创建的新变量来改变代码中的硬编码 90,命名为faceAngle
,其值为360
除以选项数量。
让我们通过在MainActivity
上调用来测试这个新特性,就在设置不同的颜色之后:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
GLDrawer glDrawer = (GLDrawer) findViewById(R.id.gldrawer);
glDrawer.setOnMenuClickedListener(new
GLDrawer.OnMenuClickedListener() {
@Override
public void menuClicked(int option) {
Log.i("Example37-Menu3D", "option clicked " + option);
}
});
glDrawer.setColors(new int[] {
0xff4a90e2,
0xff161616,
0xff594236,
0xffff5964,
0xff8aea92,
0xffffe74c
});
glDrawer.setNumOptions(6);
}
我们没有特别添加检查,但是颜色的数量必须至少是相同数量的选项,否则我们将在渲染时得到一个例外。
如果我们运行这个示例,我们将看到类似于下面截图的内容,这始终取决于当前的旋转:
在 GitHub 存储库中的Example37-Menu3D
文件夹中查看该示例的完整源代码。
摘要
在本章中,我们已经看到了如何将交互添加到 3D 自定义视图中,以使其具有交互性。此外,我们已经看到了如何使用scroller
实例来管理滚动和投掷手势,以及如何将文本渲染为纹理,并使用具有不同缓冲区和不同Shaders
的不同几何图形。最后,我们还看到了如何轻松生成几何图形,以使自定义视图具有适应性和动态性。
在这本书里,我们已经看到了如何构建不同种类的定制视图,以及如何根据我们的需要使用安卓软件开发工具包中的方法和类,或者使用我们自己的方法和类。我们还看到了如何构建 2D 视图和三维自定义视图,并使它们对用户输入做出反应。最终,使用我们展示的所有 API 和大量的创造力,我们可以构建任何我们想要的定制视图。我们仍然必须记住,安卓为我们提供了一个不断发展的伟大框架,并且包含了许多优秀、高效的方法来绘制令人敬畏的用户界面,但是有时我们想要构建一些特殊的东西,这些东西是我们无法使用标准的应用编程接口轻松制作的。
要了解更多关于构建安卓用户界面和自定义视图的信息,开发博客上有很多教程,一些开源的开放视图,以及许多会议和大会上的会议。参加当地的会议和大会是一个很好的方式,不仅可以了解自定义视图,还可以了解最新的安卓开发。有许多由安卓社区领导的倡议,我真的很想鼓励任何人以他们能做的任何方式做出贡献,以保持安卓社区的活力和强大。
版权属于:月萌API www.moonapi.com,转载请注明出处