RTP DesportoGolo de penálti Gonçalo Ramos ajuda AC Milan, de Amorim, a vencer AtalantaDaily MaverickTROUBLED WATERS: ‘Environmental tragedy’ — mismanagement blamed for decline of Gqeberha’s North End LakeESPN Deportes¿Tiempo de Fernando Mendoza? Siete cosas que Ben Solak espera para la Semana 6The Jerusalem PostRabbis for Human Rights files police complaint over Rabbi Lior’s Palestinian olive harvest rulingPunchPolice arrest 56-year old over wife’s death in OgunESPNBig 12 admits error in Notre Dame-BYU ending, says Irish had 1 second left한겨레[단독]다수 집단 중심 데이터 반복학습하는…AI 알고리즘이 ‘배제의 벽’ 높여ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioBBC News BrasilDebate na Band: Lula e Flávio Bolsonaro se enfrentam antes do 2º turnoVanguard2027: Umahi projects 95% votes for Tinubu, NwifuruCBS NewsBoy among 2 killed after truck drives onto Wichita soccer fieldTVN24Gosek powiedział, ile wyciska. "To Pudzianowski wejdzie wszędzie i zawsze"
The Daily Newsstand · Free, Always
Sunday, October 18, 2026

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

Translate

Привет, Хабр! Наконец-то я закончил работу над новым большим блоком 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 можно. Пепелац теперь летает с гравицапой.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.

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