三、纸板盒子

还记得当你还是个孩子的时候,喜欢在一个纸箱里玩耍吗?这个项目甚至可能比那个更有趣!我们的第一个纸板项目将是一个简单的场景,有一个盒子(一个几何立方体)、一个三角形和一点用户交互。让我们称它为“卡片箱”明白吗?

具体来说,我们将创建一个新项目,构建一个只绘制三角形的简单应用,然后增强该应用以绘制一个着色的 3D 立方体,并通过在您查看立方体时突出显示它来说明一些用户交互。

在本章中,您将:

  • 创建新的纸板项目
  • 向场景添加三角形对象,包括几何图形、简单着色器和渲染缓冲区
  • 使用三维相机、透视和头部旋转
  • 使用模型转换
  • 制作和绘制立方体对象
  • 添加光源和阴影
  • 旋转立方体
  • 添加楼层
  • 突出显示用户正在查看的对象

本章中的项目源自谷歌纸板团队提供的名为寻宝的示例应用。最初,我们考虑指导您简单地下载寻宝软件,我们将引导您完成解释其工作原理的代码。相反,我们决定从头开始构建一个类似的项目,边走边解释。这也降低了谷歌在本书出版后改变甚至取代该项目的可能性。

这个项目的源代码可以在 Packt Publishing 网站和 GitHub 上的https://github.com/cardbookvr/cardboardbox找到(每个主题作为一个单独的提交)。

Android SDK 版本对您的成品应用很重要,但您的桌面环境也可以通过多种方式进行设置。我们前面提到,我们使用 Android Studio 2.1 来构建本书中的项目。我们还使用了 Java SDK 版本 8 (1.8)。为了导入项目,安装此版本(您可以并排安装多个版本)将非常重要。与任何开发环境一样,对 Java 或 Android Studio 所做的任何更改都可能会在未来“中断”导入过程,但实际的源代码应该会编译并运行很多年。

创建新项目

