How-To: Layered Pool Bars
Goal: draw a combat resource whose layers all share one track - a health fill, a shield film over it, a temporary barrier appended past the maximum, reserved capacity locked off at the end, and a trail band that reads both damage and healing.
Image to. UCrimsonPoolBarWidget takes plain numbers and knows nothing about the ability system - see CrimsonCore -> Vitals: Shield, Barrier & Reservation for the GAS-driven half. Tick Show Plugin Content in the Content Browser settings to see the shipped assets.Why one material and not stacked progress bars
Every layer here shares a track, a chamfered shape and a fill edge. Split across widgets, each one re-derives the same reservation remap and they drift apart by a pixel; each needs its own copy of the shape mask, and their antialiased edges composite into a bright seam; and each distinct material breaks the Slate batch, which a HUD carrying thirty nameplates pays for thirty times over.
So everything on the track is one Image driven by one dynamic material instance. Anything not on the track - readouts, status icons, a low-health vignette, floating combat numbers - stays a separate widget, because none of it shares the geometry.
1. Create the widget
Make a Widget Blueprint parented to CrimsonPoolBarWidget and give it one Image named exactly Image_Bar. That name is a BindWidgetOptional, so the widget still runs without it - it just does not draw.
W_CrimsonPoolBar (/CrimsonUI/UI/Foundation/Widgets/). Its whole tree is SizeBox_Root -> Image_Bar, with the brush set to MI_UI_CrimsonPoolBar_Nameplate and an explicit brush Image Size. Duplicate it as your starting point.Image whose brush Image Size is left at zero collapses to nothing and the bar is invisible with no error anywhere. Set the size on the brush, or wrap the Image in a SizeBox.2. Point it at a material
Set Style -> Bar Material to one of the shipped instances. If you leave it unset the widget falls back to whatever material is already on Image_Bar's brush, so setting the brush in the designer alone is enough - but naming it in Style is clearer and survives a brush change.
| Instance | Use for | Notes |
|---|---|---|
MI_UI_CrimsonPoolBar_Player | The player's HUD bar | Tall, segment ticks, full rim. |
MI_UI_CrimsonPoolBar_Nameplate | Floating enemy plates | Hairline rim, no ticks. Four decorative features are switched OFF as static switches, so they compile out of the shader: 143 -> 106 pixel instructions. |
MI_UI_CrimsonPoolBar_Boss | Boss / target bars | Wide, heavily segmented so a long drain still reads. |
All three parent M_UI_CrimsonPoolBar (/CrimsonUI/UI/Foundation/Materials/), which shares its pixel-accurate chamfer with the M_UI_ChamferBox frame master through Functions/MF_UI_ChamferBoxSDF. That master and the function library beneath it (MF_UI_PixelSpace, MF_UI_ChamferSDF, MF_UI_EdgeBand, MF_UI_TopShade, MF_UI_SoftShadow, MF_UI_AccentBar, MF_UI_LinearSweep) are the frame material family every Crimson panel, row plate, hairline and sweep instances; see the README's Frame materials section for the parameter table.
LogCrimsonUI and draws nothing. The failure that used to be silent - a static material instance on the brush with Style.BarMaterial unset - is now handled by the fallback: the widget builds its dynamic instance from the brush. Without that, every parameter write goes to an instance that was never created and the bar looks correct while never moving.MaterialInstanceDynamic, not a MaterialInstanceConstant. If it is still Constant, no parameter will ever apply.3. Push values
Fill an FCrimsonPoolSnapshot and hand it over. Use Set Pool State for a change the player should see animate, and Set Pool State Immediate on bind, on respawn, and anywhere a change is not a hit or a heal worth showing.
| Field | Meaning |
|---|---|
Health | Current health. |
MaxHealth | Untaxed total. This is the whole width of the track, reserved capacity included. |
Reserved | Slice of MaxHealth that cannot be filled. Drawn as a hatched dead zone at the end. |
Shield | Absorb pool. Drawn as a translucent film across the whole track. |
MaxShield | Shield capacity. The film is Shield / MaxShield, not a slice of MaxHealth. |
Overhealth | Temporary barrier. May exceed MaxHealth; drawn past the 100% mark. |
Event Construct -> Make Crimson Pool Snapshot (set Health, MaxHealth, MaxShield, Shield) -> Set Pool State Immediate (Target: self).
For later changes call Set Pool State instead, so the trail band and the eased rise both play.
Health and the fill snaps down with a dark trail closing behind it; raise it and the fill eases up with a green band ahead of it.4. Read the derived fractions
Compute Fractions is a public static and is the normalization specification. Nothing else may re-derive these numbers - the material consumes them already normalized, which is why the numeric readout and the pixels cannot disagree.
| Fraction | Meaning |
|---|---|
MainFrac | Seam between the main pool and the barrier tail. 1 when there is no barrier. |
HealthFrac | Right edge of the health fill, as a share of the whole track. |
GhostFrac | Far edge of the trail band. Never below the drawn fill. |
ShieldFrac | Shield / MaxShield, 0..1, across the whole track. |
ReservedStart | Left edge of the reserved dead zone. |
HealthPercent | Health over EFFECTIVE max. Drives the low-life treatment and the readout. |
Get Pool Fractions returns where the bar is heading. While a gain eases in, the material is showing something slightly behind it - read Get Display Health Frac and Get Display Shield Frac for what is actually on screen.5. Drive the readouts
On Pool Visual fires on every change with the raw snapshot, the derived fractions and a bIsLow flag. Use it for everything a material cannot draw: the numeric text, a barrier badge, a low-health warning.
Health / MaxHealth would show it as permanently wounded. Use MaxHealth - Reserved.6. Tune the motion
Everything below lives on Style (FCrimsonPoolBarStyle) and can be set per widget or swapped wholesale at runtime with Set Pool Bar Style.
| Property | Default | Effect |
|---|---|---|
GhostDelay | 0.15 | Seconds the trail holds at the old value before closing. |
GhostSpeed | 3.0 | How fast the trail closes on the fill. |
FillRiseSpeed | 4.0 | How fast a fill eases UPWARD. Drops are always instant. |
FlashDuration | 0.12 | Seconds the white damage flash takes to decay. |
LowThreshold | 0.30 | Health percent below which the low-life treatment engages. |
CompressionStrength | 0.75 | How hard the main pool is squeezed to make room for overhealth. |
MinMainFraction | 0.55 | Floor on the main pool's share of the track. |
Segments | 0 | Tick count across the unreserved track. 0 disables. |
The trail band reads both directions
There is one band, not two. Falling, it spans the fill out to where the fill was, in GhostColor. Rising, it spans the fill out to where the fill is going, in HealColor. Identical geometry and the same material layer - only the far edge and the tint change, so a gain reads with the same weight a loss does.
| Situation | Band |
|---|---|
| Damage lands | Red, fill -> previous drawn position. |
| Healing with no damage pending | Green, fill -> incoming target. |
| Healing while a loss is still closing ABOVE the new target | Stays red - the unclosed loss is not erased. |
| Damage lands mid-heal | Red, anchored to what was on SCREEN, never to the undrawn heal target. |
Compression
Overhealth is unbounded, so appending it linearly would erase the health readout the moment a build stacks barriers. The main pool is squeezed logarithmically instead, and floored:
MainFrac = clamp( 1 / (1 + log2(1 + Overhealth / MaxHealth) * CompressionStrength),MinMainFraction, 1 )Overhealth 0% -> MainFrac 1.000 barrier takes 0%Overhealth 50% -> MainFrac 0.719 barrier takes 28%Overhealth 100% -> MainFrac 0.571 barrier takes 43%Overhealth 400% -> MainFrac 0.550 barrier takes 45% (floored)
Performance
Node count is a compile-time figure and is not what the GPU runs. Material functions and reroute nodes are inlined and cost nothing; they buy readability. Static switches are the lever that actually pays, because the unused branch is compiled out of the instance rather than multiplied by a zero parameter every pixel.
| Material | Pixel instructions | Texture samples |
|---|---|---|
M_UI_CrimsonPoolBar (all features) | 143 | 0 |
MI_UI_CrimsonPoolBar_Nameplate (4 switches off) | 106 | 0 |
M_ProgressBar_Segmented (for comparison) | 128 | 2 |
unreal.MaterialEditingLibrary.get_statistics(asset) reports these, and it works on material instances too - so a per-instance static-switch saving is directly measurable.Motion runs on a timer, not NativeTick
The trail, the flash and the eased fills all advance on a self-stopping repeating timer. UUserWidget suppresses ticking when nothing needs it, and a screen full of nameplates should cost nothing while everyone is at full health. The timer starts when something is in flight and stops itself the moment everything has settled.
See also
- How-To: UI Extension Points - registering a bar at a HUD slot.
- CrimsonCore -> Vitals: Shield, Barrier & Reservation - the GAS-driven bar.
- CrimsonCore -> Nameplates - the floating enemy plate that embeds one of these.
- API Reference -> Layered pool bars