Variants

Exploring OpenUSD's variants feature, enabling multiple lightweight versions in a single asset

Variants

Graphics models can often benefit from having a few different configurations that share common values. A t-shirt model could have different materials. A tree model might have a version with green leaves, with fall colors and without leaves.

Variants provide a way to construct content that has these types of differences while reusing the common data, like base meshes, materials and textures.

A USD Variant is not a prim type, it's defined using metadata on a prim. This means any prim can have one or more variant sets if they are useful.

How Does It Work?

Let's look at a simple but not very useful example first, just to understand the feature. For this article I'm going to put example content in a github repo, in the folder variants.

GitHub - rstelzleni/ConfusingAcronym
Contribute to rstelzleni/ConfusingAcronym development by creating an account on GitHub.

For a first simple example let's make a prim that can be a sphere or a cube. In text format this would look something like the following.

def Xform "Model" (
    prepend variantSets = "shape"
)
{
    variantSet "shape" = {
        "box" {
            def Mesh "BoxMesh"
            {
                int[] faceVertexCounts = [4, 4, 4, ...]
                int[] faceVertexIndices = [0, 1, 3, ...]
                point3f[] points = [(-1, -1, -1), ...]
                token subdivisionScheme = "none"
            }
        }
        "sphere" {
            def Mesh "SphereMesh"
            {
                int[] faceVertexCounts = [3, 3, 3, ...]
                int[] faceVertexIndices = [0, 2, 3, ...]
                point3f[] points = [(-4.2221952e-8, 1.1313371e-8, 1), ...]
                uniform token subdivisionScheme = "none"
            }
        }
    }
}

variants/model.usda

The complete file in the github repo can be loaded in usdview or your favorite USD enabled tool.

We've defined a variant set called shape. That set of variants has two options, named box and sphere. Inside each of those variants I've create a new Mesh with the geometry inside it.

When you first open this file you'll get nothing rendered. The stage will contain only one prim, /Model. It has no contents. This is because we didn't set a default variant, and all the content in Model is inside the shape variant set. With no variant chosen, Model has nothing in it.

If we select the box variant in the shape variant set (in usdview you do this in the MetaData tab) then a new prim will appear at /Model/BoxMesh and a cube will render. If you switch to sphere then BoxMesh disappears and now there is a prim at /Model/SphereMesh and a sphere will render.

Variant sets can have a default selection, in this case to avoid having nothing in /Model. Most authoring tools will set that up for you. In text format it would change the previous example to add this variants bit.

def Xform "Model" (
    variants = {
        string shape = "box"
    }
    prepend variantSets = "shape"
)

Adding a default variant selection

But, That's Useless

Good point straw man!

You could easily make a Box and Sphere asset and reference those instead of making a variant. Since nothing is really shared, there isn't much advantage to the example in the first section.

Let's imagine we make the same thing, but in our variant we just turn on and off subdivision. So, the default is a box still, but we can choose a variant that turns the mesh into a subdivision surface. The subdiv will use the same points as the box, so it will subdivide into a sphere and we save all the size of hard coding our sphere. This is still a bit of a tortured example, but it has the advantage of being simple so I'm going with it.

def Xform "Model" (
    variants = {
        string shape = "box"
    }
    prepend variantSets = "shape"
)
{
    def Mesh "Geo"
    {
        int[] faceVertexCounts = [4, 4, 4, ...]
        int[] faceVertexIndices = [0, 1, 3, ...]
        point3f[] points = [(-1, -1, -1), ...]
    }

    variantSet "shape" = {
        "box" {
            over "Geo" {
                token subdivisionScheme = "none"
            }
        }
        "sphere" {
            over "Geo" {
                token subdivisionScheme = "catmullClark"
            }
        }
    }
}

variants/model-smaller.usda

In this file I've moved the CubeMesh out of the variantSet and renamed it to Geo. I used over to indicate we're overriding Geo instead of using def like in the previous example. The box variant is the same as in the first example. The sphere variant now enables catmullClark subdivision for the Mesh Geo. That way it subdivides into a sphere, and we don't need to keep all the sphere geometry in the file.

ℹ️
If viewing this in usdview you may need to set Display->Complexity to Medium or better to see a sphere

Still a bit of a contrived example, but I hope this shows the ways variants can actually be useful. If we put a material binding in the variants, then we could choose a material without changing the meshes. If we wanted to add geometry in some cases, we could add new meshes in the variants. You could imagine other creative uses, changing out physics representations, LODs. switching UVs, adding lights, there are tons of potential use cases.

Strong Local Opinions

It's good to be aware of a common pitfall with variant sets. In the model-smaller.usda example if we had set the subdivisionScheme outside the variants then the variant would have no effect.

To be clear, this version is broken.

    def Mesh "Geo"
    {
        int[] faceVertexCounts = [4, 4, 4, ...]
        int[] faceVertexIndices = [0, 1, 3, ...]
        point3f[] points = [(-1, -1, -1), ...]
        token subdivisionScheme = "none"
    }

    variantSet "shape" = {
        "box" {
            over "Geo" {
                token subdivisionScheme = "none"
            }
        }
        "sphere" {
            over "Geo" {
                token subdivisionScheme = "catmullClark"
            }
        }
    }

❗Beware❗

The only difference between this and the working example is that we set subdivisionScheme in the def Mesh "Geo" section. If we now load this on a stage we'd find that no matter what our variant selection is, we still get the cube with no subdivision. There are no warnings or errors in this case either. The file is valid, it just doesn't do what we want.

So what went wrong? We got stung by the LIVRPS algorithm. This controls how USD resolves the strongest values on a stage. The strongest opinion is always "Local". Since we set the subdivisionScheme directly on the Mesh in the same layer that has the "Variant" opinion, that subdivisionScheme opinion is "Local" and is stronger than the variant.

This can be extra confusing, because it is OK to set the subdivisionScheme directly on the Mesh if it's in a different file. If we got the Geo by a "Reference" or a "Sublayer" we would not run into this issue. They are weaker than "Variant" in LIVRPS.

This is a bit of an edge case, but people run into it quite often and it can eat a lot of time to debug, so I wanted to call it out.

Multiple Variants

It's possible to have more than one variant set on the same prim. In that case you can make a selection in each variant at the same time. So if we had a shape variant set that chooses box or sphere, and we had a color variant set that chooses red, blue or green, there would be six unique combinations. Red boxes, green spheres; my god, the power.

I modified the model-smaller.usda file to add a color variant set. There are a few ways to do something like this. I chose to add a Material and USD Preview Surface shader inside /Model. That Material is outside the variants in Model so that only the diffuse color gets changed out in each variant. This way the variant should read clearly, but remember you could change as many parameters as needed, or even define new materials in variants.

The relevant parts of the new file look like this.

def Xform "Model" (
    apiSchemas = ["MaterialBindingAPI"]
    kind = "component"
    variants = {
        string shape = "box"
        string color = "blue"
    }
    prepend variantSets = ["shape", "color"]
)
{
    rel material:binding = </Model/Color>

    def Mesh "Geo"
    {
        ... Unchanged ...
    }

    def Material "Color"
    {
        token outputs:surface.connect = </Model/Color/ColorShader.outputs:surface>

        def Shader "ColorShader"
        {
            uniform token info:id = "UsdPreviewSurface"
            float inputs:roughness = 1
            token outputs:surface
        }
    }

    variantSet "shape" = {
        ... Unchanged ...
    }

    variantSet "color" = {
        "red" {
            over "Color" {
                over "ColorShader" {
                    color3f inputs:diffuseColor = (1.0, 0.2, 0.2)
                }
            }
        }
        "blue" {
            over "Color" {
                over "ColorShader" {
                    color3f inputs:diffuseColor = (0.2, 0.2, 1.0)
                }
            }
        }
        "green" {
            over "Color" {
                over "ColorShader" {
                    color3f inputs:diffuseColor = (0.2, 1.0, 0.2)
                }
            }
        }
    }
}

variants/multiple-variants.usda

This is a lot of text, but the idea is pretty simple. Now there are two variant sets on Model, the old one called shape and a new one called color.

We can open this in a USD editor and select different combinations. We can also set the variant selections when referencing into another layer. That's what I did for the image above. Each reference looks something like this in text form.

def "Model_0_1" (
    append references = @multiple-variants.usda@
    variants = {
        string color = "green"
        string shape = "sphere"
    }
)
{
}

variants/showcase.usda

If we didn't have variants we might need six different files/models/meshes to create these. If we later decided convert the sphere geometry back to polygons we could make that change in one place and it would update everywhere. If these were separate files we'd need to edit three of them to update the sphere.

Conflicting Opinions

One obvious question at this point is, what if I edit the same property in two different variant sets? In that case the strongest variant set wins.

In our simple example with shape and color variant sets the strongest one is the one that's earliest in the list of variantSets on the Model prim.

    prepend variantSets = ["shape", "color"]

So, shape is stronger than color. If shape had an opinion on diffuse color it would override the opinion from color.

This can get more complicated with new variant sets coming from sublayers, references, parent prims with variants that affect the same properties, etc. All kinds of complicated situations could be constructed, and they will all have a consistent result for the strongest opinion, even if that result could be surprising in some cases. My general advice is to avoid trying to get too tricky with variant sets (or fancy composition tricks in general).

Creating one or more variants sets on a prim, even across sublayers, is pretty easy to understand. More complicated structures can definitely work, but if they get hard to reason about they may become thorns in your side in the future.