Working with VFX in Kaiju Engine
Introduction
Visual effects (VFX) are a cornerstone of modern game development, providing everything from subtle smoke trails to spectacular fireworks. Kaiju Engine's VFX subsystem is designed to be lightweight, extensible, and tightly integrated with the editor, allowing designers to craft and iterate on particle effects in real time.
In this post we'll explore the architecture of the VFX system, dive into the key data structures (Particle, Emitter, and ParticleSystem), and show how to edit emitters using the built‑in editor UI.
VFX Architecture Overview
At a high level the VFX pipeline consists of three main pieces:
- Particle - a lightweight struct that stores transform, velocity, opacity and lifespan.
- Emitter - owns a list of particles, spawns them according to a configuration, and updates them each frame.
- ParticleSystem - aggregates one or more emitters and provides a single interface for the renderer.
Relevant source files include:
src/rendering/vfx/particle.go- defines theParticletype and its update logic.src/rendering/vfx/emitter.go- implements spawning, path functions, and per‑particle data.src/rendering/vfx/emitter_path_funcs.go- registers built‑in path functions (e.g.,Circle).src/editor/editor_workspace/vfx_workspace/vfx_workspace.go- UI glue that lets you edit emitters in the editor.
Particle Structure
type particleTransformation struct {
Position matrix.Vec3
Rotation matrix.Vec3 // TODO: This can be 1D for billboarded particle
Scale matrix.Vec3 // TODO: This can be 2D for billboarded particle
}
type Particle struct {
Transform particleTransformation
Velocity particleTransformation
OpacityVelocity float32
LifeSpan float32
}
The update method advances the particle based on its velocity and reduces its remaining lifespan:
func (p *Particle) update(deltaTime float64) {
p.LifeSpan -= float32(deltaTime)
t := &p.Transform
v := &p.Velocity
t.Position.AddAssign(v.Position.Scale(matrix.Float(deltaTime)))
t.Rotation.AddAssign(v.Rotation.Scale(matrix.Float(deltaTime)))
t.Scale.AddAssign(v.Scale.Scale(matrix.Float(deltaTime)))
}
Emitters
An Emitter holds a slice of Particle objects and a configuration struct (EmitterConfig). The config controls texture, spawn rate, particle lifespan, direction ranges, velocity ranges, color, and optional path functions.
Key fields in EmitterConfig:
type EmitterConfig struct {
Texture content_id.Texture
SpawnRate float64
ParticleLifeSpan float32
LifeSpan float64
Offset matrix.Vec3
DirectionMin matrix.Vec3
DirectionMax matrix.Vec3
VelocityMinMax matrix.Vec2
OpacityMinMax matrix.Vec2
Color matrix.Color
PathFuncName string `options:"PathFuncName"`
PathFunc func(t float64) matrix.Vec3 `visible:"hidden"`
PathFuncOffset float64
PathFuncScale float32
PathFuncSpeed float32
FadeOutOverLife bool
Burst bool
Repeat bool
}
The emitter spawns particles based on SpawnRate and applies the optional path function to offset the whole system over time.
Path Functions
Path functions let you move an entire emitter along a curve. The engine ships with a Circle function, but you can register your own.
func init() {
RegisterPathFunc("None", nil)
RegisterPathFunc("Circle", pathFuncCircle)
}
func pathFuncCircle(t float64) matrix.Vec3 {
// Normalise t to the range [0,1]
for t < 0 { t += 1 }
for t > 1 { t -= 1 }
angle := matrix.Float(2 * math.Pi * t)
var pos matrix.Vec3
pos.SetX(matrix.Cos(angle))
pos.SetZ(matrix.Sin(angle))
return pos
}
Editing VFX in the Editor
The VFX editor UI is defined in editor/ui/workspace/vfx_workspace.go.html. It provides two panels:
- Left panel - list of emitters, system name, and add/save buttons.
- Right panel - per‑emitter data bindings (texture, color, direction, etc.).

Adding a New Emitter
Click the Add Emitter button to create a new emitter with a default EmitterConfig:
w.addEmitter(vfx.EmitterConfig{
Texture: "smoke.png",
SpawnRate: 0.05,
ParticleLifeSpan: 2,
Color: matrix.ColorWhite(),
DirectionMin: matrix.NewVec3(-0.3, 1, -0.3),
DirectionMax: matrix.NewVec3(0.3, 1, 0.3),
VelocityMinMax: matrix.Vec2One().Scale(1),
OpacityMinMax: matrix.NewVec2(0.3, 1.0),
FadeOutOverLife: true,
PathFuncScale: 1,
PathFuncSpeed: 1,
})
You can then edit each field in the right‑hand panel. When you're satisfied, click Save - the workspace serialises the ParticleSystemSpec to JSON and writes it back to the project's content database.
Still a work in progress
Known limitations
- CPU‑only simulation - At the moment particles are updated on the CPU. This works well for modest counts, but large fire‑works or dense smoke quickly become a bottleneck.
- Path functions are static - The built‑in
Circleis the only non‑trivial path function. Custom functions can be registered, but there is no UI for authoring functions in-editor. - Limited editor feedback - The VFX workspace shows the raw config values, but does not visualise the spawn area, direction cones, or velocity ranges directly in the viewport.
Planned improvements
- GPU particle pipelines - Off‑load the
updateandspawnlogic to a compute shader. - Rich path‑function editor - Expose some curve editors in the UI to set path curves.
- Live preview helpers - Visual gizmos for spawn cones, velocity vectors, and opacity envelopes to make tweaking feel immediate.
Contributing
The VFX subsystem is deliberately lightweight, but I welcome extensions. To add a new path function:
- Implement the function in
src/rendering/vfx/emitter_path_funcs.gofollowing thepathFuncCircleexample. - Register it with
RegisterPathFunc("MyPath", myPathFunc)inside the same file. - Submit a pull request with tests that verify the function's output range.
If you encounter bugs or have ideas for new emitter features, open an issue on the repository or join the discussion in Discord.