Procedural Geometry
Before this section, complete:
- Core Types — Path, Paint, Renderer basics
- Drawing API — Transforms and clipping
- Functions — Math functions (sin, cos, floor)
Rive Context: Drawing Shapes with Code
Procedural geometry means creating shapes dynamically through code rather than importing static assets. In Rive Node Scripts, you build paths point-by-point using mathematical functions, enabling:
- Shapes that respond to data or user Input
- Animated geometry (pulsing circles, flowing waves)
- Particle systems and effects
- Visualizations driven by external data
The pattern: Build or update paths in advance, draw them in draw.
Path Building Basics
| Method | Description |
|---|---|
path:moveTo(vec) | Move pen to position (start a new subpath) |
path:lineTo(vec) | Draw straight line to position |
path:cubicTo(c1, c2, end) | Draw cubic bezier curve |
path:close() | Connect back to subpath start |
path:reset() | Clear all path data |
Exercise 1: Procedural Polygon ⭐⭐
Premise
Regular polygons are built by distributing points evenly around a circle. The angle offset (-π/2) makes the first vertex point upward.
By the end of this exercise, you will be able to Complete the buildPolygon function to generate a regular polygon with N sides using trigonometry.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_Exercise1ProceduralPolygon
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
-- Generate a regular polygon with N sides
export type Polygon = {
sides: Input<number>,
radius: Input<number>,
path: Path,
fill: Paint,
stroke: Paint,
}
local function buildPolygon(self: Polygon)
local n = math.floor(self.sides)
if n < 3 then n = 3 end
local r = self.radius
self.path:reset()
-- TODO 1: Loop from i = 0 to n - 1
-- TODO 2: Calculate angle = (i / n) * math.pi * 2 - math.pi / 2
-- TODO 3: Calculate x = math.cos(angle) * r, y = math.sin(angle) * r
-- TODO 4: If i == 0, moveTo; otherwise lineTo
self.path:close()
print(`Building {n}-sided polygon...`)
print("Polygon built!")
print("ANSWER: polygon")
end
function init(self: Polygon): boolean
buildPolygon(self)
return true
end
function update(self: Polygon)
buildPolygon(self)
end
function draw(self: Polygon, renderer: Renderer)
renderer:drawPath(self.path, self.fill)
renderer:drawPath(self.path, self.stroke)
end
return function(): Node<Polygon>
return {
init = init,
update = update,
draw = draw,
sides = 6,
radius = 80,
path = late(),
fill = Paint.with({ style = "fill", color = Color.rgb(100, 180, 255) }),
stroke = Paint.with({ style = "stroke", thickness = 3, color = Color.rgb(40, 80, 140) }),
}
end
Assignment
Complete these tasks:
- Complete the buildPolygon function to generate a regular polygon with N sides using trigonometry.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: polygon
Verify Your Answer
Checklist
- All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Key points:
- Calculate vertex positions using trigonometry
update()rebuilds when inputs change- Start angle offset (
- math.pi / 2) points the first vertex upward
Exercise 2: Animated Star ⭐⭐
Premise
Stars alternate between outer and inner radius. Animating the innerRatio creates a pulsing effect as the path rebuilds each frame.
By the end of this exercise, you will be able to Complete the advance function to animate the star's inner radius and rebuild the path each frame.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_Exercise2AnimatedStar
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
-- Animated star with pulsing inner radius
export type AnimatedStar = {
points: Input<number>,
outerRadius: Input<number>,
path: Path,
paint: Paint,
time: number,
innerRatio: number,
}
function init(self: AnimatedStar): boolean
self.time = 0
self.innerRatio = 0.5
print("Star initialized")
return true
end
function advance(self: AnimatedStar, seconds: number): boolean
-- TODO 1: Add seconds to time
self.time += seconds
-- TODO 2: Animate innerRatio = 0.3 + math.sin(self.time * 2) * 0.2
local n = math.floor(self.points)
if n < 3 then n = 3 end
local outer = self.outerRadius
local inner = outer * self.innerRatio
self.path:reset()
-- TODO 3: Loop from i = 0 to n * 2 - 1
-- TODO 4: Calculate angle, use outer radius for even i, inner for odd
-- TODO 5: moveTo for first point, lineTo for rest
self.path:close()
if math.floor(self.time) == 1 and math.floor(self.time - seconds) < 1 then
print("Star pulsing...")
print("ANSWER: star")
end
return true
end
function draw(self: AnimatedStar, renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end
return function(): Node<AnimatedStar>
return {
init = init,
advance = advance,
draw = draw,
points = 5,
outerRadius = 100,
path = late(),
paint = Paint.with({ style = "fill", color = Color.rgb(255, 200, 60) }),
time = 0,
innerRatio = 0.5,
}
end
Assignment
Complete these tasks:
- Complete the advance function to animate the star's inner radius and rebuild the path each frame.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: star
Verify Your Answer
Checklist
- All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Key points:
- Alternate between outer and inner radius for star points
- Animate
innerRatioinadvancefor pulsing effect - Path rebuilt every frame when animating
Exercise 3: Sine Wave Path ⭐⭐
Premise
Adding time to the sine phase creates animation. More segments means smoother curves. Use stroke style for open paths.
By the end of this exercise, you will be able to Complete the advance function to build an animated sine wave path.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_Exercise3SineWavePath
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
-- Animated sine wave path
export type SineWave = {
amplitude: Input<number>,
frequency: Input<number>,
segments: Input<number>,
path: Path,
paint: Paint,
time: number,
}
function init(self: SineWave): boolean
self.time = 0
print("Sine wave initialized")
return true
end
function advance(self: SineWave, seconds: number): boolean
self.time += seconds
local amp = self.amplitude
local freq = self.frequency
local segs = math.floor(self.segments)
local width = 300
self.path:reset()
-- TODO: Loop from i = 0 to segs
-- Calculate t = i / segs
-- Calculate x = -width/2 + t * width
-- Calculate y = math.sin((t * freq + self.time) * math.pi * 2) * amp
-- moveTo for first, lineTo for rest
if math.floor(self.time) == 1 and math.floor(self.time - seconds) < 1 then
print("Wave flowing...")
print("ANSWER: wave")
end
return true
end
function draw(self: SineWave, renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end
return function(): Node<SineWave>
return {
init = init,
advance = advance,
draw = draw,
amplitude = 40,
frequency = 2,
segments = 50,
path = late(),
paint = Paint.with({ style = "stroke", thickness = 4, color = Color.rgb(120, 220, 180), cap = "round" }),
time = 0,
}
end
Assignment
Complete these tasks:
- Complete the advance function to build an animated sine wave path.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: wave
Verify Your Answer
Checklist
- All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Key points:
- Add
self.timeto the phase for animation - More segments = smoother curve
- Use stroke style for line-based paths
Exercise 4: Bezier Curves ⭐⭐
Premise
cubicTo creates smooth curves using two control points. Animating the control points creates elegant flowing motion.
By the end of this exercise, you will be able to Complete the advance function to create an animated cubic bezier curve.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_Exercise4BezierCurves
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
-- Animated cubic bezier curve
export type BezierDemo = {
path: Path,
paint: Paint,
time: number,
}
function init(self: BezierDemo): boolean
self.time = 0
print("Bezier demo initialized")
return true
end
function advance(self: BezierDemo, seconds: number): boolean
self.time += seconds
-- TODO 1: Calculate offset = math.sin(self.time * 1.5) * 30
local offset = 0 -- Replace with animated value
-- TODO 2: Reset path and build curve
-- moveTo(-120, 0)
-- cubicTo(Vector(-60, -80 + offset), Vector(60, 80 - offset), Vector(120, 0))
self.path:reset()
if math.floor(self.time) == 1 and math.floor(self.time - seconds) < 1 then
print("Curve flowing...")
print("ANSWER: bezier")
end
return true
end
function draw(self: BezierDemo, renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end
return function(): Node<BezierDemo>
return {
init = init,
advance = advance,
draw = draw,
path = late(),
paint = Paint.with({ style = "stroke", thickness = 5, color = Color.rgb(255, 100, 150), cap = "round" }),
time = 0,
}
end
Assignment
Complete these tasks:
- Complete the advance function to create an animated cubic bezier curve.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: bezier
Verify Your Answer
Checklist
- All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Key points:
cubicTo(control1, control2, end)creates smooth curves- Animate control points for flowing motion
- Bezier curves are resolution-independent
Exercise 5: Simple Particle System ⭐⭐⭐
Premise
Particle systems spawn objects, update their physics, and remove dead ones. Filter arrays to efficiently remove particles.
By the end of this exercise, you will be able to Complete the advance function to spawn particles, update physics, and remove dead particles.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_Exercise5SimpleParticleSystem
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
-- Basic particle system with spawning and aging
type Particle = {
x: number, y: number,
vx: number, vy: number,
life: number, maxLife: number,
}
export type ParticleSystem = {
particles: { Particle },
spawnRate: Input<number>,
path: Path,
paint: Paint,
spawnTimer: number,
}
local function spawnParticle(): Particle
local angle = math.random() * math.pi * 2
local speed = 40 + math.random() * 60
return {
x = 0, y = 0,
vx = math.cos(angle) * speed,
vy = math.sin(angle) * speed,
life = 0,
maxLife = 1.0 + math.random() * 0.5,
}
end
function init(self: ParticleSystem): boolean
self.particles = {}
self.spawnTimer = 0
print("Particle system started")
return true
end
function advance(self: ParticleSystem, seconds: number): boolean
self.spawnTimer += seconds
local spawnInterval = 1 / self.spawnRate
-- TODO 1: Spawn particles when timer exceeds interval
-- while self.spawnTimer >= spawnInterval do
-- self.spawnTimer -= spawnInterval
-- if #self.particles < 100 then
-- table.insert(self.particles, spawnParticle())
-- end
-- end
-- TODO 2: Update particles and filter out dead ones
-- For each particle: p.life += seconds
-- If still alive: update position (x += vx*s, y += vy*s), add gravity (vy += 50*s)
-- Keep only alive particles in a new array
if #self.particles > 5 then
print("Particles spawning...")
print("ANSWER: particles")
end
return true
end
function draw(self: ParticleSystem, renderer: Renderer)
for _, p in ipairs(self.particles) do
local alpha = 1 - (p.life / p.maxLife)
local size = 4 * alpha
self.path:reset()
self.path:moveTo(Vector.xy(p.x - size, p.y - size))
self.path:lineTo(Vector.xy(p.x + size, p.y - size))
self.path:lineTo(Vector.xy(p.x + size, p.y + size))
self.path:lineTo(Vector.xy(p.x - size, p.y + size))
self.path:close()
self.paint.color = Color.rgba(255, 180, 80, math.floor(alpha * 255))
renderer:drawPath(self.path, self.paint)
end
end
return function(): Node<ParticleSystem>
return {
init = init,
advance = advance,
draw = draw,
particles = {},
spawnRate = 20,
path = late(),
paint = Paint.with({ style = "fill", color = Color.rgb(255, 180, 80) }),
spawnTimer = 0,
}
end
Assignment
Complete these tasks:
- Complete the advance function to spawn particles, update physics, and remove dead particles.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: particles
Verify Your Answer
Checklist
- All TODOs are replaced with working code
- Console output includes the
ANSWER:line
This example modifies the path in draw for simplicity. For production particle systems, consider creating a pool of paths or using instanced rendering techniques.
Key points:
- Store particle data in a table
- Update physics in
advance, render indraw - Remove dead particles by filtering the array
- Fade alpha based on remaining life
Exercise 6: Circular Progress Ring ⭐⭐
Premise
Progress rings are built as arcs. The foreground arc length is based on progress (0-1), starting from -π/2 (top) and sweeping clockwise.
By the end of this exercise, you will be able to Complete the buildArc helper function to generate arc paths based on start and end angles.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise6_Exercise6CircularProgressRing
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
-- Circular progress ring with background and foreground arcs
export type ProgressRing = {
progress: Input<number>,
radius: Input<number>,
thickness: Input<number>,
bgPath: Path,
fgPath: Path,
bgPaint: Paint,
fgPaint: Paint,
}
local function buildArc(path: Path, radius: number, startAngle: number, endAngle: number, segments: number)
path:reset()
-- TODO 1: Loop from i = 0 to segments
-- TODO 2: Calculate t = i / segments
-- TODO 3: Calculate angle = startAngle + (endAngle - startAngle) * t
-- TODO 4: Calculate x = cos(angle) * radius, y = sin(angle) * radius
-- TODO 5: moveTo for first point, lineTo for rest
end
function init(self: ProgressRing): boolean
local r = self.radius
buildArc(self.bgPath, r, 0, math.pi * 2, 64)
print("Progress ring initialized")
print(`Progress at {math.floor(self.progress * 100)}%`)
print("ANSWER: ring")
return true
end
function update(self: ProgressRing)
local r = self.radius
local prog = math.clamp(self.progress, 0, 1)
local startAngle = -math.pi / 2
local endAngle = startAngle + prog * math.pi * 2
buildArc(self.bgPath, r, 0, math.pi * 2, 64)
buildArc(self.fgPath, r, startAngle, endAngle, math.floor(64 * prog) + 1)
self.bgPaint.thickness = self.thickness
self.fgPaint.thickness = self.thickness
end
function draw(self: ProgressRing, renderer: Renderer)
renderer:drawPath(self.bgPath, self.bgPaint)
renderer:drawPath(self.fgPath, self.fgPaint)
end
return function(): Node<ProgressRing>
return {
init = init,
update = update,
draw = draw,
progress = 0.7,
radius = 60,
thickness = 8,
bgPath = late(),
fgPath = late(),
bgPaint = Paint.with({ style = "stroke", thickness = 8, color = Color.rgba(255, 255, 255, 40), cap = "round" }),
fgPaint = Paint.with({ style = "stroke", thickness = 8, color = Color.rgb(100, 200, 255), cap = "round" }),
}
end
Assignment
Complete these tasks:
- Complete the buildArc helper function to generate arc paths based on start and end angles.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: ring
Verify Your Answer
Checklist
- All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Key points:
- Build arcs by calculating points around a circle
- Use stroke style with
cap = "round"for smooth ends - Segment count affects smoothness vs performance
Exercise 7: Data-Driven Bar Chart ⭐⭐⭐
Premise
Bar charts interpolate toward target values for smooth animation. Each bar's x position is calculated from its index, and colors are generated procedurally.
By the end of this exercise, you will be able to Complete the advance function to animate bar values toward their targets using lerp interpolation.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise7_Exercise7DataDrivenBarChart
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
-- Animated bar chart with data-driven values
export type BarChart = {
values: { number },
targetValues: { number },
barWidth: Input<number>,
maxHeight: Input<number>,
path: Path,
paint: Paint,
animating: boolean,
}
function init(self: BarChart): boolean
self.values = { 0, 0, 0, 0, 0 }
self.targetValues = { 60, 80, 45, 90, 70 }
self.animating = true
print(`Bar chart initialized with {#self.targetValues} bars`)
return true
end
function advance(self: BarChart, seconds: number): boolean
local speed = 3
-- TODO 1: Loop through targetValues with ipairs
-- TODO 2: Get current value: self.values[i] or 0
-- TODO 3: Lerp toward target: current + (target - current) * min(1, seconds * speed)
-- TODO 4: Store the new value back in self.values[i]
-- Check if animation is mostly complete (values close to targets)
if self.animating then
local done = true
for i, target in ipairs(self.targetValues) do
if math.abs((self.values[i] or 0) - target) > 1 then
done = false
break
end
end
if done then
self.animating = false
print("Bars animating...")
print("ANSWER: bars")
end
end
return true
end
function draw(self: BarChart, renderer: Renderer)
local barW = self.barWidth
local maxH = self.maxHeight
local gap = 10
local totalWidth = #self.values * (barW + gap) - gap
local startX = -totalWidth / 2
for i, val in ipairs(self.values) do
local height = (val / 100) * maxH
local x = startX + (i - 1) * (barW + gap)
local y = maxH / 2
self.path:reset()
self.path:moveTo(Vector.xy(x, y))
self.path:lineTo(Vector.xy(x + barW, y))
self.path:lineTo(Vector.xy(x + barW, y - height))
self.path:lineTo(Vector.xy(x, y - height))
self.path:close()
-- Generate rainbow colors from index
local hue = (i - 1) / #self.values
local r = math.floor(255 * (0.5 + 0.5 * math.sin(hue * math.pi * 2)))
local g = math.floor(255 * (0.5 + 0.5 * math.sin(hue * math.pi * 2 + 2)))
local b = math.floor(255 * (0.5 + 0.5 * math.sin(hue * math.pi * 2 + 4)))
self.paint.color = Color.rgb(r, g, b)
renderer:drawPath(self.path, self.paint)
end
end
return function(): Node<BarChart>
return {
init = init,
advance = advance,
draw = draw,
values = {},
targetValues = {},
barWidth = 30,
maxHeight = 120,
path = late(),
paint = Paint.with({ style = "fill", color = Color.rgb(100, 180, 255) }),
animating = true,
}
end
Assignment
Complete these tasks:
- Complete the advance function to animate bar values toward their targets using lerp interpolation.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: bars
Verify Your Answer
Checklist
- All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Key points:
- Animate values toward targets for smooth transitions
- Calculate positions based on array index
- Generate colors procedurally from index
Path Winding and Fill Rules
When you build multiple contours into one Path, fill behavior follows an even-odd rule. Points with an odd number of contour crossings are filled. Points with an even number are unfilled.
If contour winding direction flips between clockwise (CW) and counter-clockwise (CCW), overlapping regions can cancel out and appear invisible.
Why Loops Can Break Winding
In radial loops (spikes, rays, bursts), the perpendicular offset (-sin(angle), cos(angle)) changes sign across quadrants. If you always emit vertices in the same local order, some contours end up CW and others CCW.
-- This can break: winding may flip across the loop
for i = 0, 9 do
local angle = (i / 10) * math.pi * 2
local ax = math.cos(angle)
local ay = math.sin(angle)
local px = -ay
local py = ax
path:moveTo(Vector.xy(cx + ax * 80 + px * 30, cy + ay * 80 + py * 30))
path:lineTo(Vector.xy(cx + ax * 350, cy + ay * 350))
path:lineTo(Vector.xy(cx + ax * 80 - px * 30, cy + ay * 80 - py * 30))
path:close()
end
Robust Fix: Cross Product Winding Check
Before you emit each contour, compute the 2D cross product of two triangle edges. If winding is CW, swap base vertices to enforce a consistent direction.
for i = 0, 9 do
local angle = (i / 10) * math.pi * 2
local ax = math.cos(angle)
local ay = math.sin(angle)
local px = -ay
local py = ax
local b1x = cx + ax * 80 + px * 30
local b1y = cy + ay * 80 + py * 30
local tx = cx + ax * 350
local ty = cy + ay * 350
local b2x = cx + ax * 80 - px * 30
local b2y = cy + ay * 80 - py * 30
-- (tip - b1) x (b2 - b1)
local cross = (tx - b1x) * (b2y - b1y) - (ty - b1y) * (b2x - b1x)
if cross > 0 then
-- Already CCW
path:moveTo(Vector.xy(b1x, b1y))
path:lineTo(Vector.xy(tx, ty))
path:lineTo(Vector.xy(b2x, b2y))
else
-- CW detected, swap base points
path:moveTo(Vector.xy(b2x, b2y))
path:lineTo(Vector.xy(tx, ty))
path:lineTo(Vector.xy(b1x, b1y))
end
path:close()
end
Choosing the Right Strategy
| Approach | Winding Matters? | Draw Calls | Best For |
|---|---|---|---|
| Single path, multiple contours | Yes, enforce consistent winding | 1 | Batched geometry and static effects |
| Separate path per shape | No (single contour each draw) | N | Per-shape styling or per-shape animation |
When This Applies
- Loop-generated triangles or polygons at computed angles
- Radial spike/ray/burst effects
- Directional particle geometry
- Any procedural mesh where vertices rotate around a center
When This Usually Does Not Apply
- Axis-aligned rectangles with fixed vertex order
- Circle/ellipse approximations built symmetrically
- Single-contour shapes with no overlap interactions
This failure is usually silent: no warnings, no runtime errors, just missing fills. If a contour draws fine alone but disappears in a generated loop, validate winding direction first.
Exercise 8: Fix Conflicting Winding in a Spike Burst ⭐⭐⭐
Premise
A radial spike burst is generated as multiple triangle contours in one path. The geometry is valid, but inconsistent winding causes some spikes to disappear.
By the end of this exercise, you will be able to enforce consistent contour winding in a loop-generated burst using a cross-product check.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Procedural sunburst effects
- Audio-reactive radial shapes
- Geometry generated from polar coordinates
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise8_Exercise8FixConflictingWindingInASpikeBurst
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
-- Fix disappearing spikes caused by inconsistent contour winding
export type WindingBurst = {
spikes: Input<number>,
baseRadius: Input<number>,
tipRadius: Input<number>,
halfWidth: Input<number>,
path: Path,
fill: Paint,
}
local function addSpike(path: Path, b1x: number, b1y: number, tx: number, ty: number, b2x: number, b2y: number)
-- Cross product: (tip - b1) x (b2 - b1)
local cross = (tx - b1x) * (b2y - b1y) - (ty - b1y) * (b2x - b1x)
-- TODO 1:
-- If cross > 0, emit b1 -> tip -> b2
-- Otherwise emit b2 -> tip -> b1 (swap to keep winding consistent)
-- Remember to close the contour
end
local function rebuildBurst(self: WindingBurst)
local n = math.max(3, math.floor(self.spikes))
local cx, cy = 0, 0
self.path:reset()
for i = 0, n - 1 do
local angle = (i / n) * math.pi * 2
local ax = math.cos(angle)
local ay = math.sin(angle)
local px = -ay
local py = ax
local b1x = cx + ax * self.baseRadius + px * self.halfWidth
local b1y = cy + ay * self.baseRadius + py * self.halfWidth
local tx = cx + ax * self.tipRadius
local ty = cy + ay * self.tipRadius
local b2x = cx + ax * self.baseRadius - px * self.halfWidth
local b2y = cy + ay * self.baseRadius - py * self.halfWidth
-- TODO 2: Route each triangle through addSpike(...)
end
print(`Built {n} spikes with consistent winding`)
print("ANSWER: winding-fixed")
end
function init(self: WindingBurst): boolean
self.path = Path.new()
self.fill = Paint.with({ style = "fill", color = Color.rgb(255, 190, 70) })
rebuildBurst(self)
return true
end
function update(self: WindingBurst)
rebuildBurst(self)
end
function draw(self: WindingBurst, renderer: Renderer)
renderer:drawPath(self.path, self.fill)
end
return function(): Node<WindingBurst>
return {
init = init,
update = update,
draw = draw,
spikes = 10,
baseRadius = 80,
tipRadius = 340,
halfWidth = 28,
path = late(),
fill = late(),
}
end
Assignment
Complete these tasks:
- Implement
addSpikeso every contour uses consistent winding. - Call
addSpikefrom the loop using the computed vertices. - Run the script and verify the console output.
- Copy the
ANSWER:line into the validator.
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: winding-fixed
Verify Your Answer
Checklist
- All TODOs are replaced with working code
- Contours render consistently around the full circle
- Console output includes the
ANSWER:line
Key points:
- Even-odd fill can cancel overlapping contours with mixed winding
- Cross-product checks let you enforce contour order deterministically
- This bug appears most often in radial loop-generated geometry
Optimization Tips
1. Minimize Path Rebuilds
If geometry doesn't change, build it once in init:
function init(self: Static): boolean
self.path = Path.new()
self.path:moveTo(Vector.xy(-50, -50))
self.path:lineTo(Vector.xy(50, -50))
self.path:lineTo(Vector.xy(50, 50))
self.path:lineTo(Vector.xy(-50, 50))
self.path:close()
return true
end
2. Use Transforms Instead of Rebuilding
Instead of changing path coordinates, use renderer transforms:
function draw(self: Spinning, renderer: Renderer)
renderer:save()
renderer:transform(Mat2D.withRotation(self.angle))
renderer:drawPath(self.path, self.paint)
renderer:restore()
end
3. Limit Particle Counts
Set maximum limits and remove particles efficiently:
if #self.particles < MAX_PARTICLES then
table.insert(self.particles, newParticle)
end
Knowledge Check
Key Takeaway
Procedural geometry gives you complete control over shapes through code. Build paths using trigonometry and math functions, update them in advance, and render them in draw. For moving or rotating static shapes, prefer renderer transforms over rebuilding geometry.
Next Steps
- Continue to Architecture
- Need a refresher? Review Quick Reference