八、三维模型查看器

三维模型无处不在,从机械零件的机械工程到医学影像;从电子游戏设计到 3D 打印。3D 模型像照片、视频、音乐和其他媒体一样丰富。然而,虽然浏览器和应用对其他媒体类型有本地支持,但 3D 模型却没有这么多。总有一天 3D 观看标准会集成到浏览器中(比如 WebGL 和 WebVR)。在那之前,我们将不得不依靠插件和姐妹应用来查看我们的模型。例如,在网上可以找到 OBJ 格式的免费 3D 文件模型,包括 TF3DM(http://tf3dm.com/)、TurboSquid(http://www.turbosquid.com/)和其他许多模型(http://www . hongkiat . com/blog/60-卓越-免费-3D-model-网站/ )。

在这个项目中,我们将构建一个安卓 3D 模型查看器应用,让您使用纸板虚拟现实耳机以 3D 方式打开和查看模型。我们将使用的文件格式是 OBJ,这是一种由波前技术公司首先为电影 3D 动画开发的开放格式。OBJs 可以由许多 3D 设计应用创建和导出,包括开源应用,如 Blender 和 MeshLab,以及商业应用,如 3D Studio Max 和 Maya。OBJ 是一个非压缩的纯文本文件,它存储由三角形(或更高度数的多边形)组成的三维对象的表面网格的描述。

为了实现查看器,我们将读取和解析 OBJ 文件模型,并以 3D 形式显示它们,以便用纸板进行查看。我们将通过执行以下步骤来实现这一点:

  • 设置新项目
  • 编写 OBJ 文件解析器来导入几何图形
  • 显示三维模型
  • 使用用户的头部运动旋转对象的视图

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

建立新项目

为了构建这个项目,我们将使用在第 5 章RenderBox 引擎中创建的RenderBox库。您可以使用您的,或者从本书提供的可下载文件或我们的 GitHub 存储库中获取一份副本(使用提交标记的after-ch7https://GitHub . com/cardbookr/renderboxlib/releases/tag/after-ch7)。关于如何导入RenderBox库的更详细描述,请参考第 5 章、RenderBox 引擎的最后一节在未来项目中使用 RenderBox。要创建新项目,请执行以下步骤:

  1. Android Studio 打开后,创建一个新项目。让我们将其命名为Gallery360,并以空活动为目标安卓 4.4 KitKat (API 19)
  2. 使用文件 | 新模块 | 导入,为renderboxcommoncore包创建新模块。JAR/。AAR 包装
  3. 使用文件 | 项目结构,将模块设置为应用的依赖项。
  4. 按照第 2 章框架纸板项目中的说明编辑build.gradle文件,根据 SDK 22 进行编译。
  5. 更新/res/layout/activity_main.xmlAndroidManifest.xml,如前几章所述。
  6. MainActivity编辑为class MainActivity extends CardboardActivity implements IRenderBox,实现接口方法存根( Ctrl + I )。

我们可以在MainActivity中定义onCreate方法。该类现在具有以下代码:

public class MainActivity extends CardboardActivity implements IRenderBox {
    private static final String TAG = "ModelViewer";
    CardboardView cardboardView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
        cardboardView.setRenderer(new RenderBox(this, this));
        setCardboardView(cardboardView);
    }
    @Override
    public void setup() {
    }
    @Override
    public void preDraw() {
        // code run beginning each frame
    }
    @Override
    public void postDraw() {
        // code run end of each frame
    }
}

您可以暂时在场景中添加一个立方体,以帮助确保一切设置正确。将其添加到setup方法中,如下:

    public void setup() {
        new Transform()
            .setLocalPosition(0,0,-7)
            .setLocalRotation(45,60,0)
            .addComponent(new Cube(true));
    }

如果你记得的话,一个Cube就是一个Component,加到一个Transform上。Cube定义其几何形状(例如顶点)。Transform定义其在三维空间中的位置、旋转和缩放。

您应该能够在没有编译错误的情况下点击运行‘应用’,并在您的安卓设备上看到立方体和纸板分屏视图。

了解 OBJ 文件格式

本项目的目标是以波前 OBJ 格式查看三维模型。在开始编码之前,我们先来看看文件格式。可以在http://www.fileformat.info/format/wavefrontobj/egff.htm找到参考。

众所周知,三维模型可以表示为一个由 X、Y 和 Z 顶点组成的网格。顶点集被连接以定义网格表面的一个面。全网格曲面是这些面的集合。

还可以为每个顶点指定一个法向量和/或纹理坐标。法线向量定义了顶点处的朝外方向,用于照明计算。紫外线纹理坐标可用于将纹理图像映射到网格表面。该格式还有其他功能,包括自由形式的曲线和材质,我们在本项目中不支持。

作为纯文本文件,OBJ 被组织为单独的文本行。每一个非空行都以一个关键字和该关键字的数据开始,用空格分隔。注释以#开头,被解析器忽略。

OBJ 数据关键词包括:

  • v:几何顶点(例如v 0.0 1.0 0.0)
  • vt:纹理顶点(例如vt 0.0 1.0 0.0)【我们的项目不支持】
  • vn:顶点法线(例如vn 0.0 1.0 0.0)
  • f:多边形面索引(例如f 1 2 3)

表面值是指向顶点列表中顶点的索引(第一个从 1 开始)。

至于指定面索引的f命令,它们是索引到顶点列表中的整数值。当有三个指数时,它描述一个三角形;四描述一个四边形,以此类推。

当纹理顶点存在时,它们被引用为斜线后的第二个数字,例如f 1/1 2/2 3/3。我们现在不支持它们,但是我们可能需要在f命令中解析它们。当顶点法线存在时,它们被引用为斜线后的第三个数字,例如f 1//1 2//2 3//3f 1/1/1 2/2/2 3/3/3

索引可以是负数,在这种情况下,它们将最后一个(最近遇到的)项目引用为-1,将前一个引用为-2,依此类推。

其他行,包括我们这里不支持的数据,将被忽略。

例如,以下数据表示一个简单的三角形:

# Simple Wavefront file
v 0.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 0.0 0.0
f 1 2 3

我们在 OBJ 的实施是有限的。它可以安全地处理本书中包含的示例模型,以及您可能会在互联网上找到或自己制作的其他模型。但是,这是一个示例代码和演示项目。编写一个健壮的数据导入器并在我们的RenderBox引擎中支持 OBJ 的许多特性超出了本书的范围。

创建模型对象类

首先,我们将定义一个扩展RenderObjectModelObject类。它将从 OBJ 文件加载模型数据,并设置其材质所需的缓冲区(以及要在虚拟现实场景中渲染的 OpenGL ES 着色器)。

右键点击app/java/com.cardboardvr.modelviewer/文件夹,转到新建 | Java 类,命名为ModelObject。将其定义为extends RenderObject,如下所示:

public class ModelObject extends RenderObject {
}

就像我们在前面几章中所做的一样,当引入新的RenderObjects时,我们将有一个或多个构造函数可以实例化一个Material并设置缓冲区。对于ModelObject,我们将传入一个文件资源句柄,解析该文件(参考下一个主题),并创建一个纯色材质(最初,没有灯光),如下所示:

    public ModelObject(int objFile) {
        super();
        InputStream inputStream = RenderBox.instance.mainActivity.getResources().openRawResource(objFile);
        if (inputStream == null)
            return; // error
        parseObj(inputStream);
        createMaterial();
    }

现在添加如下材质。首先,为缓冲区声明变量(就像我们在前面的项目中为其他RenderObjects所做的那样)。这些可以是私有的,但如果我们想在外部定义新材质,我们的惯例是保持它们的公共性:

    public static FloatBuffer vertexBuffer;
    public static FloatBuffer colorBuffer;
    public static FloatBuffer normalBuffer;
    public static ShortBuffer indexBuffer;
    public int numIndices;

下面是createMaterial方法(从构造函数调用):

    public ModelObject createMaterial(){
        SolidColorLightingMaterial scm = new SolidColorLightingMaterial(new float[]{0.5f, 0.5f, 0.5f, 1});
        scm.setBuffers(vertexBuffer, normalBuffer, indexBuffer, numIndices);
        material = scm;
        return this;
    }

接下来,我们实现parseObj方法。

解析 OBJ 模型

parseObj方法将资源文件作为InputStream打开。它一次读取一行,解析命令和数据,构建模型的顶点、法线和索引列表。然后,我们从数据中构建缓冲区。

首先,在ModelObject类的顶部,为数据列表声明变量:

    Vector<Short> faces=new Vector<Short>();
    Vector<Short> vtPointer=new Vector<Short>();
    Vector<Short> vnPointer=new Vector<Short>();
    Vector<Float> v=new Vector<Float>();
    Vector<Float> vn=new Vector<Float>();
    Vector<Material> materials=null;

让我们用助手方法的占位符来编写parseObj。我们打开文件,处理每一行,构建缓冲区,并处理潜在的 IO 错误:

    void parseObj(InputStream inputStream) {
        BufferedReader reader = null;
        String line = null;

        reader = new BufferedReader(new InputStreamReader(inputStream));
        if (reader == null)
            return; // error

        try { // try to read lines of the file
            while ((line = reader.readLine()) != null) {
                parseLine(line);
            }
            buildBuffers();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

parseLine代码相当简单。该行的第一个标记是一个或两个字符的命令(如vvnf,后跟数据值(浮点坐标或整数索引)。下面是parseLine的代码和vvn顶点的解析器:

    private void parseLine(String line) {
        Log.v("obj", line);
        if(line.startsWith("f")){//a polygonal face
            processFLine(line);
        }
        else
        if(line.startsWith("vn")){
            processVNLine(line);
        }
        else
        if(line.startsWith("v")){ //line having geometric position of single vertex
            processVLine(line);
        }
    }

    private void processVLine(String line){
        String [] tokens=line.split("[ ]+"); 
        //split the line at the spaces
        int c=tokens.length;
        for(int i=1; i<c; i++){ //add the vertex to the vertex array
            v.add(Float.valueOf(tokens[i]));
        }
    }

    private void processVNLine(String line){
        String [] tokens=line.split("[ ]+"); 
        //split the line at the spaces
        int c=tokens.length;
        for(int i=1; i<c; i++){ //add the vertex to the vertex array
            vn.add(Float.valueOf(tokens[i]));
        }
    }

f线需要处理各种价值情况。

至于指定面索引的f命令,它们是索引到顶点列表中的整数值。当有三个索引时,它描述一个三角形,四个描述一个四边形,依此类推。任何超过三条边的东西都需要细分成三角形,以便我们用 OpenGL ES 渲染。

此外,索引值可以有任何组合,包括vv/vtv/vt/vn等格式,甚至还有v//vn/vt/vn//vn等格式。(请记住,由于我们没有映射纹理,我们将只使用第一个和第三个。)

让我们先处理最简单的情况,一个三角形面:

    private void processFLine(String line){
        String [] tokens=line.split("[ ]+");
        int c=tokens.length;

        if(tokens[1].matches("[0-9]+")){//f: v
            if(c==4){//3 faces
                for(int i=1; i<c; i++){
                    Short s=Short.valueOf(tokens[i]);
                    s--;
                    faces.add(s);
                }
            }
        }
    }

现在考虑一下脸上有三个以上的指数。我们需要一种三角化多边形的方法。让我们现在写下来:

    public static Vector<Short> triangulate(Vector<Short> polygon){
        Vector<Short> triangles=new Vector<Short>();
        for(int i=1; i<polygon.size()-1; i++){
            triangles.add(polygon.get(0));
            triangles.add(polygon.get(i));
            triangles.add(polygon.get(i+1));
        }
        return triangles;
    }

我们可以在processFLine中使用它:

    private void processFLine(String line) {
        String[] tokens = line.split("[ ]+");
        int c = tokens.length;

        if (tokens[1].matches("[0-9]+") || //f: v
            tokens[1].matches("[0-9]+/[0-9]+")) {//f: v/vt

            if (c == 4) {//3 faces
                for (int i = 1; i < c; i++) {
                    Short s = Short.valueOf(tokens[i]);
                    s--;
                    faces.add(s);
                }
            }
            else{//more faces
                Vector<Short> polygon=new Vector<Short>();
                for(int i=1; i<tokens.length; i++){
                    Short s=Short.valueOf(tokens[i]);
                    s--;
                    polygon.add(s);
                }
                faces.addAll(triangulate(polygon));
                //triangulate the polygon and //add the resulting faces
            }
        }
        //if(tokens[1].matches("[0-9]+//[0-9]+")){//f: v//vn
        //if(tokens[1].matches("[0-9]+/[0-9]+/[0-9]+")){
        //f: v/vt/vn
    }

这个代码是应用于面值vv/vt,因为我们正在跳过纹理。我还评论了面部指数值的另外两种排列。剩下的大部分只是蛮力字符串解析。v//vn案如下:

    if(tokens[1].matches("[0-9]+//[0-9]+")){//f: v//vn
        if(c==4){//3 faces
            for(int i=1; i<c; i++){
                Short s=Short.valueOf(tokens[i].split("//")[0]);
                s--;
                faces.add(s);
                s=Short.valueOf(tokens[i].split("//")[1]);
                s--;
                vnPointer.add(s);
            }
        }
        else{//triangulate
            Vector<Short> tmpFaces=new Vector<Short>();
            Vector<Short> tmpVn=new Vector<Short>();
            for(int i=1; i<tokens.length; i++){
                Short s=Short.valueOf(tokens[i].split("//")[0]);
                s--;
                tmpFaces.add(s);
                s=Short.valueOf(tokens[i].split("//")[1]);
                s--;
                tmpVn.add(s);
            }
            faces.addAll(triangulate(tmpFaces));
            vnPointer.addAll(triangulate(tmpVn));
        }
    }

最后v/vt/vn情况如下:

    if(tokens[1].matches("[0-9]+/[0-9]+/[0-9]+")){//f: v/vt/vn
        if(c==4){//3 faces
            for(int i=1; i<c; i++){
                Short s=Short.valueOf(tokens[i].split("/")[0]);
                s--;
                faces.add(s);
                // (skip vt)
                s=Short.valueOf(tokens[i].split("/")[2]);
                s--;
                vnPointer.add(s);
            }
        }
        else{//triangulate
            Vector<Short> tmpFaces=new Vector<Short>();
            Vector<Short> tmpVn=new Vector<Short>();
            for(int i=1; i<tokens.length; i++){
                Short s=Short.valueOf(tokens[i].split("/")[0]);
                s--;
                tmpFaces.add(s);
                // (skip vt)
                s=Short.valueOf(tokens[i].split("/")[2]);
                s--;
                tmpVn.add(s);
            }
            faces.addAll(triangulate(tmpFaces));
            vnPointer.addAll(triangulate(tmpVn));
        }
    }

如前所述,在 OBJ 文件格式描述中,索引可以是负数;在这种情况下,需要从顶点列表的末尾向后引用它们。这可以通过将索引值添加到索引列表的大小中来实现。为了支持这一点,在前面的代码中,替换所有s--;具有以下内容的行:

                   if (s < 0)
                       s = (short)(s + v.size());
                   else
                       s--;

构建缓冲区

parseObj方法的最后一步是从模型数据构建我们的着色器缓冲区,即vertexBuffernormalBufferindexBuffer变量。我们现在可以将它添加到一个buildBuffers方法中,如下所示:

    private void buildBuffers() {
        numIndices = faces.size();
        float[] tmp = new float[v.size()];
        int i = 0;
        for(Float f : v)
            tmp[i++ ] = (f != null ? f : Float.NaN);
        vertexBuffer = allocateFloatBuffer(tmp);

        i = 0;
        tmp = new float[vn.size()];
        for(Float f : vn)
            tmp[i++ ] = (f != null ? -f : Float.NaN); 
            //invert normals
        normalBuffer = allocateFloatBuffer(tmp);

        i = 0;
        short[] indicies = new short[faces.size()];
        for(Short s : faces)
            indicies[i++ ] = (s != null ? s : 0);
        indexBuffer = allocateShortBuffer(indicies);
    }

一个警告。我们注意到对于RenderBox坐标系和着色器,有必要反转 OBJ 数据的法线(使用-f而不是f)。实际上,这取决于 OBJ 出口商(3Ds Max、Blender 和 Maya)。有些会,有些不会翻转法线。不幸的是,除了查看模型,没有办法确定法线是否翻转。出于这个原因,一些 OBJ 导入器/查看器提供(可选的)函数来根据面几何计算法线,而不是依赖于导入数据本身。

模型范围、比例和中心

3D 模型有各种形状和尺寸。要在我们的应用中查看它们,我们需要知道模型的最小和最大边界及其几何中心,以便对其进行适当的缩放和定位。现在让我们将此添加到ModelObject中。

ModelObject类的顶部,添加以下变量:

    public Vector3 extentsMin, extentsMax;

在解析模型数据之前,初始化解析器中的范围。最小范围被初始化为最大可能值;最大范围被初始化为最小可能值:

    public ModelObject(int objFile) {
        super();
        extentsMin = new Vector3(Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE);
        extentsMax = new Vector3(Float.MIN_VALUE, Float.MIN_VALUE, Float.MIN_VALUE);
        . . .

我们将在导入过程中计算范围,而不是在模型加载后计算范围。当我们向顶点列表添加一个新顶点时,我们将计算当前范围。在processVLine循环中添加对setExtents的呼叫:

    private void processVLine(String line) {
        String[] tokens = line.split("[ ]+"); 
        //split the line at the spaces
        int c = tokens.length;
        for (int i = 1; i < c; i++) { //add the vertex to the vertex array
            Float value = Float.valueOf(tokens[i]);
            v.add(value);
            setExtents(i, value);
        }
    }

那么setExtents方法可以实现如下:

    private void setExtents(int coord, Float value) {
        switch (coord) {
            case 1:
                if (value < extentsMin.x)
                    extentsMin.x = value;
                if (value > extentsMax.x)
                    extentsMax.x = value;
                break;
            case 2:
                if (value < extentsMin.y)
                    extentsMin.y = value;
                if (value > extentsMax.y)
                    extentsMax.y = value;
                break;
            case 3:
                if (value < extentsMin.z)
                    extentsMin.z = value;
                if (value > extentsMax.z)
                    extentsMax.z = value;
                break;
        }
    }

让我们添加一个标量方法,当我们将模型添加到场景中时会很有用(正如您将在下一个主题中看到的),使用范围-11将它缩放到规范化的大小:

    public float normalScalar() {
        float sizeX = (extentsMax.x - extentsMin.x);
        float sizeY = (extentsMax.y - extentsMin.y);
        float sizeZ = (extentsMax.z - extentsMin.z);
        return (2.0f / Math.max(sizeX, Math.max(sizeY, sizeZ)));
    }

现在,让我们试试吧!

我是一个小茶壶

几十年来,3D 计算机图形研究人员和开发人员一直在使用这个可爱的茶壶模型。真是经典!后面的故事是,著名的计算机图形学先驱和研究者马丁·纽维尔需要一个模型来做他的工作,他的妻子建议他在家里给他们的茶壶做模型。原件现在在波士顿计算机博物馆展出。我们在这本书的可下载文件中包含了这一经典模型的 OBJ 版本。

当然,你可以选择自己的 OBJ 文件,但是如果你想用茶壶,找到teapot.obj文件,复制到res/raw文件夹(必要时创建文件夹)。

现在加载模型并尝试它。在MainActivity中,在MainActivity类的顶部添加一个变量来保存当前模型:

    Transform model;

将以下代码添加到setup方法中。请注意,我们将它缩放到原始大小的一小部分,并将其放在摄像机前 3 个单位:

    public void setup() {
        ModelObject modelObj = new ModelObject(R.raw.teapot);
        float scalar = modelObj.normalScalar();
        model = new Transform()
                .setLocalPosition(0, 0, -3)
                .setLocalScale(scalar, scalar, scalar)
                .addComponent(modelObj);
    }

运行项目,它应该如下所示:

I'm a little teapot

可以看到模型加载渲染成功。不幸的是,阴影很难辨别。为了更好地观察阴影茶壶,让我们把它向下移动一点。修改设置中的setLocalPosition方法,如下:

                .setLocalPosition(0, -2, -3) 

下面的截图经过裁剪和放大,因此您可以在这里看到阴影茶壶,类似于您在纸板查看器中看到的样子:

I'm a little teapot

我是一个小小的旋转茶壶

让我们通过在用户旋转头部的同时旋转模型来增强观看体验。效果将不同于“正常”的虚拟现实体验。通常,在虚拟现实中移动头部会旋转场景中相机的主观视图,以便与头部移动一致地环顾四周。在这个项目中,头部的移动就像一个旋转模型的输入控件。模特一直在你面前的固定位置。

实现这个特性非常简单。在每一帧的开始调用RenderBox preDraw接口方法。我们将获得当前的头部角度,并相应地旋转模型,将头部后欧拉角转换为四元数。(组合多个欧拉角会导致意外的最终旋转方向)。我们还将共轭(即反转或反转)旋转,这样当你向上看时,你会看到物体的底部,等等。这样感觉更自然。

MainActivity中,给preDraw添加以下代码:

    public void preDraw() {
        float[] hAngles = RenderBox.instance.headAngles;
        Quaternion rot = new Quaternion();
        rot.setEulerAnglesRad(hAngles[0], hAngles[1], hAngles[2]);
        model.setLocalRotation(rot.conjugate());
    }

setup中,确保setLocalPosition方法将茶壶放在摄像机正前方:

                .setLocalPosition(0, 0, -3)

试着运行它。我们快到了!模型随着头部旋转,但我们仍然在虚拟现实空间中四处张望。

要锁定头部位置,我们只需要在RenderBox中禁用头部跟踪。如果您的版本RenderBox(内置于第 5 章RenderBox 引擎)还没有这个功能,请将其添加到您单独的RenderBoxLib lib 项目中,如下所示:

Camera.java文件中,首先为headTracking添加一个新的公共变量:

    public boolean headTracking = true;

修改onDrawEye方法,有条件地更新视图变换,如下所示:

        if (headTracking) {
            // Apply the eye transformation to the camera.
            Matrix.multiplyMM(view, 0, eye.getEyeView(), 0, camera, 0);
        } else {
             // copy camera into view
            for (int i=0; i < camera.length; i++) { view[i] = camera[i]; }
        }

重建后,确保将更新后的.aar文件复制到ModelViewer项目的RenderBox模块文件夹中。

现在,在MainActivity类的setup()中,添加以下设置:

        RenderBox.instance.mainCamera.headTracking = false;

现在运行它,当你移动你的头时,模型保持相对静止,但随着你转动你的头而旋转。好极了。好多了。

线程安全

第 7 章360 度画廊中,我们解释了工作线程从渲染线程卸载处理的需要。在这个项目中,我们将向ModelObject构造函数添加线程,在这里我们读取并解析模型文件:

    public ModelObject(final int objFile) {
        super();
        extentsMin = new Vector3(Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE);
        extentsMax = new Vector3(Float.MIN_VALUE, Float.MIN_VALUE, Float.MIN_VALUE);

        SolidColorLightingMaterial.setupProgram();
        enabled = false;
        new Thread(new Runnable() {
            @Override
            public void run() {
                InputStream inputStream = RenderBox.instance.mainActivity.getResources().openRawResource(objFile);
                if (inputStream == null)
                    return; // error
                createMaterial();
                enabled = true;
                float scalar = normalScalar();
                transform.setLocalScale(scalar, scalar, scalar);
            }
        }).start();
    }

我们必须将文件句柄声明为final,以便能够从内部类中访问它。你可能也注意到了,我们在启动线程之前给素材的setup程序添加了一个调用,以确保它被及时正确地设置,并避免应用崩溃。这避免了在queueEvent过程中调用createMaterial的需要,因为着色器编译器利用了图形上下文。同样,我们禁用该对象,直到它完成数据加载。最后,由于负载是异步的,因此有必要在此过程结束时设置刻度。我们之前的方法在setup()中设置比例,现在在模型加载完成之前完成。

有目的地发射

第 7 章360 度图库中,我们介绍了使用安卓意图将应用与特定文件类型相关联,以便作为这些文件的查看者启动我们的应用。我们将在这里对 OBJ 文件进行同样的操作。

一个的意图是任何应用都可以向安卓系统发送的消息,该消息声明它打算为某个目的使用另一个应用。意图对象包含许多成员来描述需要执行什么类型的操作,以及需要对其执行的数据(如果有的话)。对于图像库,我们将意图过滤器与图像 mime 类型相关联。对于这个项目,我们将把一个意图过滤器和一个文件扩展名关联起来。

**在您的AndroidManifest.xml文件中,向活动块添加一个意图过滤器。这让安卓知道该应用可以用作 OBJ 文件查看器。我们需要将其指定为文件方案和文件名模式。安卓也需要通配符 mime 类型和主机。添加以下 XML 代码:

            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="file" />
                <data android:mimeType="*/*" />
                <data android:pathPattern=".*\\.obj" />
                <data android:host="*" />
            </intent-filter>

为了处理这种情况,我们将向ModelObject添加一个新的构造函数,它采用 URI 字符串而不是资源标识,就像我们之前所做的那样。像其他构造函数一样,我们需要打开一个输入流并将其传递给parseObj。下面是构造函数,包括工作线程:

    public ModelObject(final String uri) {
        super();
        extentsMin = new Vector3(Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE);
        extentsMax = new Vector3(Float.MIN_VALUE, Float.MIN_VALUE, Float.MIN_VALUE);
        SolidColorLightingMaterial.setupProgram();
        enabled = false;
        new Thread(new Runnable() {
            @Override
            public void run() {
                File file = new File(uri.toString());
                FileInputStream fileInputStream;
                try {
                    fileInputStream = new FileInputStream(file);
                } catch (IOException e) {
                    e.printStackTrace();
                    return; // error
                }
                parseObj(fileInputStream);
                createMaterial();
                enabled = true;
                float scalar = normalScalar();
                transform.setLocalScale(scalar, scalar, scalar);
            }
        }).start();
    }

现在在MainActivity类的setup中,我们将检查该应用是否是从一个意图启动的,并使用该意图 URI。否则,我们将查看默认模型,就像我们之前所做的那样:

    public void setup() {
        ModelObject modelObj;
        Uri intentUri = getIntent().getData();
        if (intentUri != null) {
            Log.d(TAG, "!!!! intent " + intentUri.getPath());
            modelObj = new ModelObject(intentUri.getPath());
        } else {
            // default object
            modelObj = new ModelObject(R.raw.teapot);
        }
        //...        

现在项目已经完成并安装在手机上,让我们尝试一些网络集成。打开网络浏览器,访问三维模型下载网站。

找到感兴趣车型的下载链接下载到手机,然后出现提示时,使用ModelViewer app 查看!

实用且生产准备就绪

请注意,正如前面提到的,我们已经创建了 OBJ 模型格式的有限实现,因此在这一点上,并不是您找到的每个模型都能正确查看(如果有的话)。话说回来,这可能就足够了,这取决于您自己项目的需求,例如,如果您在资源文件夹中包含特定的模型,这些模型可以在您的应用的发布版本中查看。当您完全控制输入数据时,您可以偷工减料。

虽然 OBJ 文件格式的基本结构不是很复杂,但正如我们在这里所展示的,就像软件(和生活)中的许多事情一样,“魔鬼在细节中。”使用这个项目作为起点,然后构建您自己的实用的和生产就绪的 OBJ 文件解析器和渲染器将需要大量的额外工作。您还可以对预先存在的包、其他模型格式进行一些研究,甚至可以从像 LibGDX 这样的开源游戏引擎中提取一些代码。我们忽略但值得考虑的 OBJ 特色包括:

  • 纹理顶点
  • 材质定义
  • 曲线元素
  • 几何分组
  • 颜色和其他顶点属性

总结

在这个项目中,我们用开放的 OBJ 文件格式为三维模型编写了一个简单的查看器。我们实现了一个ModelObject类,该类解析模型文件并构建RenderBox渲染场景中的对象所需的向量和法线缓冲区。然后我们启用了阴影。然后我们让观众互动,这样当你移动你的头时,模型就会旋转。

在下一章,我们将探索另一种媒体,你的音乐。音乐可视化器响应当前的音乐播放器,在虚拟现实世界中显示跳舞的几何图形。**