これはドリコム 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 の採用
- 地図
- ユーザーと吹き出し: OpenGL
- ボタンとか UI
ユーザーと吹き出しの上に UI を表示する必要があります。
こちらはつぶやき入力で EditText を使うため標準の View を使用したいのですが、背景を透過した GLSurfaceView が必ず一番手前に表示されてしまうため、要件を満たせません。
そこで標準の View を重ねて使える TextureView と EGLContext を使って OpenGL 対応しつつ UI を表示します。
コードは長いので割愛しますが、GLTextureViewを公開しました にて公開されている GLTextureView が非常に参考となりますので、興味ある方は是非ご一読ください。
最後に
個人的な体感ですが iOS に比べてパフォーマンスの出にくい Android でも、iOS 版に近いスムーズな体感を与えたい!という思いから、このやり方にたどり着きました。
(ちなみに、これでダメだった場合は C++ に手を出すつもりだった。)
Android 版がリリースされれば、今まで使うことができなかった友達も誘えるようになるので、リリースまでしばしお待ち下さい!
明日は
ドリコム Advent Calendar 2016 7日目は onk さんです。