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)
            }}
        />
    )
}

Cleaner Pattern: useVectorContent#

onejs-react ships a useVectorContent hook that handles the ref, callback assignment, and MarkDirtyRepaint() automatically when dependencies change. The same animated circle, rewritten:

import { View, render, useVectorContent } from "onejs-react"
import { useState, useEffect } from "react"
import { Color } from "UnityEngine"
import { Angle, ArcDirection, FillRule } from "UnityEngine/UIElements"

function AnimatedCircle() {
    const [radius, setRadius] = useState(50)

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

    const ref = useVectorContent((mgc) => {
        const p = mgc.painter2D
        p.fillColor = new Color(0, 0.5, 1, 1)
        p.BeginPath()
        p.Arc(
            new CS.UnityEngine.Vector2(100, 100),
            radius,
            Angle.Degrees(0),
            Angle.Degrees(360),
            ArcDirection.Clockwise
        )
        p.Fill(FillRule.NonZero)
    }, [radius])

    return <View ref={ref} style={{ width: 200, height: 200 }} />
}

The draw callback re-runs and the element repaints whenever any value in the deps array changes (same semantics as useEffect).

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#

Painter2D has no built-in transforms or state stack. onejs-react ships a Transform2D helper that provides client-side matrix math, so you can write code that feels like Canvas:

import { Transform2D } from "onejs-react"
import { FillRule } from "UnityEngine/UIElements"

onGenerateVisualContent={(mgc) => {
    const p = mgc.painter2D
    const t = new Transform2D()

    // Center origin and rotate 45 degrees
    t.translate(100, 100)
    t.rotate(Math.PI / 4)

    // Draw a square in transformed space
    const corners = t.points([-40, -40], [40, -40], [40, 40], [-40, 40])
    p.BeginPath()
    p.MoveTo(corners[0])
    for (let i = 1; i < corners.length; i++) p.LineTo(corners[i])
    p.ClosePath()
    p.Fill(FillRule.NonZero)
}}

Transform2D API:

MethodPurpose
translate(x, y)Move origin
rotate(rad)Rotate (radians, clockwise)
scale(x, y?)Scale (uniform if y omitted)
save() / restore()Push/pop the current matrix
reset()Reset to identity
point(x, y)Transform a single point to Vector2
points(...[x, y][])Transform multiple points at once
setTransform(a, b, c, d, e, f)Set the matrix directly
transform(a, b, c, d, e, f)Multiply current matrix by another
save() and restore() use an internal stack, so you can isolate transforms for nested shapes:
const t = new Transform2D()
t.translate(150, 150)

t.save()
    t.rotate(angle)
    // draw outer shape using t.point()/t.points()
t.restore()

t.rotate(-angle * 2)
t.scale(0.5)
// draw inner shape with an independent transform

If you'd rather skip the helper and compute coordinates by hand:

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
    )
}

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