これはドリコム Advent Calendar 2016 6日目です。

というわけでどうも、DRIP – Drecom Invention Project エンジニアの小川です。
社内外から最も多く問い合わせいただいた「Android 版は無いんですか?」という声に応え、
Pass! Android 版を目下開発中であります!
こちらも iOS 版と同じく地図の上に View を配置し、地図を動かしたら View も追従していくシステムを作ろうと考えたのですが、なかなか簡単にはいかず…
試行錯誤した結果、できるだけ違和感無くスムーズに動くやり方を見出したので、本日はそれを発表しようと思います。

緯度経度<–>座標変換を使う

Android 版にも GoogleMap に Projection が用意されているので、これを使います。
地図のカメラが(移動、ズームによって)更新されるたびに呼ばれるコールバックがあるので、そこで取得します。

public class MainMapFragment extends Fragment implements OnMapReadyCallback {

  @Override
  public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);

    SupportMapFragment mapFragment = (SupportMapFragment) getChildFragmentManager().findFragmentById(R.id.fragment_google_map);
    mapFragment.getMapAsync(this);
  }

  @Override
  public void onMapReady(GoogleMap googleMap) {
    this.map = googleMap;
    googleMap.setOnCameraMoveListener(this.onCameraMoveListener);
  }

  private GoogleMap.OnCameraMoveListener onCameraMoveListener = new GoogleMap.OnCameraMoveListener() {
    @Override
    public void onCameraMove() {
      Projection projection = map.getProjection();
      // projection を使ってユーザーの緯度経度を座標にする
    }
  };

}

標準の View は重かった

iOS 版では UIKit で事足りたので、Android 版でも初めは標準の View を使いました。
が、iOS 版で基準にしていた「ユーザーを50体の表示」を試したところ、地図の動きが明らかにカクつき始めてしまい…
これはちょっとユーザビリティ悪いなと。
SurfaceView も試してみましたが、それでもまだ地図がカクつく…

OpenGL の採用

これはいよいよ OpenGL を試すしかないなと考え GLSurfaceView を触る決意をしました。
簡易な2D表示さえできれば良いので OpenGL ES 1.x 系でも大丈夫ですが、2.0 での2D表示方法を確立したかったので 2.0 を使います。

Renderer の準備

2D表示に設定を寄せた GLSurfaceView.Renderer を書きます。

public class MapRenderer implements GLSurfaceView.Renderer {

  static private final String VERTEX_SHADER =
    "attribute vec4 position;" +
    "uniform mat4 mmatrix;" +
    "attribute vec2 texcoord;" +
    "varying vec2 texcoordVarying;" +
    "void main() {" +
      "gl_Position = mmatrix*position;" +
      "texcoordVarying = texcoord;" +
    "}";

  static private final String PIXEL_SHADER =
    "precision mediump float;" +
    "varying vec2 texcoordVarying;" +
    "uniform sampler2D texture;" +
    "void main() {" +
      "gl_FragColor = texture2D(texture, texcoordVarying);" +
    "}";

  private int position;
  private int mmatrix;
  private int texcoord;
  private int uniformLocation;

  @Override
  public void onSurfaceCreated(GL10 gl, EGLConfig config) {
    GLES20.glDisable(GLES20.GL_DEPTH_TEST);

    int vertexShader = GLHelper.createShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER);
    int pixelShader = GLHelper.createShader(GLES20.GL_FRAGMENT_SHADER, PIXEL_SHADER);
    int program = GLHelper.createProgram(vertexShader, pixelShader);
    GLES20.glUseProgram(program);

    this.position = GLES20.glGetAttribLocation(program, "position");
    GLES20.glEnableVertexAttribArray(this.position);

    this.texcoord = GLES20.glGetAttribLocation(program, "texcoord");
    GLES20.glEnableVertexAttribArray(this.texcoord);

    this.uniformLocation = GLES20.glGetUniformLocation(program, "texture");

    this.mmatrix = GLES20.glGetUniformLocation(program, "mmatrix");
  }

  @Override
  public void onSurfaceChanged(GL10 gl, int width, int height) {
    GLES20.glViewport(0, 0, width, height);
  }

  @Override
  public void onDrawFrame(GL10 gl) {
    GLES20.glClearColor(0, 0, 0, 0);
    GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);

    GLES20.glEnable(GLES20.GL_TEXTURE_2D);
    GLES20.glEnable(GLES20.GL_BLEND);
    GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA);

    // ココで各種描画!

    GLES20.glDisable(GLES20.GL_BLEND);
    GLES20.glDisable(GLES20.GL_TEXTURE_2D);
  }

}

ユーザー表示のテストをするには50体の板ポリゴンと1枚のテクスチャが必要です。
切り分けておけば後々便利になりそうなので、そのためのクラスを作ります。

板ポリゴン + テクスチャ

GL2DTexture

1枚のテクスチャを管理します。

public class GL2DTexture {

  private int texId;
  private int width;
  private int height;

  public GL2DTexture() {
    this.texId = 0;
  }

  public GL2DTexture(Resources res, int resId) {
    this.create(res, resId);
  }

  public GL2DTexture(Bitmap bitmap) {
    this.create(bitmap);
  }

  @Override
  public String toString() {
    return "GL2DTexture(" + this.width + ", " + this.height + ")";
  }

  public void create(Resources res, int resId) {
    this.create(BitmapFactory.decodeResource(res, resId));
  }

  public void create(Bitmap bitmap) {
    this.delete();
    final int[] textures = new int[1];
    GLES20.glGenTextures(1, textures, 0);
    this.texId = textures[0];

    this.width = bitmap.getWidth();
    this.height = bitmap.getHeight();

    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, this.texId);
    GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
  }

  public void delete() {
    if (this.texId != 0) {
      GLES20.glDeleteTextures(1, new int[]{this.texId}, 0);
      this.texId = 0;
    }
  }

  public int getTexId() {
    return this.texId;
  }

  public int getWidth() {
    return this.width;
  }

  public int getHeight() {
    return this.height;
  }

}

GL2DSprite

1つの GL2DTexture を持ち、指定した座標とサイズで表示します。
座標やサイズが更新された時のみ各種 Buffer を更新するようにして省エネを狙っています。

public class GL2DSprite {

  private GL2DTexture texture;
  private FloatBuffer vertexBuffer;
  private FloatBuffer texcoordBuffer;
  private FloatBuffer matrixBuffer;
  private float x;
  private float y;
  private float width;
  private float height;
  private boolean isUpdated = false;

  private FloatBuffer createBuffer(float[] arr) {
    FloatBuffer buffer = ByteBuffer.allocateDirect(arr.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer();
    buffer.put(arr).position(0);
    return buffer;
  }

  private float[] createVertexValues() {
    // 回転、拡縮を中心で行いたいため、(0,0)が中心になるよう設定する
    // 指定位置への移動は translate で行う
    float hw = this.width * 0.5f;
    float hh = this.height * 0.5f;
    final float[] vertexes = {
      -hw,  hh, 0.0f,
      -hw, -hh, 0.0f,
       hw,  hh, 0.0f,
       hw, -hh, 0.0f
    };
    return vertexes;
  }

  private void initMember() {
    this.x = 0.0f;
    this.y = 0.0f;
    this.width = 0.0f;
    this.height = 0.0f;
    this.rotate = 0.0f;

    float[] vertexValues = this.createVertexValues();
    this.vertexBuffer = this.createBuffer(vertexValues);

    final float[] texcoords = {
      0.0f, 0.0f,
      0.0f, 1.0f,
      1.0f, 0.0f,
      1.0f, 1.0f
    };
    this.texcoordBuffer = createBuffer(texcoords);

    float[] matrixValues = new float[16];
    Matrix.setIdentityM(matrixValues, 0);
    this.matrixBuffer = createBuffer(matrixValues);
  }

  private void updateVertexBuffer() {
    float[] vertexValues = this.createVertexValues();
    this.vertexBuffer.put(vertexValues).position(0);
  }

  private void updateMatrixBuffer(GLRenderParams glRenderParams) {
    float[] childValues = new float[16];
    Matrix.setIdentityM(childValues, 0);

    float mx = this.x + (this.width * 0.5f);
    float my = glRenderParams.getScreenHeight() - this.y - (this.height * 0.5f);
    Matrix.translateM(childValues, 0, mx, my, 0);

    float[] matrixValues = new float[16];
    Matrix.multiplyMM(matrixValues, 0, glRenderParams.getPvValues(), 0, childValues, 0);
    this.matrixBuffer.put(matrixValues).position(0);
  }

  public GL2DSprite() {
    this.initMember();
  }

  public GL2DSprite(GL2DTexture texture) {
    this.initMember();
    this.setTexture(texture);
  }

  @Override
  public String toString() {
    return "GL2DSprite(" + this.x + ", " + this.y + ", " + this.width + ", " + this.height + ")";
  }

  public GL2DTexture getTexture() {
    return texture;
  }

  public void setTexture(GL2DTexture texture) {
    this.setTexture(texture, true);
  }

  public void setTexture(GL2DTexture texture, boolean isOverrideSize) {
    this.texture = texture;
    if ((texture != null) && isOverrideSize) {
      this.setWidth(texture.getWidth());
      this.setHeight(texture.getHeight());
    }
  }

  public void setX(float x) {
    this.isUpdated = (this.x != x) ? true : this.isUpdated;
    this.x = x;
  }

  public float getX() {
    return x;
  }

  public void setY(float y) {
    this.isUpdated = (this.y != y) ? true : this.isUpdated;
    this.y = y;
  }

  public float getY() {
    return y;
  }

  public void setWidth(float width) {
    this.isUpdated = (this.width != width) ? true : this.isUpdated;
    this.width = width;
  }

  public float getWidth() {
    return width;
  }

  public void setHeight(float height) {
    this.isUpdated = (this.height != height) ? true : this.isUpdated;
    this.height = height;
  }

  public float getHeight() {
    return height;
  }

  public void setRotate(float rotate) {
    this.isUpdated = (this.rotate != rotate) ? true : this.isUpdated;
    this.rotate = rotate;
  }

  public float getRotate() {
    return rotate;
  }

  public void render(GLRenderParams glRenderParams) {
    if ((this.texture == null) || (this.texture.getTexId() == 0)) {
      return;

    } else if (this.isUpdated == true) {
      this.updateVertexBuffer();
      this.updateMatrixBuffer(glRenderParams);
      this.isUpdated = false;

    } else if (this.vertexBuffer == null) {
      return;
    }

    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, this.texture.getTexId());
    GLES20.glUniform1i(glRenderParams.uniformLocation, 0);
    GLES20.glVertexAttribPointer(glRenderParams.texcoord, 2, GLES20.GL_FLOAT, false, 0, this.texcoordBuffer);
    GLES20.glVertexAttribPointer(glRenderParams.position, 3, GLES20.GL_FLOAT, false, 0, this.vertexBuffer);
    GLES20.glUniformMatrix4fv(glRenderParams.mmatrix, 1, false, this.matrixBuffer);
    GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
  }

}

