Skip to content

Instantly share code, notes, and snippets.

@adammyhre
Created April 12, 2026 11:06
Show Gist options
  • Select an option

  • Save adammyhre/8e87f1de62f8ae418dd9b092794dbafb to your computer and use it in GitHub Desktop.

Select an option

Save adammyhre/8e87f1de62f8ae418dd9b092794dbafb to your computer and use it in GitHub Desktop.
UI Toolkit radial menu that uses the Painter API and Unity 6.3 gradients, layout, rendering, and input selection.
<ui:UXML xmlns:ui="UnityEngine.UIElements" editor-extension-mode="False">
<Style src="RadialMenuStyles.uss" />
<ui:VisualElement name="radial-root" class="radial-root" picking-mode="Ignore">
<ui:VisualElement name="dimmer" class="radial-dimmer" picking-mode="Position" />
<ui:VisualElement name="wheel" class="radial-wheel" picking-mode="Ignore">
<ui:VisualElement name="items-host" class="radial-items-host" picking-mode="Position" />
</ui:VisualElement>
</ui:VisualElement>
</ui:UXML>
.radial-root {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
.radial-dimmer {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.45);
}
.radial-wheel {
position: absolute;
left: 50%;
top: 50%;
width: 420px;
height: 420px;
margin-left: -210px;
margin-top: -210px;
}
.radial-items-host {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
.radial-menu-wheel {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
.radial-item-icons-host {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
.radial-item-icon {
position: absolute;
-unity-background-scale-mode: scale-to-fit;
}
.radial-center-label {
position: absolute;
left: 50%;
top: 50%;
translate: -50% -50%;
max-width: 160px;
color: rgb(240, 240, 245);
font-size: 15px;
-unity-font-style: bold;
-unity-text-align: middle-center;
white-space: normal;
}
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.InputSystem;
using UnityEngine.UIElements;
[RequireComponent(typeof(UIDocument))]
public class SimpleRadialMenuController : MonoBehaviour {
#region Fields
[Header("UI")]
[SerializeField] PanelSettings panelSettingsOverride;
[Header("Input")]
[Tooltip("Press opens the ring; release confirms the highlighted slice (same idea as hold-to-build radial menus).")]
[SerializeField] Key toggleMenuKey = Key.Tab;
[Header("Layout")]
[SerializeField, Min(40f)] float ringRadiusPixels = 150f;
[SerializeField, Min(0f)] float innerRadiusPixels = 44f;
[SerializeField, Min(0f)] float wheelPadding = 8f;
[SerializeField, Min(0f)] float sectorGapDegrees = 4f;
[SerializeField, Min(16f)] float iconSizePixels = 56f;
[Header("Fill gradients")]
[SerializeField] Color normalFillInner = new Color(0.07f, 0.07f, 0.09f, 0.94f);
[SerializeField] Color normalFillOuter = new Color(0.2f, 0.21f, 0.26f, 0.9f);
[SerializeField] Color highlightFillInner = new Color(0.12f, 0.22f, 0.42f, 0.97f);
[SerializeField] Color highlightFillOuter = new Color(0.42f, 0.62f, 0.98f, 0.88f);
[Header("Stroke gradient")]
[SerializeField] Color edgeColorInner = new Color(0.72f, 0.74f, 0.82f, 0.22f);
[SerializeField] Color edgeColorOuter = new Color(1f, 1f, 1f, 0.9f);
[Header("Items")]
[SerializeField] List<SimpleRadialMenuItem> items = new List<SimpleRadialMenuItem> {
new SimpleRadialMenuItem("a", "A"),
new SimpleRadialMenuItem("b", "B"),
new SimpleRadialMenuItem("c", "C")
};
[Header("Events")]
[SerializeField] UnityEvent onMenuOpened;
[SerializeField] UnityEvent onMenuClosed;
[SerializeField] UnityEvent<int> onSliceSelected;
UIDocument uiDocument;
VisualElement rootElement;
VisualElement itemsHost;
VisualElement dimmer;
SimpleRadialPainterWheel wheel;
VisualElement iconsHost;
Label centerLabel;
bool menuOpen;
int highlightedIndex = -1;
Coroutine rebuildRoutine;
#endregion
protected void Awake() {
uiDocument = GetComponent<UIDocument>();
if (panelSettingsOverride != null) uiDocument.panelSettings = panelSettingsOverride;
CacheVisualReferences();
}
void BuildRingLayout() {
ClearRing();
if (itemsHost == null || items.Count == 0) return;
float hostW = itemsHost.resolvedStyle.width;
float hostH = itemsHost.resolvedStyle.height;
if (hostW <= 1f || hostH <= 1f) return;
wheel = new SimpleRadialPainterWheel();
wheel.Configure(items.Count, ringRadiusPixels, innerRadiusPixels, wheelPadding, sectorGapDegrees, highlightedIndex);
wheel.SetColors(normalFillInner, normalFillOuter, highlightFillInner, highlightFillOuter, edgeColorInner, edgeColorOuter);
wheel.RegisterCallback<PointerMoveEvent>(OnWheelPointerMove);
wheel.RegisterCallback<PointerDownEvent>(OnWheelPointerDown);
itemsHost.Add(wheel);
iconsHost = new VisualElement();
iconsHost.AddToClassList("radial-item-icons-host");
iconsHost.pickingMode = PickingMode.Ignore;
itemsHost.Add(iconsHost);
((float x, float y) center, (float outer, float inner) radii) radial = SimpleRadialWheelLayout.GetRadii(hostW, hostH, ringRadiusPixels, innerRadiusPixels, wheelPadding);
float halfIcon = iconSizePixels * 0.5f;
for (int i = 0; i < items.Count; i++) {
Texture2D tex = items[i].Icon;
if (tex == null) continue;
(float ix, float iy) = SimpleRadialWheelLayout.GetIconCenter(i, items.Count, radial.center.x, radial.center.y, radial.radii.outer, radial.radii.inner, sectorGapDegrees);
VisualElement iconVe = new VisualElement();
iconVe.AddToClassList("radial-item-icon");
iconVe.style.position = Position.Absolute;
iconVe.style.width = iconSizePixels;
iconVe.style.height = iconSizePixels;
iconVe.style.left = ix - halfIcon;
iconVe.style.top = iy - halfIcon;
iconVe.style.backgroundImage = new StyleBackground(tex);
iconsHost.Add(iconVe);
}
centerLabel = new Label();
centerLabel.AddToClassList("radial-center-label");
centerLabel.pickingMode = PickingMode.Ignore;
itemsHost.Add(centerLabel);
UpdateCenterLabel();
}
void ClearRing() {
if (wheel != null) {
wheel.UnregisterCallback<PointerMoveEvent>(OnWheelPointerMove);
wheel.UnregisterCallback<PointerDownEvent>(OnWheelPointerDown);
}
wheel = null;
iconsHost = null;
centerLabel = null;
itemsHost?.Clear();
}
void Update() {
Keyboard kb = Keyboard.current;
if (kb == null) return;
if (kb[toggleMenuKey].wasPressedThisFrame) {
OpenMenu();
}
if (kb[toggleMenuKey].wasReleasedThisFrame) {
if (!menuOpen) return;
if (highlightedIndex >= 0 && highlightedIndex < items.Count) SelectItemAt(highlightedIndex);
else CloseMenu();
}
}
void OnDisable() => StopRebuildRoutine();
public void ToggleMenu() {
if (menuOpen) CloseMenu();
else OpenMenu();
}
public void OpenMenu() {
if (menuOpen) return;
menuOpen = true;
SetMenuVisible(true);
highlightedIndex = items.Count > 0 ? 0 : -1;
onMenuOpened.Invoke();
StopRebuildRoutine();
rebuildRoutine = StartCoroutine(RebuildAfterLayout());
}
public void CloseMenu() {
if (!menuOpen) return;
menuOpen = false;
StopRebuildRoutine();
ClearRing();
SetMenuVisible(false);
highlightedIndex = -1;
onMenuClosed.Invoke();
}
IEnumerator RebuildAfterLayout() {
yield return null;
float w = itemsHost != null ? itemsHost.resolvedStyle.width : 0f;
if (w <= 1f) yield return null;
BuildRingLayout();
if (wheel != null) wheel.SetHighlight(highlightedIndex);
UpdateCenterLabel();
rebuildRoutine = null;
}
void StopRebuildRoutine() {
if (rebuildRoutine == null) return;
StopCoroutine(rebuildRoutine);
rebuildRoutine = null;
}
void SetMenuVisible(bool visible) {
if (rootElement == null) return;
rootElement.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None;
}
void OnWheelPointerMove(PointerMoveEvent evt) => TryActOnPick(evt.localPosition, SetHighlightedIndex);
void OnWheelPointerDown(PointerDownEvent evt) => TryActOnPick(evt.localPosition, SelectItemAt);
void SelectItemAt(int index) {
if (index < 0 || index >= items.Count) return;
SimpleRadialMenuItem it = items[index];
Debug.Log($"[SimpleRadialMenu] Selected slice {index}: id=\"{it.Id}\" label=\"{it.Label}\"");
onSliceSelected.Invoke(index);
CloseMenu();
}
void SetHighlightedIndex(int index) {
if (highlightedIndex == index) return;
highlightedIndex = index;
if (wheel != null) wheel.SetHighlight(highlightedIndex);
UpdateCenterLabel();
}
void TryActOnPick(Vector2 localPosition, Action<int> onHit) {
if (wheel == null) return;
int i = wheel.PickSegment(localPosition);
if (i >= 0) onHit(i);
}
void UpdateCenterLabel() {
if (centerLabel == null) return;
if (highlightedIndex >= 0 && highlightedIndex < items.Count) {
SimpleRadialMenuItem it = items[highlightedIndex];
centerLabel.text = string.IsNullOrEmpty(it.Label) ? it.Id : it.Label;
} else centerLabel.text = string.Empty;
}
void CacheVisualReferences() {
rootElement = uiDocument.rootVisualElement.Q<VisualElement>("radial-root");
if (rootElement == null) {
Debug.LogError($"{nameof(SimpleRadialMenuController)}: UXML needs element named 'radial-root'.", this);
return;
}
itemsHost = rootElement.Q<VisualElement>("items-host");
dimmer = rootElement.Q<VisualElement>("dimmer");
}
}
using System;
using UnityEngine;
[Serializable]
public struct SimpleRadialMenuItem {
[SerializeField] string id;
[SerializeField] string label;
[SerializeField] Texture2D icon;
public string Id => id;
public string Label => label;
public Texture2D Icon => icon;
public SimpleRadialMenuItem(string itemId, string itemLabel, Texture2D itemIcon = null) {
id = itemId;
label = itemLabel;
icon = itemIcon;
}
}
using UnityEngine;
using UnityEngine.UIElements;
public class SimpleRadialPainterWheel : VisualElement {
#region Fields
int segmentCount = 1;
int highlightedIndex = -1;
float outerRadiusPixels = 150f;
float innerRadiusPixels = 44f;
float wheelPadding = 8f;
float sectorGapDegrees;
Color normalFillInner;
Color normalFillOuter;
Color highlightFillInner;
Color highlightFillOuter;
Color edgeInner;
Color edgeOuter;
readonly Gradient runtimeStrokeGradient = new Gradient();
#endregion
public SimpleRadialPainterWheel() {
AddToClassList("radial-menu-wheel");
pickingMode = PickingMode.Position;
generateVisualContent += OnGenerateVisualContent;
}
public int PickSegment(Vector2 localPosition) {
float w = contentRect.width;
float h = contentRect.height;
((float x, float y) center, (float outer, float inner) radii) radial = SimpleRadialWheelLayout.GetRadii(w, h, outerRadiusPixels, innerRadiusPixels, wheelPadding);
float dx = localPosition.x - radial.center.x;
float dy = localPosition.y - radial.center.y;
float dist = Mathf.Sqrt(dx * dx + dy * dy);
if (dist < radial.radii.inner || dist > radial.radii.outer) return -1;
int n = segmentCount;
float angleDeg = Mathf.Atan2(dy, dx) * Mathf.Rad2Deg;
float shifted = angleDeg + 90f;
if (shifted < 0f) shifted += 360f;
if (shifted >= 360f) shifted -= 360f;
float gap = SimpleRadialWheelLayout.ClampSectorGapDegrees(n, sectorGapDegrees);
return SimpleRadialWheelLayout.PickSegmentFromShiftedDegrees(shifted, n, gap);
}
public void SetColors(Color normalFillInner, Color normalFillOuter, Color highlightFillInner, Color highlightFillOuter, Color edgeInner, Color edgeOuter) {
this.normalFillInner = normalFillInner;
this.normalFillOuter = normalFillOuter;
this.highlightFillInner = highlightFillInner;
this.highlightFillOuter = highlightFillOuter;
this.edgeInner = edgeInner;
this.edgeOuter = edgeOuter;
MarkDirtyRepaint();
}
public void SetHighlight(int index) {
highlightedIndex = index;
MarkDirtyRepaint();
}
public void Configure(int segmentCount, float outerRadiusPixels, float innerRadiusPixels, float wheelPadding, float sectorGapDegrees, int highlightedIndex) {
this.segmentCount = Mathf.Max(1, segmentCount);
this.outerRadiusPixels = Mathf.Max(8f, outerRadiusPixels);
this.innerRadiusPixels = Mathf.Max(0f, innerRadiusPixels);
this.wheelPadding = Mathf.Max(0f, wheelPadding);
this.sectorGapDegrees = SimpleRadialWheelLayout.ClampSectorGapDegrees(this.segmentCount, sectorGapDegrees);
this.highlightedIndex = highlightedIndex;
MarkDirtyRepaint();
}
static void StrokeRadialDivider(Painter2D painter, float x, float y, Vector2 center, float inner, float outer, float angleDeg) {
float a = angleDeg * Mathf.Deg2Rad;
painter.BeginPath();
if (inner <= 1f) painter.MoveTo(center);
else painter.MoveTo(new Vector2(x + Mathf.Cos(a) * inner, y + Mathf.Sin(a) * inner));
painter.LineTo(new Vector2(x + Mathf.Cos(a) * outer, y + Mathf.Sin(a) * outer));
painter.Stroke();
}
void OnGenerateVisualContent(MeshGenerationContext context) {
Painter2D painter = context.painter2D;
float w = contentRect.width;
float h = contentRect.height;
((float x, float y) center, (float outer, float inner) radii) radial = SimpleRadialWheelLayout.GetRadii(w, h, outerRadiusPixels, innerRadiusPixels, wheelPadding);
(float x, float y) = radial.center;
(float outer, float inner) = radial.radii;
Vector2 center = new Vector2(x, y);
int n = segmentCount;
if (n <= 0) return;
float gap = SimpleRadialWheelLayout.ClampSectorGapDegrees(n, sectorGapDegrees);
runtimeStrokeGradient.SetKeys(
new[] { new GradientColorKey(edgeInner, 0f), new GradientColorKey(edgeOuter, 1f) },
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) }
);
for (int i = 0; i < n; i++) {
(float start, float end, float midDeg) = SimpleRadialWheelLayout.GetSectorAnglesDeg(i, n, gap);
float mid = midDeg * Mathf.Deg2Rad;
Vector2 dir = new Vector2(Mathf.Cos(mid), Mathf.Sin(mid));
Vector2 innerPt = inner <= 1f ? center : center + dir * inner;
Vector2 outerPt = center + dir * outer;
bool isHi = i == highlightedIndex;
Color fillInner = isHi ? highlightFillInner : normalFillInner;
Color fillOuter = isHi ? highlightFillOuter : normalFillOuter;
painter.fillGradient = FillGradient.MakeLinearGradient(fillInner, fillOuter, innerPt, outerPt, AddressMode.Clamp);
painter.BeginPath();
if (inner <= 1f) {
painter.MoveTo(center);
float rs = start * Mathf.Deg2Rad;
painter.LineTo(new Vector2(x + Mathf.Cos(rs) * outer, y + Mathf.Sin(rs) * outer));
painter.Arc(center, outer, Angle.Degrees(start), Angle.Degrees(end), ArcDirection.Clockwise);
painter.LineTo(center);
} else {
float rs = start * Mathf.Deg2Rad;
float re = end * Mathf.Deg2Rad;
painter.MoveTo(new Vector2(x + Mathf.Cos(rs) * outer, y + Mathf.Sin(rs) * outer));
painter.Arc(center, outer, Angle.Degrees(start), Angle.Degrees(end), ArcDirection.Clockwise);
painter.LineTo(new Vector2(x + Mathf.Cos(re) * inner, y + Mathf.Sin(re) * inner));
painter.Arc(center, inner, Angle.Degrees(end), Angle.Degrees(start), ArcDirection.CounterClockwise);
painter.ClosePath();
}
painter.Fill();
}
painter.fillGradient = default;
painter.lineWidth = 2f;
painter.strokeGradient = runtimeStrokeGradient;
for (int i = 0; i < n; i++) {
(float start, float end, _) = SimpleRadialWheelLayout.GetSectorAnglesDeg(i, n, gap);
painter.BeginPath();
painter.Arc(center, outer, Angle.Degrees(start), Angle.Degrees(end), ArcDirection.Clockwise);
painter.Stroke();
StrokeRadialDivider(painter, x, y, center, inner, outer, start);
if (gap > 1e-4f) StrokeRadialDivider(painter, x, y, center, inner, outer, end);
}
painter.strokeGradient = null;
painter.strokeColor = edgeOuter;
}
}
using UnityEngine;
public static class SimpleRadialWheelLayout {
public static ((float x, float y) center, (float outer, float inner) radii)
GetRadii(float hostW, float hostH, float outerRadiusPixels, float innerRadiusPixels, float wheelPadding)
{
if (hostW <= 1f || hostH <= 1f) return ((0f, 0f), (0f, 0f));
float x = hostW * 0.5f;
float y = hostH * 0.5f;
float maxR = Mathf.Min(hostW, hostH) * 0.5f - wheelPadding;
float outer = Mathf.Min(outerRadiusPixels, maxR);
float inner = Mathf.Min(innerRadiusPixels, outer - 4f);
return ((x, y), (outer, inner));
}
public static float ClampSectorGapDegrees(int segmentCount, float gapDegrees) {
int n = Mathf.Max(1, segmentCount);
const float minSweep = 1f;
float maxTotalGap = Mathf.Max(0f, 360f - n * minSweep);
float maxGapPerBoundary = maxTotalGap / n;
return Mathf.Clamp(Mathf.Max(0f, gapDegrees), 0f, maxGapPerBoundary);
}
public static (float startDeg, float endDeg, float midDeg) GetSectorAnglesDeg(int index, int segmentCount, float gapDegrees) {
int n = Mathf.Max(1, segmentCount);
gapDegrees = ClampSectorGapDegrees(n, gapDegrees);
float sweep = (360f - n * gapDegrees) / n;
float startDeg = -90f + gapDegrees * 0.5f + index * (sweep + gapDegrees);
float endDeg = startDeg + sweep;
float midDeg = startDeg + sweep * 0.5f;
return (startDeg, endDeg, midDeg);
}
public static int PickSegmentFromShiftedDegrees(float shiftedDegrees, int segmentCount, float gapDegrees) {
int n = Mathf.Max(1, segmentCount);
gapDegrees = ClampSectorGapDegrees(n, gapDegrees);
float sweep = (360f - n * gapDegrees) / n;
for (int i = 0; i < n; i++) {
float segStart = gapDegrees * 0.5f + i * (sweep + gapDegrees);
float segEnd = segStart + sweep;
bool inSeg = shiftedDegrees >= segStart && (i < n - 1 ? shiftedDegrees < segEnd : shiftedDegrees <= segEnd + 0.001f);
if (inSeg) return i;
}
return -1;
}
public static (float x, float y) GetIconCenter(int segmentIndex, int segmentCount, float centerX, float centerY, float outer, float inner, float sectorGapDegrees) {
(float startDeg, float endDeg, float midDeg) angles = GetSectorAnglesDeg(segmentIndex, segmentCount, sectorGapDegrees);
float rad = angles.midDeg * Mathf.Deg2Rad;
float midR = inner <= 1f ? outer * 0.65f : (inner + outer) * 0.5f;
return (centerX + Mathf.Cos(rad) * midR, centerY + Mathf.Sin(rad) * midR);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment