十一、创建三维转轮菜单

除了第 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扩展我们的类时,没有必要调用invalidatepostInvalidate,除非特别指明,否则屏幕将不断被重绘。

改进交互和动画

我们一直在使用固定的时间步长机制来管理动画,但让我们看看使用安卓提供的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()实现上,我们只是从当前位置和拖动的距离调用scrollerstartScroll方法。要获得当前位置,我们必须先调用scroller.computeScrollOffset。如果我们不称之为,当前值将始终为零。一旦我们调用了这个方法,我们就可以通过使用getCurrX方法来检索scroller的当前值。

因为在我们的监听器中,我们将距离作为一个浮点来获取,startScroll只接受整数值,我们将对distanceX值进行舍入,只需添加 0.5,然后将其转换为整数值。

同样,在onFling()实现中,我们将调用scrollerfling方法。我们将获得当前位置,正如我们在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为模计算角度,我们会得到一个介于 089 之间的数字。如果这个结果小于从一个面切换到另一个面所需的度数的一半,我们将处于正确的面。然而,在相反的情况下,如果结果大于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变量中。根据我们想要渲染的内容,我们现在可以从shaderProgramshaderTextProgram切换。

现在,让我们创建一个方法,返回一个文本居中的位图:

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创建一个512Bitmap,每个颜色分量有八位,四个分量: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 投影中的xy,但在我们的具体情况下,我们会将其映射到xz,因为我们将y坐标设置为-1.f作为顶部垂直边缘,1.f作为底部垂直边缘。我们也在计算下一个xz坐标,所以我们可以在这些点之间创建一个面四边形。

我们为每个面生成四个点,并将它们作为索引数组中的两个三角形进行索引。这与我们以前生成颜色的方式完全匹配,因为我们为每个面生成四个颜色值,现在我们也为每个面生成四个顶点,每个面都将有一个唯一的纯色。

在方法的最后,我们调用GLRenderersetCoordinates()方法,但是实现起来非常简单:

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 和大量的创造力,我们可以构建任何我们想要的定制视图。我们仍然必须记住,安卓为我们提供了一个不断发展的伟大框架,并且包含了许多优秀、高效的方法来绘制令人敬畏的用户界面,但是有时我们想要构建一些特殊的东西,这些东西是我们无法使用标准的应用编程接口轻松制作的。

要了解更多关于构建安卓用户界面和自定义视图的信息,开发博客上有很多教程,一些开源的开放视图,以及许多会议和大会上的会议。参加当地的会议和大会是一个很好的方式,不仅可以了解自定义视图,还可以了解最新的安卓开发。有许多由安卓社区领导的倡议,我真的很想鼓励任何人以他们能做的任何方式做出贡献,以保持安卓社区的活力和强大。