Розробка GLSL шейдерів на Kotlin

Всім привіт!

Наша компанія займається розробкою онлайн ігор і зараз ми працюємо над мобільною версією нашого основного проекту. У цій статті хочемо поділитися досвідом розробки GLSL шейдерів для Android проекту з прикладами і джерела.

Про проект

Спочатку гра була браузерна на Flash, але новина про швидке припинення підтримки Flash змусила нас перенести проект на HTML5. В якості мови розробки був використаний Kotlin, і через півроку ми змогли запустити проект і на Android. На жаль, без оптимізації на мобільних пристроях грі не вистачало продуктивності.

Щоб підвищити FPS, було вирішено переробити графічний движок. Раніше ми використовували кілька універсальних шейдерів, а тепер для кожного ефекту вирішили писати окремий шейдер, заточений під певну задачу, щоб мати можливість зробити їх роботу більш ефективною.

Чого нам не вистачало

Шейдери можна зберігати в рядку, але цей спосіб виключає перевірку синтаксису і узгодження типів, тому зазвичай шейдери зберігають у Assets або Raw файлів, так як це дозволяє включити перевірку, встановивши плагін для Android Studio. Але й у цього підходу є недолік — відсутність реиспользования: щоб зробити невеликі правки, доводиться створювати новий файл шейдера.

Таким чином, щоб:

— розробляти шейдери на Kotlin,
— мати перевірку синтаксису на етапі компіляції,
— мати можливість реиспользовать код між шейдерами,
треба було написати «конвертер» Kotlin в GLSL.

Бажаний результат: код шейдера описується як Kotlin class, в якому attributes, varyings, uniforms — властивості цього класу. Параметри первинного конструктора класу використовуються для статичних розгалужень і дозволяють реиспользовать решті код шейдера. Блок init — тіло шейдера.

Рішення

Для реалізації були використані Kotlin delegates. Вони дозволили runtime дізнаватися ім’я делегується властивості, відловлювати моменти get і set звернень і оповіщати про них ShaderBuilder — базовий клас всіх шейдерів.

class ShaderBuilder {
 val uniforms = HashSet<String>()
 val attributes = HashSet<String>()
 val varyings = HashSet<String>()
 val instructions = ArrayList<Instruction>()
...
 fun getSource(): String = ...
}

Реалізація делегатівVarying делегат:

class VaryingDelegate<T : Variable>(private val factory: (ShaderBuilder) -> T) {
 private lateinit var v: T
 operator fun provideDelegate(ref: ShaderBuilder, p: KProperty<*>): VaryingDelegate<T> {
 v = factory(ref)
 v.value = p.name
 return this
}
 operator fun getValue(thisRef: ShaderBuilder, property: KProperty<*>): T {
 thisRef.varyings.add("${v.typeName} ${property.name}")
 return v
}
 operator fun setValue(thisRef: ShaderBuilder, property: KProperty<*>, value: T) {
 thisRef.varyings.add("${v.typeName} ${property.name}")
 thisRef.instructions.add(Instruction.assign(property.name, value.value))
}
}

Реалізація інших делегатів на GitHub.

Читайте також  Текстури для 64k intro: як це робиться сьогодні

Приклад шейдера:

// Так як параметр useAlphaTest відомий під час складання шейдера,
// можна уникнути попадання частини інструкцій у шейдер, і, змінюючи параметри,
// отримувати різні шейдери.
class FragmentShader(useAlphaTest: Boolean) : ShaderBuilder() {
 private val alphaTestThreshold by uniform(::GLFloat)
 private val texture by uniform(::Sampler2D)
 private val uv by varying(::Vec2)
 init {
 var color by vec4()
 color = texture2D(texture, uv)
 // static branching
 if (useAlphaTest) {
 // dynamic branching
 If(color.w lt alphaTestThreshold) {
discard()
}
}
 // Вбудовані змінні визначені в ShaderBuilder.
 gl_FragColor = color
}
}

