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:
- React reconciler assigns your callback to the element's
generateVisualContentproperty - Unity calls your callback during the repaint phase
- Your callback receives a
MeshGenerationContextwith access topainter2D - Drawing commands are batched and rendered on the GPU
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#
| Method | Description |
|---|---|
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#
| Method | Description |
|---|---|
Fill(fillRule) | Fill the current path using the specified fill rule |
Stroke() | Stroke (outline) the current path |
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#
| Property | Type | Description |
|---|---|---|
fillColor | Color | Fill color for Fill() |
strokeColor | Color | Stroke color for Stroke() |
lineWidth | float | Stroke width in pixels |
lineCap | LineCap | Line end style: Butt, Round, Square |
lineJoin | LineJoin | Line corner style: Miter, Round, Bevel |
miterLimit | float | Limit 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).
Batched Drawing (Performance)#
Every call on mgc.painter2D is its own JS→C# crossing, and so is every new Vector2/new Color you pass in. On native builds (the QuickJS interpreter) that per-op cost dominates custom drawing: a path with N segments costs roughly 2N crossings per repaint.
onejs-react ships a Painter recorder that collapses an entire draw into a single crossing. It records ops into a flat numeric buffer and replays them in C# onto Painter2D. Coordinates and colors are plain numbers, so there are no per-point object constructions either.
The simplest form is batchedVisualContent, which wraps a draw function and flushes automatically:
import { View, render, batchedVisualContent } from "onejs-react"
function Badge() {
return (
<View
style={{ width: 200, height: 200, backgroundColor: "#11111b" }}
onGenerateVisualContent={batchedVisualContent((p) => {
p.fillColor(0.9, 0.2, 0.6, 1) // plain numbers, no new Color()
p.beginPath()
p.arc(100, 100, 70, 0, Math.PI * 2) // angles in radians
p.fill() // default rule = NonZero
})}
/>
)
}For animated drawing, useBatchedVectorContent is the batched counterpart to useVectorContent - same deps semantics, repaints when they change:
import { View, useBatchedVectorContent, Painter } from "onejs-react"
function ProgressRing({ progress }: { progress: number }) {
const ref = useBatchedVectorContent((p) => {
p.strokeColor(0.2, 0.8, 0.4, 1)
p.lineWidth(8)
p.lineCap(Painter.LineCap.Round)
p.beginPath()
p.arc(60, 60, 50, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * progress)
p.stroke()
}, [progress])
return <View ref={ref} style={{ width: 120, height: 120 }} />
}The recorder mirrors Painter2D with chainable, lowercase methods (beginPath, moveTo, lineTo, arc, arcTo, bezierCurveTo, quadraticCurveTo, closePath, fill, stroke, ...). Enum options live as statics on Painter so they don't clash with the CS enum types:
| Option | Values |
|---|---|
Painter.FillRule | NonZero (default), OddEven |
Painter.ArcDirection | Clockwise (default), CounterClockwise |
Painter.LineCap | Butt (default), Round |
Painter.LineJoin | Miter (default), Bevel, Round |
fillColor/strokeColor/lineWidth/lineCap/lineJoin/miterLimit/dash. Not covered: gradients, textures, and text - use the raw mgc.painter2D / MeshGenerationContext API for those.
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:
| Feature | Unity Painter2D | HTML5 Canvas |
|---|---|---|
| Transforms | Not built-in | translate(), rotate(), scale() |
| State Stack | Not built-in | save(), restore() |
| Gradients | Limited (strokeGradient) | createLinearGradient, createRadialGradient |
| Text | Via MeshGenerationContext | fillText(), strokeText() |
| Images | Via DrawVectorImage() | drawImage() |
| Shadows | Not available | shadowBlur, shadowColor |
| Clipping | Via nested VisualElements | clip() |
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:
| Method | Purpose |
|---|---|
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 transformIf 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#
- Use the batched recorder: For anything redrawn frequently, draw through the
Painterrecorder so each repaint is a single JS→C# crossing instead of one per op. - Minimize path complexity: Complex paths with many points are more expensive
- Avoid frequent repaints: Only call
MarkDirtyRepaint()when necessary - Use simple shapes when possible: Arcs and lines are faster than complex bezier curves
- 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.MeshGenerationContextCreating 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