Supplying Array Data via UBOs in Indigo

At the time of writing, sending array data to your shaders via UBOs in Indigo is a bit of a rough experience. There are open tickets to try and improve the situation, but this is an attempt to document a path that works and to call out a few points worth knowing. The goal is to give people a way forward while acknowledging that it's less than ideal.

Notes

Always send arrays in UBOs, separate from other data

For the sake of your sanity. Example:

Do:

case class UBO1(COUNT: Int)
case class UBO2(MAP: Array[Float])

Do NOT:

case class UBO1(COUNT: Int, MAP: Array[Float])

Array sizes must be known

Arrays in GLSL are a fixed size. If you need to send variable amounts of data, what you need to do is set an array large enough for all your possible data, and then supply another value indicating the count.

Beware of large arrays (Performance bottleneck)

By default, Ultraviolet supports auto conversion of array sizes up to 4096.

You can add support for arbitrarily sized arrays, up to the UBO max (..max supported by Indigo, in practice this is hardware dependent), like this:

given ultraviolet.macros.ShaderTypeOf[Array[Float]] with
  def typeOf: String = s"float [65536]"

HOWEVER.

Because Indigo sets UBOs whenever it needs them, you pay the data transfer cost every time (unless you're using the same shader many times sequentially). The transfer cost is surprisingly high, and you may quickly find yourself running into performance problems.

Demo

▶ Click to play

UniformBlock derivation limitations

Ultraviolet is particular about what you can derive to ToUniformBlock:

Unsupported shader uniform type. Supported types From Scala (Int, Long, Float, Double), Indigo [RGBA, RGB, Point, Size, Vertex, Vector2, Vector3, Vector4, Rectangle, Matrix4, Radians, Millis, Seconds, Array[Float], js.Array[Float]], and UltraViolet [vec2, vec3, vec4, mat4]. However, if you intend to use the same case class for both Indigo and UltraViolet, you should stick to Float + the UltraViolet types.

Here out first UBO with some simple data in it.

case class CustomData(CUSTOM_COLOR: vec4) derives ToUniformBlock

Avoid derives for arrays

Technically, derives is supported, but if we're treating this example as a recipe, then in this recipe we'll opt to avoid it and follow a known good path.

case class ArrayData(ANOTHER_COLOR: array[4, Float])

Setting up the shader Environment

This is a bit unfortunate. There are a number of ways to declare the Environment in the shader itself, i.e. Shader[Env]. For example, you can do things like Env & CustomData.

In this case (..and again we're doing a happy path for arrays specifically) we're just going to make a new type with the same fields.

case class Env(
    CUSTOM_COLOR: vec4,
    ANOTHER_COLOR: array[4, Float]
) extends FragmentEnvReference
object Env:
  def ref: Env =
    Env(vec4(0.0f), array[4, Float]())

Sending the array data

More repetition as we send over our data to the shader via the shader data.

def present(context: Context, model: Model): Outcome[SceneUpdateFragment] =
  Outcome(
    SceneUpdateFragment(
      Layer(
        BlankEntity(
          model.viewport,
          ShaderData(CustomShader.shader.id)
            .withUniformBlocks(
              Batch(
                UniformBlock(
                  UniformBlockName("ArrayData"),
                  Batch(
                    Uniform("ANOTHER_COLOR") -> ShaderPrimitive
                      .array(4, Array(0.0f, 1.0f, 1.0f, 1.0f))
                  )
                )
              )
            )
            .addUniformData(CustomData(vec4(1.0f, 0.0f, 1.0f, 1.0f)))
        )
      )
    )
  )

Using the array data

Once we've gone through the steps to get our array data set-up, things get back to normal. In this example we're accessing the components by index, but you could also loop over the arrays with something like a while loop if you prefered.

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

    def fragment(color: vec4): vec4 =
      val magenta = env.CUSTOM_COLOR
      val cyan = vec4(
        env.ANOTHER_COLOR(0),
        env.ANOTHER_COLOR(1),
        env.ANOTHER_COLOR(2),
        env.ANOTHER_COLOR(3)
      )

      val mByX = vec4(magenta.xyz * env.UV.x, env.UV.x)
      val cByY = vec4(cyan.xyz * env.UV.y, env.UV.y)

      mix(mByX, cByY, mByX.a)
  }