如果您想了解更多关于这些步骤的详细信息和解释,请参考第 2 章框架纸板项目中的创建新纸板项目一节,然后继续:

  1. Android Studio 打开后,创建一个新项目。让我们将其命名为CardboardBox,并以空活动为目标安卓 4.4 KitKat (API 19)
  2. 使用文件 | | 新模块,将纸板 SDK common.aarcore.aar库文件作为新模块添加到项目中...
  3. 使用文件 | 项目结构,将库模块设置为项目应用的依赖项。
  4. 按照第二章框架纸板项目中的说明编辑AndroidManifest.xml文件,注意保留该项目的package名称。
  5. 按照第 2 章框架纸板项目中的说明编辑build.gradle文件,根据 SDK 22 进行编译。
  6. 编辑activity_main.xml布局文件,如第 2 章框架纸板项目所述。
  7. 编辑MainActivity Java 类,使其成为extends``CardboardActivity``implement``s``CardboardView.StereoRenderer。修改类申报行如下:

    java public class MainActivity extends CardboardActivity implements CardboardView.StereoRenderer {

  8. 为接口添加存根方法覆盖(使用智能感知实现方法或按下 Ctrl + I )。

  9. MainActivity类的顶部,添加以下注释作为我们将在本项目中创建的变量的占位符:

    ```java CardboardView.StereoRenderer { private static final String TAG = "MainActivity";

    // Scene variables // Model variables // Viewing variables // Rendering variables ```

  10. 最后,通过添加CardboadView实例来编辑 onCreate(),如下所示:

    ```java @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main);

        CardboardView cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
        cardboardView.setRenderer(this);
        setCardboardView(cardboardView);  
    }
    

    ```

你好,三角!

让我们给场景添加一个三角形。是的,我知道三角形甚至不是一个盒子。然而,我们将从超级简单的技巧开始。三角形是所有 3D 图形的构建块,也是 OpenGL 可以渲染的最简单的形状(即在三角形模式下)。

引入几何

在继续之前,让我们先来谈谈几何。

虚拟现实很大程度上是关于创建 3D 场景。复杂模型被组织成具有顶点、面和网格的三维数据,形成可以分层组装成更复杂模型的对象。目前,我们采用了一种非常简单的方法——一个有三个顶点的三角形,存储为一个简单的 Java 数组。

三角形由三个顶点组成(这就是为什么,它被称为一个三角!).我们将我们的三角形定义为顶部(0.0,0.6),左下(-0.5,-0.3),右下(0.5,-0.3)。第一个顶点是三角形的顶点,并且有 X=0.0 ,所以它在中心并且 Y=0.6 向上。

顶点的顺序,或者说三角形的缠绕,非常重要,因为它指示了三角形的正面方向。OpenGL 驱动程序期望它以逆时针方向缠绕,如下图所示:

Introducing geometry

如果顶点是顺时针定义的,着色器将假设三角形面向另一个方向,远离相机,因此不可见且不渲染。这是一个名为剔除的优化,它允许渲染管道轻易地丢弃对象背面的几何图形。也就是说,如果相机看不到它,甚至不要费心去画它。说到这里,您可以设置各种剔除模式来选择仅渲染正面、背面或两者。

参考http://learnopengl.com/#!Advanced-OpenGL/Face-culling的知识共享资源。

类型

戴夫·施莱纳、格雷厄姆·塞勒斯、约翰·m·凯塞尼奇、比尔·利西亚-凯恩的《OpenGL 编程指南》按照惯例,顶点在屏幕上以逆时针顺序出现的多边形称为正面的这是由全局状态模式决定的,默认值为GL_CCW(https://www.opengl.org/wiki/Face_Culling)。

三维点或顶点由 xyz 坐标值定义。例如,三维空间中的三角形由三个顶点组成,每个顶点都有一个 xyz 值。

我们的三角形位于平行于屏幕的平面上。当我们向场景添加 3D 视图时(本章稍后),我们需要一个 z 坐标来将其放置在 3D 空间中。在期待中,我们将在 Z=-1 平面上设置三角形。OpenGL 中的默认相机位于原点(0,0,0),向下看负的 z 轴。换句话说,场景中的物体正在摄像机处向上看正 z 轴。我们将三角形放置在离相机一个单位的地方,这样我们就可以在 Z=-1.0 处看到它。

三角形变量

将以下代码片段添加到MainActivity类的顶部:

    // Model variables
    private static final int COORDS_PER_VERTEX = 3;
    private static float triCoords[] = {
        // in counter-clockwise order
        0.0f,  0.6f, -1.0f, // top
       -0.5f, -0.3f, -1.0f, // bottom left
        0.5f, -0.3f, -1.0f  // bottom right
    };

    private final int triVertexCount = triCoords.length / COORDS_PER_VERTEX;
    // yellow-ish color
    private float triColor[] = { 0.8f, 0.6f, 0.2f, 0.0f }; 
    private FloatBuffer triVerticesBuffer;

我们的三角形坐标被分配给triCoords数组。所有顶点都在三维空间中,每个顶点有三个坐标( xyz)(COORDS_PER_VERTEX)。triVertexCount变量,预先计算为三角形的triCoords数组的长度,除以COORDS_PER_VERTEX。我们还为我们的三角形定义了一个任意的triColor值,它由 R、G、B 和 A 值(红、绿、蓝和 alpha(透明度))组成。triVerticesBuffer变量将用于绘制代码。

对于那些不熟悉 Java 编程的人来说,您可能还想知道变量类型。整数声明为int,浮点数声明为float。这里所有的变量都被声明为private,这意味着它们只在这个类定义中可见和使用。被声明的static将跨类的多个实例共享它们的数据。被声明为final的是不可变的,一旦被初始化就不会改变。

onSurfaceCreated

这个活动代码的目的是在安卓设备显示器上画东西。我们通过 OpenGL 图形库实现这一点,它绘制到一个表面上,一个内存缓冲区,您可以通过渲染管道在其上绘制图形。

创建活动(onCreate)后,创建曲面并调用onSurfaceCreated。它有几个职责,包括初始化场景和编译着色器。它还通过为顶点缓冲区分配内存、绑定纹理和初始化渲染管道句柄来为渲染做准备。

下面是这个方法,我们已经将它分成了几个私有方法,接下来我们将编写这些方法:

    @Override
    public void onSurfaceCreated(EGLConfig eglConfig) {
        initializeScene();
        compileShaders();
        prepareRenderingTriangle();
    }

此时场景中没有要初始化的内容:

private void initializeScene() {
}

让我们继续讨论着色器和渲染。

介绍 OpenGL ES 2.0

现在是引入图形管道的好时机。当纸板应用在屏幕上绘制 3D 图形时,它会将渲染交给单独的图形处理器。安卓和我们的纸板应用使用 OpenGL ES 2.0 标准图形库。

OpenGL 是应用如何与图形驱动程序交互的规范。你可以说这是一长串在图形硬件中执行的函数调用。硬件供应商编写他们的驱动程序以符合最新的规范,一些中介,在这种情况下是谷歌,创建了一个库,它挂钩到驱动程序函数中,以便提供方法签名,您可以从您正在使用的任何语言(通常是 Java、C++ 或 C#)调用这些方法签名。

OpenGL ES 是 OpenGL 的移动版,或称 嵌入式系统。它遵循与 OpenGL 相同的设计模式,但其版本历史非常不同。不同版本的 OpenGL ES 甚至同一版本的不同实现都将需要不同的方法来绘制 3D 图形。因此,您的代码在 OpenGL ES 1.0、2.0 和 3.0 之间可能会有很大的不同。值得庆幸的是,大多数主要的变化发生在版本 1 和版本 2 之间,纸板 SDK 被设置为使用 2.0。CardboardView界面也与正常的GLSurfaceView略有不同。

要在屏幕上绘制图形,OpenGL 需要两个基本要素:

  • 图形程序或着色器(有时可互换使用),定义如何绘制形状
  • 数据或缓冲区,定义正在绘制的内容

还有一些指定变换矩阵、颜色、向量等的参数。您可能熟悉游戏循环的概念,这是一种设置游戏环境的基本模式,然后启动一个循环,运行一些游戏逻辑,渲染屏幕,并以半规则的间隔重复,直到游戏暂停或程序退出。CardboardView为我们设置游戏循环,基本上我们要做的就是实现接口方法。

关于着色器的更多信息:至少,我们需要一个顶点着色器和一个片段着色器。顶点着色器负责将对象的顶点从世界空间(它们在世界中的位置)转换到屏幕空间(它们应该绘制在屏幕上的位置)。

片段着色器在形状占据的每个像素上调用(由光栅函数决定,管道的固定部分),并返回绘制的颜色。每个着色器都是一个单独的函数,伴随着许多可以用作输入的属性。

函数的集合(即一个顶点和一个片段)由 OpenGL 编译成一个程序。有时,整个程序被称为着色器,但这是一种口语,假设需要一个以上的函数或着色器来完全绘制对象的基本知识。该程序及其所有参数的值有时会被称为材质,因为它完全描述了它所绘制的表面的材质。

着色器很酷。然而,在你的程序建立数据缓冲区并进行一系列的绘制调用之前,它们不会做任何事情。

绘制调用包括一个 【顶点缓冲对象】(【VBO】)、将用于绘制它的着色器、指定应用于对象的变换的多个参数、用于绘制它的纹理以及任何其他着色器参数。

VBO 指的是用来描述物体形状的所有数据。一个非常基本的对象(例如,三角形)只需要一个顶点数组。顶点按顺序读取,空间中每三个位置定义一个三角形。稍微高级一点的形状使用一个顶点数组和一个索引数组,它们定义了以什么顺序绘制哪些顶点。使用索引缓冲区,可以重复使用多个顶点。

虽然 OpenGL 可以绘制许多形状类型(点、线、三角形和四边形),但我们将假设它们都是三角形。这既是一个性能优化,也是一个方便的问题。如果我们想要一个四边形,我们可以画两个三角形。如果我们想要一条线,我们可以画一条很长很细的四边形。如果我们想要一个点,我们可以画一个小三角形。这样,我们不仅可以将 OpenGL 留在三角形模式,还可以以完全相同的方式对待所有 VBO。理想情况下,您希望渲染代码完全不知道它在渲染什么。

总结一下:

  • OpenGL 图形库的目的是让我们能够访问 GPU 硬件,然后 GPU 根据场景中的几何图形在屏幕上绘制像素。这是通过渲染管道实现的,在渲染管道中,数据被转换并通过一系列着色器传递。
  • 着色器是一个小的程序,根据管道的阶段,它接受某些输入并生成相应的输出。
  • 作为一个程序,着色器是用一种特殊的 C 语言编写的。源代码经过编译,可以在安卓设备的图形处理器上非常高效地运行。

例如,顶点着色器处理单个顶点的处理,输出每个顶点的变换版本。另一个步骤是光栅化几何图形,之后片段着色器接收光栅片段并输出彩色像素。

稍后我们将讨论 OpenGL 渲染管道,您可以在https://www.opengl.org/wiki/Rendering_Pipeline_Overview上了解到。

您也可以在查看安卓 OpenGL ES API 指南。

现在,不要太担心它,让我们跟着它走。

注意:GPU 驱动程序实际上是在每个驱动程序的基础上实现整个 OpenGL 库的。这意味着英伟达的某人(或者在这种情况下,可能是高通或 ARM)编写了编译着色器和读取缓冲区的代码。OpenGL 是这个 API 应该如何工作的规范。在我们的例子中,这是作为安卓一部分的 GL 类。

简单着色器

现在,我们将编写几个简单的着色器。我们的着色器代码将写在一个单独的文件中,该文件由我们的应用加载和编译。在MainActivity类的末尾添加以下功能:

   /**
     * Utility method for compiling a OpenGL shader.
     *
     * @param type - Vertex or fragment shader type.
     * @param resId - int containing the resource ID of the shader code file.
     * @return - Returns an id for the shader.
     */
    private int loadShader(int type, int resId){
        String code = readRawTextFile(resId);
        int shader = GLES20.glCreateShader(type);

        // add the source code to the shader and compile it
        GLES20.glShaderSource(shader, code);
        GLES20.glCompileShader(shader);

        return shader;
    }

    /**
     * Converts a raw text file into a string.
     *
     * @param resId The resource ID of the raw text file about to be turned into a shader.
     * @return The content of the text file, or null in case of error.
     */
    private String readRawTextFile(int resId) {
        InputStream inputStream = getResources().openRawResource(resId);
        try {
            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line).append("\n");
            }
            reader.close();
            return sb.toString();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

我们将调用loadShader加载一个着色器程序(通过readRawTextFile)并编译它。这段代码在其他项目中也很有用。

现在,我们将在res/raw/simple_vertex.shaderres/raw/simple_fragment.shader文件中编写几个简单的着色器。

项目文件层次视图中,在 Android Studio 的左侧,找到app/res/资源文件夹,右键点击,进入新建 | 安卓资源目录。在新建资源目录对话框中,从资源类型:中选择 Raw ,然后点击确定

右键点击新建 raw文件夹,转到新建 | 文件,命名为simple_vertex.shader。添加以下代码:

attribute vec4 a_Position;
void main() {
    gl_Position = a_Position;
}

同样,对于片段着色器,右键单击raw文件夹,转到新建 | 文件,并将其命名为simple_fragment.shader。添加以下代码:

precision mediump float;
uniform vec4 u_Color;
void main() {
    gl_FragColor = u_Color;
}

基本上,这些都是身份功能。顶点着色器通过给定的顶点,片段着色器通过给定的颜色。

请注意我们声明的参数名称:在simple_vertex中名为a_Position的属性和在simple_fragment中名为u_Color的统一变量。我们将通过MainActivity onSurfaceCreated方法设置这些。属性是每个顶点的属性,当我们为它们分配缓冲区时,它们必须都是等长的数组。您将遇到的其他属性是顶点法线、纹理坐标和顶点颜色。制服将用于指定适用于整个材质的信息,例如在这种情况下,适用于整个表面的纯色。

另外,注意gl_FragColorgl_Position变量是内置的变量名,OpenGL 正在寻找您来设置。把它们想象成着色器函数的返回。还有其他内置的输出变量,我们将在后面看到。

compileShaders 方法

我们现在准备实施onSurfaceCreated调用的compileShaders方法。

MainActivity的顶部添加以下变量:

    // Rendering variables
    private int simpleVertexShader;
    private int simpleFragmentShader;

执行 compileShaders,如下:

    private void compileShaders() {
        simpleVertexShader = loadShader(GLES20.GL_VERTEX_SHADER, R.raw.simple_vertex);
        simpleFragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, R.raw.simple_fragment);
    }

preparerenderingtriangel 方法

onSurfaceCreated方法通过为顶点缓冲区分配内存、创建 OpenGL 程序和初始化渲染管道句柄来为渲染做准备。我们现在将对我们的三角形进行此操作。

MainActivity上增加以下变量:

    // Rendering variables
    private int triProgram;
    private int triPositionParam;
    private int triColorParam;

以下是该函数的框架:

    private void prepareRenderingTriangle() {
        // Allocate buffers
        // Create GL program
        // Get shader params
    }

我们需要准备一些内存缓冲区,当渲染每个帧时,这些缓冲区将被传递给 OpenGL。这是我们的三角形和简单着色器的第一轮;我们现在只需要一个顶点缓冲区:

        // Allocate buffers
        // initialize vertex byte buffer for shape coordinates (4 bytes per float)
        ByteBuffer bb = ByteBuffer.allocateDirect(triCoords.length * 4);
        // use the device hardware's native byte order
        bb.order(ByteOrder.nativeOrder());

        // create a floating point buffer from the ByteBuffer
        triVerticesBuffer = bb.asFloatBuffer();
        // add the coordinates to the FloatBuffer
        triVerticesBuffer.put(triCoords);
        // set the buffer to read the first coordinate
        triVerticesBuffer.position(0);

这五行代码导致设置 triVerticesBuffer值,如下所示:

  • 分配了一个足够大的ByteBuffer来保存我们的三角形坐标值
  • 二进制数据被安排成与硬件的本机字节顺序相匹配
  • 该缓冲区被格式化为浮点格式,并被分配给我们的FloatBuffer 顶点缓冲区
  • 将三角形数据放入其中,然后我们将缓冲区光标位置重置为开头

接下来,我们构建 OpenGL ES 程序可执行文件。使用glCreateProgram创建一个空的 OpenGL ES 程序,并将其 ID 指定为triProgram。该标识也将用于其他方法。我们将任何着色器附加到程序,然后使用glLinkProgram构建可执行文件:

        // Create GL program
        // create empty OpenGL ES Program
        triProgram = GLES20.glCreateProgram();
        // add the vertex shader to program
        GLES20.glAttachShader(triProgram, simpleVertexShader);
        // add the fragment shader to program
        GLES20.glAttachShader(triProgram, simpleFragmentShader);
        // build OpenGL ES program executable
        GLES20.glLinkProgram(triProgram);
        // set program as current
        GLES20.glUseProgram(triProgram);

最后,我们得到渲染管道的句柄。调用a_Position上的glGetAttribLocation检索顶点缓冲区参数的位置,glEnableVertexAttribArray允许访问该参数,调用u_Color上的glGetUniformLocation检索颜色分量的位置。一旦我们到达onDrawEye,我们会很高兴做到这一点:

        // Get shader params
        // get handle to vertex shader's a_Position member
        triPositionParam = GLES20.glGetAttribLocation(triProgram, "a_Position");
        // enable a handle to the triangle vertices
        GLES20.glEnableVertexAttribArray(triPositionParam);
        // get handle to fragment shader's u_Color member
        triColorParam = GLES20.glGetUniformLocation(triProgram, "u_Color");

因此,我们已经隔离了在这个函数中准备三角形模型的绘图所需的代码。首先,它为顶点设置缓冲区。然后,它创建一个总帐程序,附加它将使用的着色器。然后,我们获得着色器中参数的句柄,我们将使用这些句柄进行绘制。

onDrawEye

准备,出发,出发!如果你想到我们到目前为止写的“准备就绪”部分,现在我们来做“开始”部分!即 app 启动并创建活动,调用 onCreate。创建表面并调用onSurfaceCreated来设置缓冲区和着色器。现在,随着应用的运行,每一帧的显示都会更新。走吧。

CardboardView.StereoRenderer接口委托这些方法。我们可以处理onNewFrame(稍后会处理)。现在,我们将只实现onDrawEye方法,它将从眼睛的角度绘制内容。这个方法被调用两次,每只眼睛调用一次。

现在onDrawEye需要做的就是渲染我们可爱的三角形。尽管如此,我们将把它分成一个单独的函数(稍后会有意义):

    @Override
    public void onDrawEye(Eye eye) {
        drawTriangle();
    }

    private void drawTriangle() {
        // Add program to OpenGL ES environment
        GLES20.glUseProgram(triProgram);

        // Prepare the coordinate data
        GLES20.glVertexAttribPointer(triPositionParam, COORDS_PER_VERTEX,
                GLES20.GL_FLOAT, false, 0, triVerticesBuffer);

        // Set color for drawing
        GLES20.glUniform4fv(triColorParam, 1, triColor, 0);

        // Draw the model
        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, triVertexCount);
    }

我们需要通过调用glUseProgram来指定使用哪个着色器程序。对glVertexAttribPointer 的调用将我们的顶点缓冲区设置为管道。我们还使用glUniform4fv设置颜色(4fv是指我们的制服是一个有四个浮子的矢量)。然后,我们实际上使用glDrawArrays进行绘制。

建造和运行

就这样。易·阿哈!那个没那么差吧?事实上,如果你熟悉安卓开发和 OpenGL,你可能已经轻松通过了。

让我们构建并运行它。转到 运行 | 运行‘app’,或者只需使用工具栏上的绿色三角形运行图标。

Gradle 将完成它的构建工作。选择 Android Studio 窗口底部的渐变控制台选项卡,查看渐变构建消息。然后,假设一切顺利,APK 文件将被安装在您连接的手机上(它已连接并打开,对吗?).选择底部的运行选项卡,查看上传和启动消息。

这是它显示的内容:

Building and running

实际上,它看起来有点像万圣节南瓜雕刻!装神弄鬼。但是在虚拟现实中,你只会看到一个三角形。

请注意,当三角形顶点坐标用直线定义边时,CardboardView用桶形失真渲染它,以补偿耳机中的镜头光学器件。另外,左图像和右图像不同,每只眼睛一个。当您将手机插入谷歌纸板耳机时,左右立体视图显示为一个三角形,漂浮在具有直边的空间中。

太好了!我们刚刚从头开始为安卓构建了一个简单的 Cardboard 应用。像任何安卓应用一样,有许多不同的部分需要定义,以便让一个基本的东西运行,包括AndroidManifest.xmlactivity_main.xmlMainActivity.java文件。

希望一切都按计划进行。像一个优秀的程序员一样,你可能已经在为语法错误和未处理的异常对帐户进行增量更改后构建并运行了应用。稍后,我们将调用 GLError 函数来检查来自 OpenGL 的错误信息。像往常一样,密切关注 logcat 中的错误(尝试过滤正在运行的应用)和变量名。着色器中可能存在语法错误,导致其编译失败,或者在尝试访问句柄时,属性/统一名称中可能存在拼写错误。这类事情不会导致任何编译时错误(着色器是在运行时编译的),您的应用会运行,但可能不会呈现任何结果。

3D 相机、透视和头部旋转

虽然这是(哈哈)很牛逼,但我们的应用有点无聊,不太像纸板。具体来说,它是立体的(双视图),有镜头畸变,但它还不是 3D 透视图,它不会随着你的头移动。我们现在要解决这个问题。

欢迎来到母体

不谈三维计算机图形学的矩阵数学,就谈不上为虚拟现实而开发。

什么是矩阵?答案就在那里,尼奥,它在找你,如果你愿意,它会找到你的。没错,是时候了解矩阵了。现在一切都不一样了。你的观点即将改变。

我们正在建立一个三维场景。空间中的每个位置都由 X、Y 和 Z 坐标描述。场景中的对象可以由 X、Y 和 Z 顶点构成。可以通过移动、缩放和/或旋转顶点来变换对象。这种转换可以用 16 个浮点值的矩阵(四行,每行四个浮点)进行数学表示。它在数学上是如何工作的很酷,但是我们在这里不讨论它。

矩阵可以通过相乘组合在一起。例如,如果您有一个表示调整对象大小(缩放)的矩阵和另一个要重新定位(平移)的矩阵,那么您可以制作第三个矩阵,通过将这两个矩阵相乘来表示调整大小和重新定位。但是你不能只使用原始的*操作符。另外,请注意,与简单的标量乘法不同,矩阵乘法是不可交换的。换句话说,我们知道 a * b = b * a 。但是,对于矩阵 A 和矩阵 B 来说, AB ≠ BA !矩阵安卓类库提供了做矩阵数学的函数。这里有一个例子:

// allocate the matrix arrays
float scale[] = new float[16];
float translate[] = new float[16];
float scaleAndTranslate[] = new float[16];

// initialize to Identity
Matrix.setIdentityM(scale, 0);
Matrix.setIdentityM(translate, 0);

// scale by 2, move by 5 in Z
Matrix.scaleM(scale, 0, 2.0, 2.0, 2.0);
Matrix.translateM(translate, 0, 0, 0.0, 0.0, 5.0);

// combine them with a matrix multiply
Matrix.multipyMM(scaleAndTranslate, 0, translate, 0, scale, 0);

请注意,由于矩阵乘法的工作方式,将向量乘以结果矩阵的效果与首先将其乘以比例矩阵(右侧),然后将其乘以平移矩阵(左侧)的效果相同。这与你的预期相反。

矩阵应用编程接口的文档可以在上找到。

这种矩阵材质会被大量使用。这里值得一提的是精度损失。如果您重复缩放和转换组合矩阵,您可能会从实际值中获得“漂移”,因为浮点计算会因舍入而丢失信息。这不仅是计算机图形学的问题,也是银行和比特币开采的问题!(还记得电影办公空间吗?)

这个矩阵数学的一个基本用途是将场景转换成用户视角下的屏幕图像(投影),这是我们马上需要的。

在一个纸板虚拟现实应用中,为了从一个特定的角度渲染场景,我们想到了一个朝着特定方向看的相机。相机像任何其他对象一样具有 X、Y 和 Z 位置,并向其查看方向旋转。在 VR 中,当你转动头部时,Cardboard SDK 会读取你手机中的运动传感器,确定当前的头部姿态(查看方向和角度),并给你的 app 对应的变换矩阵。

事实上,在每一帧的虚拟现实中,我们渲染了两个稍微不同的透视图:每只眼睛一个,由眼睛之间的实际距离(瞳距)偏移。

此外,在虚拟现实中,我们希望使用透视投影(相对于等距投影)渲染场景,以便离您更近的对象看起来比离您更远的对象更大。这也可以用 4 x 4 矩阵来表示。

我们可以将这些变换相乘,得到一个modelViewProjection矩阵:

modelViewProjection = modelTransform X camera  X  eyeView  X  perspectiveProjection

完整的modelViewProjection (MVP)变换矩阵是任何模型变换(例如,在场景中缩放或定位模型)与相机眼睛视图和透视投影的组合。

当 OpenGL 去绘制一个对象时,顶点着色器可以使用这个modelViewProjection矩阵来渲染几何图形。整个场景都是从用户的角度画出来的,沿着他的头指向的方向,每只眼睛都有一个透视投影,通过你的纸板观看者立体地呈现出来。VR MVP FTW!

MVP 顶点着色器

我们之前写的超级简单的顶点着色器并不变换每个顶点;它只是通过了管道的下一步。现在,我们希望它是 3D 感知的,并使用我们的modelViewProjection (MVP)变换矩阵。创建一个着色器来处理它。

在层次视图中,右键点击app/res/raw文件夹,转到新建 | 文件,输入名称,mvp_vertex.shader,点击确定。编写以下代码:

uniform mat4 u_MVP;
attribute vec4 a_Position;
void main() {
   gl_Position = u_MVP * a_Position;
}

该着色器几乎与simple_vertex相同,但通过u_MVP矩阵变换每个顶点。(请注意,虽然矩阵和向量与*相乘在 Java 中不起作用,但在着色器代码中起作用!)

替换compleShaders函数中的着色器资源,改为使用R.raw.mvp_vertex:

simpleVertexShader = loadShader(GLES20.GL_VERTEX_SHADER, R.raw.mvp_vertex)

设置透视观察矩阵

为了给我们的场景添加摄像机和视图,我们定义了几个变量。在MainActivity.java文件中,将以下代码添加到MainActivity类的开头:

// Viewing variables
private static final float Z_NEAR = 0.1f;
private static final float Z_FAR = 100.0f;
private static final float CAMERA_Z = 0.01f;

private float[] camera;
private float[] view;
private float[] modelViewProjection;

// Rendering variables
private int triMVPMatrixParam;

Z_NEARZ_FAR常数定义了深度平面,用于计算相机眼睛的透视投影。CAMERA_Z将是摄像机的位置(例如,在 X=0.0,Y=0.0,Z=0.01)。

triMVPMatrixParam变量将用于在我们改进的着色器中设置模型变换矩阵。

cameraviewmodelViewProjection矩阵将是用于透视计算的 4×4 矩阵(16 个浮点数的数组)。

onCreate中,我们初始化cameraviewmodelViewProjection矩阵:

    protected void onCreate(Bundle savedInstanceState) {
        //...

        camera = new float[16];
        view = new float[16];
        modelViewProjection = new float[16];
    }

prepareRenderingTriangle中,我们初始化triMVPMatrixParam变量:

// get handle to shape's transformation matrix
triMVPMatrixParam = GLES20.glGetUniformLocation(triProgram, "u_MVP");

类型

OpenGL 中的默认相机在原点(0,0,0),向下看负 Z 轴。换句话说,场景中的物体正对着摄像机的正 Z 轴。要将它们放在摄像机前面,请给它们一个 Z 值为负的位置。

在 3D 图形世界中,关于哪个轴是向上的,有一场长期(且毫无意义)的争论。我们可以莫名其妙地一致认为 X 轴向左向右,但是 Y 轴向上向下,还是 Z 轴?很多软件把 Z 作为上下方向,把 Y 定义为进出屏幕。另一方面,纸板软件开发工具包,统一,玛雅和许多其他选择相反。如果你认为坐标平面是画在绘图纸上的,这完全取决于你把纸放在哪里。如果你从上面往下看的时候想到这个图,或者在白板上画出来,那么 Y 就是纵轴。如果图形坐在你面前的桌子上,那么缺失的 Z 轴是垂直的,上下指向。无论如何,Cardboard SDK,也就是本书中的项目,将 Z 作为向前和向后轴。

透视渲染

设置好之后,我们现在可以为每一帧重新绘制屏幕了。

首先,设置摄像头位置。可以定义一次,如onCreate中。但是,通常在虚拟现实应用中,场景中的相机位置会发生变化,因此我们会为每一帧重置它。

首先要做的是将新帧开始时的相机矩阵重置为通用的正面方向。定义onNewFrame方法,如下:

    @Override
    public void onNewFrame(HeadTransform headTransform) {
        // Build the camera matrix and apply it to the ModelView.
        Matrix.setLookAtM(camera, 0, 0.0f, 0.0f, CAMERA_Z, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f);
    }

类型

注意,当你写Matrix的时候,Android Studio 会想自动导入包。确保你选择的导入是android.opengl.Matrix,而不是其他一些矩阵库,比如android.graphic.Matrix

现在,当需要从每只眼睛的视点绘制场景时,我们计算透视图矩阵。修改onDrawEye如下:

    public void onDrawEye(Eye eye) {
        GLES20.glEnable(GLES20.GL_DEPTH_TEST);
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);

        // Apply the eye transformation to the camera
        Matrix.multiplyMM(view, 0, eye.getEyeView(), 0, camera, 0);

        // Get the perspective transformation
        float[] perspective = eye.getPerspective(Z_NEAR, Z_FAR);

        // Apply perspective transformation to the view, and draw
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, view, 0);

        drawTriangle();
    }

我们添加的前两行重置了 OpenGL 深度缓冲区。在渲染 3D 场景时,除了每个像素的颜色,OpenGL 还会跟踪占据像素的物体距离眼睛的距离。如果为另一个对象渲染相同的像素,深度缓冲区将知道它应该是可见的(更近)还是被忽略的(更远)。(或者,颜色可能以某种方式组合在一起,例如透明度)。我们在为每只眼睛渲染任何几何图形之前清除缓冲区。颜色缓冲区,也就是你在屏幕上实际看到的颜色缓冲区,也被清除。否则,在这种情况下,您最终会用纯色填充整个屏幕。

现在,让我们继续观看变换。onDrawEye接收当前Eye物体,描述眼睛的立体渲染细节。特别地,eye.getEyeView()方法返回一个变换矩阵,包括头部跟踪旋转、位置移动和瞳孔间距离移动。换句话说,眼睛在场景中的位置以及它在看什么方向。虽然纸板不提供位置跟踪,但眼睛的位置确实会改变,以模拟虚拟头部。你的眼睛不是围绕一个中心轴旋转,而是你的头绕着你的脖子旋转,脖子离眼睛有一定的距离。因此,当纸板软件开发工具包检测到方向的变化时,两个虚拟摄像机会在场景中移动,就像它们是实际头部中的实际眼睛一样。

