Preview

Documentation

Update 1.2: VAT Refactoring

The original vertex-based VAT path requires a separate high-bandwidth bake for each mesh-animation pair.
That becomes wasteful once many character meshes share the same skeleton animation set.

I extended SideFX Labs VAT 3.0 with a Skeleton Mode path, so animation data is no longer tied directly to one mesh.
The bake stays at bone-count scale and can be reused by characters that share the same skeleton.

Update 1.1: Python Import Pipeline

The Challenge

crowd Py challenge

Each character in the VAT workflow produces several asset types, and each type needs slightly different import settings.

In this test setup, each character has 6 animations, which means 6 Material Instances per character.

crowd PY files
The source files’ hierarchy from my VAT export pipeline.

For one character with 6 animations, the manual setup is already tedious.

At crowd scale, it becomes slow, repetitive, and easy to misconfigure:

crowd Py false
Only the right-end one is correct.

The Solution

I built this as a hybrid import tool: an Editor Utility Widget for the UI, with Python doing the file discovery and import work.

It connects my Houdini VAT export to the Unreal import path, reducing a roughly 30-minute manual setup to a 10-second automated pass.

crowd PY UI

The UI supports both one-click batch import and step-by-step debugging.
I kept the stages visible, from Source -> Target -> Settings -> Execution, so an artist can stop and fix one part without rerunning everything.

Key Capabilities

  • Scenario A (Iteration): Artist inputs rp_eric, rp_sophia to update only selected characters.
  • Scenario B (Production): Artist leaves the field empty to process the full crowd set.

Code Snippets

Extensibility

I wanted new characters and animations to work without touching the importer code.

The tool derives character identity through regex parsing instead of a hardcoded list.

vat_importer.py python
def _extract_character_name(filename):
  """
  Keep the importer data-driven: new files should be enough.
  """
  name = os.path.splitext(filename)[0]
  # Strip common UE/Houdini prefixes before matching the payload.
  name = re.sub(r'^(SM_|T_|MI_|M_)', '', name, flags=re.IGNORECASE)
  
  # Example: T_rp_eric_Angry_pos -> rp_eric
  match = re.match(r'^(.+?)_([A-Za-z]+[0-9]*)_(pos|rot|data)$', name)
  
  return match.group(1) if match else name

The importer leans on the naming convention exported by the Houdini VAT pipeline.
When a new character like rp_newHero appears in the source folder, the tool extracts the character key and pairs it with the matching animations and textures.

Core Feature: Smart Material Architecture

Instead of creating isolated Material Instances, the tool builds a small inheritance tree.

Master settings propagate to every character, while character-specific bounds stay isolated at the base instance level.

vat_importer.py python
# During MI creation for each character's animation set.
for idx, anim_name in enumerate(animations, start=1):
  
  # First MI owns the character-level data; later MIs inherit it.
  if idx == 1:
      parent_mat = base_parent_material
  else:
      parent_mat = first_mi 

  # ... create MI asset ...

  # Enable once. The rest inherit the switch.
  if idx == 1:
      _set_MI_static_switch_parm(mi_asset, "Support Legacy Parameters...", True)

Material Instance Hierarchy

Critical Fix: Post-Import Enforcement

vat_importer.py python
def _build_exr_import_task(exr_file_path, ue_target_path):
  task = _initialize_task(exr_file_path, ue_target_path)
  texture_factory = unreal.TextureFactory()
  
  # VAT textures carry data, not color.
  texture_factory.set_editor_property('mip_gen_settings', unreal.TextureMipGenSettings.TMGS_NO_MIPMAPS)
  texture_factory.set_editor_property('lod_group', unreal.TextureGroup.TEXTUREGROUP_16_BIT_DATA)
  texture_factory.set_editor_property('compression_settings', unreal.TextureCompressionSettings.TC_HDR)

  task.set_editor_property('factory', texture_factory)
  return task

The most common failure mode in a VAT import is incorrect texture settings.

In practice, Unreal’s Python AssetImportTask did not reliably apply compression_settings during the initial import pass.

