Shaders

  Edit on GitHub

LittleKt offers utility classes for writing GLSL code, called shaders, in order to render items onto the screen. By default, the SpriteBatch class uses its own default shader to handle rendering. If we forgo using a SpriteBatch, we would have to setup the render pipeline ourselves. But in this article, we will show how to build upon the LittleKt Shader API.

What are Shaders

Shaders are small programs written in WGSL, which is a C-like language, that is then loaded onto the GPU and processes data in order to render things onto the screen. For more info on what shaders are and how to use a shader check out the shader article on w3.

Creating Shaders

Using the open class, Shader we create custom shaders that handle most of the pipeline and bindings creation for us. All we need to do is pass along the shader source, a Device and the “layout” descriptors.

Shader

This is a simplified example that skips showing all the setup. But if we know WebGPU, we can create our custom pipeline to handle it all.

val shader = Shader(device, MY_SHADER_SRC, bindingGroupLayoutDescritors)
val renderPipeline = device.createRenderPipeline(...) // pipeline setup

Buffer Updates

A Shader has an open function, that expects a map of data, to allow updating specific buffers used in the shader.

    /**
     * Do any buffer updates here.
     *
     * @param data a data map that can include any data that is needed in order to update bindings ,
     *   using a string as a key.
     */
    open fun update(data: Map<String, Any>) = Unit

SpriteShader

A SpriteShader is an abstract Shader that expects a camera uniform and a texture to be passed in. This type of shader is used internally by SpriteBatch but may extended to create custom SpriteShaders. It contains functions to handle updating the internal camera uniform buffer via SpriteShader.updateCameraUniform(viewProjection: Mat4). But this SpriteShader overrides the Shader.update(data: Map<String, Any>) function and calls updateCameraUniform() automatically.

    class ColorShader(device: Device) :
        SpriteShader(
            device,
            src =
                """
        struct CameraUniform {
            view_proj: mat4x4<f32>
        };
        @group(0) @binding(0)
        var<uniform> camera: CameraUniform; // SpriteShader expects a uniform for camera
        
        struct VertexOutput {
            @location(0) color: vec4<f32>,
            @location(1) uv: vec2<f32>,
            @builtin(position) position: vec4<f32>,
        };
                   
        @vertex
        fn vs_main(
            @location(0) pos: vec3<f32>,
            @location(1) color: vec4<f32>,
            @location(2) uvs: vec2<f32>) -> VertexOutput {
            
            var output: VertexOutput;
            output.position = camera.view_proj * vec4<f32>(pos.x, pos.y, 0, 1);
            output.color = color;
            output.uv = uvs;
            
            return output;
        }
        
        @group(1) @binding(0) // expects a texture
        var my_texture: texture_2d<f32>;
        @group(1) @binding(1) // expects a sampler
        var my_sampler: sampler;
        
        @fragment
        fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
            return textureSample(my_texture, my_sampler, in.uv) * vec4<f32>(1, 0, 0, 1);
        }
        """,
            layout =
                listOf(
                    BindGroupLayoutDescriptor(
                        listOf(BindGroupLayoutEntry(0, ShaderStage.VERTEX, BufferBindingLayout())) // camera binding
                    ),
                    BindGroupLayoutDescriptor(
                        listOf(
                            BindGroupLayoutEntry(0, ShaderStage.FRAGMENT, TextureBindingLayout()), // texture binding
                            BindGroupLayoutEntry(1, ShaderStage.FRAGMENT, SamplerBindingLayout()) // sampler binding
                        )
                    )
                )
        ) {
            // we must create new bind groups with the given texture based on the spriete layout
        override fun MutableList<BindGroup>.createBindGroupsWithTexture(
            texture: Texture,
            data: Map<String, Any>
        ) {
            add(
                device.createBindGroup(
                    BindGroupDescriptor(
                        layouts[0],
                        listOf(BindGroupEntry(0, cameraUniformBufferBinding)) // we set the camera unfirom based on group & binding we set in the WGSL source
                    )
                )
            )
            add(
                device.createBindGroup(
                    BindGroupDescriptor(
                        layouts[1],
                        listOf(BindGroupEntry(0, texture.view), BindGroupEntry(1, texture.sampler)) // we set the texture & sampler required in the WGSL source
                    )
                )
            )
        }

        // when this is called we must set our bind groups, in the correct order on the given render pass
        override fun setBindGroups(encoder: RenderPassEncoder, bindGroups: List<BindGroup>) {
            encoder.setBindGroup(0, bindGroups[0])
            encoder.setBindGroup(1, bindGroups[1])
        }
    }

And we can use the new shader in a SpriteBatch quite easily:

val coloredShader = ColoredShader(device)

// onUpdate:
val renderPassEncoder = ... 
batch.shader = coloredShader
batch.begin()
batch.draw(myTexture, 0f, 0f)
batch.useDefaultShader()
batch.draw(anotherTexture, 50f, 50f)
batch.flush(renderPassEncoder)
batch.end()
renderPassEncoder.end()