我们需要一个变换来表示这个眼睛位置的摄像机的透视图。如前所述,计算方法如下:

modelViewProjection = modelTransform  X  camera  X  eyeView  X  perspectiveProjection

我们将camera乘以眼睛视图变换(getEyeView),然后将结果乘以透视投影变换(getPerspective)。目前,我们不转换三角形模型本身,而将modelTransform矩阵排除在外。

结果(modelViewProjection)被传递到 OpenGL,由渲染管道中的着色器使用(通过glUniformMatrix4fv)。然后,我们画出我们的东西(通过前面写的glDrawArrays)。

现在,我们需要将视图矩阵传递给着色器程序。在drawTriangle方法中,添加如下:

    private void drawTriangle() {
        // Add program to OpenGL ES environment
        GLES20.glUseProgram(triProgram);

        // Pass the MVP transformation to the shader
        GLES20.glUniformMatrix4fv(triMVPMatrixParam, 1, false, modelViewProjection, 0);

        // . . .

建造和运行

让我们构建并运行它。转到运行 | 运行‘app’,或者只需使用工具栏上的绿色三角形运行图标。现在,移动手机将改变与您的视图方向同步的显示。将手机插入谷歌纸板浏览器,它就像虚拟现实一样(有点像)。

请注意,如果你的手机在 app 启动时是平躺在桌子上的,我们场景中的摄像头将直接面向下,而不是在我们的三角形处向前。更糟糕的是,当你拿起电话时,中性方向可能并不正对着你的前方。所以,每次你在这本书里运行应用的时候,先拿起手机,这样你就可以在虚拟现实中向前看,或者把手机撑着放在合适的位置(就我个人而言,我用的是 Gekkopod,在http://gekkopod.com/有售)。

另外,一般情况下,在设置对话框中,确保您的手机没有设置为锁定人像

重新定位三角形

我们的矩阵符真的给我们带来了位置。让我们走得更远。

我想把三角形移开。我们将通过设置另一个变换矩阵来实现这一点,然后在绘制时将其用于模型。

添加两个名为triTransformtriView的新矩阵:

    // Model variables
    private float[] triTransform;

    // Viewing variables
    private float[] triView;

同样在onCreate中初始化它们:

        triTransform = new float[16];
        triView = new float[16];

让我们在initializeScene方法中设置定位三角形的模型矩阵(由onSurfaceCreated调用)。我们将在 X 方向上偏移 5 个单位,在 z 方向上向后偏移 5 个单位。将以下代码添加到initializeScene中:

       // Position the triangle
        Matrix.setIdentityM(triTransform, 0);
        Matrix.translateM(triTransform, 0, 5, 0, -5);

最后,我们使用模型矩阵来构建onDrawEye中的modelViewProjection矩阵。修改onDrawEye,如下:

    public void onDrawEye(Eye eye) {
        ...
        // Apply perspective transformation to the view, and draw
        Matrix.multiplyMM(triView, 0, view, 0, triTransform, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, triView, 0);
        drawTriangle();
    }

构建并运行它。现在你会看到三角形在更远的地方,偏向一边。

再总结一个时间:modelViewProjection矩阵是三角形的位置变换(triTransform)、相机的位置和方向(camera)、基于手机运动传感器的CardboardView当前眼睛的视点(eye.getEyeView)和perspective投影的组合。当在屏幕上绘制三角形时,这个 MVP 矩阵被交给顶点着色器来确定它的实际位置。

你好,cube!

漂浮在三维空间中的一个扁平三角形可能很神奇,但与我们接下来要做的事情相比,它算不了什么:一个三维立方体!

立方体模型数据

只有三个顶点的三角形在MainActivity类中被声明,以保持例子简单。现在,我们将介绍更复杂的几何。我们将把它放在一个名为Cube的班级里。

好吧,它只是一个立方体,由八个不同的顶点组成,形成六个面,对吗?

嗯,GPU 更喜欢渲染三角形而不是四边形,所以将每个面细分为两个三角形;总共有 12 个三角形。要分别定义每个三角形,总共有 36 个顶点,有适当的缠绕方向,定义我们的模型,如CUBE_COORDS所示。为什么不直接定义八个顶点并重用它们呢?稍后我们将向您展示如何做到这一点。

请记住,我们总是需要小心顶点的缠绕顺序(逆时针方向),以便每个三角形的可见边面向外。

在 Android Studio,在左侧的安卓项目层次窗格中,找到你的 Java 代码文件夹(比如com.cardbookvr.cardboardbox)。右键点击,进入新建 | Java 类。然后,设置名称:立方体,点击确定。然后,编辑该文件,如下所示(请记住,本书中项目的代码可从出版商的网站和本书的公共 GitHub 存储库中下载):

package com.cardbookvr.cardboardbox;

public class Cube {

    public static final float[] CUBE_COORDS = new float[] {
        // Front face
        -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,

        // Right face
        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,

        // Back face
        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,

        // Left face
        -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,

        // Top face
        -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,

        // Bottom face
        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,
    };
}

立方码

回到MainActivity文件,我们只是复制/粘贴/编辑三角形代码,并将其重新用于立方体。显然,这并不理想,一旦我们看到一个好的模式,我们就可以将其中的一些抽象出来成为可重用的方法。此外,我们将使用与三角形相同的着色器,然后在下一节中,我们将使用更好的照明模型来替换它们。也就是说,我们将实现照明或 2D 艺术家可能称之为的阴影,这是我们到目前为止还没有做到的。

像三角形一样,我们声明了一堆我们需要的变量。显然,顶点计数应该来自新的Cube.CUBE_COORDS数组:

    // Model variables
    private static float cubeCoords[] = Cube.CUBE_COORDS;
    private final int cubeVertexCount = cubeCoords.length / COORDS_PER_VERTEX;
    private float cubeColor[] = { 0.8f, 0.6f, 0.2f, 0.0f }; // yellow-ish
    private float[] cubeTransform;
    private float cubeDistance = 5f;

    // Viewing variables
    private float[] cubeView;

    // Rendering variables
    private FloatBuffer cubeVerticesBuffer;
    private int cubeProgram;
    private int cubePositionParam;
    private int cubeColorParam;
    private int cubeMVPMatrixParam;

onCreate中添加以下代码:

        cubeTransform = new float[16];
        cubeView = new float[16];

onSurfaceCreated中添加以下代码:

        prepareRenderingCube();

写出prepareRenderingCube方法,如下:

private void prepareRenderingCube() {
        // Allocate buffers
        ByteBuffer bb = ByteBuffer.allocateDirect(cubeCoords.length * 4);
        bb.order(ByteOrder.nativeOrder());
        cubeVerticesBuffer = bb.asFloatBuffer();
        cubeVerticesBuffer.put(cubeCoords);
        cubeVerticesBuffer.position(0);

        // Create GL program
        cubeProgram = GLES20.glCreateProgram();
        GLES20.glAttachShader(cubeProgram, simpleVertexShader);
        GLES20.glAttachShader(cubeProgram, simpleFragmentShader);
        GLES20.glLinkProgram(cubeProgram);
        GLES20.glUseProgram(cubeProgram);

        // Get shader params
        cubePositionParam = GLES20.glGetAttribLocation(cubeProgram, "a_Position");
        cubeColorParam = GLES20.glGetUniformLocation(cubeProgram, "u_Color");
        cubeMVPMatrixParam = GLES20.glGetUniformLocation(cubeProgram, "u_MVP");

        // Enable arrays
        GLES20.glEnableVertexAttribArray(cubePositionParam);
    }

我们将立方体放置在 5 个单位之外,并在(1,1,0)的对角线轴上旋转 30 度。没有旋转,我们将只看到正面的正方形。在initializeScene中添加以下代码:

        // Rotate and position the cube
        Matrix.setIdentityM(cubeTransform, 0);
        Matrix.translateM(cubeTransform, 0, 0, 0, -cubeDistance);
        Matrix.rotateM(cubeTransform, 0, 30, 1, 1, 0);

将以下代码添加到onDrawEye计算 MVP 矩阵,包括cubeTransform矩阵,然后绘制立方体:

        Matrix.multiplyMM(cubeView, 0, view, 0, cubeTransform, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, cubeView, 0);
        drawCube();

drawCube法,和drawTri法很像,如下:

    private void drawCube() {
        GLES20.glUseProgram(cubeProgram);
        GLES20.glUniformMatrix4fv(cubeMVPMatrixParam, 1, false, modelViewProjection, 0);
        GLES20.glVertexAttribPointer(cubePositionParam, COORDS_PER_VERTEX,
                GLES20.GL_FLOAT, false, 0, cubeVerticesBuffer);
        GLES20.glUniform4fv(cubeColorParam, 1, cubeColor, 0);
        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, cubeVertexCount);
    }

构建并运行它。现在会看到立方体的 3D 视图,如下图截图所示。它需要遮光。

Cube code

照明和遮阳

我们需要在场景中引入一个光源,并提供一个将使用它的着色器。为此,立方体需要额外的数据,定义每个顶点的法向量和颜色。

顶点颜色并不总是着色所必需的,但是在我们的例子中,渐变是非常微妙的,不同颜色的面将帮助您区分立方体的边缘。我们还将在顶点着色器中进行着色计算,这是一种更快的方法(顶点比光栅像素少),但对平滑对象(如球体)效果不太好。要进行顶点照明,需要在管道中使用顶点颜色,因此使用这些颜色做一些事情也是有意义的。在这种情况下,我们为立方体的每个面选择不同的颜色。在这本书的后面,你会看到一个每像素照明的例子,以及它所带来的不同。

我们现在将构建应用来处理我们的发光立方体。我们将通过执行以下步骤来做到这一点:

  • 编写并编译一个新的光照着色器
  • 生成并定义立方体顶点法向量和颜色
  • 为渲染分配和设置数据缓冲区
  • 定义并设置用于渲染的光源
  • 生成并设置用于渲染的变换矩阵

添加着色器

让我们编写一个增强的顶点着色器,它可以使用一个光源和一个模型的顶点法线。

右键单击项目层次结构中的app/res/raw文件夹,转到新建 | 文件,并将其命名为light_vertex.shader。添加以下代码:

uniform mat4 u_MVP;
uniform mat4 u_MVMatrix;
uniform vec3 u_LightPos;

attribute vec4 a_Position;
attribute vec4 a_Color;
attribute vec3 a_Normal;

const float ONE = 1.0;
const float COEFF = 0.00001;

varying vec4 v_Color;

void main() {
   vec3 modelViewVertex = vec3(u_MVMatrix * a_Position);
   vec3 modelViewNormal = vec3(u_MVMatrix * vec4(a_Normal, 0.0));

   float distance = length(u_LightPos - modelViewVertex);
   vec3 lightVector = normalize(u_LightPos - modelViewVertex);
   float diffuse = max(dot(modelViewNormal, lightVector), 0.5);

   diffuse = diffuse * (ONE / (ONE + (COEFF * distance * distance)));
   v_Color = a_Color * diffuse;
   gl_Position = u_MVP * a_Position;
}

不需要浏览编写光照着色器的细节,就可以看到顶点颜色是根据光线和表面之间的角度以及光源离顶点有多远相关的公式计算出来的。请注意,我们还引入了ModelView矩阵和 MVP 矩阵。这意味着您将需要访问流程的两个步骤,并且您不能在完成后覆盖/丢弃 MV 矩阵。

请注意,我们使用了一个小的优化。数值文字(例如1.0)使用统一的空间,在某些硬件上,这可能会导致问题,所以我们改为声明常数(参考http://stackoverflow . com/questions/13963765/声明-常数-而不是文字-在顶点着色器中-标准-实践-或)。

与之前的简单着色器相比,这个着色器中有更多的变量要设置,用于照明计算。我们会把这些发送到抽奖方法。

我们还需要一个稍微不同的片段着色器。右键单击项目层次结构中的raw文件夹,转到新建 | 文件,并将其命名为passthrough_fragment.shader。添加以下代码:

precision mediump float;
varying vec4 v_Color;

void main() {
    gl_FragColor = v_Color;
}

片段着色器与简单着色器的唯一区别是,我们用变化的vec4 v_Color替换了统一的vec4 u_Color,因为颜色现在是从管道中的顶点着色器传入的。顶点着色器现在获得了一个颜色数组缓冲区。这是我们需要在设置/绘制代码中解决的一个新问题。

然后,在MainActivity中,添加这些变量:

    // Rendering variables
    private int lightVertexShader;
    private int passthroughFragmentShader;

compileShaders方法编译着色器:

        lightVertexShader = loadShader(GLES20.GL_VERTEX_SHADER,
                R.raw.light_vertex);
        passthroughFragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER,
                R.raw.passthrough_fragment);

立方体法线和颜色

立方体的每个面都以不同的方向向外,垂直于面。向量是 XYZ 坐标。归一化长度为 1 的一个可以用来表示这个方向,称为法向量

我们传递给 OpenGL 的几何图形被定义为顶点,而不是面。因此,我们需要为面的每个顶点提供一个法向量,如下图所示。严格来说,不是给定面上的所有顶点都必须面向同一个方向。这被用在一种叫做的平滑阴影技术中,在这种技术中,光照计算给出了曲面而不是平面的错觉。我们将对每个面使用相同的法线(硬边,这也节省了我们指定法线数据的时间。我们的数组只需要指定 6 个向量,就可以扩展成 36 个法向量的缓冲区。这同样适用于颜色值。

**Cube normals and colors

每个顶点也有一种颜色。假设立方体的每个面都是纯色,我们可以为该面的每个顶点分配相同的颜色。在Cube.java文件中,添加以下代码:

    public static final float[] CUBE_COLORS_FACES = new float[] {
        // Front, green
        0f, 0.53f, 0.27f, 1.0f,
        // Right, blue
        0.0f, 0.34f, 0.90f, 1.0f,
        // Back, also green
        0f, 0.53f, 0.27f, 1.0f,
        // Left, also blue
        0.0f, 0.34f, 0.90f, 1.0f,
        // Top, red
        0.84f,  0.18f,  0.13f, 1.0f,
        // Bottom, also red
        0.84f,  0.18f,  0.13f, 1.0f,
    };

    public static final float[] CUBE_NORMALS_FACES = new float[] {
        // Front face
        0.0f, 0.0f, 1.0f,
        // Right face
        1.0f, 0.0f, 0.0f,
        // Back face
        0.0f, 0.0f, -1.0f,
        // Left face
        -1.0f, 0.0f, 0.0f,
        // Top face
        0.0f, 1.0f, 0.0f,
        // Bottom face
        0.0f, -1.0f, 0.0f,
    };

对于立方体的每个面,我们定义了一个纯色(CUBE_COLORS_FACES)和一个法向量(CUBE_NORMALS_FACES)。

现在,编写一个可重用方法cubeFacesToArray,来生成MainActivity中实际需要的浮点数组。将以下代码添加到您的Cube类中:

    /**
     * Utility method for generating float arrays for cube faces
     *
     * @param model - float[] array of values per face.
     * @param coords_per_vertex - int number of coordinates per vertex.
     * @return - Returns float array of coordinates for triangulated cube faces.
     *               6 faces X 6 points X coords_per_vertex
     */
    public static float[] cubeFacesToArray(float[] model, int coords_per_vertex) {
        float coords[] = new float[6 * 6 * coords_per_vertex];
        int index = 0;
        for (int iFace=0; iFace < 6; iFace++) {
            for (int iVertex=0; iVertex < 6; iVertex++) {
                for (int iCoord=0; iCoord < coords_per_vertex; iCoord++) {
                    coords[index] = model[iFace*coords_per_vertex + iCoord];
                    index++ ;
                }
            }
        }
        return coords;
    }

将该数据与其他变量一起添加到MainActivity中,如下所示:

    // Model variables
    private static float cubeCoords[] = Cube.CUBE_COORDS;
    private static float cubeColors[] = Cube.cubeFacesToArray(Cube.CUBE_COLORS_FACES, 4);
    private static float cubeNormals[] = Cube.cubeFacesToArray(Cube.CUBE_NORMALS_FACES, 3);

也可以删除private float cubeColor[]的声明,因为现在不需要了。

有了正常的和颜色,着色器可以计算对象占据的每个像素的值。

准备顶点缓冲区

渲染管道要求我们为顶点、法线和颜色设置内存缓冲区。我们已经有了之前的顶点缓冲区,现在需要添加其他的。

添加变量,如下所示:

    // Rendering variables
    private FloatBuffer cubeVerticesBuffer;
    private FloatBuffer cubeColorsBuffer;
    private FloatBuffer cubeNormalsBuffer;

准备缓冲区,并将以下代码添加到prepareRenderingCube方法中(从onSurfaceCreated调用)。(这是全prepareRenderingCube法的前半部分):

    private void prepareRenderingCube() {
        // Allocate buffers
        ByteBuffer bb = ByteBuffer.allocateDirect(cubeCoords.length * 4);
        bb.order(ByteOrder.nativeOrder());
        cubeVerticesBuffer = bb.asFloatBuffer();
        cubeVerticesBuffer.put(cubeCoords);
        cubeVerticesBuffer.position(0);

        ByteBuffer bbColors = ByteBuffer.allocateDirect(cubeColors.length * 4);
 bbColors.order(ByteOrder.nativeOrder());
 cubeColorsBuffer = bbColors.asFloatBuffer();
 cubeColorsBuffer.put(cubeColors);
 cubeColorsBuffer.position(0);

 ByteBuffer bbNormals = ByteBuffer.allocateDirect(cubeNormals.length * 4);
 bbNormals.order(ByteOrder.nativeOrder());
 cubeNormalsBuffer = bbNormals.asFloatBuffer();
 cubeNormalsBuffer.put(cubeNormalParam);
 cubeNormalsBuffer.position(0);

        // Create GL program

准备着色器

定义了 lighting_vertex着色器后,我们需要添加参数句柄来使用它。在MainActivity类的顶部,再给光照着色器参数添加四个变量:

    // Rendering variables
    private int cubeNormalParam;
    private int cubeModelViewParam;
    private int cubeLightPosParam;

prepareRenderingCube方法(由onSurfaceCreated调用)中,附加lightVertexShaderpassthroughFragmentShader着色器,而不是简单的着色器,获取着色器参数,并启用数组,以便它们现在如下所示。(这是prepareRenderingCube的后半部分,继续上一节):

        // Create GL program
        cubeProgram = GLES20.glCreateProgram();
        GLES20.glAttachShader(cubeProgram, lightVertexShader);
        GLES20.glAttachShader(cubeProgram, passthroughFragmentShader);
        GLES20.glLinkProgram(cubeProgram);
        GLES20.glUseProgram(cubeProgram);

        // Get shader params
        cubeModelViewParam = GLES20.glGetUniformLocation(cubeProgram, "u_MVMatrix");
        cubeMVPMatrixParam = GLES20.glGetUniformLocation(cubeProgram, "u_MVP");
        cubeLightPosParam = GLES20.glGetUniformLocation(cubeProgram, "u_LightPos");

        cubePositionParam = GLES20.glGetAttribLocation(cubeProgram, "a_Position");
        cubeNormalParam = GLES20.glGetAttribLocation(cubeProgram, "a_Normal");
 cubeColorParam = GLES20.glGetAttribLocation(cubeProgram, "a_Color");

        // Enable arrays
        GLES20.glEnableVertexAttribArray(cubePositionParam);
        GLES20.glEnableVertexAttribArray(cubeNormalParam);
 GLES20.glEnableVertexAttribArray(cubeColorParam);

如果你参考我们之前写的着色器代码,你会注意到这些对glGetUniformLocationglGetAttribLocation的调用对应于那些脚本中声明的uniformattribute参数,包括cubeColorParamu_Color到现在a_Color的变化。OpenGL 不需要这种重命名,但它可以帮助我们区分顶点属性和制服。

必须启用引用数组缓冲区的着色器属性。

添加光源

接下来,我们将在场景中添加一个光源,并在绘制时告诉着色器它的位置。灯将位于用户正上方。

MainActivity顶部,给灯光位置添加变量:

    // Scene variables
    // light positioned just above the user
    private static final float[] LIGHT_POS_IN_WORLD_SPACE = new float[] { 0.0f, 2.0f, 0.0f, 1.0f };
    private final float[] lightPosInEyeSpace = new float[4];

通过将以下代码添加到onDrawEye来计算灯光的位置:

        // Apply the eye transformation to the camera
        Matrix.multiplyMM(view, 0, eye.getEyeView(), 0, camera, 0);

        // Calculate position of the light
        Matrix.multiplyMV(lightPosInEyeSpace, 0, view, 0, LIGHT_POS_IN_WORLD_SPACE, 0);

请注意,我们正在使用view矩阵(眼睛view * camera)使用Matrix.multiplyMV功能将光线位置转换到当前视图空间。

现在,我们只告诉着色器它需要的光线位置和观察矩阵。修改drawCube 方法(由onDrawEye调用),如下:

    private void drawCube() {
        GLES20.glUseProgram(cubeProgram);

        // Set the light position in the shader
 GLES20.glUniform3fv(cubeLightPosParam, 1, lightPosInEyeSpace, 0);

        // Set the ModelView in the shader, used to calculate lighting
 GLES20.glUniformMatrix4fv(cubeModelViewParam, 1, false, cubeView, 0);

        GLES20.glUniformMatrix4fv(cubeMVPMatrixParam, 1, false, modelViewProjection, 0);

        GLES20.glVertexAttribPointer(cubePositionParam, COORDS_PER_VERTEX,
                GLES20.GL_FLOAT, false, 0, cubeVerticesBuffer);
        GLES20.glVertexAttribPointer(cubeNormalParam, 3, GLES20.GL_FLOAT, false, 0,
 cubeNormalsBuffer);
 GLES20.glVertexAttribPointer(cubeColorParam, 4, GLES20.GL_FLOAT, false, 0,
 cubeColorsBuffer);

        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, cubeVertexCount);
    }

构建和运行应用

我们现在准备好出发了。当您构建并运行该应用时,您将看到类似于以下截图的屏幕:

Building and running the app

旋转立方体

下一步是快速的一步。让我们让立方体旋转。这是通过为每一帧稍微旋转cubeTransform矩阵来实现的。我们可以为此定义一个TIME_DELTA值。添加静态变量,如下所示:

    // Viewing variables
    private static final float TIME_DELTA = 0.3f;

然后,修改每一帧的cubeTransform,并在onNewFrame方法中添加以下一行代码:

Matrix.rotateM(cubeTransform, 0, TIME_DELTA, 0.5f, 0.5f, 1.0f);

Matrix.rotateM函数基于角度和轴对变换矩阵进行旋转。在这种情况下,我们围绕轴向量(0.5,0.5,1)旋转TIME_DELTA一个角度。严格来说,你应该提供一个归一化的轴,但重要的是向量的方向,而不是大小。

构建并运行它。现在立方体正在旋转。动物化!

你好,地板!

在虚拟现实中,有一种脚踏实地的感觉可能很重要。感觉自己站着(或坐着)比像一个没有身体的眼球一样漂浮在太空中要舒服得多。那么,让我们在场景中添加一层。

这个现在应该熟悉多了。我们将有一个类似于立方体的着色器、模型和渲染管道。所以,我们就不做解释了。

着色器

地板将使用我们的light_shader与一个小的修改和一个新的片段着色器。

通过添加v_Grid变量修改light_vertex.shader,如下所示:

uniform mat4 u_Model;
uniform mat4 u_MVP;
uniform mat4 u_MVMatrix;
uniform vec3 u_LightPos;

attribute vec4 a_Position;
attribute vec4 a_Color;
attribute vec3 a_Normal;

varying vec4 v_Color;
varying vec3 v_Grid;

const float ONE = 1.0;
const float COEFF = 0.00001;

void main() {
 v_Grid = vec3(u_Model * a_Position);

    vec3 modelViewVertex = vec3(u_MVMatrix * a_Position);
    vec3 modelViewNormal = vec3(u_MVMatrix * vec4(a_Normal, 0.0));

    float distance = length(u_LightPos - modelViewVertex);
    vec3 lightVector = normalize(u_LightPos - modelViewVertex);
    float diffuse = max(dot(modelViewNormal, lightVector), 0.5);

    diffuse = diffuse * (ONE / (ONE + (COEFF * distance * distance)));
    v_Color = a_Color * diffuse;
    gl_Position = u_MVP * a_Position;
}

在名为grid_fragment.shaderapp/res/raw中创建新着色器,如下所示:

precision mediump float;
varying vec4 v_Color;
varying vec3 v_Grid;

void main() {
    float depth = gl_FragCoord.z / gl_FragCoord.w; // Calculate world-space distance.

    if ((mod(abs(v_Grid.x), 10.0) < 0.1) || (mod(abs(v_Grid.z), 10.0) < 0.1)) {
        gl_FragColor = max(0.0, (90.0-depth) / 90.0) * vec4(1.0, 1.0, 1.0, 1.0)
                + min(1.0, depth / 90.0) * v_Color;
    } else {
        gl_FragColor = v_Color;
    }
}

这看起来可能很复杂,但我们所做的只是在纯色着色器上绘制一些网格线。if语句将检测我们是否在 10 的倍数的 0.1 个单位内。如果是这样,我们根据像素的深度或它与相机的距离,画一个介于白色(1,1,1,1)和v_Color之间的颜色。gl_FragCoord是一个内置值,它给出了我们在窗口空间中渲染的像素的位置以及深度缓冲区(z)中的值,该值将在[0,1]的范围内。第四个参数w,本质上是相机绘制距离的倒数,当与深度值结合时,给出像素的世界空间深度。v_Grid变量实际上已经根据我们在顶点着色器中引入的局部顶点位置和模型矩阵,为我们提供了当前像素的世界空间位置。

MainActivity中,为新片段着色器添加一个变量:

    // Rendering variables
    private int gridFragmentShader;

compileShaders方法编译着色器,如下所示:

        gridFragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER,
                R.raw.grid_fragment);

楼层模型数据

在项目中创建新的名为Floor的 Java 文件。添加地板平面坐标、法线和颜色:

    public static final float[] FLOOR_COORDS = new float[] {
        200f, 0, -200f,
        -200f, 0, -200f,
        -200f, 0, 200f,
        200f, 0, -200f,
        -200f, 0, 200f,
        200f, 0, 200f,
    };

    public static final float[] FLOOR_NORMALS = new float[] {
        0.0f, 1.0f, 0.0f,
        0.0f, 1.0f, 0.0f,
        0.0f, 1.0f, 0.0f,
        0.0f, 1.0f, 0.0f,
        0.0f, 1.0f, 0.0f,
        0.0f, 1.0f, 0.0f,
    };

    public static final float[] FLOOR_COLORS = new float[] {
            0.0f, 0.34f, 0.90f, 1.0f,
            0.0f, 0.34f, 0.90f, 1.0f,
            0.0f, 0.34f, 0.90f, 1.0f,
            0.0f, 0.34f, 0.90f, 1.0f,
            0.0f, 0.34f, 0.90f, 1.0f,
            0.0f, 0.34f, 0.90f, 1.0f,
    };

变量

将我们需要的所有变量添加到MainActivity:

    // Model variables
    private static float floorCoords[] = Floor.FLOOR_COORDS;
    private static float floorColors[] = Floor.FLOOR_COLORS;
    private static float floorNormals[] = Floor.FLOOR_NORMALS;
    private final int floorVertexCount = floorCoords.length / COORDS_PER_VERTEX;
    private float[] floorTransform;
    private float floorDepth = 20f;

    // Viewing variables
    private float[] floorView;

    // Rendering variables
    private int gridFragmentShader;

    private FloatBuffer floorVerticesBuffer;
    private FloatBuffer floorColorsBuffer;
    private FloatBuffer floorNormalsBuffer;
    private int floorProgram;
    private int floorPositionParam;
    private int floorColorParam;
    private int floorMVPMatrixParam;
    private int floorNormalParam;
    private int floorModelParam;
    private int floorModelViewParam;
    private int floorLightPosParam;

onCreate

onCreate中分配矩阵:

        floorTransform = new float[16];
        floorView = new float[16];

onSurfaceCreated

onSufraceCreated中添加对 prepareRenderingFloor的调用,我们写如下:

        prepareRenderingFloor();

初始化场景

initializeScene方法中设置 floorTransform矩阵:

        // Position the floor
        Matrix.setIdentityM(floorTransform, 0);
        Matrix.translateM(floorTransform, 0, 0, -floorDepth, 0);

准备转嫁地板

下面是完成prepareRenderingFloor的方法:

    private void prepareRenderingFloor() {
        // Allocate buffers
        ByteBuffer bb = ByteBuffer.allocateDirect(floorCoords.length * 4);
        bb.order(ByteOrder.nativeOrder());
        floorVerticesBuffer = bb.asFloatBuffer();
        floorVerticesBuffer.put(floorCoords);
        floorVerticesBuffer.position(0);

        ByteBuffer bbColors = ByteBuffer.allocateDirect(floorColors.length * 4);
        bbColors.order(ByteOrder.nativeOrder());
        floorColorsBuffer = bbColors.asFloatBuffer();
        floorColorsBuffer.put(floorColors);
        floorColorsBuffer.position(0);

        ByteBuffer bbNormals = ByteBuffer.allocateDirect(floorNormals.length * 4);
        bbNormals.order(ByteOrder.nativeOrder());
        floorNormalsBuffer = bbNormals.asFloatBuffer();
        floorNormalsBuffer.put(floorNormals);
        floorNormalsBuffer.position(0);

        // Create GL program
        floorProgram = GLES20.glCreateProgram();
        GLES20.glAttachShader(floorProgram, lightVertexShader);
        GLES20.glAttachShader(floorProgram, gridFragmentShader);
        GLES20.glLinkProgram(floorProgram);
        GLES20.glUseProgram(floorProgram);

        // Get shader params
        floorPositionParam = GLES20.glGetAttribLocation(floorProgram, "a_Position");
        floorNormalParam = GLES20.glGetAttribLocation(floorProgram, "a_Normal");
        floorColorParam = GLES20.glGetAttribLocation(floorProgram, "a_Color");

        floorModelParam = GLES20.glGetUniformLocation(floorProgram, "u_Model");
        floorModelViewParam = GLES20.glGetUniformLocation(floorProgram, "u_MVMatrix");
        floorMVPMatrixParam = GLES20.glGetUniformLocation(floorProgram, "u_MVP");
        floorLightPosParam = GLES20.glGetUniformLocation(floorProgram, "u_LightPos");

        // Enable arrays
        GLES20.glEnableVertexAttribArray(floorPositionParam);
        GLES20.glEnableVertexAttribArray(floorNormalParam);
        GLES20.glEnableVertexAttribArray(floorColorParam);
    }

onDrawEye

计算 MVP 并在onDrawEye中画出楼层:

        Matrix.multiplyMM(floorView, 0, view, 0, floorTransform, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, floorView, 0);
        drawFloor();

drawFloor

定义一个 drawFloor方法,如下:

    private void drawFloor() {
        GLES20.glUseProgram(floorProgram);
        GLES20.glUniform3fv(floorLightPosParam, 1, lightPosInEyeSpace, 0);
        GLES20.glUniformMatrix4fv(floorModelParam, 1, false, floorTransform, 0);
        GLES20.glUniformMatrix4fv(floorModelViewParam, 1, false, floorView, 0);
        GLES20.glUniformMatrix4fv(floorMVPMatrixParam, 1, false, modelViewProjection, 0);
        GLES20.glVertexAttribPointer(floorPositionParam, COORDS_PER_VERTEX,
                GLES20.GL_FLOAT, false, 0, floorVerticesBuffer);
        GLES20.glVertexAttribPointer(floorNormalParam, 3, GLES20.GL_FLOAT, false, 0,
                floorNormalsBuffer);
        GLES20.glVertexAttribPointer(floorColorParam, 4, GLES20.GL_FLOAT, false, 0,
                floorColorsBuffer);
        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, floorVertexCount);
    }

构建并运行它。现在看起来像下面的截图:

drawFloor

哇!

嘿,看这个!

在项目的最后一部分,我们添加了一个功能,它可以检测您何时在看一个对象(立方体),并以不同的颜色突出显示它。

这是在CardboardView接口方法onNewFrame的帮助下完成的,它传递当前的头部变换信息。

is looking to object 方法

让我们从最有趣的部分开始。我们将从谷歌的寻宝演示中借用isLookingAtObject方法。它通过计算对象在眼睛空间中的位置来检查用户是否在看对象,如果用户在看对象,则返回 true。将以下代码添加到MainActivity:

/**
     * Check if user is looking at object by calculating where the object is in eye-space.
     *
     * @return true if the user is looking at the object.
     */
    private boolean isLookingAtObject(float[] modelView, float[] modelTransform) {
        float[] initVec = { 0, 0, 0, 1.0f };
        float[] objPositionVec = new float[4];

        // Convert object space to camera space. Use the headView from onNewFrame.
        Matrix.multiplyMM(modelView, 0, headView, 0, modelTransform, 0);
        Matrix.multiplyMV(objPositionVec, 0, modelView, 0, initVec, 0);

        float pitch = (float) Math.atan2(objPositionVec[1], -objPositionVec[2]);
        float yaw = (float) Math.atan2(objPositionVec[0], -objPositionVec[2]);

        return Math.abs(pitch) < PITCH_LIMIT && Math.abs(yaw) < YAW_LIMIT;
    }

