Supplying Data with UBOs

At some point, you're going to want to be able to supply data to your shader for some reason, for example, perhaps your shader renders planets and you need to give it the color scheme to change water into lava.

To send data to shaders we use Uniform Buffer Objects (UBOs).

Ultraviolet allows you to declare UBOs as case classes. It isn't a perfect set up, but it works.

UBO Data Packing Rules

Take heed! When you send data to a shader what you're really doing is sending a series of floats which will be read as a C-like struct at the other end. There are rules about how these floats get packed so that they are correctly read at the other end.

Full rules follow, but to give you some idea:

Say you want to send this case class:

final case class MyData(angle: Float, position: vec3, multiplier: Float)

And for simplicity, lets say the values are:

MyData(1.0f, vec3(2.0f, 3.0f, 4.0f), 5.0f)

This will be very inefficiently packed into this:

1.0,0.0,0.0,0.0,
2.0,3.0,4.0,0.0,
5.0,0.0,0.0,0.0

Look at all that waste! So many 0.0s! Might not be a problem for simple cases, but if you're trying to send a lot of data within the limits, then you could run into trouble.

Why does it do this? Well, angle seems good, and we might expect that we can fit position into the remaining three floats after it (i.e. 1.0,<2.0,3.0,4.0>,5.0,0.0,0.0,0.0), but the stride is 16 bytes, and a vec3 is treated as a 16 bytes of data, so by default they cannot be merged.

Better strategies...

1. Reorder the fields

Move the multiplier:

final case class MyData(angle: Float, multiplier: Float, position: vec3)
MyData(1.0f, 5.0f, vec3(2.0f, 3.0f, 4.0f))

Result:

1.0,5.0,0.0,0.0,
2.0,3.0,4.0,0.0

2. Manually pack the data

final case class MyData(angleAndPosition: vec4, multiplier: Float)
MyData(vec4(1.0f, 2.0f, 3.0f, 4.0f), 5.0f)

Result:

1.0,2.0,3.0,4.0,
5.0,0.0,0.0,0.0

The Rules

These rules have saved me many times and originally came from here: https://youtu.be/bdIZ2ZloXEA?t=113

"UBO - Uniform Buffer Object"

Uses a struct as a way to defined the data in the buffer.
Struct data based on STD140 layout requires data to exist in 16 byte chunks.

Float, Int and Bools are treated as 4 Bytes of Data.

Arrays, no matter the type, each element is 16 Bytes.

vec2, Contains 2 floats so 4*2 bytes of data (8 Bytes)
vec4, Contains 4 floats so 4*4 bytes of data (16 Bytes)
vec3, Must be treated as 16 bytes of data (i.e. a vec4), last 4 bytes are buffer space

mat3, Contains 3 sets of Vec3 BUT each vec3 is treated as vec4, 3*16 Bytes of data
mat4, Contains 4 sets of Vec4, so 4 * 16 Bytes

For EXAMPLE
Float - Float - Vec3 - Float

FF00 VVVV F000

You also can't straddle byte boundries. So if you're trying to pack this: Float-Vec2
This is valid: F0VV
But this isn't: FVV0

How to use Uniform Buffer Objects (UBOs) to supply data to a shader

The process of setting up UBOs is a little more involved than setting up a simple shader, and there are a few steps to follow. It's an imperfect arrangement, but works fairly well. Hopefully it can be improved in the future.

This example is built on top of Indigo's IndigoShader entry point, which allows us to send data to the shader by setting the uniformBlocks. Please note that the fields exposed by this entry point are the fields on Indigo's ShaderData type, so when doing this in a real game, you'd fill in the details there instead.

Creating the data structure

First we need to define a structure that will be used to define the data that will be passed to the shader. In this case, it's a case class called CustomData.

Technically, there is no link between the case class used in the shader data, and the case class used in the shader itself. However, it's a good idea to use the same one if you can. It may not always be convenient as we'll see below.

In order to use one case class to define both the structure sent and how it is defined in the shader, we need a case class that is not finalised, so that we can extend it later.

Notice that the case class derives from ToUniformBlock. This is a type class that allows use to automagically convert the case class into a UniformBlock. You can mannually set up uniform block data if you prefer, but you'll still need a case class for the representation in the shader code.

The CUSTOM_COLOR field is a vec4 that will represent the RGBA color (a common use of vec4 types in shaders). The field name has been capitalised since environment fields in Indigo are all capitalised by convention, but it isn't necessary.

case class CustomData(CUSTOM_COLOR: vec4) derives ToUniformBlock

Setting up the uniform block data

We need to instantiate the data, which is no more complicated that creating an instance of CustomData. This works, because our case class is derived from ToUniformBlock, and the IndigoShader entry point will perform an implicit conversion for us. The same conversion is applied when you add data to an Indigo ShaderData instance.

Here we've set the RGBA color to magenta.

val uniformBlocks: Batch[UniformBlock] =
  Batch(
    CustomData(vec4(1.0f, 0.0f, 1.0f, 1.0f))
  )

Accessing the values via the environment

The CustomData case class is used to define the data that will be passed to the shader, but we need to somehow include that in the shader's environment so that we can access the data in the shader.

The default environment for an Indigo fragment shader is FragmentEnv, which provides access to fields like env.TIME. We need all the fields from FragmentEnv, but we also need to include the fields of our CustomData case class.

To do that, we make a new class (called Env here) that extends CustomData and a stub version of FragmentEnv called FragmentEnvReference.

Note that the data used to instantiate CustomData here does not matter, these are just placeholder values. The actual data will be supplied by the UniformBlock we set up earlier.

class Env extends CustomData(vec4(0.0f)) with FragmentEnvReference

Supplying the environment

The reason we have to set up the Env class is that under the current set up, we need to supply an instance of the environment to the shader, as reference data. Here we just give a new Env and we're done.

val shader: ShaderProgram =
  UltravioletShader.entityFragment(
    ShaderId("shader"),
    EntityShader.fragment[Env](fragment, new Env)
  )

Using the data

We're almost ready to use our ubo data in the shader, but first we need to tell Ultraviolet to define the UBO structure in the final shader code. We do that by calling ubo[CustomData] at the top of the shader body.

We can now access the CUSTOM_COLOR field from the shader environment, and return it as the fragment color.

  inline def fragment: Shader[Env, Unit] =
    Shader[Env] { env =>
      ubo[CustomData]

      def fragment(color: vec4): vec4 =
        env.CUSTOM_COLOR
    }