Всем привет! Меня зовут Юрий Дорофеев, я Android-разработчик и преподаватель в Mail.ru Group. Расскажу про отрисовку в Android на примере анимации огня из игры Doom. Эту игру за многие годы на чём только не запускали, от компьютеров до домофонов. Один программист однажды разобрал весь исходный код Doom и обратил внимание на алгоритм, генерирующий изображение огня. Он используется, к примеру, в официальной заставке одной из частей игры.

Как же отрисовать огонь? Нам нужно придумать реалистичное движение пикселей, изменение цветов. На самом деле алгоритм очень прост и уже описан не раз. Давайте реализуем его в 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)

Теперь программа выполняется гораздо быстрее, в нижней части появилась полоса белого цвета:


Делаем огонь


Напомню, что нам нужно случайным образом перемешивать и охлаждать температуры пикселей. Воспользуемся классом 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)
    }
}

Запустим получившийся код:


Появились небольшие языки пламени, но жизни в этом огне нет. Дело в том, что Android не будет просто так перерисовывать экран и тратить ресурсы. Необходимо явным образом сообщить, что View нужно перерисовать. Для этого внутри метода onDraw вызовем метод invalidate. Конечно, в этом случае будет выполняться бесконечная перерисовка, но поскольку у нас горит бесконечный огонь, такое решение допустимо.


Теперь языки пламени двигаются, но очень медленно. Попробуем уменьшить размерность нашего bitmap'а: определим коэффициент масштабирования scale. Теперь будем использовать не исходные значения высоты и ширины, а поделённые на коэффициент масштабирования. Уменьшим таким образом площадь bitmap'а в четыре раза и запустим вновь.


Получилось маленькое окошечко, в котором горит огонь, причём гораздо быстрее, чем в предыдущем варианте. А чтобы растянуть пламя на весь экран, нужно масштабировать Canvas.

Можно ли ещё больше повысить скорость отрисовки? Да, если применить более оптимальный алгоритм или увеличить scale.

Комментарии (5)


  1. Error1024
    05.10.2021 17:53
    +8

    Необходимо явным образом сообщить, что View нужно перерисовать. Для этого внутри метода onDraw вызовем метод invalidate. Конечно, в этом случае будет выполняться бесконечная перерисовка, но поскольку у нас горит бесконечный огонь, такое решение допустимо.
    Я кончено не senior Android developer, но разве мы не получим разное FPS на разных девайсах из-за этого? Да и вообще выглядит такой подход адово. Лучше же по какому-то таймеру попросить View перерисоваться, например 30 раз в секунду.
    Всем привет! Меня зовут Юрий Дорофеев, я Android-разработчик и преподаватель в Mail.ru Group.
    Вот он — истинный скил преподавателей и качество преподавания в вайтишных школах mailru.


    1. iurii_dorofeev Автор
      05.10.2021 17:55
      -1

      Это не продакшен код, а лишь пример того, как можно решить подобную задачу. Конечно в реальности никто не будет вызывать invalidate внутри onDraw


      1. Error1024
        05.10.2021 18:02
        +2

        Вы же преподаватель — так? Разве ваша задача не учить тому, как делать правильно?
        Не, ок — понимаю, простой пример и все такое, но зачем показывать очевидно ужасный пример.
        Новичек скопирует этот код, а потом будет ещё 999 вопросов на stackoverflow про тормоза и выжирание батареи приложением.
        И вообще сами же написали:

        такое решение допустимо.


    1. Atreides07
      09.10.2021 23:24

      Если бы программированию обучали исключительно топовые эксперты нашей индустрии, то некому было бы учить программистов, так как подавляющее большинство экспертов, попросту не хотят и/или не умеют обучать людей, а если и пишут, то часто пишут для таких же экспертов как они сами. К сожалению не все могут быть Jon Skeet-ом. https://habr.com/ru/post/137317/


  1. scorpka
    06.10.2021 07:52
    -2

    демосцена это всегда круто, и лайк влепил