该方法采用两个参数:我们要测试的对象的modelViewmodelTransform变换矩阵。它还引用了headView类变量,我们将在onNewFrame中设置。

更精确的方法可能是从摄像机向摄像机观察的方向投射光线,并确定光线是否与场景中的任何几何图形相交。这将是非常有效的,但也非常昂贵的计算。

相反,这个函数采用了一种更简单的方法,甚至不使用对象的几何形状。而是使用对象的视图变换来确定对象离屏幕中心有多远,并测试该向量的角度是否在狭窄范围内(PITCH_LIMITYAW_LIMIT)。是的,我知道,人们让博士想出这种东西!

让我们定义我们需要的变量如下:

    // Viewing variables
    private static final float YAW_LIMIT = 0.12f;
    private static final float PITCH_LIMIT = 0.12f;

    private float[] headView;

onCreate中分配headView:

        headView = new float[16];

获取每个新帧的当前headView值。将以下代码添加到onNewFrame:

        headTransform.getHeadView(headView, 0);

然后,修改drawCube检查用户是否在看立方体,并决定使用哪些颜色:

        if (isLookingAtObject(cubeView, cubeTransform)) {
            GLES20.glVertexAttribPointer(cubeColorParam, 4, GLES20.GL_FLOAT, false, 0,
                    cubeFoundColorsBuffer);
        } else {
            GLES20.glVertexAttribPointer(cubeColorParam, 4, GLES20.GL_FLOAT, false, 0,
                    cubeColorsBuffer);
        }

