Открыть список
Как стать автором
Обновить

Рисуем мерцающий текст системой частиц

Разработка под Android
В позапрошлой своей статье, посвящённой созданию открытки средствами 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 / 2, 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, 12);
		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-файл, который позволит оценить результат на своём устройстве, а также сравнить производительность. Впрочем, в течение этого времени не исключено появление новых статей в случае, если я захочу добавить к открытке ещё какие-нибудь спецэффекты.
Теги:androidopengl es 2.0shaderseffectspoint sprites
Хабы: Разработка под Android
Всего голосов 14: ↑11 и ↓3 +8
Просмотры7K

Комментарии 7

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Похожие публикации

Разработчик под Android (Kotlin)
от 100 000 ₽eKassirСанкт-Петербург
Младший разработчик под Android (Kotlin)
от 50 000 ₽eKassirСанкт-Петербург
Android-разработчик 🧑🏽‍💻
от 100 000 ₽FlowwowМожно удаленно
Ведущий разработчик C++/Qt под Android
от 180 000 ₽2GISМожно удаленно
Android- разработчик
до 2 000 $G1 SoftwareМожно удаленно

Лучшие публикации за сутки