A LÖVE 2D binding for the Effekseer particle/VFX engine via LuaJIT FFI.
⚠️ Alpha. The binding is feature-complete for 2D runtime VFX — the full Effekseer surface relevant to 2D is bound (transforms, color, dynamic parameters, layers, sound, callbacks, …) — with a test suite (47 tests). It's validated on macOS; Linux/Windows build via CI but haven't been battle-tested in real projects yet. Expect rough edges; please report anything you hit.2D only. This drives Effekseer as a 2D screen-space overlay — effects are placed in pixel coordinates and composited over the LÖVE frame. No 3D scenes, cameras, or perspective. (Effekseer is internally a 3D engine; we constrain it to a 2D orthographic setup on purpose.)
Effekseer runs as a native shim (a small C++ shared lib over Effekseer + EffekseerRendererGL, OpenGL
only); the Lua side is a thin, idiomatic wrapper. Effects are authored in the Effekseer editor
(.efk) and played at runtime — placed by pixel coordinate, recolored, scaled, and parameterized
live.
The reason to use it: LÖVE's built-in ParticleSystem is a basic single-emitter sprayer. Effekseer
gives you ribbons, rings, tracks, distortion, and multi-emitter effect trees authored in a
dedicated editor — the primitives real ability/impact VFX need — driven at runtime. The central
engineering problem a LÖVE binding must solve is GL-state coexistence (Effekseer issues raw GL into
LÖVE's context); this binding handles it (Effekseer's state-restoration flag + a GL state guard +
premultiplied-alpha canvas compositing), so LÖVE renders cleanly before and after each effect draw.
The shim pins Effekseer 1.80.4.
Run love example from this repo for a demo (click to spawn tinted effects).
The Effekseer surface relevant to 2D runtime VFX is bound in full. By area:
- Lifecycle — create manager (single- or multi-threaded worker pool), load effects from memory, hot-reload, free; GC-finalized resources.
- Playback — play / stop / stop-root / exists; pause (per-handle and all); spawn-disable; shown; random seed; auto-drawing.
- Transforms — location, add-location, target-location, rotation (Euler + axis-angle), scale, full matrix and base matrix — with location/matrix getters.
- Appearance — color, speed (+ get), per-handle and per-group time-scale.
- Dynamic parameters & triggers —
setDynamicInput/getDynamicInput,sendTrigger: the author-once / drive-at-runtime mechanism. - Layers & groups — layer, group mask (+ getters), per-group time-scale.
- Update & render — frame-based update (per-manager, per-handle, seek-to-frame), camera + projection matrices, guarded draw, the 2D ortho/camera convention.
- Queries & profiling — instance counts (total / per-handle / remaining), location, matrix, current LOD, is-culled, update / draw time.
- Sound — effect-authored sounds bridged to
love.audio, in sync with the effect timeline (respects time-scale; volume / pan / pitch from the effect). - Callbacks — removing callback (fires on the main thread; safe).
- Resources — textures, models, materials, and curves, via a custom file interface.
The complete method-by-method reference is in docs/api.md; a quickstart + asset prep + troubleshooting is in docs/getting-started.md.
Deliberately not bound: GPU-particle compute path, 3D collision callback, Effekseer sound's built-in players (we bridge to
love.audioinstead), custom effect-file loader (we load from memory), node-tree internals, and non-GL renderer backends. See docs/api.md for the full list and rationale — these are scope decisions, not technical limits.
It's a native binding, so a consuming game needs two things:
-
The Lua module — copy
effekseer/into your project sorequire("effekseer.system")resolves. It's a folder module, so your require path must include?/init.lua(LÖVE's default already does). -
The native library for each platform you ship, placed at
lib/native/<platform>/:macos-arm64/libeffekseer_shim.dylib,macos-x86_64/libeffekseer_shim.dyliblinux-x64/libeffekseer_shim.sowindows-x64/effekseer_shim.dll
Prebuilt binaries are attached to each GitHub Release (and committed under
lib/native/). The loader (effekseer/ffi.lua) resolves<source>/lib/native/<platform>/<libname>— under LÖVE that'slove.filesystem.getSource(), otherwise the cwd (or$EFFEKSEER_ROOT).
The effekseer.system adapter owns the LÖVE 2D render integration (canvas isolation, the pixel-space
projection, premultiplied compositing, and the love.audio sound bridge):
local System = require("effekseer.system")
function love.load()
local w, h = love.graphics.getDimensions()
sys = System.new{ width = w, height = h, workerThreads = 2 }
local bytes = love.filesystem.read("assets/fireball.efk") -- exported .efk, not .efkefc
fireball = sys:loadEffect(bytes, love.filesystem.getSource() .. "/assets/", 20)
end
function love.update(dt) sys:update(dt) end
function love.mousepressed(x, y)
local h = sys:play(fireball, x, y) -- pixel coords; (x,y) is where it appears
h:setColor(80, 200, 255, 255) -- runtime tint (optional)
end
function love.draw()
sys:draw() -- composites effects over the current target
endAssets must be exported .efk (not the editor's .efkefc), with textures/sounds at the relative
paths the effect references under your materialPath. See
docs/getting-started.md for asset layout and troubleshooting, and
docs/rendering.md for the render model, coordinate/blend conventions, and
performance tuning (multithreading + culling).
The shim is a C++/CMake project (native/effekseer_shim) over the Effekseer submodule, built
OpenGL-only (Metal/Vulkan/DirectX renderers and editor/tools are disabled). scripts/build-native.sh
builds the host platform into lib/native/<platform>/. C++ with GL deps doesn't cross-compile cleanly,
so each platform builds natively on its own CI runner rather than from one host.
Prerequisite — Git LFS. The Effekseer submodule stores textures/test assets in Git LFS. After
git clone --recursive, rungit lfs install && git lfs pullin the submodule — otherwise those assets are pointer stubs and textures silently fail to load.
cd native/effekseer_shim/third_party/Effekseer && git lfs install && git lfs pull && cd -
scripts/build-native.sh # builds the host shim into lib/native/<platform>/Tests (tests/) are split by whether they need a GL context. The no-GL tier (matrix math, the
FFI boundary) runs under LuaJIT; the GL tier (lifecycle, load, transforms, queries, sound, leak
checks) runs under LÖVE:
EFFEKSEER_ROOT="$PWD" luajit tests/run.lua # no-GL tier (dependency-free runner)
EFFEKSEER_ROOT="$PWD" love tests/gl # GL tier (needs a context; exits non-zero on failure)CI (.github/workflows/build.yml) builds each platform natively, runs the no-GL tier everywhere and
the GL tier on Linux under xvfb, and gates the tagged Release on it.
love-effekseer (the wrapper + shim) is MIT — see LICENSE. The compiled binary links Effekseer,
which is MIT-licensed; retain its notice when redistributing the binaries.