Гравицапа для GPU: OpenGL ES на Kotlin DSL на примере 3D-“Жизни” Конвея

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Привет, Хабр! Наконец-то я закончил работу над новым большим блоком RedByteFX, тем самым, который в планах звучал скромно: “а ещё было бы неплохо OpenGL ES”. “Неплохо” растянулось на много вечеров и вылилось в версию 1.2.0, которая уже лежит в Maven Central. Если коротко: теперь на Kotlin DSL можно писать не только эффекты для Compose, но и полноценную 3D-графику и вычисления на GPU. Если подробно, то читайте дальше: я покажу всё на живом примере и сравню с тем, как пришлось бы писать без библиотеки.

Немного контекста. В первой статье я показывал, как RedByteFX превращает AGSL из “не трогай, это на Новый год” в обычный Kotlin-код. Тогда библиотека умела красить то, что уже нарисовал Compose, и только на Android 13+. Пепелац был, но летал без гравицапы.

В версии 1.2.0 у RedByteFX выросла вторая половина: тот же Kotlin DSL собирает GLSL ES 3.0–3.2. Это вершинные, фрагментные и compute-шейдеры, плюс рантайм поверх GLES и Compose-хосты GlSurface/GlCompute. Всё работает с API 24.

Рассказывать про это на очередном “волновом эффекте” скучно. Пораскинув мозгами над тем, что могло быть красивой демонстрацией и в то же время не совсем тривиальным, я пришел к выводу и написал реализацию игры: “Жизнь” Конвея, но не в двумерном пространстве, а в трёхмерном. Поколения считает compute-шейдер, тысячи клеток рисуются инстансами в вязкой светящейся жидкости. А по ходу статьи я честно сравниваю каждый шаг с тем, как то же самое пришлось бы писать по старинке, на голом GLES31 и GLSL в строковых литералах.

Жизнь 3D: правило "Лава" на торе
Жизнь 3D: правило "Лава" на торе

Репозиторий с игрой: github.com/i-redbyte/life3d. Библиотека: github.com/i-redbyte/redbytefx.

Что значит “по старинке”

Кто писал OpenGL ES на Android руками, помнит этот набор:

  • шейдер живёт в строке, и опечатка в имени переменной всплывает в рантайме, в логе драйвера, на чужом телефоне;

  • uniform-ы ищутся по строковому имени (glGetUniformLocation(program, "u_sise")), а неверное имя драйвер молча превращает в -1, и запись в него - тихий no-op;

  • раскладку std140 считаешь на пальцах: float[27] в uniform-блоке занимает не 108 байт, а 432;

  • ByteBuffer.allocateDirect(...).order(ByteOrder.nativeOrder()) - на каждый буфер;

  • всё должно происходить на GL-потоке, а UI живёт на своём, и мост между ними строишь сам;

  • GLSurfaceView, его жизненный цикл, потеря EGL-контекста и исключение на GL-потоке, которое роняет приложение целиком.

Ничего из этого не сложно по отдельности. Сложно всё это одновременно, каждый раз заново. RedByteFX забирает рутину, но оставляет вам полный контроль: итоговый GLSL всегда можно посмотреть.

Подключение

dependencies {
    implementation("io.github.i-redbyte:redbytefx-core:1.2.0")
    implementation("io.github.i-redbyte:redbytefx-stdlib:1.2.0")
    implementation("io.github.i-redbyte:redbytefx-gl:1.2.0")
    implementation("io.github.i-redbyte:redbytefx-gl-compose:1.2.0")
    implementation("io.github.i-redbyte:redbytefx-3d:1.2.0")
}

Модуль

Что даёт

redbytefx-core

DSL и генерация AGSL / GLSL ES 3.0–3.2

redbytefx-stdlib

рецепты: свет, маски, шумы, SDF

redbytefx-gl

рантайм: линковка, uniform-ы, блоки, dispatch, чтение буферов

redbytefx-gl-compose

GlSurface, GlCompute, GlController, меши и примитивы

redbytefx-3d

матрицы и камера: lookAt, perspective

Compute-шейдерам нужен OpenGL ES 3.1, поэтому в манифест сразу пишем:

<uses-feature
    android:glEsVersion="0x00030001"
    android:required="true" />

Грабля №1, бесплатная. Эмулятор Android на Apple Silicon отдаёт только OpenGL ES 3.0. Compute там не заведётся. Отлаживался я на Galaxy S21. Эмулятор на маке в этой истории это пацак которому забыли выдать цак!

Игра в двух словах

У клетки в 3D 26 соседей (куб 3×3×3 без центра). Правило пишется в нотации B/S/C (в отличие от обычной “Жизни”, где клетка просто жива или мертва, здесь у клетки может быть несколько состояний, что и позволяет имитировать “тление” и создавать сложные структуры): при каком числе соседей клетка рождается, при каком выживает и сколько у неё состояний. Если состояний больше двух, умершая клетка не исчезает сразу, а несколько поколений “тлеет”: не считается соседом и не может родиться заново. Именно это делает из каши волны, оболочки и кораллы. Главная героиня - “Лава”, B6-8/S4-7/C10.

Мир - тор: решётка N³, у которой противоположные грани склеены. Классика “Жизни”: ушёл за край - пришёл с другой стороны.

Экранов два: настройки и поле. Бары в цвет приложения, тексты на двух языках, кнопка “Назад” возвращает к настройкам.

Слева экран настроек, справа поле.
Слева экран настроек, справа поле.

Теперь по раундам: старый подход против DSL.

Раунд 1. Шейдер

По старинке

private const val LIFE_COMPUTE = """
#version 310 es
layout(local_size_x = 4, local_size_y = 4, local_size_z = 4) in;
layout(std430, binding = 0) buffer world { float cells[]; };
layout(std140, binding = 0) uniform rules { float birth[27]; float survive[27]; };
uniform int u_size;
uniform int u_read_offset;
uniform int u_write_offset;
void main() {
    ...
}
"""

val shader = GLES31.glCreateShader(GLES31.GL_COMPUTE_SHADER)
GLES31.glShaderSource(shader, LIFE_COMPUTE)
GLES31.glCompileShader(shader)
val status = IntArray(1)
GLES31.glGetShaderiv(shader, GLES31.GL_COMPILE_STATUS, status, 0)
if (status[0] == 0) error(GLES31.glGetShaderInfoLog(shader))
val program = GLES31.glCreateProgram()
GLES31.glAttachShader(program, shader)
GLES31.glLinkProgram(program)
GLES31.glGetProgramiv(program, GLES31.GL_LINK_STATUS, status, 0)
if (status[0] == 0) error(GLES31.glGetProgramInfoLog(program))
val sizeLocation = GLES31.glGetUniformLocation(program, "u_size")
val readLocation = GLES31.glGetUniformLocation(program, "u_read_offset")
val writeLocation = GLES31.glGetUniformLocation(program, "u_write_offset")

Многоточие в main() - это ещё 40 строк GLSL, которые IDE считает просто текстом. Рефакторинг имени uniform-а? Поиск по строкам и молитва.

В RedByteFX

val program = shader(ShaderTarget.Gles31) {
    size = uniformInt("size", 0)
    readOffset = uniformInt("read_offset", 0)
    writeOffset = uniformInt("write_offset", 0)

    lateinit var birth: StorageArray<Flt<High>>
    lateinit var survive: StorageArray<Flt<High>>
    rules = uniformBlock("rules") {
        birth = floatArray("birth", NEIGHBOUR_COUNTS)
        survive = floatArray("survive", NEIGHBOUR_COUNTS)
    }
    lateinit var cells: StorageArray<Flt<High>>
    world = storageBlock("world") {
        cells = floatArray("cells")
    }

    compute(WORKGROUP_EDGE, WORKGROUP_EDGE, WORKGROUP_EDGE) {
        val n = size.expr
        val wrap = fn(0.intLit, 0.intLit, name = "wrap") { value, edge ->
            ifElse(value lt 0.intLit, value + edge, ifElse(value ge edge, value - edge, value))
        }
        val block = local(0.intLit, "block")
        repeat(3) { dz ->
            repeat(3) { dy ->
                repeat(3) { dx ->
                    val neighbour = read(x + dx - 1.intLit, y + dy - 1.intLit, z + dz - 1.intLit)
                    block.set(block.expr + ifElse(neighbour gt ALIVE, 1.intLit, 0.intLit))
                }
            }
        }
        val self = local(read(x, y, z), "self")
        val alive = self.expr gt ALIVE
        val neighbours = block.expr - ifElse(alive, 1.intLit, 0.intLit)
        val lives = ifElse(alive, survive[neighbours] gt 0.5f, birth[neighbours] gt 0.5f)
        cells[writeOffset.expr + here].store(nextValue)
    }
}

