これはドリコム 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 さんです。




