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.0
s! 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
Example Links
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
}