В позапрошлой своей статье, посвящённой созданию открытки средствами OpenGL под Android, я оставил фразу «текст поздравления добавим позже». Так вот, время пришло.
Текст будет выглядеть примерно так, как на картинке, за исключением того, что он слегка мерцает, каждая «звёздочка» плавно исчезает и появляется (кроме того, в финальном варианте и сам текст другой, и цвет не тот, и размеры шрифта и частиц тоже). Нарисован он с помощью анимированной системы частиц, причём в массиве вершин задаются лишь координаты центра каждой точки и некий «сдвиг по фазе» для анимации, ну а сама анимация сделана через шейдеры.
Отрисовка частиц осуществляется механизмом Point Sprites, который как раз и создан для таких случаев. Основная его особенность в том, что мы задаём лишь координаты центра точки и её размер, а OpenGL сам генерит нам по четыре угловых вершины и два треугольника, включая их пространственные и текстурные координаты, для отрисовки множества одинаковых (в смысле, имеющих одну и ту же текстуру) квадратных картинок. Итак, заглянем под ка[по]т.
Собственно текст
Первое, что нам надо сделать — это определить координаты точек. Для этого мы сделаем Bitmap, в котором выведем произвольный текст в удобном нам месте, после чего найдём в результирующей картинке точки нужного цвета и случайно выберем из них те, к которым привяжем наши частицы.
Генерим Bitmap:
int width = screenWidth, height = screenHeight, fontSize = (int) (screenHeight / 8);
// Create an empty, mutable bitmap
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_4444);
// get a canvas to paint over the bitmap
Canvas canvas = new Canvas(bitmap);
bitmap.eraseColor(Color.BLACK);
// Draw the text
Paint textPaint = new Paint();
textPaint.setTextSize(fontSize);
textPaint.setAntiAlias(false);
textPaint.setARGB(0xff, 0xff, 0xff, 0xff);
textPaint.setTextAlign(Paint.Align.CENTER);
textPaint.setTypeface(Typeface.SANS_SERIF);
// draw the text centered
canvas.drawText("Привет,", width / 2, height / 4, textPaint);
canvas.drawText("Хабр!", width / 2, height / 3, textPaint);
int[] pixels = new int[width * height];
bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
bitmap.recycle();
Здесь, screenWidth и screenHeight получены из аргументов к функции onSurfaceChanged наследника класса Renderer. Предпоследняя строчка в этом куске кода получила массив пикселей в виде упакованных целых чисел. Чёрные точки (фон) имеют в нём цвет 0xff000000, белые — 0xffffffff (старший байт — это альфа-канал).
Кстати, если бы мы захотели натянуть этот текст на текстуру, мы сделали бы это добавлением, например, таких строчек перед вызовом bitmap.recycle():
GLES20.glGenTextures(1, textures, 0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[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);
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_REPEAT);
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_REPEAT);
// Use the Android GLUtils to specify a two-dimensional texture image from our bitmap
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
Однако, нам нужно нечто совершенно иное. Находим белые точки на чёрном фоне, выбираем из них случайные, создаём массив частиц и сразу конвертируем его в вершинный буфер (функцию конвертации см. в позапрошлом посте):
private final int mParticles = 1200;
private int glParticleVB;
...
int colored = 0;
float[] cx = new float[width * height];
float[] cy = new float[width * height];
for (int y = 0, idx = 0; y < height; y++)
for (int x = 0; x < width; x++)
if ((pixels[idx++] & 0xffffff) != 0) {
cx[colored] = x / (float)width;
cy[colored] = y / (float)height;
colored++;
}
float[] particleBuf = new float[3 * mParticles];
for (int i = 0, idx = 0; i < mParticles; i++, idx += 3) {
int n = (int) (Math.random() * colored);
particleBuf[idx + 0] = cx[n] * 2 - 1;
particleBuf[idx + 1] = 1 - cy[n] * 2;
particleBuf[idx + 2] = (float) Math.random();
}
glParticleVB = createBuffer(particleBuf);
Количество частиц подбирается на глаз. Как было сказано выше, каждая частица содержит лишь пару координат и «сдвиг по фазе» для анимации. Стоит заметить, что Y-координата инвертируется, так как в OpenGL низ экрана имеет координату "-1", а верх — "+1", тогда как в bitmap'е верх картинки — это «0», а низ — «height».
Текстура частицы
Теперь загрузим текстуру частицы. Я воспользовался вот такой картинкой (сгенерировал её отдельно), хотя можно использовать любую другую, лишь бы устраивал конечный результат. Файл с картинкой (допустим, он называется particle.png) кладём в папку res/drawable проекта, после чего пишем код загрузки текстуры из ресурса:
private int particleTex;
...
public static int loadTexture(final Context context, final int resourceId)
{
final int[] textureHandle = new int[1];
GLES20.glGenTextures(1, textureHandle, 0);
if (textureHandle[0] != 0)
{
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inScaled = false; // No pre-scaling
// Read in the resource
final Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), resourceId, options);
// Bind to the texture in OpenGL
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle[0]);
// Set filtering
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST);
// Load the bitmap into the bound texture.
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
// Recycle the bitmap, since its data has been loaded into OpenGL.
bitmap.recycle();
}
return textureHandle[0];
}
...
particleTex = loadTexture(mContext, R.drawable.particle);
Здесь предполагается, что mContext мы сохранили в момент создания экземпляра класса Renderer.
Шейдеры
Мне хотелось создать следующий эффект: каждая частица должна «пульсировать», т.е. циклически увеличиваться и сжиматься, причём каждая должна двигаться независимо. Посмотрев на результат, я добавил ещё одно улучшение: когда размер частицы достигает 3/4 от максимального, её цвет начинает становиться белым (и становится таким в максимальной точке).
private final String particleVS =
"precision mediump float;n" +
"attribute vec4 vPosition;n" +
"attribute float vSizeShift;n" +
"uniform float uPointSize;n" +
"uniform float uTime;n" +
"uniform vec4 uColor;n" +
"varying vec4 Color;n" +
"void main() {n" +
" float Phase = abs(fract(uTime + vSizeShift) * 2.0 - 1.0);n" +
" vec4 pColor = uColor;n" +
" if (Phase > 0.75) {n" +
" pColor.y = (Phase - 0.75) * 4.0;n" +
" };n" +
" Color = pColor;n" +
" gl_PointSize = uPointSize * Phase;n" +
" gl_Position = vPosition;n" +
"}n";
private final String particleFS =
"precision mediump float;n" +
"uniform sampler2D uTexture0;n" +
"varying vec4 Color;n" +
"void main()n" +
"{n" +
" gl_FragColor = texture2D(uTexture0, gl_PointCoord) * Color;n" +
"}n";
Легко видеть, что вершинный шейдер здесь отвечает за анимацию размера частицы и цвета, а фрагментный — только применяет текстуру с помощью системной переменной gl_PointCoord. Аттрибут vSizeShift имеет здесь диапазон от 0 до 1, при сложении с uTime и выделении дробной части получаем своё значение фазы анимации для каждой частицы. Кстати, поскольку исходный цвет будет задан фиолетовым, то переход к белому цвету делается только за счёт зелёной компоненты. Копируем позицию, определяем цвет частицы и её размер — и готово.
Осталось только загрузить шейдеры (опять же, функцию Compile см. в исходном посте):
private int mPProgram;
private int maPPosition;
private int maPSizeShift;
private int muPPointSize;
private int muPTime;
private int muPTexture;
private int muPColor;
...
mPProgram = Compile(particleVS, particleFS);
maPPosition = GLES20.glGetAttribLocation(mPProgram, "vPosition");
maPSizeShift = GLES20.glGetAttribLocation(mPProgram, "vSizeShift");
muPPointSize = GLES20.glGetUniformLocation(mPProgram, "uPointSize");
muPTime = GLES20.glGetUniformLocation(mPProgram, "uTime");
muPTexture = GLES20.glGetUniformLocation(mPProgram, "uTexture0");
muPColor = GLES20.glGetUniformLocation(mPProgram, "uColor");
и отрисовать всё.
Рендер
У нас всё готово, осталось лишь задать константы и вызвать функцию отрисовки.
private void DrawText()
{
GLES20.glUseProgram(mPProgram);
GLES20.glDisable(GLES20.GL_DEPTH_TEST);
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, particleTex);
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, glParticleVB);
GLES20.glEnableVertexAttribArray(maPPosition);
GLES20.glVertexAttribPointer(maPPosition, 2, GLES20.GL_FLOAT, false, 12, 0);
GLES20.glEnableVertexAttribArray(maPSizeShift);
GLES20.glVertexAttribPointer(maPSizeShift, 1, GLES20.GL_FLOAT, false, 12, 8);
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0);
GLES20.glUniform1f(muPPointSize, 16);
GLES20.glUniform4f(muPColor, 1, 0, 1, 1);
GLES20.glUniform1i(muPTexture, 0);
GLES20.glUniform1f(muPTime, (SystemClock.uptimeMillis() % 1000) / 1000.0f);
GLES20.glDrawArrays(GLES20.GL_POINTS, 0, mParticles);
GLES20.glDisableVertexAttribArray(maPPosition);
GLES20.glDisableVertexAttribArray(maPSizeShift);
}
Заключение
На этом моя серия tutorial'ов по OpenGL ES 2.0 на Android пока заканчивается, и я объявляю недельный перерыв, по истечении которого смогу выложить .apk-файл, который позволит оценить результат на своём устройстве, а также сравнить производительность. Впрочем, в течение этого времени не исключено появление новых статей в случае, если я захочу добавить к открытке ещё какие-нибудь спецэффекты.
Автор: ginkage