Всем привет!

Наша компания занимается разработкой онлайн игр и сейчас мы работаем над мобильной версией нашего основного проекта. В этой статье хотим поделиться опытом разработки 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 и простые примеры шейдеров и компонентов.

Комментарии (6)


  1. nemilya
    01.10.2018 23:27

    Спасибо, что поделились!


    Я честно говоря совершенно не знал про шейдеры) но тем интереснее узнавать сферы применения
    Kotlin


    Здесь немного, что такое шейдер (GLSL): https://ru.m.wikipedia.org/wiki/OpenGL_Shading_Language


    А здесь пример: https://mrdoob.com/#/139/glsl_sandbox


  1. alex_zzzz
    02.10.2018 00:17

    Полтора года назад чуваки из Unity во время ежегодной hack week приделали к движку поддержку шейдеров на C#: https://www.youtube.com/watch?v=hJZwaaXMfUA


    Больше тему нигде никак не светили, и вроде бы она заглохла. Но с другой стороны, они сейчас пилят новую математическую библиотеку на C#, заточенную на совместимость с привычным синтаксисом HLSL (swizzling присутствует). На замечания, эй, народ, у вас naming guidelines совершенно не шарповские ? отмахиваются; говорят, делаем так, как все привыкли писать в шейдерах. Подозреваю, что-то намечается. Конвертер IL2CPP они уже сделали, могут и IL2HLSL сделать.


    1. SlavniyTeo
      02.10.2018 09:50

      Шикарно. Плюс за ссылку.


    1. FDsagizi
      02.10.2018 12:10

      да, они часто говорили что хотят сделать трансляцию C# кода в код для GPU
      правда говорили больше не про шейдеры — графические, а про вычислительные.

      Типа написал код на c# а юнити его скормил в GPU без всяких CUDA и прочего, по сути они так уже делают с Metal, транслируя код из HLSL Compute в Metal код


  1. lgorSL
    02.10.2018 12:28

    Воу, очень прикольно получилось. Похожие мысли приходили в голову, но я хотел на выходе иметь не строку с glsl кодом, а синтаксическое дерево, которое потом можно конвертировать либо в glsl, либо как-то отлаживать и тестировать на CPU.


    В нашем проекте компиляция шейдеров может происходить в игровом цикле по требованию, и подобные выделения объектов порождают major вызовы GC, появляются лаги.

    Подождите, но ведь шейдер можно при загрузке скомпилировать только один раз, и нагрузка от этого одного раза не должна быть большой. Вдобавок, телефон при загрузке шейдера всё равно будет сам его как-то парсить и компилировать для gpu.


    Поэтому мы решили перенести сборку исходников шейдеров на этап компиляции с использованием обработчика аннотаций.

    Если список шейдеров известен заранее, то их же можно грузить не в иговом цикле, а только один раз — при загрузке приложения.


    Но у меня шейдеров не очень много, поэтому оказалось проще писать их в glsl файликах. Единсвенное синтаксическое улучшение — я реализовал синтаксис типа shader.uniformName=uniformValue для установки юниформ, но он без проверок на корректность.


    1. Dananas Автор
      02.10.2018 15:41

      Если список шейдеров известен заранее, то их же можно грузить не в иговом цикле, а только один раз — при загрузке приложения.

      Хорошее замечание :) Общие для всех 3d сцен шейдеры мы и правда компилируем как только получен GL контекст. Однако список сгенерированных шейдеров значительно больше, чем список используемых для конкретной 3d сцены.

      Для рендера статики в 3d сценах мы используем static batching (описание в unity).

      Заранее мы генерируем исходники шейдеров для всех 1..32 размеров массивов sampler2D. Какой именно параметр количества выбрать для «батчинга» зависит от количества текстур сцены T и от максимального числа uniform'ов доступных для устройства U. Тогда в рендере отдельной части сцены в худшем случае будут участвовать 2 шейдера одного типа. Один будет иметь массив из U сэмплеров, а второй из T % U. Поскольку сцены подгружаются в runtime, мы не можем подобрать оба параметра заранее, делаем это налету.