(Фрагмент сокращён: в репозитории ещё есть остывание клеток, “вспышка жизни” от тапа и режим бесконечного поля.)

Что изменилось по существу:

  • Типы вместо строк. size - это Uniform<IntS>, а не "u_size". Тот же объект потом передаётся в controller.set(compute.size, n). Опечатку поймает компилятор Kotlin, а переименование можно легко сделать в IDE.

  • Ошибки до GPU. Попробуйте написать compute { } или объявить storage-буфер в программе под ShaderTarget.Gles30: DSL откажет ещё при сборке шейдера в Kotlin, а не в логе драйвера на устройстве пользователя.

  • Нормальные абстракции. fn превращается в отдельную GLSL-функцию, local - в локальную переменную, repeat(3) - в настоящий for. Почему в шейдере свой wrap, а не %? Потому что в GLSL -1 % n не даёт n - 1, и тор рвётся по швам.

  • Прозрачность. program.computeSource() отдаёт итоговый GLSL. Ничего не спрятано:

#version 310 es
precision highp float;
uniform int u_size;
uniform int u_read_offset;
uniform int u_write_offset;
layout(std140, binding = 0) uniform rules {
  highp float birth[27];
  highp float survive[27];
} b_rules;
layout(std430, binding = 0) buffer world {
  highp float cells[];
} b_world;
layout(local_size_x = 4, local_size_y = 4, local_size_z = 4) in;
int wrap(int p0, int p1) {
  return ((p0 < 0) ? (p0 + p1) : ((p0 >= p1) ? (p0 - p1) : p0));
}
void main() {
  int block;
  highp float self;
  block = 0;
  for (int i = 0; i < 3; ++i) {

Биндинги назначены, std140/std430 расставлены, а повторяющиеся подвыражения библиотека сама выносит в локальные переменные.

Раунд 2. Буферы и std140

Поле N³ лежит в одном storage-буфере, где две решётки подряд: поколение читает одну половину и пишет другую, потом половины меняются ролями. Плюс uniform-блок с правилами: 27 флагов рождения и 27 флагов выживания.

По старинке

val buffers = IntArray(2)
GLES31.glGenBuffers(2, buffers, 0)

val cells = ByteBuffer.allocateDirect(world.size * 4).order(ByteOrder.nativeOrder())
cells.asFloatBuffer().put(world)
GLES31.glBindBuffer(GLES31.GL_SHADER_STORAGE_BUFFER, buffers[0])
GLES31.glBufferData(GLES31.GL_SHADER_STORAGE_BUFFER, cells.capacity(), cells, GLES31.GL_DYNAMIC_COPY)
GLES31.glBindBufferBase(GLES31.GL_SHADER_STORAGE_BUFFER, 0, buffers[0])

val rules = ByteBuffer.allocateDirect(54 * 16).order(ByteOrder.nativeOrder())
for (i in values.indices) rules.putFloat(i * 16, values[i])
GLES31.glBindBuffer(GLES31.GL_UNIFORM_BUFFER, buffers[1])
GLES31.glBufferData(GLES31.GL_UNIFORM_BUFFER, rules.capacity(), rules, GLES31.GL_STATIC_DRAW)
GLES31.glBindBufferBase(GLES31.GL_UNIFORM_BUFFER, 0, buffers[1])

Видите i * 16? В std140 каждый элемент массива float выравнивается до 16 байт. Забудете - и правило “рождение при 5 соседях” превратится в “рождение при 1, 5 и 9”. Колония начнёт жить по законам, которые вы ей не давали.

В RedByteFX

controller.set(compute.world, world)
controller.set(compute.rules, rule.uniformValues())

Плоский FloatArray на входе. Раскладку std140/std430, выравнивание, выделение буферов и их привязку библиотека делает сама. Повторные записи переиспользуют внутренние массивы и не аллоцируют новые.

Раунд 3. Запуск, потоки и чтение результата

По старинке

glSurfaceView.queueEvent {
    GLES31.glUseProgram(program)
    GLES31.glUniform1i(sizeLocation, n)
    GLES31.glUniform1i(readLocation, read)
    GLES31.glUniform1i(writeLocation, write)
    GLES31.glDispatchCompute(n / 4, n / 4, n / 4)
    GLES31.glMemoryBarrier(GLES31.GL_BUFFER_UPDATE_BARRIER_BIT)
    GLES31.glBindBuffer(GLES31.GL_SHADER_STORAGE_BUFFER, buffers[0])
    val mapped = GLES31.glMapBufferRange(
        GLES31.GL_SHADER_STORAGE_BUFFER, 0, cells.capacity(), GLES31.GL_MAP_READ_BIT,
    ) as ByteBuffer
    mapped.order(ByteOrder.nativeOrder()).asFloatBuffer().get(into)
    GLES31.glUnmapBuffer(GLES31.GL_SHADER_STORAGE_BUFFER)
    mainHandler.post { onGeneration(into) }
}

Здесь притаилась классическая ловушка. Барьер для маппинга - GL_BUFFER_UPDATE_BARRIER_BIT. Многие ставят привычный GL_SHADER_STORAGE_BARRIER_BIT, и на одних GPU всё работает, а на других приходят данные прошлого поколения. Отлаживать такое - удовольствие уровня просидеть в эцихе с гвоздями пожизненно, до выплаты.

В RedByteFX

val computeController = rememberGlController(compute.program)

GlCompute(controller = computeController, modifier = Modifier.size(1.dp), overlay = {})

controller.set(compute.readOffset, read)
controller.set(compute.writeOffset, write)
controller.dispatch(groups, groups, groups)
controller.read(compute.world, into) { count -> onGeneration() }

Что здесь происходит за кадром:

  • GlCompute - невидимый хост с EGL-контекстом нужной версии. Он сам ставит на паузу и возобновляет работу вместе с жизненным циклом экрана.

  • set, dispatch и read можно звать с любого потока. Они ставятся в очередь GL-потока и применяются в том порядке, в котором вы их сделали.

  • dispatch ставит барьер для последующих шейдеров, а read перед маппингом ставит именно GL_BUFFER_UPDATE_BARRIER_BIT. Ловушка выше просто не случается.

  • Если контекст пересоздан, последние значения uniform-ов и блоков применяются к новой программе заново.

  • Статус линковки - это Compose-State (controller.linkState). Не тянет устройство ES 3.1? Получите GlLinkState.Failed и покажете вежливый экран вместо падения.

Грабля №2, уже моя. Записи set(...) в GlController склеиваются: если записать uniform дважды до того, как GL-поток их применил, применится последнее значение. Так UI-поток никогда не ждёт драйвер, и для анимации это идеально. Но для поколений это ловушка: тик N+1 мог переписать смещения раньше, чем отработал dispatch тика N, и оба поколения читали одну половину. Получалась “Жизнь” с амнезией. Лечится одним флагом: новый тик стартует, только когда предыдущее поколение вернулось с GPU.

fun tick(): Boolean {
    if (!inFlight.compareAndSet(false, true)) return false
    val started = epoch
    val previous = latest
    val read = current
    val write = if (read == 0) total else 0
    gpu.offsets(read, write)
    gpu.dispatch(n / WORKGROUP_EDGE)
    gpu.readBack(readBack) {
        if (started == epoch) {
            latest = Generation.of(readBack, write, n, previous.origin, previous.number + 1)
            latestAt = clock()
            current = write
            inFlight.set(false)
        }
    }
    return true
}

(Упрощено. epoch выбрасывает поколение, которое прилетело уже после нажатия “Заново”.)

Мораль: абстракция снимает рутину, но не отменяет мышление. Зато думать приходится о логике игры, а не о байтах.

Раунд 4. Тесты

По старинке

Шейдер в строке протестировать можно одним способом: запустить приложение и посмотреть. Unit-тест на JVM даже не скомпилирует его, потому что нет драйвера.

В RedByteFX

Шейдер собирается на JVM, поэтому обычные unit-тесты проверяют его структуру:

@Test
fun theGenerationRunsInFourCubedWorkgroups() {
    val source = lifeCompute().program.computeSource()

    assertTrue(source, source.contains("local_size_x = 4, local_size_y = 4, local_size_z = 4"))
}

А главное - рантайм GlProgramRuntime работает и без Compose, с любым EGL-контекстом. Поэтому в проекте есть тест, который на реальном GPU прогоняет поколение и сравнивает каждую клетку с эталоном на Kotlin, требуя точного совпадения:

val runtime = GlProgramRuntime(compute.program, Gles30Device())
runtime.link()
runtime.set(compute.world, world)
runtime.set(compute.rules, rule.uniformValues())
runtime.set(compute.size, n)
runtime.set(compute.readOffset, 0)
runtime.set(compute.writeOffset, total)
runtime.dispatch(groups, groups, groups)
runtime.read(compute.world, back)

assertArrayEquals(CpuLife.step(seed, n, rule), back.copyOfRange(total, 2 * total), 0f)

Эталон - пять строк when на Kotlin. На Samsung Galaxy S21 шейдер совпадает с ним до бита для всех правил. Когда кто-то спрашивает “а оно точно правильно считает?”, ответ занимает одну команду: ./gradlew connectedDebugAndroidTest.

Раунд 5. Отрисовка тысяч клеток

Клетки - это инстансы сферы. Каждой нужны координаты и состояние, а всем вместе - один draw call.

По старинке

val data = ByteBuffer.allocateDirect(instances.size * 4).order(ByteOrder.nativeOrder())
data.asFloatBuffer().put(instances)
GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, instanceBuffer)
GLES30.glBufferData(GLES30.GL_ARRAY_BUFFER, data.capacity(), data, GLES30.GL_STREAM_DRAW)
for (column in 0 until 4) {
    val location = modelLocation + column
    GLES30.glEnableVertexAttribArray(location)
    GLES30.glVertexAttribPointer(location, 4, GLES30.GL_FLOAT, false, 64, column * 16)
    GLES30.glVertexAttribDivisor(location, 1)
}
GLES30.glDrawElementsInstanced(GLES30.GL_TRIANGLES, indexCount, GLES30.GL_UNSIGNED_SHORT, 0, count)

И после этого не забыть вернуть divisor в ноль, иначе следующий меш нарисуется очень креативно.

В RedByteFX

frame.draw(backgroundMesh, model = background, pipeline = liquidPipeline)
frame.draw(cellMesh, instances = drawn.instances, pipeline = cellPipeline)

instances - плоский FloatArray, по 16 чисел на инстанс. Библиотека держит отдельный инстанс-буфер на каждый меш, раскладывает данные по атрибутам model0..model3 с divisor 1 и прибирает за собой состояние. GlPipeline(depthMask = false) - это “фон не пишет глубину” одной строкой вместо пачки glDepthMask/glEnable/glDisable. Сфера и квад - готовые примитивы sphere(1f, 6, 10) и quad(2f, 2f).

Матрицы считает redbytefx-3d прямо в переиспользуемые массивы, без аллокаций на кадр:

lookAt(eye[0], eye[1], eye[2], camera.targetX, camera.targetY, camera.targetZ, 0f, 1f, 0f, viewMatrix)
perspective(OrbitCamera.verticalFov(frame.aspect), frame.aspect, NEAR, FAR, projection)

В вершинном шейдере матрица инстанса нам вообще не нужна. В первый столбец мы кладём (x, y, z, value) и сами решаем, что с этим делать. Шейдер наш, мы тут правительство:

val column0 = attributeVec4("model0")
val cell = vec3(column0.x, column0.y, column0.z)
val value = column0.w
val grow = smoothstep(0f, 1f, phase.expr)
val life = ifElse(value lt -0.5f, fadingLife, ifElse(value lt 1.5f, grow, 1f.lit))

Новорождённая клетка вырастает за поколение, умершая сжимается в тёмного “призрака” и тает. Свет взят из stdlib, без единой формулы, переписанной из учебника:

val diffuse = wrapLambert(n, lightDir, 0.3f)
val rim = fresnel(n, toEye, 2.4f)

fresnel даёт светящийся ободок, и непрозрачные сферы выглядят влажными, без блендинга.

Оболочка “Лавы” на 31-м поколении и вид изнутри среды на 96-м
Оболочка “Лавы” на 31-м поколении и вид изнутри среды на 96-м

А вот правило “Волны” (B4/S4/C5): волна расходится от искры и оставляет гаснущий след.

Волны
Волны

Раунд 6. Kotlin как макроязык для GLSL

Первый фон-“жидкость” я собрал за пять минут из stdlib: domainWarp + fbm. На эмуляторе вышла вязкая жидкость. На Galaxy S21 - плитка в ванной:

Квадратная жидкость на Mali
Квадратная жидкость на Mali

Грабля №3. Шумы строятся на хеше fract(sin(x) * 43758.5), а мобильные GPU считают sin от больших аргументов неточно. Лечится жидкостью, где все аргументы синуса маленькие:

private fun liquidFlow(point: Expr<Vec2<Flt<High>>>, time: Expr<Flt<High>>): Expr<Flt<High>> {
    var q = point
    for (pass in 1..LIQUID_PASSES) {
        val k = pass.toFloat()
        val push = vec2(
            sin(q.y * (0.9f + 0.2f * k) + time * 0.23f + k),
            cos(q.x * (0.8f + 0.25f * k) - time * 0.19f + k * 1.7f),
        )
        q = q + push * (0.55f / k)
    }
    return sin(q.x * 1.3f + q.y * 0.7f) * 0.5f + 0.5f
}

Главное тут не математика, а то, как это написано. Это обычная Kotlin-функция с обычным for. Цикл выполняется при сборке шейдера и разворачивается в пять проходов выражений, а функцию можно переиспользовать, параметризовать и покрыть тестом. По старинке пришлось бы либо копировать куски GLSL пять раз, либо склеивать строки через buildString, прощаясь с подсветкой и здравым смыслом.

В DSL у вас два вида циклов, и это фича. repeat из DSL - настоящий for на GPU (как подсчёт соседей). Kotlin-for - развёртка во время сборки (как жидкость). Выбираете по задаче.

Раунд 7. Compose

По старинке

AndroidView(factory = { context ->
    GLSurfaceView(context).apply {
        setEGLContextClientVersion(3)
        preserveEGLContextOnPause = true
        setRenderer(LifeRenderer())
    }
})

Дальше вручную: DisposableEffect с наблюдателем за жизненным циклом, чтобы звать onPause/onResume; мост между потоками; пересоздание ресурсов после потери контекста. И try-catch на GL-потоке, потому что исключение там убивает процесс, а не показывает ошибку.

В RedByteFX

GlSurface(
    controller = sceneController,
    mesh = renderer.backgroundMesh,
    modifier = Modifier
        .fillMaxSize()
        .fieldGestures(camera) { renderer.touchedAt = System.nanoTime() }
        .pointerInput(engine) {
            detectTapGestures { tap ->
                val point = camera.pick(tap.x, tap.y, size.width.toFloat(), size.height.toFloat())
                engine.spark(torusCell(worldToCell(point), engine.n))
            }
        },
    onFrame = renderer::draw,
)

(Немного упрощено относительно репозитория.)

GlSurface - обычный composable:

  • он сам следит за жизненным циклом и сохраняет EGL-контекст на паузе: после возврата из фона ничего не перелинковывается;

  • исключение в onFrame не роняет приложение: отрисовка останавливается, ошибка публикуется в controller.failure и показывается через failureOverlay;

  • модификаторы Compose - жесты, pointerInput, отступы - работают как с любым другим composable.

Тап рождает “вспышку жизни”: точка касания превращается в луч из камеры, луч - в клетку тора, и в следующем поколении вокруг неё оживает облако.

Вспышки жизни от тапов
Вспышки жизни от тапов

Цвета клеток передаются в шейдер двумя uniform-ами. Кроме однотонных, есть градиент “Сияние” и свой градиент, собранный в отдельном диалоге. Весь интерфейс подстраивается под выбранный цвет:

Слева: интерфейс подстраивается под выбранный цвет клеток. Справа: диалог “Свой градиент”, где оттенок, насыщенность и яркость задаются для начала и конца.
Слева: интерфейс подстраивается под выбранный цвет клеток. Справа: диалог “Свой градиент”, где оттенок, насыщенность и яркость задаются для начала и конца.

Первый блин, кю!

Первая версия игры выглядела вот так:

Первая версия: куб
Первая версия: куб

“Что-то, как-то не так…”, подумал я. Немного порывшись и помучав нейронку, я нашел, что были виноваты две вещи.

  1. Правила. Классические 3D-правила с двумя состояниями либо засыпают всё пространство, либо заливают его кашей, так что каша принимает форму контейнера. Правила с затуханием (C > 2) дают форму.

  2. Подача. Камера смотрела на тор снаружи, а тор это мир без краёв. Честно его рисовать как бесконечную периодическую среду: камера внутри, клетки повторяются через каждые N по каждой оси, дальние тонут в тумане. Готовим ровно столько инстансов, сколько видно, но не больше 16 000. Лимит держится без сортировки: гистограмма расстояний за один проход, и лишние дальние “скорлупки” отрезаются.

Куба больше нет. Эцилоп больше не имеет право бить меня по ночам. Никогда.

Итоговое сравнение

По старинке

RedByteFX

Шейдер

строка, ошибки в рантайме

Kotlin, типы, ошибки при сборке шейдера

Uniform-ы

имена-строки, -1 при опечатке

типизированные дескрипторы

std140/std430

выравнивание руками

плоский FloatArray

Потоки

queueEvent, Handler, синхронизация

очередь GlController с любого потока

Барьеры и чтение

легко перепутать биты

dispatch/read ставят нужные

Инстансинг

атрибуты, divisor, уборка состояния

draw(instances = …)

Жизненный цикл и контекст

вручную

GlSurface/GlCompute

Ошибки на GL-потоке

падение процесса

failure и overlay

Тесты

только глазами

JVM-тесты шейдера и побитовая проверка на GPU

Прозрачность

полная

полная: computeSource(), vertexSource(), fragmentSource()

Честные ограничения

  • Нужен OpenGL ES 3.1 для compute. Почти все устройства с 2016 года, но эмулятора на Apple Silicon нету (или я не нашел).

  • Два контекста - два мира. GlCompute и GlSurface не делят буферы, поэтому результат поколения читается на CPU. Для поля 48³ это около 885 КБ на поколение, при шести поколениях в секунду - копейки, но для гигантских полей понадобится общий контекст.

  • DSL нужно освоить один раз. Выражения вместо операторов, ifElse вместо if, два вида циклов. Это цена входа, и, по-моему, она окупается уже на втором шейдере.

Итоги

Из 26 соседей, двух половин буфера и пяти строк when получилась игра, на которую можно залипнуть минут на двадцать, а то и час. При этом:

  • ни одной строки шейдера в строковом литерале;

  • ни одного glGetUniformLocation, allocateDirect и ручного выравнивания;

  • шейдер покрыт тестами, включая побитовую проверку на живом GPU;

  • итоговый GLSL открыт и читается как написанный руками.

Если первая статья была про то, что AGSL можно писать по-человечески, то эта про то, что и OpenGL ES можно. Пепелац теперь летает с гравицапой.

  • Игра: github.com/i-redbyte/life3d

  • Библиотека: github.com/i-redbyte/redbytefx

Приходите в issues: показывайте, что собрали, и ругайте по делу. Ку!

Источник: https://habr.com/ru/articles/1095948/


Интересные статьи

Интересные статьи

Сооснователь Google Сергей Брин создал оперативный штаб в Маунтин‑Вью, где он объединил ключевую команду инженеров. Топ‑менеджер задался целью убрать внутреннюю бюрократию, ускорить разработку фл...
Magento — дорого, Ensi — сложно, Bitrix — просто. Или не так?Всем привет! Я Роман, тимлид e-commerce агентства KISLOROD.Платформа для интернет-магазина — стратегическое решение. Выбор между Bitrix, Ma...
О нейросетях не писал только ленивый, причём тон статей меняется от откровенно пренебрежительного до восхищения возможностями нейронок. Мы же решили сделать практичный пост и показать инструменты, кот...
Цель статьи, – показать примеры управления реализацией стратегии с помощью корпоративной единой информационной площадки на доступном инструменте, - Битрикс24. В статье на простом языке обсуждаются воз...
Приветствую вас (лично вас, а не всех кто это читает)! Сегодня мы: Создадим приложение (навык) Алисы с использованием нового (октябрь 2019) сервиса Yandex Cloud Functions. Настроим н...