GLRenderParams

GL2DSprite を描画する際に必要なパラメータを管理します。
先程の MapRenderer が生成してオリジナルを保持し、生成したパラメータも渡してしまいます。

public class GLRenderParams {

  private float[] pvValues;
  private int screenWidth;
  private int screenHeight;

  public int position;
  public int mmatrix;
  public int texcoord;
  public int uniformLocation;

  public GLRenderParams() {
    this.pvValues = new float[16];
  }

  public boolean setScreenSize(int width, int height) {
    if ((this.screenWidth == width) && (this.screenHeight == height)) {
      return false;
    }
    float[] projectionValues = new float[16];
    float[] viewValues = new float[16];
    Matrix.orthoM(projectionValues, 0, 0, width, 0, height, 0, 50);
    Matrix.setLookAtM(viewValues, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0);
    Matrix.multiplyMM(this.pvValues, 0, projectionValues, 0, viewValues, 0);
    this.screenWidth = width;
    this.screenHeight = height;
    return true;
  }

  public float[] getPvValues() {
    return pvValues;
  }

  public int getScreenWidth() {
    return screenWidth;
  }

  public int getScreenHeight() {
    return screenHeight;
  }

}

緯度経度<–>座標変換のタイミング

地図を動かしまくると GoogleMap.OnCameraMoveListener が呼び出されまくります。
その度に頂点バッファやマトリクスを再生成するとパフォーマンスが悪化するため、取得した Projection を保持し、OpenGL 描画のタイミングで再生成すれば最低限の回数で済みます。
先程で言うところの、ここです。

  @Override
  public void onDrawFrame(GL10 gl) {
    GLES20.glClearColor(0, 0, 0, 0);
    GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);

    GLES20.glEnable(GLES20.GL_TEXTURE_2D);
    GLES20.glEnable(GLES20.GL_BLEND);
    GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA);

    // ココで各種描画!
    // Projection から描画位置の確定

    GLES20.glDisable(GLES20.GL_BLEND);
    GLES20.glDisable(GLES20.GL_TEXTURE_2D);
  }

表示してみる

50体表示してみたところ、iOS 版に比べて地図への追従は若干遅れますが、地図そのものの動きはスムーズになりました。
ユーザーの描画が UI スレッドを邪魔しなくなったので、その効果が高いです。
100体にしてみても同じくらいのスムーズさ。
なので地図に追従する View の表示には OpenGL を使うことにしました。

TextureView の採用

  1. 地図
  2. ユーザーと吹き出し: OpenGL
  3. ボタンとか UI

ユーザーと吹き出しの上に UI を表示する必要があります。
こちらはつぶやき入力で EditText を使うため標準の View を使用したいのですが、背景を透過した GLSurfaceView が必ず一番手前に表示されてしまうため、要件を満たせません。
そこで標準の View を重ねて使える TextureView と EGLContext を使って OpenGL 対応しつつ UI を表示します。
コードは長いので割愛しますが、GLTextureViewを公開しました にて公開されている GLTextureView が非常に参考となりますので、興味ある方は是非ご一読ください。

最後に

個人的な体感ですが iOS に比べてパフォーマンスの出にくい Android でも、iOS 版に近いスムーズな体感を与えたい!という思いから、このやり方にたどり着きました。
(ちなみに、これでダメだった場合は C++ に手を出すつもりだった。)
Android 版がリリースされれば、今まで使うことができなかった友達も誘えるようになるので、リリースまでしばしお待ち下さい!

明日は

ドリコム Advent Calendar 2016 7日目は onk さんです。

About the Author

小川 光典

小川 光典

アプリケーションエンジニア

iPhone 3G が日本で発売開始した頃からスマホネイティブを開発し続け、2015年にドリコム入社。DRIP のエンジニアとして少人数チームでの開発を行い、新たなサービス価値の創出に尽力している。子煩悩。