Practical workflows for variants

Hi, I’m trying to understand how to work with variants from a practical standpoint.

I think I understand the idea, but I keep getting “bitten” by local overriding variants, especially when creating new variants for a file.

Imagine this scenario – an artist made a branch:

def Xform "Branch" {
  custom double length = 1
}

Now, we want to introduce “small” and “large” variants for this branch:

def Xform "Branch" (
  variantSets = ["sizes"]
  variants = {
    string sizes = "small"
  }
)
{
  custom double length = 1

  variantSet "sizes" = {
    "small" {
      custom double length = 0.5
    }
    "large" {
      custom double length = 2
    }
  }
}

To me this would be the intuitive way to add variants. However, it doesn’t work – the existing local length property overrides whatever opinion the variants have. So, to make it work, the authored length needs to be removed:

def Xform "Branch" (
  variantSets = ["sizes"]
  variants = {
    string sizes = "small"
  }
)
{
  variantSet "sizes" = {
    "small" {
      custom double length = 0.5
    }
    "large" {
      custom double length = 2
    }
  }
}

I find this can be quite confusing – especially since the property is now “missing” in the place where I would expect it to be, and “feels” missing for purposes of connecting it to other properties. For example, when I have a nested

def Cylinder "Visual" {
  double height.connect = </Branch.length>
}

then it’s connected to “nothing” when I don’t have the full (potentially large and coming from elsewhere) variant hierarchy in my head.

How do people navigate this in practice?

Hello Felix,

just a quick reply without going to deep.
LIVRPS → you say the Local is overriding, right?

As I understand you have a structure where your Geometry is in the Root file. From my understanding, the Geo itself is therefore a Local opinion, hence ist is overriding, the Variant.

…but I am just learning myself everyday….

yet for me the following structure has worked:

Asset_Root_File.usda (usd)
├── Lofted Variant Sets (accessible without loading payload)
├── Lofted Primvars (material controls)
└── Payload Arc ───> Payload_File.usdc (Heavy Geometry)

→ Asset root is the Local, it is basicly ‘empty’ initially as it only acts as a container.
→ at the bottom you load a Layer that loads the payload / reference, this insures that anything in there is never Local.
→ all the other stuff is piled seperately on top.

This has worked for me so far. As I am coming from the artist side of things, I had hard time wrapping my head arounf the USD learning paths from NVIDIA / Pixar. I therefore started a gihub repo:

I hope what you find in there is helpful, if not let me know! and please tell me if you (or others) find BS in there.
If you disagree or find BS please add another Opinion on top :wink:

BTW. as you said you are not working with Omniverse atm…, what tools do you use for authoring?

cheers Jan

p.s. I just looked through the project one more time and made some updates…

Thanks for sharing, Jan :raising_hands:t2: Your USD_GoodStart GitHub repository is truly a gold mine of best-practice information for OpenUSD. I work in the construction sector, and it is sometimes challenging to get a clear view of the best way to work with OpenUSD

1 Like

Instead of removing custom double length = 1, you can just define custom double length and set/author it in the variants. This way you also have a place to add meta like
custom color3f myColor ( colorSpace = “srgb_linear“)

That is great to hear!! and as I say in the Readme, the reason I started this repo is, that I also suffered from the same experience.
I decided to act on it and wrote this little cheat sheet. Or let me be more precise - ‘I’ is me and a quite sophisticated AI Framework in Cursor, that spits out Tutorials and Research papers on subjects.

In Cursor I have my USDcodeNIM MCP (on my github) and the MCP from https://www.synapgarden.com/ running + lots of context engineering.
Than I am doing editorial work over the generated Text, a sort of co-working where I learn, ask more quetions and get additional Answers.

1 Like

I asked my Wizard….

’Thomas Kumlehn suggests keeping the property declaration at the local level without a value, then setting values in the variants. This preserves the property definition and allows metadata.

What Thomas is suggesting

Instead of removing the property entirely, declare it without a value:


def Xform “Branch” (
variantSets = [“sizes”] # Declare that this prim has a variant set called “sizes”
variants = {
string sizes = “small” # Set the default variant selection to “small”
}
)
{

#KEY POINT: Declare the property WITHOUT assigning a value

#This creates a property definition but NO Local opinion

#Without a Local opinion, variants (which are weaker than Local but stronger than Payloads)

#can successfully set the value via LIVERPS composition strength

custom double length #Property exists for connections, but variants control the value

variantSet “sizes” = {
“small” {

#Variant “small” sets length to 0.5

#This works because there’s no Local opinion overriding it

custom double length = 0.5
}
“large” {

#Variant “large” sets length to 2.0

#This also works because there’s no Local opinion

custom double length = 2
}
}
}


Why this helps

  1. Property exists: The property is declared, so connections like </Branch.length> resolve even when variants aren’t loaded.

  2. Metadata: You can attach metadata to the declaration:


custom double length (
doc = “Branch length in meters”
displayName = “Length”
)


  1. Type safety: The type is defined upfront, which helps tools and validation.

The key difference

  • Original problem: custom double length = 1 creates a local opinion that overrides variants.

  • Thomas’s solution: custom double length (no value) declares the property; variants provide the values.

This follows LIVRPS: the local declaration doesn’t create a value opinion, so variant opinions can provide the values without conflict.

Should I check your USD_GoodStart repo to see if this pattern is documented, or add it if it’s missing? –> Yes



Also, please explain when to use which approach:

