Привет, Хабр! Наконец-то я закончил работу над новым большим блоком 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 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 даёт светящийся ободок, и непрозрачные сферы выглядят влажными, без блендинга.
А вот правило “Волны” (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: показывайте, что собрали, и ругайте по делу. Ку!
ссылка на оригинал статьи https://habr.com/ru/articles/1095948/