vat_importer.py python
def _apply_exr_settings_to_texture(texture_asset):
  """
  Second pass after import. ImportTask misses some texture flags.
  """
  try:
      texture_asset.set_editor_property('compression_settings', unreal.TextureCompressionSettings.TC_HDR)
      texture_asset.set_editor_property('mip_gen_settings', unreal.TextureMipGenSettings.TMGS_NO_MIPMAPS)
      texture_asset.set_editor_property('lod_group', unreal.TextureGroup.TEXTUREGROUP_16_BIT_DATA)
      texture_asset.set_editor_property('srgb', False)
      return True
  except Exception as e:
      _log_error(f"Failed to apply settings to exr: {e}")
      return False

So after import, the tool force-writes the final texture properties instead of trusting the first import pass.

EUW to Python Bridge

crowd PY EUW
The Blueprint graph handling UI logic and Python execution.

The EUW Blueprint acts as a thin bridge: it formats user input, sanitizes paths and character names, then calls Python through Execute Python Script.

Initial Ver 1.0: Breakdown

The Core Challenge

The assignment was an indoor stadium scene with an optimized, customizable crowd system that could populate stands dynamically.

The initial v1.0 work below came from a 4-day Unreal Engine Technical Artist test assignment.

Requirements:

C++ System Architecture

The runtime system is split across four C++ classes with narrow responsibilities.

That separation keeps the data flow explicit and makes later optimization easier.

Data Flow:

BP_SeatArea (C++) -> BP_SeatManager (C++)
BP_SeatManager (C++) -> BP_CrowdManager (C++)
BP_CrowdVolume (C++) -> BP_CrowdManager (C++)

BP_SeatArea (AASeatSpawnerBase)

BP_SeatArea uses a Spline Component to define a seating region, then generates seat transforms inside that shape.
Multiple actors can be placed to create separate seating groups.

Parameters: Column Spacing · Row Spacing

  • Automatic Hookup: Spawners find the Manager on creation; the Manager also notifies existing Spawners when it becomes available.
  • Spline-Based Generation: Works with 2D or 3D spline outlines, including concave shapes.
crowd1 1
  • Automatic Slopes: Interpolates seat height from the spline, so sloped stands can be authored directly in the scene.
crowd1 2
  • Scanline Fill: Fills the polygon row by row instead of testing every possible point.
  • Scale Proof: Actor or spline scaling changes the generated seat count and spacing rather than scaling existing instances.
crowd1 3

BP_SeatManager (AAGlobalSeatManager)

BP_SeatManager collects seat transforms from all Spawners and renders the full stadium seating as one HISM.

Parameters: Seat Mesh · Use Debug Mesh · Seat Rotation Offset

  • 1 Draw Call: Combines transforms from all BP_SeatArea actors into a single HISM, regardless of Spawner count.
crowd2 1
  • Automatic Hookup: Registers and unregisters BP_SeatArea actors as they enter or leave the level.
  • Debug Mode: Swaps seats for cones to inspect rotation and density without mesh detail.
  • Editor QOL: HISM set to bSelectable = false and unexposed UPROPERTY() — prevents editor freeze from selection highlights and Details Panel queries on 100k+ instances.

BP_CrowdVolume (AACrowdVolume)

BP_CrowdVolume defines where crowd characters are allowed to spawn.
Multiple volumes can be layered to create separate crowd sections.

Parameters: Crowd Density · Random Seed

  • Live Re-Bake: Moving the volume or changing a property triggers BP_CrowdManager to update the crowd.
  • Flexible Filtering: One large volume or many smaller volumes can create different density zones, such as home and away sections.
crowd3 1

BP_CrowdManager (AACrowdManager)

BP_CrowdManager fetches seats from BP_SeatManager, filters them through BP_CrowdVolume, then renders the final crowd through a small set of HISM components.
It owns animation variation, material variation, and weighted random selection.

Parameters: Crowd Character Variants · Material Weights · Offset Transform · Bake Crowd

  • Dynamic HISM Array: One HISM per animation x mesh variation, e.g. 3 meshes x 6 animations = 18 HISMs.
  • Random Animation Start: Uses PerInstanceCustomData to offset animation phase per instance and break synchronization.
  • Weighted Animation: Uses weights such as Idle: 100 and Yell: 10, keeping the crowd from looking too uniformly excited.
crowd4 1
  • Editor QOL: HISM set to bSelectable = false and unexposed UPROPERTY() — prevents editor freeze on 100k+ instances.

Code Snippets

Scanline Fill Algorithm

The seat generator uses a scanline pass to fill spline-defined polygons with evenly spaced seat positions.

ASeatSpawnerBase.cpp cpp
static void FindVerticalScanlineIntersections(
  float ScanlineX,
  const TArray& PolygonVertices,
  TArray& OutYIntersections)
{
  // loop over polygon edges: Vi=current, Vj=previous
  {
      // Edge crosses this vertical line.
      if ((Vi.X > ScanlineX) != (Vj.X > ScanlineX))
      {
          float IntersectY = (Vj.Y - Vi.Y) * (ScanlineX - Vi.X) 
                           / (Vj.X - Vi.X) + Vi.Y;
          OutYIntersections.Add(IntersectY);
      }
  }
}

// inside GenerateTransforms()
FindVerticalScanlineIntersections(ScanlineX, SplinePoints2D, YIntersections);
YIntersections.Sort();

// Odd-even pairs become valid spans.
for (int32 i = 0; i < YIntersections.Num(); i += 2)
{
  float Y_Enter = YIntersections[i];
  float Y_Exit = YIntersections[i + 1];
  
  int32 MinCol = FMath::CeilToInt(Y_Enter / ColumnSpacing);
  int32 MaxCol = FMath::FloorToInt(Y_Exit / ColumnSpacing);
  
  // Emit grid points in this span.
}

The algorithm sorts Y intersections, then applies the odd-even rule to identify valid spans.
A point is inside the polygon if a ray from that point crosses the boundary an odd number of times.

This still handles concave regions, but avoids a brute-force point-in-polygon test for every candidate seat.

Editor QOL: Fix the Selection Freeze

Selecting an actor with hundreds of thousands of instances can freeze the Unreal Editor for more than 10 seconds.

This lag comes from two sources:

selection outline rendering and Details Panel queries.

I addressed both by changing UPROPERTY exposure in the header and component flags in C++.

AGlobalCrowdManager.h cpp
// Keep the Details Panel away from the component array.
UPROPERTY() 
TArray CrowdHISMs;

This keeps the Details Panel from walking every HISM component and poking through large instance buffers.

AGlobalCrowdManager.cpp cpp
UHierarchicalInstancedStaticMeshComponent* NewHISM;
// ... HISM setup ...
NewHISM->bSelectable = false;

bSelectable = false disables selection outlines for those instance components.
That avoids the GPU / Render Thread spike caused by drawing outlines over 100,000+ instances.

Reactive Feedback

For authoring, the volumes need quick feedback, but rebaking on every mouse move is too expensive.

So I use PostEditMove() and PostEditChangeProperty() to trigger updates only when they are useful.

ACrowdVolume.h cpp
virtual void PostEditMove(bool bFinished) override;
virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;

These hooks let the system react when the user moves a volume or edits a property in the Details Panel.

ACrowdVolume.cpp cpp
void AACrowdVolume::PostEditMove(bool bFinished)
{
  Super::PostEditMove(bFinished);

  if (bFinished && CrowdManager)
  {
      CrowdManager->BakeCrowd();
  }
}

void AACrowdVolume::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
  Super::PostEditChangeProperty(PropertyChangedEvent);

  if (PropertyChangedEvent.ChangeType == EPropertyChangeType::Interactive) return;
  
  if (CrowdManager)
  {
      CrowdManager->BakeCrowd();
  }
}

PostEditMove() only rebakes after the transform edit finishes.
PostEditChangeProperty() skips Interactive changes, so slider dragging stays responsive and the bake runs after release.

Performance & Optimization

crowd rhi

Key Metrics (RHI)

CPU (Draw Calls)

CPU (Game Thread)

GPU

Pipeline

The project is not only runtime code; it also includes the asset path that feeds it.

crowd houdini

Procedural Stadium Builder

The stadium environment was generated procedurally in Houdini.
The layout can scale from a small field to a large stadium while preserving modular structure.

The final assets are baked into Unreal through Houdini Engine as modular Instanced Static Mesh components.

crowd houVAT
crowd VAT e1762715227541

Automated One-Click VAT Export Pipeline

A one-click Houdini export pass generates all character and animation permutations.
When I add a new character or animation, the matching VAT files are generated in the same pass.

Each character and VAT export uses matching LOD reduction rates.