Visual effects
Find one before you build one
Effects are assets: asset.list("effect") lists the ones this world holds,
zeromind.search the published ones, and procgen.search every graph the
engine can see, a result inside an effect carrying effect (the effect's
identity) and role. An effect's README says what it shows and what each input
changes; effectRef:entries() lists each entry with the inputs it takes, their
types and defaults.
Make one
asset.create("effect", name) scaffolds an effect playing one entry, named after
it, of one graph:
beacons.effect/
effect.yaml entry name -> one graph, or a list of graphs played together
graphs/ the graphs entries play
parts/ graphs those graphs call, each one piece of the look
shaders/ materials/ compute/ textures/ what they draw with
Each graph is a .procGraph whose init.luau returns
proc.define(function(g) ... end), built the way the procgen guide teaches: nodes
from the node catalogue called by family and name, knobs declared with g:input, and
what the effect shows handed to io.output. A graph's declared inputs are the inputs
of every entry that plays it.
effectRef:newGraph(name) adds a graph under graphs/, setEntry(name, graphs)
sets what an entry plays (or adds the entry), and removeEntry, renameEntry and
setDefault change the rest of effect.yaml. Inside an effect, ~.x in
effect.yaml names the effect's own x, and g["~/name"] in a graph calls a node
inside the effect.
A piece worth building and looking at on its own (the crack of a fissure, the spray,
the smoke) is a graph under parts/. The graphs the effect plays call it as they call
a node, g["~/parts/crack"]({ length = length }), and it answers what it returns;
being a whole graph, it previews and plays alone. asset.validate(effectRef) reports an entry that does not
resolve, a graph under graphs/ no entry plays, an asset in a folder that holds
another kind, and a README still as scaffolded: the README says what the effect
shows, what each entry plays and what each input changes.
How an effect is built
- What the effect shows stands as entities or as instanced draws. Points that
carry their own components stand as one entity each, which the scene can pick and
script, at a CPU cost every frame and memory per entity. A piece placed on points
is one geometry drawn at every point's pose in one instanced draw, each copy facing
its velocity when it carries no
orient, at a cost that grows with the GPU work. The procgen guide's "Entities and instanced draws" sets the two side by side. - Particles are points a solver holds. The
simfamily births them on any points (an emitter shape, a curve, a surface, the hits of another solver), pushes them with forces and bounces them off colliders; everything that colours, stretches or lights them is attribute work over the whole set of live particles. - Time comes from the graph clock. Anything that grows, fades or pulses reads
g.clock.time;g:timelinesets whether the graph runs once ({ realTime = true, frames = 120 }) or on until stopped ({ realTime = true }). - Materials are records of a shader and its values; a surface
.shaderof the effect's own becomes one the same way a built-in shader does. - A piece used in more than one place is a node, the way a module is: write a
.procNode(asset.create("procNode", name)) whenever the same nodes would be built again, a spark streak look or a smoke puff. Any effect or graph calls it by name, and editing it cooks every graph that uses it again.
Play one
From gameplay, play an entry on the entity it belongs to, with the inputs that fit
the moment: effectRef:play(entry, opts) takes root, offset, rotation,
inputs, ending ("graph", "loop" or "hold"), speed and keep (stay once
finished), and the play follows root; with no root it stands in the world at
offset. The handle it returns offers set (change an input), pause, resume,
seek, getTime, stop, isFinished and onFinished. An input goes to every
graph of the entry that declares it, and one no graph declares is refused, naming
the inputs the entry takes.
A play finishes, and goes back to be played again, when every graph of its entry
reaches the end of its timeline. A graph has an end when it states its frames: a
blast is g:timeline({ realTime = true, fps = 60, frames = 120 }). A graph that
states realTime alone has no end, and a play of it runs until stop(): a fire, a
flow of water, a glow held while a button is.
Plays are kept ready: built ahead of time, handed out by play, and taken back
when they finish or stop, so a play costs no build. effectRef:warm(entry, count)
sizes the pool of an entry for a burst, at a level's load or before it; the
effect's pool setting is the size of every entry nothing sized.
A play belongs to the peer that calls play: it is not sent to the other
players. A moment every player sees is shared by the gameplay that makes it,
and each peer plays the effect for it. Declare an event Sync on the component
the moment happens in, fire it when it happens, and play the entry in a handler
connected to it: the handler runs on every peer, each playing it on its own
(the components guide's "Events" has replication). An Effect placed in a
scene is part of the scene every peer has.
To place an entry in a scene, put an Effect component on an entity (effect,
entry, inputs, playOn, ending, speed), or drop the effect into the
viewport with effectRef:instantiate. An Effect plays the entry's graphs on
Generators under its entity, and the entity's transform is where the effect
stands. playOn = "manual" waits for play().
Size
A graph's values (a speed, a lifetime, a distance, a particle's size, a count) are numbers in the graph's own space, and an effect keeps its look at another size only when those numbers change together. Each way of placing an effect carries a different part of the transform it is placed with:
- An
Effectcomponent's entity carries its whole transform, scale included: the Generators under it take the entity's scale, and what they show is drawn at it. - A play's
rootcarries its position and rotation, which the play follows withoffsetandrotation. The root's scale does not reach the play.
An effect meant to be played at several sizes declares the size as an input
(g:input("size", 1, ...)) and derives every length, speed and count from it,
so a play states its size through inputs (and set while it plays) wherever
it stands.
See one
An effect changes from frame to frame, so a screenshot of the scene shows whichever frame it happened to be on. Check it with a picture of the moment and with data:
procgen.previewpictures a graph's output alone.procgen.framespictures a play at the graph times you name: every graph of it is brought to each time, and each picture holds what they draw together, alone, framed to fit, over a grey studio backdrop (or a near-black one withbackdrop = "dark", over which glows and sparks read at full brightness). It takes the play's entity, or the entity of a Generator, and each frame'soutputscounts the elements drawn by name.procgen.onPresenttakes the same entity and fires while the effect plays, on the frame you describe (an output, a graph time or a time within a repeating cycle, an element count); withcapture = trueits notice carries the picture of that frame.effectRef:preview()renders the default entry to a still.
What it costs
shown:playback() reports where a play stands and what it has cost, its graphs
added together: cost.frameMs + cost.cookMs over cost.frames is its cost a
frame, and heldFrames says whether every graph plays from the frames the engine
holds once its timeline is cooked through. graphs lists each graph with its own
playback(), which also says why a graph is not held (notHeldFrames) and how
each output is drawn. Measure it at the particle count the effect plays at, and
again at ten times that: the cost of one piece placed on many points grows with
the GPU work, where an entity per particle grows on the CPU every frame.
Every play of one graph with the same inputs plays the frames the first one
cooked, so an effect played every shot cooks once, and the plays warm builds
cook ahead of the shot. A graph states what one play was measured to cost with
g:cost { gpuMs, vramBytes, cookMs, measuredOn }, recorded in its .metadata.