
Hello!
Our company is developing online games and now we are working on a mobile version of our main project. In this article we want to share the experience of developing GLSL shaders for the Android project with examples and
sources .
about the project
Initially, the game was browser-based on Flash, but the news of the imminent end of support for Flash made us move the project to HTML5. Kotlin was used as the development language, and six months later we were able to launch the project on Android. Unfortunately, the game lacked performance without optimization on mobile devices.
')
To increase the FPS, it was decided to rework the graphics engine. Previously, we used several universal shaders, and now for each effect we decided to write a separate shader, sharpened for a specific task, in order to be able to make their work more efficient.
What we lacked
Shaders can be stored in a string, but this method eliminates syntax checking and type matching, so shaders are usually stored in Assets or Raw files, as this allows you to enable validation by installing a plugin for Android Studio. But this approach also has a drawback - the lack of reuse: in order to make small edits, you have to create a new shader file.
In the way that:
- develop shaders on Kotlin,
- have a syntax check at compile time,
- be able to reuse code between shaders,
it took to write a "converter" Kotlin to GLSL.
Desired result: the shader code is described as the Kotlin class, in which the attributes, varyings, uniforms are properties of this class. Parameters of the primary class constructor are used for static branching and allow reuse of the rest of the shader code. The init block is the shader body.
Decision
For implementation, Kotlin
delegates were used. They allowed in the runtime to find out the name of the delegated property, catch the moments of get and set hits, and notify them about ShaderBuilder, the base class of all shaders.
class ShaderBuilder { val uniforms = HashSet<String>() val attributes = HashSet<String>() val varyings = HashSet<String>() val instructions = ArrayList<Instruction>() ... fun getSource(): String = ... }
Delegates implementationVarying delegate:
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)) } }
Implementation of the remaining delegates on
GitHub .
Shader example:
// 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 } }
But the resulting GLSL source (the result of the execution of FragmentShader (useAlphaTest = true) .getSource ()). Preserved content and structure of the code:
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; }
It is convenient to reuse the shader code by setting different parameters when building the source code, but this does not completely solve the reuse problem. In the case when you need to write the same code in different shaders, you can put these instructions in a separate ShaderBuilderComponent and add them, if necessary, to the main 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) } }
Hurray, the resulting functionality allows you to write shaders on Kotlin, reuse the code, check the syntax!
And now let's remember about
Swizzling in GLSL and look at its implementation in 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) ... }
In our project, the compilation of shaders can occur in the game cycle on demand, and similar selection of objects generate major GC challenges, lags appear. Therefore, we decided to move the source code assembly to the compilation stage using the annotation processor.
We mark the class with the ShaderProgram annotation:
@ShaderProgram(VertexShader::class, FragmentShader::class) class ShaderProgramName(alphaTest: Boolean)
And the annotation processor collects all sorts of shaders, depending on the parameters of the vertex and fragment constructors for us:
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 } }
Now you can get the shader text from the generated class:
val sources = ShaderProgramNameSources.get(replaceAlpha = true) println(sources.vertex) println(sources.fragment)
Since the result of the get function, ShaderProgramSources, is the value from enum, it is convenient to use it as keys in the program registry (ShaderProgramSources) -> CompiledShaderProgram.
The
GitHub has project sources, including the annotation processor and simple examples of shaders and components.