А от отриманий ісходник GLSL (результат виконання FragmentShader(useAlphaTest = true).getSource()). Збереглися зміст і структура коду:

uniform sampler2D texture;
uniform float alphaTestThreshold;
varying vec2 uv;
void main(void) {
 vec4 color;
 color = texture2D(texture, uv);
 if ((color.w < alphaTestThreshold)) {
discard;
}
 gl_FragColor = color;
}

Реиспользовать код шейдера, задаючи різні параметри при складанні исходника зручно, але це не вирішує проблему реиспользования повністю. У разі коли необхідно написати один і той же код в різних шейдери, можна винести ці інструкції в окремий ShaderBuilderComponent і додавати їх за необхідності в основні ShaderBuilders:

class ShadowReceiveComponent : ShaderBuilderComponent() {
...
 fun vertex(parent: ShaderBuilder, inp: Vec4) {
 vShadowCoord = shadowMVP * inp
...
parent.appendComponent(this)
}

 fun fragment(parent: ShaderBuilder, brightness: GLFloat) {
 var pixel by float()
 pixel = texture2D(shadowTexture, vShadowCoord.xy).x
...
parent.appendComponent(this)
}
}

Ура, отриманий функціонал дозволяє писати шейдери на Kotlin, реиспользовать код, перевіряти синтаксис!

А тепер згадаємо про Swizzling в GLSL і подивимося на його реалізацію у Vec2, Vec3, Vec4.

class Vec2 {
 var x by ComponentDelegate(::GLFloat)
 var y by ComponentDelegate(::GLFloat)
}
class Vec3 {
 var x by ComponentDelegate(::GLFloat)
...
 // створюємо 9шт Vec2
 var xx by ComponentDelegate(::Vec2)
 var xy by ComponentDelegate(::Vec2)
...
}
class Vec4 {
 var x by ComponentDelegate(::GLFloat)
...
 // створюємо 16шт Vec2
 var xy by ComponentDelegate(::Vec2)
...
 // створюємо 64шт Vec3
 var xxx by ComponentDelegate(::Vec3)
...
}

У нашому проекті компіляція шейдерів може відбуватися в ігровому циклі на вимогу, і подібні виділення об’єктів породжують major виклики GC, з’являються лаги. Тому ми вирішили перенести збірку исходников шейдерів на етап компіляції з використанням обробника анотацій.

Читайте також  Все, що потрібно знати про вирівнювання по Flexbox

Ми помічаємо клас анотацією ShaderProgram:

@ShaderProgram(VertexShader::class, FragmentShader::class)
class ShaderProgramName(alphaTest: Boolean)

І annotation processor збирає всілякі шейдери в залежності від параметрів конструкторів vertex і fragment класів за нас:

class ShaderProgramNameSources {
 enum class Sources(vertex: String, fragment: String): ShaderProgramSources {
 Source0("<vertex code>", "<fragment code>")
...
}
 get fun(alphaTest: Boolean) {
 if (alphaTest) return Source0
 else return Source1
}
}

Відтепер можна отримати текст шейдера з згенерованого класу:

val sources = ShaderProgramNameSources.get(replaceAlpha = true)
println(sources.vertex)
println(sources.fragment)

Оскільки результат функції get — ShaderProgramSources — значення enum, його зручно використовувати в якості ключів в реєстрі програм (ShaderProgramSources) -> CompiledShaderProgram.

На GitHub є вихідні коди проекту, включаючи annotation processor і прості приклади шейдерів і компонентів.

Степан Лютий

Обожнюю технології в сучасному світі. Хоча частенько і замислююся над тим, як далеко вони нас заведуть. Не те, щоб я прям і знаюся на ядрах, пікселях, коллайдерах і інших парсеках. Просто приходжу в захват від того, що може в творчому пориві вигадати людський розум.

You may also like...

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *