Version 1.0.0 · Unreal Engine 5.8 · Win64 · Support: teufelsilvan@gmail.com
A shadow system for games that need far more shadow casters than the renderer can reasonably rasterise:
RTS armies, horde shooters, top-down survival, bullet-hell projectiles, tower defence.
Instead of asking the shadow-map pipeline to handle 10,000 movers, every shadow becomes one instance in
one instanced static mesh component. The CPU writes a single transform plus four custom-data floats per
unit per update. Everything that makes the shadow look like a shadow — sliding along the light direction as
a unit rises, stretching when the sun is low, blending softly into uneven terrain — happens in the material.
The result: one draw call per mesh/material pair, regardless of unit count.
What ships:
UMaterialDrivenShadowComponent |
Scene component you attach to any shadow caster. |
UMaterialDrivenShadowSubsystem |
Tickable world subsystem that batches, updates and profiles them all. |
UMaterialDrivenShadowSettings |
Project settings (Developer Settings) under Plugins. |
UMaterialDrivenShadowStatics |
Blueprint function library for reaching the subsystem in one node. |
M_MaterialDrivenShadow |
Ready-to-use master material with the full vertex/pixel graph. |
| Demo map + Blueprints | Playable stress test with a live stats HUD. |
| Full C++ source | Runtime module, no editor-only dependency. |
| Engine version | Unreal Engine 5.8 |
| Modules | MaterialDrivenShadows — Runtime, LoadingPhase: Default, PlatformAllowList: Win64 |
| Supported target platform | Win64 (Development, Shipping, and Editor targets all build clean) |
| Dependencies | Core, CoreUObject, Engine, RenderCore, RHI, DeveloperSettings, Projects — engine modules only, no third-party or Marketplace dependencies |
| Renderer | Deferred and forward. Works with Lumen, VSMs and static lighting — the plugin never touches the shadow-map pipeline, it only draws instanced geometry. |
| Nanite | The shadow quads themselves are non-Nanite instanced meshes. Your units can be Nanite; that is independent. |
| Blueprint only projects | Supported — the plugin ships precompiled binaries, and the whole API is Blueprint-exposed. |
The module is gated to
Win64in the.uplugin. Other platforms are technically portable (nothing in the
source is platform-specific), but they are not built, tested or supported in version 1.0.0. Adding a
platform means adding it toPlatformAllowListand rebuilding.
MaterialDrivenShadows folder into your project's Plugins/ directoryPlugins/ if it does not exist).M_MaterialDrivenShadow. You only need to touch this to restyle or to tune performance.Nothing has to be placed in a level. The subsystem is created automatically in every game and PIE world.
That is the whole setup. The component registers itself with the world subsystem on BeginPlay, the
subsystem creates the instanced component on first use, and every unit sharing a mesh and material collapses
into one draw call.
To confirm it is working, open the console and type stat RHI, then toggle MDS.Enabled 0 and MDS.Enabled 1.
The difference in DrawPrimitive calls should equal the number of batches, not the number of units.
/MaterialDrivenShadows/MaterialDrivenShadows/Maps/L_MaterialDrivenShadowsDemo
A playable stress test. Press Play and use the on-screen buttons:
| Button | What it does |
|---|---|
| +200 UNITS | Spawns another 200 BP_ShadowDemoUnit actors, each with a shadow component. |
| SHADOWS: ON / OFF | Calls SetMaterialDrivenShadowsEnabled — the A/B comparison. |
| SUN: NOON / LOW / SUNSET | Rotates the directional light so you can watch every shadow stretch and rotate at once. |
The HUD (WBP_ShadowDemoHUD) reads GetMaterialDrivenShadowStats() every frame and shows shadow casters,
draw calls (ISM batches), instance slots, recycled free slots, ground traces this update, subsystem update
milliseconds and the current light stretch factor. It is about thirty nodes of Blueprint and is worth
copying into your own debug HUD.
Reference measurement from the shipped demo (editor PIE, 1080p): 1,101 shadow casters → 1 draw call,
0.91 ms subsystem update, 256 ground traces per update.
Demo content lives under Content/MaterialDrivenShadows/ and can be deleted from a shipping project without
touching the plugin code — only the master material M_MaterialDrivenShadow is referenced by the default
project settings.
UMaterialDrivenShadowComponent (Scene Component)Attach one per shadow caster. It never ticks and owns no primitive of its own.
Class group Rendering, spawnable from Blueprint.
Properties — all EditAnywhere, BlueprintReadOnly, category Shadows:
| Property | Type | Default | Meaning |
|---|---|---|---|
ShadowRadius |
float |
100.0 | Base size in world units. Converted to instance scale via Reference Mesh Radius. |
BaseOpacity |
float |
0.8 | Opacity on the ground, before the height fade. |
MaxShadowStretch |
float |
2.0 | Cap on the stretch the material may apply at grazing sun angles. 1 keeps the shadow perfectly round. |
CustomShadowMesh |
UStaticMesh* |
none | Per-unit shadow quad. Units sharing a mesh share a draw call. |
CustomShadowMaterial |
UMaterialInterface* |
none | Per-unit shadow material. |
ShadowOffset |
FVector |
0,0,0 | Extra offset in the component's own space. |
GroundMode |
EMaterialDrivenShadowGroundMode |
Trace Downward | How ground height is found — see below. |
MaxGroundTraceDistance |
float |
2000.0 | Reach of the downward trace. Only shown in Trace Downward mode. |
FadeStartHeight |
float |
250.0 | Height above ground where the shadow begins to fade. |
FadeEndHeight |
float |
1500.0 | Height above ground where the shadow is gone. Must exceed Fade Start Height. |
bShadowEnabled |
bool |
true | Skip this shadow without releasing its instance slot. |
bAutoRegister |
bool |
true | Register on BeginPlay. Turn off to control registration yourself. |
Functions — all Blueprint-callable, category Material-Driven Shadows:
| Function | Meaning |
|---|---|
RegisterShadow() |
Claim an instance slot. Safe to call twice; the second call is a no-op. |
UnregisterShadow() |
Release the slot back to the free list. |
IsShadowRegistered() |
True while this component owns a slot. |
SetShadowOpacity(float) |
Set base opacity. Takes effect on the next subsystem update. |
SetShadowRadius(float) |
Set radius in world units. |
SetShadowEnabled(bool) |
Enable/disable this one shadow, keeping its slot. |
SetMaxShadowStretch(float) |
Set the per-unit stretch cap. |
SetGroundHeight(float WorldZ) |
Supply the ground Z yourself (Manual mode). |
GetGroundHeight() |
Last known ground Z. |
HasGroundHeight() |
True once a trace hit or a manual value established a real ground height. |
SetShadowMeshAndMaterial(Mesh, Material) |
Move this shadow into a different batch, releasing the old slot cleanly. Pass null for either to fall back to the project default. |
The setters are deliberately cheap: they write a float on the component and the value reaches the GPU on the
next subsystem pass. Nothing marks a render state dirty, so calling SetShadowOpacity on 10,000 units in one
frame costs 10,000 float stores.
EMaterialDrivenShadowGroundMode)| Mode | Cost | Use when |
|---|---|---|
| Owner Location (No Trace) | free | Units never leave the floor. Height above ground is always 0. |
| Trace Downward (Budgeted) | budgeted | Units jump, fly or walk over cliffs. Traces are served round-robin from a per-update budget shared by the whole world. |
| Manual (SetGroundHeight) | free | You already know the terrain height — call SetGroundHeight() from your movement or terrain code. |
UMaterialDrivenShadowSubsystem (Tickable World Subsystem)Created automatically in every game and PIE world. Reachable from Blueprint through
Get Material Driven Shadows or a Get World Subsystem node, and from C++ through
World->GetSubsystem<UMaterialDrivenShadowSubsystem>().
| Function | Meaning |
|---|---|
SetShadowsEnabled(bool) |
Master switch. Disabling collapses every instance without destroying batches. |
AreShadowsEnabled() |
Current state of the master switch. |
GetStats() |
FMaterialDrivenShadowStats — batch count, slot usage, trace count, timings. |
SetLightDirectionOverride(FVector) |
Cast along a fixed direction instead of following the sun. |
ClearLightDirectionOverride() |
Resume following the scene's directional light. |
GetLightDirection() |
Normalised direction the light travels in (pointing away from the sun). |
GetShadowDirection() |
That direction flattened onto the ground plane. |
GetNumBatches() |
Number of instanced components — this is your added draw-call count. |
RequestImmediateUpdate() |
Ignore the update interval for one tick. |
RegisterShadowComponent() / UnregisterShadowComponent() are C++-only and are called for you by the
component; you normally never touch them.
UMaterialDrivenShadowStatics (Blueprint Function Library)Every node takes a world context object.
| Node | Meaning |
|---|---|
GetMaterialDrivenShadows |
The subsystem, or null outside a game world. |
SetMaterialDrivenShadowsEnabled |
Master on/off. |
GetMaterialDrivenShadowStats |
Stats struct; returns defaults when there is no subsystem. |
SetMaterialDrivenShadowLightDirection |
Explicit light direction override. |
ClearMaterialDrivenShadowLightDirection |
Back to following the sun. |
FMaterialDrivenShadowStats (Blueprint struct)| Field | Meaning |
|---|---|
NumBatches |
Instanced components in play. Each one is exactly one draw call. |
NumRegisteredShadows |
Shadow components currently registered. |
NumInstanceSlots |
Slots allocated across all batches (registered + recycled). |
NumFreeSlots |
Allocated but currently unused slots waiting to be recycled. |
NumGroundTracesLastUpdate |
Traces performed in the last update. Never exceeds the configured budget. |
LastUpdateMilliseconds |
Wall-clock cost of the last subsystem update. |
ShadowDirection |
Normalised ground direction the shadows currently extend towards. |
LightStretchFactor |
1 at high noon, rising towards the per-component maximum at grazing angles. |
FMaterialDrivenShadowHandle (Blueprint struct)BatchIndex + InstanceIndex, plus IsValid(). Returned by GetShadowHandle(). You rarely need it —
it exists so tooling and tests can point at a specific instance.
MDS.Enabled 0|1 — hide or show every batched shadow at runtime without touching game code. Useful for
A/B profiling against the engine's own shadows.
Project Settings ▸ Plugins ▸ Material-Driven Shadows, stored in DefaultGame.ini under
[/Script/MaterialDrivenShadows.MaterialDrivenShadowSettings].
| Setting | Default | Meaning |
|---|---|---|
| Enable Shadows | true | Master switch applied when a world starts. |
| Default Shadow Mesh | /Engine/BasicShapes/Plane |
Flat, XY-aligned quad facing +Z. |
| Default Shadow Material | /MaterialDrivenShadows/MaterialDrivenShadows/Materials/M_MaterialDrivenShadow |
Master material, shipped with the plugin. Empty keeps the mesh's own material. |
| Reference Mesh Radius | 50.0 | Radius of the default mesh at scale 1. The engine plane is 100×100 uu, hence 50. Change this if you swap in a mesh of a different size. |
| Batch Mode | Instanced (ISM) | See section 10. |
| Update Interval | 0.0 | Seconds between updates. 0 = every frame; 0.033 halves the CPU cost at 30 Hz. |
| Max Ground Traces Per Update | 256 | Hard cap on line traces per update, shared by all shadows. |
| Ground Trace Channel | WorldStatic | Channel used by the downward trace. |
| Ground Offset | 2.0 | Lift above the detected ground, as cheap Z-fighting insurance. |
| Batch Bounds Scale | 4.0 | Culling headroom for the vertex-shader displacement. Keep ≥ your largest Max Shadow Stretch. |
| Instance Start / End Cull Distance | 0 / 0 | Per-instance distance culling. 0 disables it. |
| Light Refresh Interval | 2.0 | Seconds between re-scans for the primary directional light. |
| Fallback Light Direction | 0,0,-1 | Used when the level has no usable directional light. |
#include "MaterialDrivenShadowComponent.h"
AMyUnit::AMyUnit()
{
ShadowComponent = CreateDefaultSubobject<UMaterialDrivenShadowComponent>(TEXT("Shadow"));
ShadowComponent->SetupAttachment(RootComponent);
ShadowComponent->ShadowRadius = 90.0f;
ShadowComponent->BaseOpacity = 0.75f;
ShadowComponent->MaxShadowStretch = 2.5f;
// Ground-locked infantry: skip the trace entirely.
ShadowComponent->GroundMode = EMaterialDrivenShadowGroundMode::OwnerLocation;
}
void AMyUnit::OnDeath(float Alpha)
{
// Cheap: writes one float. Reaches the GPU on the next subsystem pass.
ShadowComponent->SetShadowOpacity(FMath::Lerp(0.75f, 0.0f, Alpha));
}
// Manual mode — no line trace at all. Ideal when you already own a heightfield.
ShadowComponent->GroundMode = EMaterialDrivenShadowGroundMode::Manual;
void AMyUnit::Tick(float DeltaSeconds)
{
const float GroundZ = MyTerrain->SampleHeight(GetActorLocation());
ShadowComponent->SetGroundHeight(GroundZ);
}
#include "MaterialDrivenShadowSubsystem.h"
if (UMaterialDrivenShadowSubsystem* Shadows = GetWorld()->GetSubsystem<UMaterialDrivenShadowSubsystem>())
{
const FMaterialDrivenShadowStats& Stats = Shadows->GetStats();
UE_LOG(LogTemp, Log, TEXT("%d shadows in %d draw call(s), %.2f ms, %d traces"),
Stats.NumRegisteredShadows,
Stats.NumBatches,
Stats.LastUpdateMilliseconds,
Stats.NumGroundTracesLastUpdate);
}
// Ignore the sun; always cast towards +X and slightly down.
if (UMaterialDrivenShadowSubsystem* Shadows = GetWorld()->GetSubsystem<UMaterialDrivenShadowSubsystem>())
{
Shadows->SetLightDirectionOverride(FVector(1.0f, 0.0f, -2.0f));
}
Set Material Driven Shadow Light Direction (Light Direction = 1,0,-2) →
Clear Material Driven Shadow Light Direction when you want the sun back.
For a quick A/B, Set Material Driven Shadows Enabled on a key press does exactly what the demo's
SHADOWS: ON/OFF button does.
// e.g. an infantry unit boarding a vehicle
ShadowComponent->SetShadowMeshAndMaterial(VehicleShadowQuad, /*Material=*/nullptr);
ShadowComponent->SetShadowRadius(260.0f);
The old instance slot is released to its batch's free list and a slot in the vehicle batch is claimed.
Note that this creates a second batch — a second draw call — the first time a new mesh/material pair appears.
M_MaterialDrivenShadow ships ready to use and is already wired into the project settings. This section
documents its graph so you can restyle it, rebuild it from scratch, or write your own.
Material settings
Translucent (shipped) — gives you control over shadow tint. Modulate is cheaper andUnlit.Parameters you can tune (create a Material Instance and override these):
| Parameter | Meaning |
|---|---|
ShadowColor |
Colour the shadow multiplies onto the ground. |
SlideScale |
How far the shadow walks away from a unit that is off the ground. 0.5 is a good stylised default. |
EdgeSharpness |
Falloff exponent of the blob's radial gradient. |
GroundBlendDistance |
DepthFade distance used for slope fitting, in world units (20–60 is the useful range). |
Per-instance custom data
The subsystem writes exactly four floats per instance. The material reads them through
PerInstanceCustomData at these indices (mirrored in C++ as
MaterialDrivenShadows::CustomDataIndex_* in MaterialDrivenShadowTypes.h):
| Index | Name | Contents |
|---|---|---|
| 0 | Radius | Shadow radius in world units. Scale your soft edge with this so different unit sizes look consistent. |
| 1 | Opacity | Final opacity in 0..1, height fade already applied. |
| 2 | Height Above Ground | World units the unit is above its ground point. |
| 3 | Stretch Factor | ≥ 1. Grows as the sun approaches the horizon, clamped per component. |
Vertex shader — World Position Offset
The subsystem has already yawed each instance so its local +X points along the shadow direction, which
makes the projection one-dimensional:
Local Position.PerInstanceCustomData[3], then subtract the original X so you have aPerInstanceCustomData[2] * SlideScale along the same local X axis, so the shadow walksTransformVector, Local → World,Because these offsets leave the mesh's own bounds, the subsystem sets the component's Bounds Scale from
the Batch Bounds Scale project setting. Raise it if shadows pop at screen edges.
Pixel shader — shape, fade and slope fitting
EdgeSharpness. Multiply byPerInstanceCustomData[1] for opacity.DepthFade nodeGroundBlendDistance ≈ 20–60 uu) feeds the opacity chain: where the ground cuts through the quad, theShadowColor → Emissive Color, the opacity chain → Opacity. For a Modulate variant,lerp(1, ShadowColor, Opacity) into Emissive Color instead.If the material is missing, batches fall back to the mesh's own material and the plugin logs a warning under
LogMaterialDrivenShadows — the system still runs, it just renders untextured quads.
Why ISM is the default and not HISM. The original design for this plugin specified HISM. In UE 5.8,
HISM's UpdateInstanceTransform flags the CPU cluster tree out of date and schedules an async rebuild
whenever an instance changes its translation. With 10,000 shadows moving every frame that is a full tree
rebuild per frame — precisely the CPU cost the plugin exists to avoid. A plain ISM has no cluster tree:
transform updates are a memcpy into the instance buffer and culling happens per-instance on the GPU, which is
both faster and more accurate here. Both produce a single draw call.
Batch Mode therefore defaults to Instanced (ISM). Switch it to Hierarchical (HISM) only when your
shadows barely move — static props, wreckage, buildings — where the cluster tree pays for itself.
Instance slots are never removed. Removing an ISM instance swaps indices around and would invalidate
every handle above it. Instead, unregistering a shadow collapses its instance to a negligible scale and
returns the slot to a free list, where the next shadow reuses it. Spawning and despawning units therefore
costs nothing but a list pop, and the instance buffer settles at your peak concurrent unit count.
Two writes per batch per update. The whole update is one BatchUpdateInstancesTransforms and one ranged
SetCustomData per batch, both with bMarkRenderStateDirty = false. The render proxy is never recreated;
the instance data manager streams the deltas to the GPU scene.
Trace budget. In Trace Downward mode the per-update trace count is capped by Max Ground Traces Per
Update and served round-robin. At the default 256, 10,000 units refresh their ground height about every
40 frames — invisible, because ground height changes slowly relative to frame rate.
Tuning checklist for very large armies
GetNumBatches().stat RHI → DrawPrimitive calls. Toggle MDS.Enabled 0 / 1 and compare. The deltaGetNumBatches().GetStats().LastUpdateMilliseconds is the wall-clock cost of the whole subsystem pass.GetStats().NumGroundTracesLastUpdate must never exceed the configured maximum.LastUpdateMilliseconds does not change — the stretch is vertex-shader work.NumFreeSlots rise and fall while NumInstanceSlots| Symptom | Cause / fix |
|---|---|
| No shadows at all | No shadow mesh could be loaded, or MDS.Enabled is 0, or Enable Shadows is off in project settings. Check the log for LogMaterialDrivenShadows. |
| White / untextured quads | The master material could not be loaded. Re-assign Default Shadow Material in project settings. |
| Shadows pop at the screen edge | Raise Batch Bounds Scale above your largest MaxShadowStretch. |
| Shadows flicker on the floor | Increase Ground Offset, or add Pixel Depth Offset to the material. |
| Shadows clip through hills | The material's DepthFade is missing or GroundBlendDistance is too small. |
| Shadow lags behind a fast unit | Update Interval is too high; lower it towards 0. |
| Shadow sits at the wrong height | Ground Mode is Trace Downward but the floor is not on the Ground Trace Channel, or it is further away than Max Ground Trace Distance. |
| More than one draw call | Units use different meshes or materials. Consolidate, then re-check GetNumBatches(). |
| Shadow is the wrong size | Reference Mesh Radius does not match your custom shadow mesh. The engine plane is 100×100 uu → 50. |
| Nothing happens in the editor viewport | The subsystem only runs in game and PIE worlds, by design. Press Play. |
Stated plainly, so you know what you are buying:
DepthFade blend hides intersections rather than projecting onto them.ADirectionalLight (or an explicit© 2026 Silvan Teufel. All rights reserved.