UMG/Slate Coordinate Spaces
Almost every UMG coordinate bug comes from one of two traps. First, UE has
two different things called "screen": Slate events and
geometry work in desktop screen space (OS pixels, rooted at the virtual-desktop
origin — the primary monitor's top-left on Windows), while PlayerController and the
ScreenTo* conversion functions mean game-screen
space (render-resolution pixels, origin at the top-left of the game viewport). Second,
DPI scale silently multiplies or divides coordinates depending on which function you call
and which flag you leave at its default.
This page gives you the mental model first, then a cookbook of the conversions you actually
need — "is the mouse over this?", "what did I click on?", "put this widget at a world
position" — each verified against the engine source
(FGeometry, USlateBlueprintLibrary,
UWidgetLayoutLibrary, UE 5.8).
1. The Mental Model
Four spaces, nested from the OS desktop down to a single widget. The picture below shows all of them at once — one point P inside a widget, with its coordinates in every space. Windowed PIE on a 4K monitor, viewport scale 2.0; all numbers are self-consistent.
Windowed PIE on a 4K monitor: window at desktop (640, 300), viewport panel offset (160, 220) inside it, viewport scale 2.0. In fullscreen the window and chrome offsets collapse to zero — which is exactly why coordinate bugs hide on dev machines.
FGeometry "absolute" coordinate, every
FPointerEvent::GetScreenSpacePosition(), and GetMousePositionOnPlatform()
live here. Yes — "Absolute" means the desktop, not the game window and not the viewport.PlayerController::GetMousePosition,
ProjectWorldLocationToScreen / DeprojectScreenPositionToWorld,
every "Screen Position" input and every "Pixel Position" output in the UMG libraries.
The window border and (in PIE) the editor chrome are already excluded.GetViewportScale(). This is the space of
GetMousePositionOnViewport() and of widget placement — a full-viewport
Canvas Panel is laid out in these units.0..Size are valid — they describe points beyond the
widget's edges. Convert in and out with FGeometry::AbsoluteToLocal / LocalToAbsolute
(which go through desktop space).SetRenderTransformAngle, SetRenderScale…) visually warp a widget
without changing its layout. Contrary to popular belief, AbsoluteToLocal,
LocalToAbsolute, IsUnderLocation, and Slate hit-testing all
include render transforms — a rotated button is clickable exactly where you see it.
What ignores render transforms is layout: desired size, slot arrangement, and
GetLayoutBoundingRect(). Details in §4.5.
SetPositionInViewport(Pos, bRemoveDPIScale) divides by the DPI scale when the
flag is true — and true is the default. So:
pixel-space input (game-screen / "Pixel Position") → use the default;
scaled input ("Viewport Position", GetMousePositionOnViewport,
ProjectWorldLocationToWidgetPosition) → pass false.
Mixing these up divides twice and your widget drifts toward the top-left on any
DPI scale ≠ 1.
2. Conversion Cheat Sheet
Find your starting point in the first column. All functions are callable from both C++ and Blueprint unless noted.
| You have | You want | Use |
|---|---|---|
| Mouse cursor | "Is it over widget X?" | IsUnderLocation(Geom, GetMousePositionOnPlatform()) — R1 |
| Pointer event (OnMouseDown…) | Point inside the widget | MyGeometry.AbsoluteToLocal(Event.GetScreenSpacePosition()) — R2 |
| Mouse cursor | Widget-placeable position | GetMousePositionOnViewport() → SetPositionInViewport(…, false) — R3 |
| 3D world location | Widget-placeable position | ProjectWorldLocationToWidgetPosition → SetPositionInViewport(…, false) — R4 |
| A point in some widget | Viewport position (place another widget there) | LocalToViewport → Viewport Position + SetPositionInViewport(…, false) — R5 |
| A point in some widget | World-space ray (trace from UI) | LocalToViewport → Pixel Position → DeprojectScreenPositionToWorld — R6 |
| Game-screen pixels (GetMousePosition, ProjectWorldLocationToScreen) | Widget local point | ScreenToWidgetLocal (leave bIncludeWindowPosition false) — R7 |
| Game-screen pixels | Viewport (scaled) position | ScreenToViewport |
| Desktop / absolute point | Widget local point | Geometry.AbsoluteToLocal / USlateBlueprintLibrary::AbsoluteToLocal |
| Desktop / absolute point | Viewport position (pixel + scaled) | AbsoluteToViewport |
| Widget local point | Desktop / absolute point | Geometry.LocalToAbsolute |
| A distance or size (not a position) | Same, in another space | Scalar_* / Vector_* variants (no translation applied) |
| Mouse cursor | Raw desktop coordinates | GetMousePositionOnPlatform() |
3. Recipes
R1 — Is the mouse over this widget?
The cursor position from GetMousePositionOnPlatform() is
desktop-space — exactly the space a widget's cached geometry lives in, so you can test it
directly with IsUnderLocation. Render transforms are
respected: a rotated widget tests against its rotated bounds. If the widget receives pointer
events anyway, IsHovered() is simpler.
const FGeometry& Geom = MyWidget->GetCachedGeometry();
const FVector2D MouseDesktop = UWidgetLayoutLibrary::GetMousePositionOnPlatform();
if (USlateBlueprintLibrary::IsUnderLocation(Geom, MouseDesktop))
{
// cursor is over the widget (render transforms respected)
}
// Simplest alternative when the widget is hit-testable anyway:
if (MyWidget->IsHovered()) { /* ... */ } R2 — Where inside the widget did the click land?
Pointer events hand you both pieces: the widget's geometry and the event position. Despite
the name, GetScreenSpacePosition() is
desktop-space (Slate "absolute"), so it pairs directly with
AbsoluteToLocal. No viewport, no DPI, no window offset —
the geometry's transform handles all of it, including render transforms.
FReply UMyWidget::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
// "ScreenSpacePosition" is desktop-space — same space as InGeometry's absolute side
const FVector2D LocalClick = InGeometry.AbsoluteToLocal(InMouseEvent.GetScreenSpacePosition());
// Normalised 0..1 position inside the widget (handy for sliders, colour pickers):
const FVector2D UV = LocalClick / InGeometry.GetLocalSize();
return FReply::Handled();
} R3 — Make a widget follow the mouse
GetMousePositionOnViewport() returns a scaled
viewport position, so tell SetPositionInViewport not to
divide again: pass bRemoveDPIScale = false. The
pixel-space route via PlayerController works too — then
you keep the default.
// Scaled route — flag must be false:
FVector2D ViewportPos = UWidgetLayoutLibrary::GetMousePositionOnViewport(this);
CursorWidget->SetPositionInViewport(ViewportPos, /*bRemoveDPIScale=*/ false);
// Pixel route — default flag (true) divides for you:
float PixelX, PixelY;
if (GetOwningPlayer()->GetMousePosition(PixelX, PixelY))
{
CursorWidget->SetPositionInViewport(FVector2D(PixelX, PixelY));
} R4 — Pin a widget to a world location (nameplates, pings, markers)
ProjectWorldLocationToWidgetPosition projects to
game-screen pixels internally, then divides by the DPI scale — its output is already
scaled viewport space, even though the output pin is labelled
Screen Position (the implementation runs its result through
ScreenToViewport). So again: bRemoveDPIScale = false.
The return value is false when the point is behind the camera — hide the widget instead of
placing it. Use the …WithDistance variant if you also want
the depth for distance-based scaling.
FVector2D WidgetPos;
const bool bOnScreen = UWidgetLayoutLibrary::ProjectWorldLocationToWidgetPosition(
PC, TargetActor->GetActorLocation(), WidgetPos, /*bPlayerViewportRelative=*/ false);
if (bOnScreen)
{
// Output is already DPI-scaled viewport space — don't let it be divided again:
Nameplate->SetPositionInViewport(WidgetPos, /*bRemoveDPIScale=*/ false);
}
Nameplate->SetVisibility(bOnScreen ? ESlateVisibility::HitTestInvisible : ESlateVisibility::Hidden); AddToPlayerScreen (split-screen),
pass bPlayerViewportRelative = true so the result is relative to that player's
sub-rect. See §7.
R5 — Place widget B at widget A's position
LocalToViewport takes a point in widget A's local space
and returns both flavours of viewport position. Use Viewport Position (+ flag
false) for placement. If both widgets live in the same hierarchy you can skip the viewport
entirely and hop through desktop space.
const FGeometry& AnchorGeom = AnchorWidget->GetCachedGeometry();
FVector2D PixelPos, ViewportPos;
USlateBlueprintLibrary::LocalToViewport(
this, AnchorGeom, FVector2D::ZeroVector /* anchor's top-left */, PixelPos, ViewportPos);
Follower->SetPositionInViewport(ViewportPos, /*bRemoveDPIScale=*/ false);
// Widget → widget directly, no viewport round-trip:
FVector2D InTargetSpace = TargetGeom.AbsoluteToLocal(AnchorGeom.LocalToAbsolute(SomeLocalPoint)); R6 — From a widget point into the world (trace from UI)
Going the other way — say, line-tracing from a custom crosshair widget —
DeprojectScreenPositionToWorld expects
game-screen pixels, so this is the one place you use the
Pixel Position output instead:
FVector2D PixelPos, ViewportPos;
USlateBlueprintLibrary::LocalToViewport(this, CrosshairGeom, CrosshairLocalPoint, PixelPos, ViewportPos);
FVector WorldOrigin, WorldDirection;
if (PC->DeprojectScreenPositionToWorld(PixelPos.X, PixelPos.Y, WorldOrigin, WorldDirection))
{
// trace along WorldDirection from WorldOrigin...
} R7 — Game-screen pixels → widget local
When you have a coordinate in game-screen pixels —
PlayerController::GetMousePosition,
ProjectWorldLocationToScreen, hit results from a scene
capture — ScreenToWidgetLocal maps it into a widget's
local space. Leave bIncludeWindowPosition at its default
(false) — see the gotchas
for why setting it to true breaks this.
float MouseX, MouseY;
if (PC->GetMousePosition(MouseX, MouseY)) // game-screen pixels
{
FVector2D LocalCoord;
USlateBlueprintLibrary::ScreenToWidgetLocal(
this, MyWidget->GetCachedGeometry(), FVector2D(MouseX, MouseY), LocalCoord);
// LocalCoord is now in MyWidget's local space
} 4. The Spaces in Detail
4.1 Desktop space — Slate's "Absolute"
Slate builds every widget's geometry by accumulating transforms from the root down, and the
root is placed at the window's desktop position: when a widget is painted, the
engine appends the window-to-desktop transform and caches the result
(SWidget::Paint →
PersistentState.DesktopGeometry, which is what
GetCachedGeometry() / GetTickSpaceGeometry()
return). So an "absolute" coordinate is measured from the OS virtual-desktop origin
(the primary monitor's top-left on Windows), in OS pixels — the engine's own parameter
name for it is AbsoluteDesktopCoordinate.
The comment in Geometry.h hedges — "Absolute
coordinates could be either desktop or window space depending on what space the root of
the widget hierarchy is in" — because during the paint traversal the root is the
window. But everything you touch from game or widget code (cached geometry, pointer
events, the mouse functions) is desktop-rooted. That's why
Geometry.AbsoluteToLocal(MouseEvent.GetScreenSpacePosition())
just works, in every window mode, on every monitor, in PIE and standalone alike.
4.2 Game-screen space (viewport pixels)
The space gameplay code calls "screen": origin at the top-left of the game viewport, units
are render-resolution pixels (FViewport size). In PIE the
origin is the viewport panel inside the editor window — the chrome offset is
already excluded, which is why game-screen code behaves identically in PIE and standalone.
APlayerController::GetMousePosition— mouse in game-screen pixelsProjectWorldLocationToScreen/DeprojectScreenPositionToWorld- Every
ScreenPositioninput ofUSlateBlueprintLibrary(ScreenToWidgetLocal,ScreenToWidgetAbsolute,ScreenToViewport) - Every
PixelPositionoutput (LocalToViewport,AbsoluteToViewport)
GetMousePositionOnPlatform() (desktop coords) into
ScreenToWidgetLocal gives garbage whenever the window isn't at desktop origin
— and in PIE, always. For OS/desktop input, convert through absolute space instead
(AbsoluteToViewport, Geometry.AbsoluteToLocal).
4.3 Viewport space (scaled)
Same origin as game-screen space, divided by the viewport scale
(GetViewportScale() — see §6).
This is the coordinate system UMG lays widgets out in: a full-screen Canvas Panel spans
GetViewportSize() / GetViewportScale() of these units
regardless of render resolution. Conversion functions that output a
Viewport Position mean this space.
| Output pin | Space | Feed it to |
|---|---|---|
| Pixel Position | Game-screen pixels | Deproject / line traces, render targets, SetPositionInViewport(pos) with the default flag |
| Viewport Position | Viewport scaled | Widget placement: SetPositionInViewport(pos, false), Canvas Panel slot positions |
4.4 Widget local space
The coordinate system internal to one widget: origin at its own top-left, X right, Y down,
in Slate units. (Size.X, Size.Y)
(GetLocalSize()) is the bottom-right corner. Points
outside 0..Size — including negative values — are valid;
they describe positions beyond the widget's edges, which you'll see when converting a
point that isn't over the widget, or doing drag-offset math.
Slate units are resolution-independent layout units, the same idea as CSS pixels or
Android dp: pixels = slate_units × scale, where the
scale accumulates in FGeometry::Scale. At 1× scale they
equal pixels; on a 4K display with a 2× UI scale, 100 Slate units cover 200 pixels.
NativeConstruct, right after AddToViewport, or in a
constructor, GetCachedGeometry() returns a zeroed struct
(Size = (0,0)). Defer geometry reads to NativeTick or guard with
if (Geom.GetLocalSize().X > 0.f).
4.5 Render transforms and paint space
A render transform (SetRenderTransform,
SetRenderTransformAngle,
SetRenderScale) rotates/scales/shears the widget
visually without re-flowing layout. FGeometry tracks
two accumulated transforms, and which one a function uses decides whether render
transforms are "seen":
| Uses the render transform (sees rotation/scale) | Uses the layout transform (ignores it) |
|---|---|
AbsoluteToLocal / LocalToAbsoluteIsUnderLocation (and Slate hit-testing generally)GetRenderBoundingRect(), GetAbsoluteSize()Scalar_* / Vector_* conversions
|
Slot arrangement & desired size (layout never re-flows)GetLayoutBoundingRect()AbsolutePosition / Scale membersGetAccumulatedLayoutTransform() |
So a button rotated 45° is still clickable exactly where you see it, and
AbsoluteToLocal on a click gives correct local
coordinates inside the rotated frame. What does not change is layout: siblings
don't move out of the way, and the layout bounding box stays axis-aligned at the
pre-transform position.
GetTickSpaceGeometry() and GetPaintSpaceGeometry()
differ by root, not by transforms: tick-space geometry is desktop-rooted
(use it with mouse positions and other widgets' geometry), paint-space geometry is
window-rooted and refreshed during paint. For coordinate conversions, use
GetTickSpaceGeometry() / GetCachedGeometry().
5. FGeometry Reference
FGeometry encodes a widget's position, size, and the two
accumulated transforms. Get it from GetCachedGeometry()
(or the geometry parameter of a paint/input callback) and pass it to conversion functions.
| Member / Method | Type | Space | What it gives you |
|---|---|---|---|
| AbsolutePosition | FVector2f | Desktop | Layout-transform translation: the widget's top-left in desktop space (ignores render transforms) |
| Size / GetLocalSize() | FVector2f | Local (Slate units) | Widget's width and height in Slate units |
| Scale | float | — | Accumulated layout scale (includes UMG DPI scale): pixels = slate_units × Scale |
| LocalToAbsolute(local) | FVector2D | Local → Desktop | Via the accumulated render transform — includes render rotation/scale |
| AbsoluteToLocal(abs) | FVector2D | Desktop → Local | Inverse render transform; result may be negative / outside bounds — that's valid |
| GetAbsolutePositionAtCoordinates(uv) | FVector2D | Desktop | Normalised UV → absolute: (0,0)=top-left, (0.5,0.5)=centre, (1,1)=bottom-right |
| GetAbsoluteSize() | FVector2D | Desktop | Size pushed through the render transform (≈ Size × Scale when no render transform) |
| GetLayoutBoundingRect() | FSlateRect | Desktop | AABB via the layout transform — ignores render transforms |
| GetRenderBoundingRect() | FSlateRect | Desktop | AABB enclosing the rendered (possibly rotated) widget — can be larger than layout |
| IsUnderLocation(abs) | bool | Desktop | Point-in-widget test through the render transform — matches what you see |
FGeometry and hold it, but the copy goes
stale the next time the widget is laid out or moved. Fine within a frame; re-fetch each
tick for anything longer.
6. DPI Scale: Who Multiplies What
UWidgetLayoutLibrary::GetViewportScale() is the UMG
DPI scale: the project's DPI curve (Project Settings → User Interface) evaluated at the
current viewport resolution, times the user's application scale. Notably it does
not include the OS's native DPI scale — the game layer manager absorbs that
before UMG sees any coordinates, so UMG code can treat the platform scale as 1.0.
float Scale = UWidgetLayoutLibrary::GetViewportScale(this); // UMG DPI scale
FVector2D PixelSize = UWidgetLayoutLibrary::GetViewportSize(this); // game-screen PIXELS
// The relationships:
// viewport_scaled = game_screen_pixels / Scale
// layout size of a fullscreen Canvas Panel = PixelSize / Scale GetViewportSize() returns the pixel size, not the
scaled size. If you want the size UMG widgets are laid out in (e.g. to centre something with
SetPositionInViewport(…, false)), divide by GetViewportScale().
With a 1× scale, pixel and scaled positions are identical — which is exactly why DPI bugs
ship: everything looks right on a 1080p dev machine and falls apart on a 4K display or a
console UI scale. Test with a non-1 DPI curve value before shipping. The full flag logic
for SetPositionInViewport is in the
mental model callout; the same rule applies to
SetDesiredSizeInViewport (always scaled units, no flag).
7. Multiple Viewports & AddToPlayerScreen
Widgets can be added to the full game viewport or to a per-player screen area. In split-screen these are different regions of the window; even in single-player they have different geometry origins. Pair the add-function with the matching geometry-function:
| Function | Scope | Notes |
|---|---|---|
| AddToViewport(ZOrder) | Full game viewport | Shared across all local players. ZOrder controls draw order. |
| AddToPlayerScreen(ZOrder) | Per-player area | Correct for split-screen HUD. Requires a valid owning player. |
| GetViewportWidgetGeometry(World) | Full viewport geometry | Pair with widgets added via AddToViewport. |
| GetPlayerScreenWidgetGeometry(PC) | Player screen geometry | Pair with widgets added via AddToPlayerScreen. |
AddToPlayerScreen but convert
coordinates through GetViewportWidgetGeometry, every position is shifted by
that sub-rect offset. Likewise, pass bPlayerViewportRelative = true to
ProjectWorldLocationToWidgetPosition for player-screen widgets.
8. Common Gotchas
⚡ There are two "screen" spaces — and they're unrelated
Slate's "screen space" (FPointerEvent::GetScreenSpacePosition,
GetMousePositionOnPlatform) is the OS desktop.
Gameplay "screen space" (GetMousePosition,
ProjectWorldLocationToScreen, the
ScreenTo* conversions) is game-viewport pixels.
Desktop coordinates pair with FGeometry and the
Absolute* functions; game-screen pixels pair with the
ScreenTo* functions and deprojection. Crossing the
streams produces offsets that happen to be zero on a fullscreen primary monitor —
and nowhere else.
⚡ "Absolute" means desktop, not window and not viewport
Cached widget geometry is desktop-rooted (window geometry plus the window's desktop
position). A widget at the viewport's top-left has an
AbsolutePosition equal to the viewport panel's
position on your monitor — in PIE that includes the editor window position and chrome.
Never compare absolute coordinates against viewport sizes or hardcoded positions; convert
through AbsoluteToViewport or a geometry first.
⚡ SetPositionInViewport's default flag divides by DPI — feed it pixels or say false
bRemoveDPIScale defaults to true,
which divides your position by the viewport scale. That's correct for game-screen
pixel input. Passing an already-scaled position (a Viewport Position
output, GetMousePositionOnViewport,
ProjectWorldLocationToWidgetPosition) with the default
divides twice — everything works at 1× DPI, then drifts toward the top-left on
HiDPI displays. Scaled input → false.
⚡ GetCachedGeometry() is zero on the first frame
Geometry is produced by the first tick/paint pass. In
NativeConstruct, immediately after
AddToViewport, or in any constructor context it's a
zeroed struct. Defer geometry reads to NativeTick, or
guard with if (Geom.GetLocalSize().X > 0.f).
⚡ Leave bIncludeWindowPosition alone (false)
On ScreenToWidgetLocal /
ScreenToWidgetAbsolute, setting
bIncludeWindowPosition = true subtracts the
window's desktop position from the result, converting it to window-relative
coordinates. Since geometry conversions expect desktop-absolute values, turning this on
shifts everything by the window position in windowed mode (and by the editor window
position in PIE). It exists for the rare case where you genuinely need window-relative
output. Default is false; keep it.
⚡ Render transforms: conversions and hit tests see them, layout doesn't
AbsoluteToLocal, LocalToAbsolute,
IsUnderLocation, and hit-testing all go through the
accumulated render transform (see Geometry.h)
— a rotated widget converts and clicks exactly where it's drawn. What ignores render transforms is layout: desired size, slot
arrangement, GetLayoutBoundingRect(), and the raw
AbsolutePosition/Scale
members. If you need the visual AABB of a transformed widget, use
GetRenderBoundingRect().
⚡ AddToPlayerScreen and GetViewportWidgetGeometry don't mix
The player screen is a sub-region of the viewport with its own origin. Pair
AddToViewport with
GetViewportWidgetGeometry, and
AddToPlayerScreen with
GetPlayerScreenWidgetGeometry(PC) — mixing them
offsets every conversion by the player's sub-rect in split-screen.
⚡ The deprecated Transform* nodes return inverted results
TransformScalarAbsoluteToLocal,
TransformVectorLocalToAbsolute, etc. were deprecated
in UE 5.6 because they transform in the opposite direction of their names. Use
the replacements — Scalar_AbsoluteToLocal /
Vector_LocalToAbsolute ("Absolute to Local (Scalar)"
etc. in Blueprint) — which are correct, and handy for converting distances and deltas
without picking up a translation offset.