Development of GLSL shaders on Kotlin
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.
So that:
- to develop shaders on Kotlin,
- to have syntax checking at the compilation stage,
- to be able to reuse code between shaders,
it was necessary to write Kotlin "converter" 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.
classShaderBuilder{
val uniforms = HashSet<String>()
val attributes = HashSet<String>()
val varyings = HashSet<String>()
val instructions = ArrayList<Instruction>()
...
fungetSource(): String = ...
}
Delegates implementation
Varying делегат:
Реализация остальных делегатов на GitHub.
classVaryingDelegate<T : Variable>(privateval factory: (ShaderBuilder) -> T) {
privatelateinitvar v: T
operatorfunprovideDelegate(ref: ShaderBuilder, p: KProperty<*>): VaryingDelegate<T> {
v = factory(ref)
v.value = p.name
returnthis
}
operatorfungetValue(thisRef: ShaderBuilder, property: KProperty<*>): T {
thisRef.varyings.add("${v.typeName}${property.name}")
return v
}
operatorfunsetValue(thisRef: ShaderBuilder, property: KProperty<*>, value: T) {
thisRef.varyings.add("${v.typeName}${property.name}")
thisRef.instructions.add(Instruction.assign(property.name, value.value))
}
}
Реализация остальных делегатов на GitHub.
Shader example:
// Так как параметр useAlphaTest известен во время сборки шейдера,// можно избежать попадания части инструкций в шейдер, и, изменяя параметры,// получать разные шейдеры.classFragmentShader(useAlphaTest: Boolean) : ShaderBuilder() {
privateval alphaTestThreshold by uniform(::GLFloat)
privateval texture by uniform(::Sampler2D)
privateval uv by varying(::Vec2)
init {
var color by vec4()
color = texture2D(texture, uv)
// static branchingif (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;
uniformfloat 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:
classShadowReceiveComponent : ShaderBuilderComponent() {
…
funvertex(parent: ShaderBuilder, inp: Vec4) {
vShadowCoord = shadowMVP * inp
...
parent.appendComponent(this)
}
funfragment(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.
classVec2{
var x by ComponentDelegate(::GLFloat)
var y by ComponentDelegate(::GLFloat)
}
classVec3{
var x by ComponentDelegate(::GLFloat)
...
// создаем 9шт Vec2var xx by ComponentDelegate(::Vec2)
var xy by ComponentDelegate(::Vec2)
...
}
classVec4{
var x by ComponentDelegate(::GLFloat)
...
// создаем 16шт Vec2var xy by ComponentDelegate(::Vec2)
...
// создаем 64шт Vec3var 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)classShaderProgramName(alphaTest: Boolean)
And the annotation processor collects all sorts of shaders, depending on the parameters of the vertex and fragment constructors for us:
classShaderProgramNameSources{
enumclassSources(vertex: String, fragment: String): ShaderProgramSources {
Source0("<vertex code>", "<fragment code>")
...
}
funget(alphaTest: Boolean) {
if (alphaTest) return Source0
elsereturn 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.