Гравицапа для 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 eslayout(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 esprecision 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-тесты проверяют его структуру:

@Testfun 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.wval 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: показывайте, что собрали, и ругайте по делу. Ку!

ссылка на оригинал статьи https://habr.com/ru/articles/1095948/