Pull to refresh
VK
Building the Internet

Делаем на Android анимацию как в Doom. Приложение-огонь

Reading time5 min
Views7.2K
Всем привет! Меня зовут Юрий Дорофеев, я 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.

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 30: ↑27 and ↓3+24
Comments5

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен