Vector Drawing

OneJS exposes Unity's Painter2D API for GPU-accelerated vector graphics. Any element can render custom vector content via the onGenerateVisualContent prop.

Use cases: Charts, graphs, custom shapes, progress indicators, path animations, game UI overlays.

Basic Usage

import { View, render } from "onejs-react"

function Circle() {
    return (
        <View
            style={{ width: 200, height: 200, backgroundColor: "#333" }}
            onGenerateVisualContent={(mgc) => {
                const p = mgc.painter2D

                // Set fill color (red)
                p.fillColor = new CS.UnityEngine.Color(1, 0, 0, 1)

                // Draw a circle
                p.BeginPath()
                p.Arc(
                    new CS.UnityEngine.Vector2(100, 100), // center
                    80,                                   // radius
                    CS.UnityEngine.UIElements.Angle.Degrees(0),
                    CS.UnityEngine.UIElements.Angle.Degrees(360),
                    CS.UnityEngine.UIElements.ArcDirection.Clockwise
                )
                p.Fill(CS.UnityEngine.UIElements.FillRule.NonZero)
            }}
        />
    )
}

render(<Circle />, __root)

How It Works

The onGenerateVisualContent prop maps directly to Unity's generateVisualContent delegate on VisualElement:

  1. React reconciler assigns your callback to the element's generateVisualContent property
  2. Unity calls your callback during the repaint phase
  3. Your callback receives a MeshGenerationContext with access to painter2D
  4. Drawing commands are batched and rendered on the GPU
No C# code modifications are needed. The existing interop layer handles the delegate assignment.

Path Operations

Painter2D uses a path-based drawing model similar to HTML5 Canvas:

onGenerateVisualContent={(mgc) => {
    const p = mgc.painter2D

    // Start a new path
    p.BeginPath()

    // Move to starting point (no line drawn)
    p.MoveTo(new CS.UnityEngine.Vector2(10, 10))

    // Draw lines
    p.LineTo(new CS.UnityEngine.Vector2(100, 10))
    p.LineTo(new CS.UnityEngine.Vector2(100, 100))

    // Close path back to start
    p.ClosePath()

    // Render the path
    p.strokeColor = new CS.UnityEngine.Color(1, 1, 1, 1)
    p.lineWidth = 2
    p.Stroke()
}}

Path Methods

MethodDescription
BeginPath()Start a new path, clearing any existing path data
MoveTo(point)Move to point without drawing a line
LineTo(point)Draw a line from current position to point
ClosePath()Close the path by drawing a line back to the starting point
Arc(center, radius, startAngle, endAngle, direction)Draw an arc or circle
ArcTo(p1, p2, radius)Draw an arc tangent to two lines
BezierCurveTo(cp1, cp2, end)Draw a cubic bezier curve
QuadraticCurveTo(cp, end)Draw a quadratic bezier curve

Rendering Methods

MethodDescription
Fill(fillRule)Fill the current path using the specified fill rule
Stroke()Stroke (outline) the current path
Fill rules:
  • FillRule.NonZero - Standard fill (most common)
  • FillRule.OddEven - Alternate fill for complex shapes with holes

Styling Properties

onGenerateVisualContent={(mgc) => {
    const p = mgc.painter2D

    // Colors
    p.fillColor = new CS.UnityEngine.Color(0.2, 0.6, 1.0, 1.0)  // RGBA
    p.strokeColor = new CS.UnityEngine.Color(1, 1, 1, 0.8)

    // Line styling
    p.lineWidth = 3
    p.lineCap = CS.UnityEngine.UIElements.LineCap.Round
    p.lineJoin = CS.UnityEngine.UIElements.LineJoin.Round
    p.miterLimit = 10
}}

Properties Reference

PropertyTypeDescription
fillColorColorFill color for Fill()
strokeColorColorStroke color for Stroke()
lineWidthfloatStroke width in pixels
lineCapLineCapLine end style: Butt, Round, Square
lineJoinLineJoinLine corner style: Miter, Round, Bevel
miterLimitfloatLimit for miter joins before beveling

Triggering Repaints

The callback is only invoked when Unity decides the element needs repainting. To trigger a repaint when your drawing state changes, call MarkDirtyRepaint():

import { View, render } from "onejs-react"
import { useState, useEffect, useRef } from "react"

function AnimatedCircle() {
    const ref = useRef<CS.UnityEngine.UIElements.VisualElement>(null)
    const [radius, setRadius] = useState(50)

    // Animate radius
    useEffect(() => {
        const interval = setInterval(() => {
            setRadius(r => 30 + Math.sin(Date.now() / 500) * 20)
        }, 16)
        return () => clearInterval(interval)
    }, [])

    // Trigger repaint when radius changes
    useEffect(() => {
        ref.current?.MarkDirtyRepaint()
    }, [radius])

    return (
        <View
            ref={ref}
            style={{ width: 200, height: 200 }}
            onGenerateVisualContent={(mgc) => {
                const p = mgc.painter2D
                p.fillColor = new CS.UnityEngine.Color(0, 0.5, 1, 1)
                p.BeginPath()
                p.Arc(
                    new CS.UnityEngine.Vector2(100, 100),
                    radius,
                    CS.UnityEngine.UIElements.Angle.Degrees(0),
                    CS.UnityEngine.UIElements.Angle.Degrees(360),
                    CS.UnityEngine.UIElements.ArcDirection.Clockwise
                )
                p.Fill(CS.UnityEngine.UIElements.FillRule.NonZero)
            }}
        />
    )
}

Practical Examples

Pie Chart

function PieChart({ data }: { data: { value: number; color: Color }[] }) {
    const total = data.reduce((sum, d) => sum + d.value, 0)

    return (
        <View
            style={{ width: 200, height: 200 }}
            onGenerateVisualContent={(mgc) => {
                const p = mgc.painter2D
                const center = new CS.UnityEngine.Vector2(100, 100)
                const radius = 80
                const Angle = CS.UnityEngine.UIElements.Angle
                const Clockwise = CS.UnityEngine.UIElements.ArcDirection.Clockwise

                let startAngle = -90 // Start at top

                for (const slice of data) {
                    const sweepAngle = (slice.value / total) * 360
                    const endAngle = startAngle + sweepAngle

                    p.fillColor = slice.color
                    p.BeginPath()
                    p.MoveTo(center)
                    p.Arc(
                        center,
                        radius,
                        Angle.Degrees(startAngle),
                        Angle.Degrees(endAngle),
                        Clockwise
                    )
                    p.ClosePath()
                    p.Fill(CS.UnityEngine.UIElements.FillRule.NonZero)

                    startAngle = endAngle
                }
            }}
        />
    )
}

Progress Ring

function ProgressRing({ progress }: { progress: number }) {
    return (
        <View
            style={{ width: 120, height: 120 }}
            onGenerateVisualContent={(mgc) => {
                const p = mgc.painter2D
                const center = new CS.UnityEngine.Vector2(60, 60)
                const radius = 50
                const thickness = 8
                const Angle = CS.UnityEngine.UIElements.Angle
                const Clockwise = CS.UnityEngine.UIElements.ArcDirection.Clockwise

                // Background ring
                p.strokeColor = new CS.UnityEngine.Color(0.2, 0.2, 0.2, 1)
                p.lineWidth = thickness
                p.lineCap = CS.UnityEngine.UIElements.LineCap.Round
                p.BeginPath()
                p.Arc(center, radius, Angle.Degrees(0), Angle.Degrees(360), Clockwise)
                p.Stroke()

                // Progress arc
                p.strokeColor = new CS.UnityEngine.Color(0.2, 0.8, 0.4, 1)
                p.BeginPath()
                p.Arc(
                    center,
                    radius,
                    Angle.Degrees(-90),
                    Angle.Degrees(-90 + 360 * progress),
                    Clockwise
                )
                p.Stroke()
            }}
        />
    )
}

Custom Shape

function Star({ points = 5, outerRadius = 50, innerRadius = 25 }) {
    return (
        <View
            style={{ width: 120, height: 120 }}
            onGenerateVisualContent={(mgc) => {
                const p = mgc.painter2D
                const cx = 60, cy = 60

                p.fillColor = new CS.UnityEngine.Color(1, 0.8, 0, 1)
                p.BeginPath()

                for (let i = 0; i < points * 2; i++) {
                    const radius = i % 2 === 0 ? outerRadius : innerRadius
                    const angle = (Math.PI / points) * i - Math.PI / 2
                    const x = cx + Math.cos(angle) * radius
                    const y = cy + Math.sin(angle) * radius

                    if (i === 0) {
                        p.MoveTo(new CS.UnityEngine.Vector2(x, y))
                    } else {
                        p.LineTo(new CS.UnityEngine.Vector2(x, y))
                    }
                }

                p.ClosePath()
                p.Fill(CS.UnityEngine.UIElements.FillRule.NonZero)
            }}
        />
    )
}

Comparison with HTML5 Canvas

If you're familiar with HTML5 Canvas, here are the key differences:

FeatureUnity Painter2DHTML5 Canvas
TransformsNot built-intranslate(), rotate(), scale()
State StackNot built-insave(), restore()
GradientsLimited (strokeGradient)createLinearGradient, createRadialGradient
TextVia MeshGenerationContextfillText(), strokeText()
ImagesVia DrawVectorImage()drawImage()
ShadowsNot availableshadowBlur, shadowColor
ClippingVia nested VisualElementsclip()

Handling Transforms

Since Painter2D lacks built-in transforms, calculate transformed points manually:

// Helper for rotation
function rotatePoint(x: number, y: number, cx: number, cy: number, angle: number) {
    const cos = Math.cos(angle)
    const sin = Math.sin(angle)
    const dx = x - cx
    const dy = y - cy
    return new CS.UnityEngine.Vector2(
        cx + dx * cos - dy * sin,
        cy + dx * sin + dy * cos
    )
}

// Usage in drawing
const rotatedPoint = rotatePoint(x, y, centerX, centerY, Math.PI / 4)
p.LineTo(rotatedPoint)

Text Drawing

For text, use MeshGenerationContext directly instead of painter2D:

onGenerateVisualContent={(mgc) => {
    // Vector drawing
    const p = mgc.painter2D
    p.fillColor = new CS.UnityEngine.Color(0.2, 0.2, 0.2, 1)
    // ... draw background

    // Text drawing (separate API)
    // Note: DrawText API depends on Unity version
    // Check Unity documentation for MeshGenerationContext.DrawText
}}

Performance Tips

  1. Minimize path complexity: Complex paths with many points are more expensive
  2. Batch similar draws: Group elements with similar colors/styles
  3. Avoid frequent repaints: Only call MarkDirtyRepaint() when necessary
  4. Use simple shapes when possible: Arcs and lines are faster than complex bezier curves
  5. Consider caching: For static graphics, render once and avoid state changes

API Reference

Types

// Available via CS.UnityEngine namespace
type Vector2 = CS.UnityEngine.Vector2
type Color = CS.UnityEngine.Color

// Available via CS.UnityEngine.UIElements namespace
type Angle = CS.UnityEngine.UIElements.Angle
type ArcDirection = CS.UnityEngine.UIElements.ArcDirection
type LineCap = CS.UnityEngine.UIElements.LineCap
type LineJoin = CS.UnityEngine.UIElements.LineJoin
type FillRule = CS.UnityEngine.UIElements.FillRule
type Painter2D = CS.UnityEngine.UIElements.Painter2D
type MeshGenerationContext = CS.UnityEngine.UIElements.MeshGenerationContext

Creating Values

// Vector2
new CS.UnityEngine.Vector2(x, y)

// Color (RGBA, 0-1 range)
new CS.UnityEngine.Color(r, g, b, a)

// Angle
CS.UnityEngine.UIElements.Angle.Degrees(degrees)
CS.UnityEngine.UIElements.Angle.Radians(radians)

// Enums
CS.UnityEngine.UIElements.ArcDirection.Clockwise
CS.UnityEngine.UIElements.ArcDirection.CounterClockwise
CS.UnityEngine.UIElements.FillRule.NonZero
CS.UnityEngine.UIElements.FillRule.OddEven
CS.UnityEngine.UIElements.LineCap.Butt
CS.UnityEngine.UIElements.LineCap.Round
CS.UnityEngine.UIElements.LineCap.Square
CS.UnityEngine.UIElements.LineJoin.Miter
CS.UnityEngine.UIElements.LineJoin.Round
CS.UnityEngine.UIElements.LineJoin.Bevel