Разработка 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>")         ...     }     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 и простые примеры шейдеров и компонентов.


ссылка на оригинал статьи https://habr.com/post/425027/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *