Всем привет! Меня зовут Юрий Дорофеев, я Android-разработчик и преподаватель в Mail.ru Group. Расскажу про отрисовку в Android на примере анимации огня из игры Doom. Эту игру за многие годы на чём только не запускали, от компьютеров до домофонов. Один программист однажды разобрал весь исходный код Doom и обратил внимание на алгоритм, генерирующий изображение огня. Он используется, к примеру, в официальной заставке одной из частей игры.
Как же отрисовать огонь? Нам нужно придумать реалистичное движение пикселей, изменение цветов. На самом деле алгоритм очень прост и уже описан не раз. Давайте реализуем его в Android.
Создадим новый пустой проект с единственным Activity. Создадим и добавим туда кастомное
В оригинальном алгоритме всего 37 значений температуры: самая горячая зона внизу экрана — это значение 36 (белый цвет), и чем выше, тем пламя холоднее и темнее — значения приближаются к 0 (чёрный цвет).
![](https://lh6.googleusercontent.com/Imr61r3MGzH35DzPkrxVbuJFZKW5pvqIkd30MeEuJQRZd4PZ4wjoZosloy0QEW6Nz0jT4Ny7tMAe1b-OdMpq4FJ1-RjE6H_yS4gIk-vY5y2Fte16cwbfhu-Z68CreBTZ73tVLEp6=s0)
Чтобы языки пламени выглядели реалистично, нужно добавить случайные флуктуации пикселей по горизонтали и вертикали. Каждый пиксель вычисляется так: из строки ниже случайным образом выбираем один пиксель, затем охлаждаем на случайное значение и помещаем в случайную позицию в текущей строке. Отрисовываем пламя строка за строкой, затем сменяем кадр и всё повторяем.
![](https://lh5.googleusercontent.com/8s9-IfFVZmKEqjxerrWLNtGMnD1AxElQuybFW2NrE9EoXX89f0DgEyvQSTBxUcp2I7HbWj0rn-Bky73i4tFOTCn7ox5Bjt52bBtYTo8fjghI3_OuA6n4iNmacEtBpnSJn8oFrWQl=s0)
Зададим исходную палитру в виде массива int’ов:
Создадим двумерный массив
Для начала нам нужно узнать размеры исходного
После получения размера нам нужно инициализировать массив
Заполним нижнюю строку, самую горячую. Для этого в каждый пиксель строки нужно записать значение, равное размерности палитры минус 1:
Весь массив
Для отрисовки во
Если мы запустим такое приложение, то оно будет ооооооочень долго выводить первый кадр нашего
Давайте воспользуемся bitmap'ом — изображением, наполненным нужными пикселями и их цветами.
После создания
Теперь программа выполняется гораздо быстрее, в нижней части появилась полоса белого цвета:
![](https://habrastorage.org/r/w1560/getpro/habr/post_images/2ff/4ca/128/2ff4ca12883bb7760dbce62abe3b77f0.png)
Напомню, что нам нужно случайным образом перемешивать и охлаждать температуры пикселей. Воспользуемся классом
У
Перепишем карту пикселей, исходя из новых случайных значений: к текущим значениям прибавляем случайные смещения. Не забудьте учитывать граничные случаи и проверять, не вышли ли пиксели за пределы экрана: минимальные и максимальные значения не должны быть меньше 0 и больше размерностей массива.
Запустим получившийся код:
![](https://habrastorage.org/r/w1560/getpro/habr/post_images/900/dbf/351/900dbf351de77032792a5ae6fcf3c5f5.png)
Появились небольшие языки пламени, но жизни в этом огне нет. Дело в том, что Android не будет просто так перерисовывать экран и тратить ресурсы. Необходимо явным образом сообщить, что
![](https://habrastorage.org/r/w1560/getpro/habr/post_images/ec2/df3/724/ec2df37244076b607b5a940f5660afe0.png)
Теперь языки пламени двигаются, но очень медленно. Попробуем уменьшить размерность нашего
![](https://habrastorage.org/r/w1560/getpro/habr/post_images/1d9/e6a/c13/1d9e6ac139eaeb79f2e21dfc0ee70e6a.png)
Получилось маленькое окошечко, в котором горит огонь, причём гораздо быстрее, чем в предыдущем варианте. А чтобы растянуть пламя на весь экран, нужно масштабировать
Можно ли ещё больше повысить скорость отрисовки? Да, если применить более оптимальный алгоритм или увеличить
Как же отрисовать огонь? Нам нужно придумать реалистичное движение пикселей, изменение цветов. На самом деле алгоритм очень прост и уже описан не раз. Давайте реализуем его в Android.
Базовая подготовка
Создадим новый пустой проект с единственным Activity. Создадим и добавим туда кастомное
FireView
.<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="<http://schemas.android.com/apk/res/android>"
xmlns:app="<http://schemas.android.com/apk/res-auto>"
xmlns:tools="<http://schemas.android.com/tools>"
android:layout_width=«match_parent»
android:layout_height=«match_parent»
tools:context=».MainActivity»>
<com.otopba.fireview.FireView
android:layout_width=«0dp»
android:layout_height=«0dp»
app:layout_constraintBottom_toBottomOf=«parent»
app:layout_constraintEnd_toEndOf=«parent»
app:layout_constraintStart_toStartOf=«parent»
app:layout_constraintTop_toTopOf=«parent» />
</androidx.constraintlayout.widget.ConstraintLayout>
Алгоритм
В оригинальном алгоритме всего 37 значений температуры: самая горячая зона внизу экрана — это значение 36 (белый цвет), и чем выше, тем пламя холоднее и темнее — значения приближаются к 0 (чёрный цвет).
Чтобы языки пламени выглядели реалистично, нужно добавить случайные флуктуации пикселей по горизонтали и вертикали. Каждый пиксель вычисляется так: из строки ниже случайным образом выбираем один пиксель, затем охлаждаем на случайное значение и помещаем в случайную позицию в текущей строке. Отрисовываем пламя строка за строкой, затем сменяем кадр и всё повторяем.
Подготовка
Зададим исходную палитру в виде массива int’ов:
private companion object {
private val firePalette = intArrayOf(
-0xf8f8f9,
-0xe0f8f9,
-0xd0f0f9,
-0xb8f0f9,
-0xa8e8f9,
-0x98e0f9,
-0x88e0f9,
-0x70d8f9,
-0x60d0f9,
-0x50c0f9,
-0x40b8f9,
-0x38b8f9,
-0x20b0f9,
-0x20a8f9,
-0x20a8f9,
-0x28a0f9,
-0x28a0f9,
-0x2898f1,
-0x3090f1,
-0x3088f1,
-0x3080f1,
-0x3078e9,
-0x3878e9,
-0x3870e9,
-0x3868e1,
-0x4060e1,
-0x4060e1,
-0x4058d9,
-0x4058d9,
-0x4050d1,
-0x4850d1,
-0x4848d1,
-0x4848c9,
-0x303091,
-0x202061,
-0x101039,
-0x1,
)
}
Создадим двумерный массив
temp
, который будем наполнять индексами температур (от 0 до 36):private lateinit var temp: Array<IntArray>
Для начала нам нужно узнать размеры исходного
View
. При изменении размеров View
вызовем метод onSizeChanged
, в его теле мы и начнем нашу работу:override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
}
После получения размера нам нужно инициализировать массив
temp
: h
строк (высота экрана) и w
столбцов (ширина экрана).temp = Array(h) { IntArray(w) }
Заполним нижнюю строку, самую горячую. Для этого в каждый пиксель строки нужно записать значение, равное размерности палитры минус 1:
for (x in 0 until w) {
temp[h - 1][x] = firePalette.size - 1
}
Отрисовка
Весь массив
temp
заполнен нулями, за исключением последней строки. Как же нам это отобразить? Всё банально: нам нужен цикл, в котором мы будем идти построчно отрисовывать каждый пиксель.Для отрисовки во
View
есть метод onDraw
. В качестве аргумента он получает Canvas
— «холст», на котором мы будем рисовать. У canvas
'а есть множество разных методов отрисовки. Мы выберем canvas.drawPoint
, чтобы рисовать каждый пиксель отдельно. Для этого нам нужно указать его координаты (x
, y
) и цвет (paint
). Paint
зададим полем класса и лишь будем менять у него цвет.for (y in temp.indices) {
for (x in temp[y].indices) {
val color = firePalette[temp[y][x]]
paint.color = color
canvas.drawPoint(x.toFloat(), y.toFloat(), paint)
}
}
Оптимизируем алгоритм
Если мы запустим такое приложение, то оно будет ооооооочень долго выводить первый кадр нашего
FireView
. Дело в том, что сейчас огонь отрисовывается крайне неоптимальным способом. Проблема в огромном размере очереди задач на отрисовку. Лучше один раз отобразить тысячу пикселей, чем тысячу раз по одному пикселю.Давайте воспользуемся bitmap'ом — изображением, наполненным нужными пикселями и их цветами.
bitmap = createBitmap(w, h)
После создания
Bitmap
его нужно заполнить. Оставим прежний цикл из onDraw
, в нём будем использовать вызов bitmap.setPixel
.for (y in temp.indices) {
for (x in temp[y].indices) {
val color = firePalette[temp[y][x]]
paint.color = color
bitmap.setPixel(x, y, color)
}
}
canvas.drawBitmap(bitmap, 0f, 0f, paint)
Теперь программа выполняется гораздо быстрее, в нижней части появилась полоса белого цвета:
![](https://habrastorage.org/getpro/habr/post_images/2ff/4ca/128/2ff4ca12883bb7760dbce62abe3b77f0.png)
Делаем огонь
Напомню, что нам нужно случайным образом перемешивать и охлаждать температуры пикселей. Воспользуемся классом
Random
для генерации случайных значений.У
Random
вызываем метод nextInt
, который сгенерирует случайные числа. В качестве аргумента он принимает количество генерируемых значений. Чтобы пиксели огня флуктуировали не только вверх, но и в стороны, зададим для х
диапазон из четырёх значений от -1 до 2. А по y
диапазон 0-6 будет всегда из положительных чисел, потому что огонь поднимается только вверх. Кроме того, по мере движения огонь охлаждается, поэтому добавим случайное изменение температуры от 0 до 2.Перепишем карту пикселей, исходя из новых случайных значений: к текущим значениям прибавляем случайные смещения. Не забудьте учитывать граничные случаи и проверять, не вышли ли пиксели за пределы экрана: минимальные и максимальные значения не должны быть меньше 0 и больше размерностей массива.
for (y in 0 until temp.size - 1) {
for (x in temp[y].indices) {
val dx = random.nextInt(3) - 1
val dy = random.nextInt(6)
val dt = random.nextInt(2)
val x1 = min(temp[y].size - 1, max(0, x + dx))
val y1 = min(temp.size - 1, y + dy)
temp[y][x] = max(0, temp[y1][x1] - dt)
}
}
Запустим получившийся код:
![](https://habrastorage.org/getpro/habr/post_images/900/dbf/351/900dbf351de77032792a5ae6fcf3c5f5.png)
Появились небольшие языки пламени, но жизни в этом огне нет. Дело в том, что Android не будет просто так перерисовывать экран и тратить ресурсы. Необходимо явным образом сообщить, что
View
нужно перерисовать. Для этого внутри метода onDraw
вызовем метод invalidate
. Конечно, в этом случае будет выполняться бесконечная перерисовка, но поскольку у нас горит бесконечный огонь, такое решение допустимо.![](https://habrastorage.org/getpro/habr/post_images/ec2/df3/724/ec2df37244076b607b5a940f5660afe0.png)
Теперь языки пламени двигаются, но очень медленно. Попробуем уменьшить размерность нашего
bitmap
'а: определим коэффициент масштабирования scale
. Теперь будем использовать не исходные значения высоты и ширины, а поделённые на коэффициент масштабирования. Уменьшим таким образом площадь bitmap
'а в четыре раза и запустим вновь.![](https://habrastorage.org/getpro/habr/post_images/1d9/e6a/c13/1d9e6ac139eaeb79f2e21dfc0ee70e6a.png)
Получилось маленькое окошечко, в котором горит огонь, причём гораздо быстрее, чем в предыдущем варианте. А чтобы растянуть пламя на весь экран, нужно масштабировать
Canvas
.Можно ли ещё больше повысить скорость отрисовки? Да, если применить более оптимальный алгоритм или увеличить
scale
.