Розробка 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.
Приклад шейдера:
// Так як параметр 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, з’являються лаги. Тому ми вирішили перенести збірку исходников шейдерів на етап компіляції з використанням обробника анотацій.
Ми помічаємо клас анотацією 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 і прості приклади шейдерів і компонентів.