これはドリコム Advent Calendar 2016 6日目です。
というわけでどうも、DRIP – Drecom Invention Project エンジニアの小川です。
社内外から最も多く問い合わせいただいた「Android 版は無いんですか?」という声に応え、
Pass! Android 版を目下開発中であります!
こちらも iOS 版と同じく地図の上に View を配置し、地図を動かしたら View も追従していくシステムを作ろうと考えたのですが、なかなか簡単にはいかず…
試行錯誤した結果、できるだけ違和感無くスムーズに動くやり方を見出したので、本日はそれを発表しようと思います。
緯度経度<–>座標変換を使う
Android 版にも GoogleMap に Projection が用意されているので、これを使います。
地図のカメラが(移動、ズームによって)更新されるたびに呼ばれるコールバックがあるので、そこで取得します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | 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 を書きます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | 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枚のテクスチャを管理します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | 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 を更新するようにして省エネを狙っています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | 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 が生成してオリジナルを保持し、生成したパラメータも渡してしまいます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | 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 描画のタイミングで再生成すれば最低限の回数で済みます。
先程で言うところの、ここです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | @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 さんです。