Всем привет!
Наша компания занимается разработкой онлайн игр и сейчас мы работаем над мобильной версией нашего основного проекта. В этой статье хотим поделиться опытом разработки 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 = ...
}
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>")
...
}
fun get(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 и простые примеры шейдеров и компонентов.
Автор: Dananas