就是这样!除了一个(次要的)细节:我们需要第二组顶点颜色用于高光模式。我们将通过用相同的黄色绘制所有面来突出立方体。为了做到这一点,需要做一些改变。

Cube中,添加以下 RGBA 值的:

    public static final float[] CUBE_FOUND_COLORS_FACES = new float[] {
        // Same yellow for front, right, back, left, top, bottom faces
        1.0f,  0.65f, 0.0f, 1.0f,
        1.0f,  0.65f, 0.0f, 1.0f,
        1.0f,  0.65f, 0.0f, 1.0f,
        1.0f,  0.65f, 0.0f, 1.0f,
        1.0f,  0.65f, 0.0f, 1.0f,
        1.0f,  0.65f, 0.0f, 1.0f,
    };

MainActivity中,添加这些变量:

    // Model variables
    private static float cubeFoundColors[] = Cube.cubeFacesToArray(Cube.CUBE_FOUND_COLORS_FACES, 4);

    // Rendering variables
    private FloatBuffer cubeFoundColorsBuffer;

将以下代码添加到prepareRenderingCube方法中:

        ByteBuffer bbFoundColors = ByteBuffer.allocateDirect(cubeFoundColors.length * 4);
        bbFoundColors.order(ByteOrder.nativeOrder());
        cubeFoundColorsBuffer = bbFoundColors.asFloatBuffer();
        cubeFoundColorsBuffer.put(cubeFoundColors);
        cubeFoundColorsBuffer.position(0);

