Гравицапа для 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 в строковых литералах.

Репозиторий с игрой: 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")
}Модуль | Что даёт |
|---|---|
| DSL и генерация AGSL / GLSL ES 3.0–3.2 |
| рецепты: свет, маски, шумы, SDF |
| рантайм: линковка, uniform-ы, блоки, dispatch, чтение буферов |
|
|
| матрицы и камера: |
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 даёт светящийся ободок, и непрозрачные сферы выглядят влажными, без блендинга.

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

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

Грабля №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-ами. Кроме однотонных, есть градиент “Сияние” и свой градиент, собранный в отдельном диалоге. Весь интерфейс подстраивается под выбранный цвет:

Первый блин, кю!
Первая версия игры выглядела вот так:

“Что-то, как-то не так…”, подумал я. Немного порывшись и помучав нейронку, я нашел, что были виноваты две вещи.
Правила. Классические 3D-правила с двумя состояниями либо засыпают всё пространство, либо заливают его кашей, так что каша принимает форму контейнера. Правила с затуханием (C > 2) дают форму.
Подача. Камера смотрела на тор снаружи, а тор это мир без краёв. Честно его рисовать как бесконечную периодическую среду: камера внутри, клетки повторяются через каждые N по каждой оси, дальние тонут в тумане. Готовим ровно столько инстансов, сколько видно, но не больше 16 000. Лимит держится без сортировки: гистограмма расстояний за один проход, и лишние дальние “скорлупки” отрезаются.
Куба больше нет. Эцилоп больше не имеет право бить меня по ночам. Никогда.
Итоговое сравнение
По старинке | RedByteFX | |
|---|---|---|
Шейдер | строка, ошибки в рантайме | Kotlin, типы, ошибки при сборке шейдера |
Uniform-ы | имена-строки, | типизированные дескрипторы |
std140/std430 | выравнивание руками | плоский |
Потоки |
| очередь |
Барьеры и чтение | легко перепутать биты |
|
Инстансинг | атрибуты, divisor, уборка состояния |
|
Жизненный цикл и контекст | вручную |
|
Ошибки на GL-потоке | падение процесса |
|
Тесты | только глазами | JVM-тесты шейдера и побитовая проверка на GPU |
Прозрачность | полная | полная: |
Честные ограничения
Нужен 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/redbytefx
Приходите в issues: показывайте, что собрали, и ругайте по делу. Ку!
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.