
Olá pessoal!
Nossa empresa está desenvolvendo jogos online e agora estamos trabalhando em uma versão móvel do nosso projeto principal. Neste artigo, queremos compartilhar a experiência de desenvolvimento de shaders GLSL para um projeto Android com exemplos e
fontes .
Sobre o projeto
Inicialmente, o jogo era baseado em navegador no Flash, mas as notícias sobre a cessação iminente do suporte ao Flash nos forçaram a transferir o projeto para o HTML5. Kotlin foi usado como a linguagem de desenvolvimento e, seis meses depois, conseguimos lançar o projeto no Android. Infelizmente, sem otimização em dispositivos móveis, o jogo não teve desempenho.
Para aumentar o FPS, foi decidido redesenhar o mecanismo gráfico. Costumávamos usar vários shaders universais, mas agora, para cada efeito, decidimos escrever um shader separado, afiado para uma tarefa específica, a fim de tornar seu trabalho mais eficiente.
O que nos faltava
Os sombreadores podem ser armazenados em uma string, mas esse método elimina a verificação de sintaxe e a correspondência de tipos; portanto, os sombreadores geralmente são armazenados em arquivos Assets ou Raw, pois isso permite ativar a validação instalando o plug-in para o Android Studio. Mas essa abordagem também tem uma desvantagem - a falta de reutilização: para fazer pequenas edições, é necessário criar um novo arquivo de sombreador.
Para que:
- desenvolver shaders no Kotlin,
- faça uma verificação de sintaxe na fase de compilação,
- poder reutilizar o código entre shaders,
Eu precisava escrever um "conversor" Kotlin para GLSL.
Resultado desejado: o código shader é descrito como uma classe Kotlin, na qual atributos, variações e uniformes são as propriedades dessa classe. Os parâmetros do construtor principal da classe são usados para ramificações estáticas e permitem reutilizar o restante do código de sombreador. O bloco init é o corpo do shader.
Solução
Para implementação, foram utilizados
delegados da Kotlin. Eles permitiram que o tempo de execução descobrisse o nome da propriedade delegada, capturasse os horários de obtenção e definição das chamadas e notificá-los sobre o ShaderBuilder - a classe base de todos os shaders.
class ShaderBuilder { val uniforms = HashSet<String>() val attributes = HashSet<String>() val varyings = HashSet<String>() val instructions = ArrayList<Instruction>() ... fun getSource(): String = ... }
Delegar ImplementaçãoDelegado variável:
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)) } }
Implementação dos demais delegados no
GitHub .
Exemplo de sombreador:
// 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 } }
E aqui está a fonte GLSL resultante (o resultado de FragmentShader (useAlphaTest = true) .getSource ()). O conteúdo e a estrutura do código são preservados:
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; }
É conveniente reutilizar o código de sombreador definindo parâmetros diferentes ao montar a fonte, mas isso não resolve completamente o problema de reutilização. No caso em que você precisa escrever o mesmo código em shaders diferentes, você pode colocar essas instruções em um ShaderBuilderComponent separado e adicioná-las conforme necessário aos ShaderBuilders principais:
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) } }
Viva, a funcionalidade recebida permite que você escreva shaders no Kotlin, reutilize o código, verifique a sintaxe!
Agora, lembremos do
Swizzling no GLSL e vejamos sua implementação no 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) ... }
Em nosso projeto, a compilação do shader pode ocorrer no ciclo do jogo sob demanda, e tais alocações de objetos geram chamadas importantes para o GC, atrasos aparecem. Portanto, decidimos transferir a montagem das fontes de sombreador para o estágio de compilação usando o processador de anotação.
Marcamos a classe com a anotação ShaderProgram:
@ShaderProgram(VertexShader::class, FragmentShader::class) class ShaderProgramName(alphaTest: Boolean)
E o processador de anotações coleta todos os tipos de shaders, dependendo dos parâmetros dos construtores de classes de vértice e fragmento para nós:
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 } }
Agora você pode obter o texto do sombreador da classe gerada:
val sources = ShaderProgramNameSources.get(replaceAlpha = true) println(sources.vertex) println(sources.fragment)
Como o resultado da função get - ShaderProgramSources é o valor de enum, é conveniente usá-lo como chaves no registro do programa (ShaderProgramSources) -> CompiledShaderProgram.
O GitHub possui o código fonte do projeto, incluindo o processador de anotação e exemplos simples de shaders e componentes.