When to Use Thomas’s Approach vs Jan’s Payload Pattern

These are complementary strategies that solve different problems at different architectural levels:

Thomas’s Approach (Property Declaration Technique):

  • Use when: Adding variants to existing assets that already have properties
  • Use when: Retrofitting variant support to legacy USD files
  • Use when: Properties need to exist for connections but values come from variants
  • Scope: Property-level technique within an asset
  • Example: Artist created custom double length = 1, now you want variants to control it

Jan’s Approach (Payload-Based Architecture):

  • Use when: Designing new assets from scratch
  • Use when: Building production-ready, scalable asset libraries
  • Use when: Heavy geometry needs lazy loading (CAD, robots, machinery)
  • Scope: Asset-level architecture pattern
  • Example: Creating a new pump asset with geometry in payload, variants lofted above

Combined Best Practice:
For new assets, use Jan’s payload pattern AND Thomas’s property declaration technique together:

# Asset Root File (minimal, as per Jan's approach)
def Xform "Branch" (
  prepend payload = @./Payloads/Branch_payload.usdc@  # Geometry in payload (never Local)
)
{
  # Property declared without value (Thomas's approach)
  # This allows variants to set values while keeping property available for connections
  custom double length  # No Local opinion - variants control the value
  
  # Variants lofted above payload (Jan's approach)
  variantSet "sizes" = "small" {
    "small" {
      custom double length = 0.5
    }
    "large" {
      custom double length = 2
    }
  }
}

Decision Matrix:

Scenario Approach Reason
New asset with heavy geometry Jan’s payload pattern Performance, scalability
Adding variants to existing asset Thomas’s property declaration Preserves connections, allows overrides
Property needs metadata Thomas’s approach Metadata requires property declaration
Building asset library Jan’s payload pattern Standard production architecture
Legacy asset retrofit Thomas’s approach Works with existing structure
Property must exist for connections Thomas’s approach Property declaration enables connections

Key Insight: Jan’s approach prevents the problem (no Local opinions in payloads), while Thomas’s approach solves it when you can’t avoid Local opinions (retrofitting existing assets).


→ it is now ‘living’ here: USD_GoodStart/WIP_Docs/OpenUSD_Best_Practices_Guide (17).md at main · jph2/USD_GoodStart · GitHub ’ 5.7 Best Practices for Variants’

@herbst , I’ll give some context on how we got to the current behavior (which was not the original behavior!), at the end, but wanted first to contribute one more pattern that’s somewhat in-between Jan’s and Thomas’s that serves us well in our pipeline. And yeah, it’s both a blessing and a curse that there’s often no “One True Way” to do things in USD…

The following pattern attempts to satisfy two goals:

  1. Keep the entire definition in one file/layer
  2. Allow for maximal sharing of common scene description between all variants, and preserve a “meaningful” presentation of the asset even when no variant is selected.

Here it is:

def Xform "Branch" (
  references = </Branch_base>
  variantSets = ["sizes"]
  variants = {
    string sizes = "small"
  }
)
{
  variantSet "sizes" = {
    "small" {
      custom double length = 0.5
    }
    "large" {
      custom double length = 2
    }
  }
}

# Typically, MODEL_base will contain a complete description of the "undifferentiated" asset...
# In our pipeline, that often corresponds to a "default" variant
over "Branch_base"
{
  custom double length = 1
}

It’s perhaps a bit easier of a pattern for DCC’s to follow than Thomas’s pattern, though I haven’t actually seen it in any :slight_smile: We follow this pattern for our shading layers and their variants, produced by an in-house tool. Though, now that USD supports convenient namespace editing, it would be pretty straightforward to deploy as a “chaser” post-processing script on top of e.g. stock Maya export, I think.

LIVERPS History

@herbst , you are not alone in your expectation of how variants should work, including some Pixarians as they came into USD - after all, variants are “more specific” opinions than local, so shouldn’t they be stronger? In fact that was the original behavior reached by the Presto designers back in 2004/05. However, as we worked with it in the pipeline over the next six or so years, we learned two things:

  1. It can be incredibly inconvenient. Imagine I have a layerStack, in which a weak layer defines a variantSet with a bunch of variants. Now in a stronger layer in that stack, I simply want to universally override a value that happens to be specified in that weaker variantSet. In the “simple” and very efficient composition engine that ships with USD today, we essentially first “flatten” the layerStack before interpreting the composition arcs. That would mean that the stronger layer’s local/direct opinion would lose to the weaker variant opinions, so to override such an opinion, you’d need to add a new one to each variant in the stronger layer… assuming you knew which variantSet was providing it. This obviously wouldn’t stand, so…
  2. The Presto algorithm became not-simple, with arcs (possibly even only variantSets, though I’m not sure) being interpreted layer-by-layer within a stack. As we needed to add and modify core composition behaviors, these special rules had to become more and more complex, and by 2010/11, the composition system was kind of buckling under its own complexity and becoming unmaintainable.

The creation of the modern Pcp composition engine was the single-biggest precursor that gave us confidence we could create an open-sourceable scene description system, and IIRC, @blevin discovered in his prototyping that making that change to the relative strength of variant and local opinions was (one of) the big unlock that made the current, recursive, encapsulated composition algorithm possible. Luckily for us, the pattern I described above (and our other tooling) just so happened to not, at that point in our pipeline’s evolution, rely on the old behavior. So we were able to roll out the new behavior without much of a blip.

2 Likes