Custom groom (Alembic from Blender) renders in PIE but is completely invisible in Standalone and packaged build — UE 5.8 MetaHuman

Setup: UE 5.8. MetaHuman Character → Assemble → Blueprint actor. A custom hair groom was authored by an artist in Blender and exported as Alembic. It imports fine (42,634 curves, Root UV and Width attributes present). Hair cards generated with the Hair Card Generator. Groom bound to the character’s face mesh.

Problem: The hair renders correctly in the editor and in PIE. In Standalone (-game, uncooked, same assets) and in a packaged Development build it is completely invisible. There are no errors or warnings in the log — it simply isn’t drawn.

The part that makes this strange: the same character’s other grooms — beard, mustache, eyebrows, all MetaHuman-provided — render perfectly in Standalone and in the packaged build, in the same frame. If I swap my groom for a MetaHuman one on the same groom component, it renders immediately with no other changes. So the groom system itself works fine in a build; only this one imported asset is invisible.

What I have already verified or tried:

  • r.HairStrands.Dump 1 in the packaged build shows the cards data present and GPU-resident (~7.3 MB) — the data is cooked and loaded, it just isn’t drawn
  • showflag.LODColoration 1 in Standalone shows the character at LOD 0 (white), so it is not an LOD or screen-size problem
  • r.HairStrands.Cards = 1, r.SkinCache.CompileShaders = 1, HairStrands plugin mounted (confirmed in the build log)
  • Tried r.HairStrands.Strands 0 and 1, r.HairStrands.UseCardsInsteadOfStrands 1, r.HairStrands.SwapType 3
  • Tried RBF Interpolation on and off, Forced LOD via the LODSync component, Use Cards on the groom component
  • Cards descriptors now exist for every LOD index of the groom (LOD 0–5), each with a mesh and a material assigned
  • Groom binding rebuilt after the cards were added
  • Materials are assigned on the groom’s material slots

Two differences I can see versus the grooms that do work:

  1. The working ones have cards as <Groom>_CardsMesh_Group0_LOD0..4; mine has Hair Card Generator output as <Groom>_LOD0/<Groom>_LOD0 plus _TS0.._TS3 texture sets
  2. Assemble generates an MI_WI_<name>_Cards material instance for the working grooms, but never generates one for mine

Question: what does a groom with generated hair cards require in order to render outside the editor? It works everywhere WITH_EDITOR is true and nowhere it isn’t, with no error reported. I also noticed that UGroomComponent::IsHairMaterialCompatible returns Valid immediately when the geometry type is not Strands, so no material validation warning is ever emitted for cards — which would explain the total silence in the logs.

Any pointer would be appreciated. Happy to provide logs, the groom asset settings, or a repro.
(First pic is from standalone, second in PIE)

The checks you already ran rule out the usual “groom system is off” causes. Other MetaHuman grooms draw in the same standalone frame, LOD 0 is confirmed, and r.HairStrands.Dump shows cards data resident. The interesting part is the two differences you listed, plus IsHairMaterialCompatible returning Valid immediately for non-strands.

That early-out is why the log is silent, and it also means a cards material can be rejected (or simply not be the shader the cards pass binds) without a warning. Editor and PIE are WITH_EDITOR, so they can show the Hair Card Generator preview. Standalone -game and a packaged build only draw what was serialized onto the groom asset and what the component actually binds. A few things line up with “data is resident, nothing is drawn”:

1. The missing MI_WI_*_Cards is not cosmetic. Assemble writes that cards material instance for grooms that went through the MetaHuman groom path. It will not create one for an alembic you assign afterwards. In the editor the generator preview material still displays. Outside the editor the cards pass wants a hair-cards material (the engine hair-card shading path, or a duplicate of a working MI_WI_*_Cards). A default surface material, or a strands MI left on the component, produces no draw and no warning, because of the early-out you found. Concrete test: duplicate a working groom’s MI_WI_*_Cards, point its textures at your TS0–TS3 set, assign that MI on this groom’s cards material slot and on the Groom Component’s material overrides on the assembled Blueprint (Assemble may be pushing a slot override that only the official grooms fill in). Run standalone again. If the hair appears even with wrong-looking textures, the generator material was the reject.