构建并运行它。当你直视立方体时,它会高亮显示。

类型

如果立方体不是那么近,可能会更有趣更有挑战性。尝试将cubeDistance设置为类似 12f 的值。

就像寻宝演示一样,尝试在每次查看立方体位置时为其设置一组新的随机值。现在,你有一个游戏!

总结

在这一章中,我们从头开始构建了一个 Cardboard Android 应用,从一个新项目开始,一次添加一点 Java 代码。在我们的第一个构建中,我们有一个三角形的立体视图,您可以在谷歌纸板耳机中看到。

然后我们添加了模型转换、三维相机视图、透视和头部旋转转换,并讨论了一些矩阵数学。我们建立了一个立方体的三维模型,然后创建了着色器程序来使用光源渲染带有阴影的立方体。我们还制作了立方体的动画,并添加了地板网格。最后,我们添加了一个功能,当用户查看立方体时,它会高亮显示。

在此过程中,我们享受了关于 3D 几何、OpenGL、着色器、用于 3D 透视查看的矩阵数学、几何法线和渲染管道的数据缓冲区的良好讨论。我们还开始思考如何将代码中的常见模式抽象成可重用的方法。

在下一章中,我们将采用不同的方法来使用安卓布局视图进行立体渲染,以构建一个有用的“虚拟大厅”,该大厅可以用作 3D 菜单系统或进入其他世界的门户。**