TangoとRajawaliを組み合わせたら辛かった話

この記事はNIFTY Advent Calendar 2017の18日目の記事です。大遅刻です。
17日目は@ikaimanさんの｢Swift + CollectionView + StoryBoardなしでカレンダーをつくる｣でした。

はじめまして。新卒1年目のエンジニア@jimmysharpです。
普段はメールシステムの運用などをしています。
新人研修の中でGoogleのAR規格であるTangoを触る機会があったので、実際に触って辛かった描画周りについて書こうと思います。

# Tangoとは
Googleが開発しているAndorid用AR規格です。[Zenfone AR](https://www.asus.com/jp/Phone/ZenFone-AR-ZS571KL/)など一部のAndroid端末でのみ使えます。
AR用規格は他にもAppleのARKitなどがありますが、Tangoは赤外線深度カメラを備えていることが大きな特徴です。
これによりカメラに映った物体の距離や立体形状を捉えることができます。AndroidにKinectが載ったようなものですね。

なおそんなTangoですが、記事を書いている最中にこんな情報が出ました。
[The Tango project will be deprecated on March 1st, 2018.](https://developers.google.com/tango/?hl=ja)
公式に終息宣言です。あまりアプリが出ないまま終息となってしまいました。

# 前提
前提として、私が作っていたTangoアプリは下のようなものです(再現画像です)。

![作っていたもの](https://qiita-image-store.s3.amazonaws.com/0/223156/4897e4c1-b5a9-73f2-8c5f-38f388028389.png)

カメラを向けた先にドロイド君を立たせるだけです。
折角距離情報が取れるので、物体の陰になるべき部分は描画しないようにしています。

これをTangoのJava APIを元に実装していきます。
なお、Tangoの初期化処理は非常に長いので省略しています。
詳細は[Googleのサンプル](https://github.com/googlesamples/tango-examples-java)をご覧ください。

# Androidでの3D描画
ARでいきなり引っかかるのが画面描画です。
ARは現実空間、つまり3次元空間を扱うので、画面描画も必然的に3Dで行うことになります。
Android標準のAPIではOpenGL ESでゴリゴリ書く事になるので、かなり荷が重い作業になります。

という事でここはGoogleに頼ります。
Tangoを扱う上で[Googleのサンプル](https://github.com/googlesamples/tango-examples-java)は非常に参考になります。このサンプルを真似ていく事にしましょう。
サンプルのうちいくつかでは、３Dライブラリとして[Rajawali](https://github.com/Rajawali/Rajawali)を使用しているので、これに倣いましょう。

```java:MapObject.java
import org.rajawali3d.Object3D;
import org.rajawali3d.materials.Material;
import org.rajawali3d.materials.methods.DiffuseMethod;
import org.rajawali3d.materials.plugins.AlphaMaskMaterialPlugin;
import org.rajawali3d.materials.textures.ATexture;
import org.rajawali3d.materials.textures.Texture;
import org.rajawali3d.primitives.Plane;

public class MapObject {
    private static final String TAG = MapObject.class.getSimpleName();

    protected String name;
    protected Material material;
    protected Object3D plane;

    public MapObject(String name, int width, int height, int resouceId,
                     boolean transparentEnable,
                     boolean lightEnable,
                     boolean doubleSided) {
        this.name = name;

        material = new Material();
        material.setColorInfluence(0);
        if (transparentEnable) {
            material.addPlugin(new AlphaMaskMaterialPlugin(0));
        }
        if(lightEnable) {
            material.enableLighting(true);
            material.setDiffuseMethod(new DiffuseMethod.Lambert());
        }

        try {
            Texture t = new Texture(name, resouceId);
            material.addTexture(t);
        } catch (ATexture.TextureException e) {
            Log.e(TAG, "Exception generating texture: " + name, e);
        }

        plane = new Plane(width,height,1,1);
        plane.setDoubleSided(doubleSided);
        plane.setMaterial(material);
    }

    public void setPosition(double x, double y, double z){
        plane.setPosition(x, y, z);
    }

    public Object3D getObject(){
        return plane;
    }
}
```
```java:MainRenderer.java
import org.rajawali3d.renderer.Renderer;

public class MainRenderer extends Renderer {

    @Override
    public void initScene() {
        MapObject mapObject = new MapObject("android", 1, 1, R.drawable.ic_android, true, true, true);
        mapObject.setPosition(0,0,-5);
        getCurrentScene().addChild(mapObject.getObject());
    }
    
    @Override
    public void onTouchEvent(MotionEvent event){
    }

    @Override
    public void onOffsetsChanged(float x, float y, float z, float w, int i, int j){
    }
}
```
```java:MainActivity.java
Renderer renderer;

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

    // org.rajawali3d.view.SurfaceView。android.view.SurfaceViewではない
    SurfaceView surface = (SurfaceView) findViewById(R.id.surfaceview);

    renderer = new MainRenderer(this);
    surface.setSurfaceRenderer(renderer);
}
```
奥行1のPlaneをつくり、そこにドロイド君のテクスチャを張り付けています。
位置はとりあえずz=-5に登場させています。
DoubleSidedをtrueにすると裏面からも見えるようになります。
描画はRenderer.getCurrentScene().addChild()してあげるだけで勝手にやってくれます。便利。

RajawaliはOpenGL ESを直接叩くよりは圧倒的に楽なのですが、**公式のドキュメントが古く当てにならない**のが困りものです。
MapObject.javaで使っているAlphaMaskMaterialPluginはテクスチャのα値を有効にするものですが、公式ドキュメントに載っていません。
Rajawaliのソースコードを追う羽目になります。

# 3Dメッシュの描画

ドロイド君を表示できたところで、次は現実の物体に隠れるように表示してみます。
深度カメラから得られる3Dメッシュを「透過色のオブジェクト」として描画してあげれば、メッシュに隠れる部分だけ消して描画することができそうです。
3DメッシュはTangoMeshとして取得出来るので、これを描画する事を考えます。

```java:MapSegment.java
import android.opengl.GLES20;

import com.google.atap.tango.mesh.TangoMesh;

import org.rajawali3d.BufferInfo;
import org.rajawali3d.Geometry3D;
import org.rajawali3d.Object3D;
import org.rajawali3d.materials.Material;
import org.rajawali3d.materials.methods.DiffuseMethod;
import org.rajawali3d.math.vector.Vector3;

public class MapSegment {
    private static final String TAG = MapSegment.class.getSimpleName();
    Material material;
    Object3D segment = null;
    Object3D masterNode;

    public MapSegment(boolean lightEnable){
        material = new Material();
        material.setColor(0);
        material.setColorInfluence(0);

        if(lightEnable) {
            material.enableLighting(true);
            material.setDiffuseMethod(new DiffuseMethod.Lambert());
        }

        masterNode = new Object3D();
        masterNode.setRotation(Vector3.Axis.X,90);
    }

    public void update(float[] vertices, float[] normals, float[] textureCoords, float[] colors, int[] indices) {
        Object3D object = new Object3D();
        object.setData(vertices, GLES20.GL_DYNAMIC_DRAW,
                normals, GLES20.GL_DYNAMIC_DRAW,
                textureCoords, GLES20.GL_DYNAMIC_DRAW,
                colors, GLES20.GL_DYNAMIC_DRAW,
                indices, GLES20.GL_DYNAMIC_DRAW,
                true);
        object.setMaterial(material);
        setChild(object);
    }

    public void update(TangoMesh mesh) {
        float[] vertices, normals;
        int[] faces;

        synchronized (mesh) {
            vertices = new float[mesh.vertices.remaining()];
            normals = new float[mesh.normals.remaining()];
            faces = new int[mesh.faces.remaining()];
            mesh.vertices.get(vertices);
            mesh.normals.get(normals);
            mesh.faces.get(faces);
            mesh.resetMesh();
        }

        update(vertices,normals,null,null,faces);
    }

    public void setChild(Object3D object) {
        removeChild();
        segment = object;
        masterNode.addChild(segment);
    }

    public void removeChild(){
        if (segment != null) {
            masterNode.removeChild(segment);
            segment.destroy();
            segment = null;
        }
    }

    public Object3D getObject(){
        return masterNode;
    }
}
```
```java:MainRenderer.java
//メッシュ更新時に呼ばれる
public void updateMesh(TangoMesh tangoMesh) {
    MapSegment mapSegment = new MapSegment(true);
    mapSegment.update(tangoMesh);
    getCurrentScene().addChild(mapSegment.getObject());
}
```

TangoMeshオブジェクトはデータをFloatBufferやIntBufferで持っています。
BufferのままRajawaliに渡す方法が見あたらなかったため、仕方なく一旦配列に書き出して渡しています。
RajawaliつかっているのにOpenGLの定数呼び出していたりと、非常にイケテナイ感があります。

ともかくもこうして得たメッシュを透過色に設定することで、メッシュに隠れた部分のみドロイド君が消えるようになります。
最後にカメラ画像と重ねれば完成です。

# 終わりに
ARやってみたいという軽い気持ちでTangoを触りましたが、気が付けば描画周りなど、Tango以外のところでかなりの時間を使ってしまいました。
ARが普及していくためには3Dを楽に扱う環境の整備も必要なのかなと思いました。Unity使っちゃえよ、という話ではあるのですが...
Tangoは終息宣言が出てしまいましたが、後継となるARCoreも触ってみたいと思います。
また次は期日中に記事を出せるようにしたいと思います。猛省。

明日?は@5_21maimaiさんの記事です。お楽しみに！