2. Generator output vs the cards mesh the runtime pass uses. Working grooms reference a real static mesh per group/LOD (<Groom>_CardsMesh_Group0_LODn) stored on the groom group’s cards geometry. The Hair Card Generator’s <Groom>_LOD0 mesh plus TS textures is a separate asset, and the in-editor preview can bind it without that assignment being the one FHairCards draws in a new process. Dump showing ~7.3 MB resident often means the textures loaded, not that the instance has a non-zero card/vertex count. Compare the dump’s card count and mesh name against a working MetaHuman groom in the same standalone session. A descriptor with a mesh assigned in the editor is not enough if that mesh is an editor preview object that did not serialize. Apply/export the generator result to a saved static mesh, assign that asset on the groom LOD cards slot (hard reference, not the preview), save the groom, restart the editor, then run standalone. If the dump mesh name is None until you do that, this was the gap.

3. Don’t keep flipping r.HairStrands.Strands / UseCards. You already showed the system can draw cards. Once a known-good cards MI is on the component and the LOD geometry type is Cards with a serialized mesh, standalone should match PIE. If strands of this groom are also invisible while cards of official grooms work, rebuild the binding against the skeletal mesh the assembled Blueprint actually uses at runtime (the generated face mesh, not the editor preview mesh). A failed binding hides strands; it is a separate path from the cards material issue.

The naming mismatch you noticed is the whole bug, not a side detail. Runtime wants the same shape as the working assets: a saved cards mesh on the group LOD, plus a cards material instance of the kind Assemble emits. The generator preview satisfies the editor and stops there.

Thanks for the detailed reply — I tested both points.

On the material (point 1): I duplicated a working groom’s MI_WI_*_Cards, assigned it on the groom asset’s cards material slots and on all material element overrides of the Groom Component on the assembled Blueprint (Element 0, 1 and 2 — Element 2 was HairDefaultMaterial). Compiled, saved, ran Standalone without re-assembling. Still nothing is drawn. So a slot override pushed by Assemble isn’t the blocker.

On the dump reading (point 2): small correction — the Cards column in r.HairStrands.Dump is cards geometry bulk data (FHairGroupPlatformData::FCards::GetDataSize()); textures aren’t counted there at all. So the ~7.3 MB is real card geometry resident on the GPU, not loaded textures. The generated card mesh is also a properly saved asset on disk (721 KB .uasset), and ImportedMesh on FHairGroupsCardsSourceDescription is a plain UPROPERTY, not inside WITH_EDITORONLY_DATA — so it isn’t an unserialized preview object either.

On the naming: I checked the lookup — GroomAsset.cpp matches cards descriptors by GroupIndex and LODIndex, not by name:

if (SourceDesc.GroupIndex == GroupIndex && SourceDesc.LODIndex == LODIndex)

So renaming has no effect. I agree the naming difference reflects provenance (Hair Card Generator output assigned manually vs. pipeline-created cards), but it isn’t what the runtime looks at.

Current state: card geometry resident on GPU, a cards descriptor present for every LOD index of the groom, materials assigned on both the asset and the component, LOD 0 confirmed in the build via showflag.LODColoration, and other MetaHuman grooms rendering in the same frame on the same character. Zero draws, zero warnings.

Is there anything else the cards pass requires that only gets filled in under WITH_EDITOR? Replacing this groom with a MetaHuman-provided one on the same component renders immediately, so the difference is entirely in how the asset was produced.

You already ran the test I was about to suggest, so ignore the material-instance idea. A known-good MI_WI on every slot, including the component overrides, still drawing nothing means the material is not the blocker. And thanks for the correction on the dump: that 7.3 MB is FHairGroupPlatformData cards geometry, so the mesh bulk really is cooked and resident.

If the geometry is there and a working cards material is bound, the renderer is not issuing a cards draw for this groom. Two things still fit that, given the editor shows it and Standalone does not.

Source type. Hair Groups, Cards, on your groom versus a working MetaHuman groom. The Hair Card Generator viewport can preview the imported mesh while the runtime buffer is still the generated-from-strands cards, which is a different group and can be empty. Set the cards source to Imported, point ImportedMesh at the generator mesh (the saved .uasset, not the preview), then run the groom asset’s own generate/apply so the derived cards data is rebuilt from that mesh, and save the groom. Until that derived data is rebuilt, editor preview and the cooked buffer are not the same thing, even when GetDataSize is non-zero.

Where the draw goes. A binding built against the strands will not skin the cards mesh. The cards can be drawn at the world origin, or with no valid clusters, which looks exactly like invisible on the head. Rebuild the binding after the cards source change, assign that binding on the component, then in Standalone check the world origin as well as the actor. On the dump line, read cluster count and vertex count, not just the byte size. Non-zero bytes with zero clusters means the mesh never made it into the buffer the cards pass iterates.

The working groom on the same component is the diff that matters. Compare HairGroupsCards source type, ImportedMesh, and the LOD geometry type. The first field that differs is the one the game is using. Assemble will not fix this one, since it only sets up grooms it created.