Created
April 12, 2026 11:06
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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