A shader is an asset: a node graph that becomes the GLSL fragment a sprite is drawn with. Like a
texture or a state machine it cannot be placed — a sprite wears it through its Shader property,
and the numbers the shader declares appear as rows below that dropdown. There are no built-in
shaders; the node set is sized for the recipes below: "Adjust colors", and the coordinate effects
(flip, scroll, wave, pixelate, scanlines).
The Shader tab
+ in the Assets panel makes a shader and opens the Shader tab on it (a double-click on
an existing one does the same; a click only selects). The tab has the
same canvas the Behavior tab has: right-click places a node, drag moves, drag from a port links,
the magnet snaps to the 24 px grid, Fit frames the graph, undo takes every step back. The readout on
the right says N properties while the graph compiles, otherwise why it does not (No Output node,
a bad property name, ...). A node that keeps the graph from compiling shows the reason on its body
and a red frame.
A shader graph is compiled on every change. A sprite wearing a graph that does not compile is drawn plain, as if it wore none.
The preview
Right of the graph, behind a splitter you can drag like the one in the Particles tab, the tab shows one picture wearing the shader. The picture is whatever you selected last that has one:
| Selected last | The preview shows |
|---|---|
| a placed sprite | that sprite, with its overrides: its picture, its width and height, its colour |
| a sprite asset | the sprite as the asset describes it |
| a texture | the whole sheet |
| a frame (Texture tab) | that frame |
| nothing of the above yet | a built-in test image |
Selecting the shader itself, one of its nodes or anything without a picture (a behavior, a scene) keeps the picture that is there. The line below the buttons names it. The picture is looked up again on every change, so an edit in the inspector shows at once. Once it is deleted, the test image comes back. The test image is 64 pixels square: colour runs from left to right and from top to bottom, a checker of 8-pixel cells shows where a UV lands, and the outer 8 pixels fade out so an alpha effect has something to work on.
Below the picture there is a slider for every property the shader declares, and a colour
field for every colour property (a click on the swatch opens the colour picker). They belong to
the preview: changing one never writes into the project, not into the sprite and not into the
Property node. They start at the sprite's own values when the picture is a sprite that already wears
this shader, otherwise at the property's default. A slider reaches from the property's min to its
max. A property without a max gets twice its default's distance from the min.
A shader with a Time node runs on the preview's own clock. Hold stops it at the second it shows
(the button then reads Run), Restart goes back to 0. Without a Time node both buttons are greyed
out, because nothing in the picture moves.
The preview draws the picture unsliced, even a nine-sliced frame: it is about what the shader does to the pixels, not about how a sprite stretches.
The nodes
Every value is either a number (a GLSL float) or a colour (a vec3, RGB in 0-1). Inputs
typed any take both: with a colour on either side the result is a colour, per channel; a number
fed to a colour port is broadcast to all three channels. A colour cannot be fed to a number port.
A result shows the type that really comes out. It is drawn as a number while only numbers go
in (an unconnected input counts as the number typed on it) and as a colour as soon as a colour
feeds one of the any inputs; the pin, the edge leaving it and what it may be plugged into follow.
The inputs themselves stay any and are painted as whatever is plugged in. The type travels along
a chain of such nodes, so a Multiply fed by a colour makes the Add and the Sin behind it
colours too. The number inputs of a node (t of Mix, edge of Step, exponent of Pow, min and
max of Clamp) have no say in it.
What this changes when wiring: a result that is a colour no longer lands on a number input
(the Output's alpha, the t of a Mix); before, the edge was accepted and the graph then refused
to compile. An edge wired while the result was still a number stays where it is when a colour
arrives behind it: it is drawn in the danger colour, and the node at its end says
"alpha" needs a number, not a color. In the other direction nothing is lost: a number may be
plugged into a colour port, straight from a Number or a Luminance as well.
| Node | Ports | What it does |
|---|---|---|
| Texture color | in picture · out color |
The sprite's own pixel before the shader: its texture tinted by its colour, straight (unpremultiplied). Fed a Pass's picture, that picture's pixel at the same spot (see Drawing in passes). |
| Texture alpha | in picture · out value |
The opacity of the sprite's own pixel: 0 transparent, 1 opaque. Fed a Pass's picture, that picture's. |
| UV | out u, v |
Where this pixel is on the sprite's picture, 0 to 1: u left to right, v top to bottom. |
| Sample texture | in u, v (number), picture · out color, alpha |
The picture at another spot, tinted like Texture color. Unconnected, u and v are this pixel's own and picture is the sprite's. Past the border the picture continues as the Output's Edge says. |
| Blur | in radius (number, 2), u, v (number), picture · out color, alpha · Kind, Direction |
The picture softened around a spot, tinted like Texture color. radius is in pixels of the picture, 0 to 8, fractions allowed. Kind Box (default) weighs every pixel alike, Gaussian the middle more; Direction Both, Horizontal or Vertical. Unconnected, u and v are this pixel's own and picture is the sprite's. See Softening the picture. |
| Texture size | in picture · out width, height |
The picture in pixels: of the frame, when the sprite wears one. Fed a Pass's picture, that one's, Padding included. Feeds the Vertex output too. |
| Time | out value |
Seconds since the editor started; see Coordinates and time below. |
| Output | in color, alpha (number) |
What the sprite is drawn with: the colour, clamped to 0-1, and the opacity, clamped too. Each input left unconnected stays the texture's. One per graph. Its Edge setting says how the picture continues past its border when sampled outside it: Clamp (the default) holds the border pixel, Mirror turns the picture back on itself, Repeat starts it over for tiles, Transparent leaves everything outside empty. Its Padding is room around the picture to draw into, in pixels of the picture per side (see Room around the picture). |
| Pass | in color, alpha (number) · out picture |
Draws what feeds it into a picture of its own first; the nodes with a picture input read that picture when it feeds them (see Drawing in passes). Unconnected, color and alpha are the texel's. Its Resolution is Full (the default), Half or Quarter of the sprite's picture per side. Up to 8 per graph. |
| Vertex output | in x, y (number) |
Moves the corners of the grid the picture is drawn on: x to the right and y down, in pixels of the picture; the picture bends with them. One per graph, and optional: without it the wearer draws on a plain rectangle. Its Subdivisions setting is how many cells the grid has each way, 1 to 64, 8 by default; 1 is the plain rectangle (see Moving the corners). |
| Property | out value |
Declares a number the sprite sets: Name is the row below the sprite's Shader dropdown and the uniform in the fragment; Default, Min, Max are the row's range. A name is one word (letters, digits, _), unique in the graph, and not one of the rows a sprite has anyway — enabled, scale, rotation, mirrorX, mirrorY, width, height, color, texture, shader. Both rows would sit on the same key and the uniform would read the sprite's width instead of the declared default, so the node says "width" is a sprite property and nothing compiles. |
| Color property | out color |
Declares a colour the sprite sets: the same as Property, with a colour row (swatch and hex) instead of a number row and a vec3 uniform. Default is a colour, white unless picked; there is no Min and Max. Placed from the palette it is called tint, then tint2. Number and colour properties share one set of names: a second node of the same name says Another property is named "tint". Like Color it cannot feed the Vertex output. |
| Color | out color |
A constant colour, picked in the inspector. |
| Number | out value |
A constant number, typed on the node. Handy where the same value feeds more than one input, or where an input has nothing to type on (Luminance, the Output's alpha). |
| Add, Subtract, Multiply, Divide | in a, b (any) · out result |
Arithmetic, per channel for colours. An unconnected input is the number typed on the node (0, or 1 for the second input of Multiply and Divide). Dividing by 0 gives 0, the same answer the Divide of a behavior and of a particle graph give: a / 0.0 is NaN in GLSL, and the Output's clamp(NaN, 0, 1) is undefined — the pixel would be whatever the driver does, different on Intel, on Nvidia and on a phone. |
| Mix | in a, b (any), t (number) · out result |
Blends from a to b: t = 0 is a, 1 is b, beyond that it extrapolates. |
| Luminance | in color · out value |
How bright a colour is (Rec. 709 weights), as one number. |
| Sin, Floor, Fract, Abs | in value (any) · out result |
The sine of a number in radians (one wave every 6.283); rounded down to a whole number; the part after the decimal point (2.75 becomes 0.75, a rising value becomes a sawtooth); the distance from 0 with the sign dropped (-0.3 becomes 0.3, a value crossing 0 becomes a V). Per channel for colours. |
| Step | in edge (number), value (any) · out result |
0 while value is below edge, 1 from edge on: a hard cut. Per channel for colours. |
| Pow | in value (any), exponent (number) · out result |
value to the power of exponent: 2 squares it, 0.5 is the square root; an easing curve on u, a gamma on a colour. Below 0 the value counts as 0, because GLSL leaves pow undefined there (0 to a power of 0 or less is undefined too). Per channel for colours. |
| Sqrt | in value (any) · out result |
The square root: 0.25 becomes 0.5, 4 becomes 2. The same as Pow with exponent 0.5, in one node. Below 0 the value counts as 0, because GLSL leaves sqrt undefined there. Per channel for colours. |
| Clamp | in value (any), min, max (number) · out result |
Holds the value inside min..max, per channel for colours. The Output clamps the colour it is given anyway; this one holds a value inside the graph, before it is added to or multiplied. |
| Function input | out: what you declare | Only in a shader function: the values a shader calling the function hands in. Every input declared in the inspector (+ Input, a number or a colour) is an output here and an input on every call. One per function. See Shader functions. |
| Function output | in: what you declare | Only in a shader function: what it hands back. Every output declared here is an output on every call; unconnected, it is the value typed on it. One per function. |
| a shader function | its declared inputs · its declared outputs | Listed in the palette under the function's name: the function's graph, set in here. A double-click opens it. |
An input with nothing to type on it (the Output's two, Luminance's colour) that is left unconnected
reads the texel: its colour for a colour port, its alpha for the Output's alpha. That is why the
Output's alpha has no inline number: a typed-in value would replace the texture's transparency.
The colour has no alpha of its own — a Color node and the sprite's colour are RGB. To fade a
sprite: Property opacity (default 1, 0 to 1) and Texture alpha into a Multiply, its result into
the Output's alpha; the sprite then blends with whatever is behind it, and a behavior can write
opacity every frame.
Adjust colors
Brightness, contrast and saturation with 1 as neutral on every one, on the straight colour so a transparent pixel never gains colour:
rgb = rgb + (brightness - 1)
rgb = (rgb - 0.5) * contrast + 0.5
rgb = mix(luminance(rgb), rgb, saturation)
Brightness adds, contrast multiplies, saturation blends; with every value at 1 the pixel is
untouched. As nodes: Property brightness → Subtract (b: 1) → Add as b, with
Texture color as a → Subtract (b: 0.5) → Multiply (b: Property contrast) → Add
(b: 0.5) → both into Luminance and into Mix as b, Luminance into Mix as a, Property
saturation into t, Mix into Output. Three Property nodes with default 1, min 0, max 4. The test fixture and the three example games ship
this graph as the asset "Adjust colors" (tools/lib/shaders.mjs).
Measured on a flat #4080ff sprite: brightness 1.25 gives #80c0ff, contrast 0 gives #808080,
saturation 0 gives #7c7c7c — all byte-exact against the formula.
Coordinates and time
UV says where the pixel is, Sample texture reads the picture somewhere else, and the arithmetic
in between is the effect. A Property in place of a constant makes the amount a sprite row a
behavior can write.
- Flip:
Subtractwith 1 typed in asaandUV.uasb, intoSample texture.u. - Scroll:
AddUV.uand a Property offset (orTimetimes a speed), intoSample texture.u. Set the Output's Edge to Repeat, so the picture comes round again; with Clamp (the default) the border pixel stretches, with Mirror the picture turns back on itself. - Wave:
UV.utimes 20,AddTimetimes 4,Sin, times 0.02,AddUV.v, intoSample texture.v. - Pixelate:
UV.utimesTexture size.width,Divideby the cell size,Floor, times the cell size,Divideby the width, intoSample texture.u; the same forvand the height. - Scanlines:
UV.vtimesTexture size.height,Fract,Step(edge 0.5),MultiplywithTexture colorinto the Output's colour (or the step alone into itsalpha).
A sprite wearing a graph the Time node reaches redraws on every tick: in play, and in the scene
view as well, where nothing else moves. Time reads the global clock (see Architecture), so it
holds still for a pause and begins a run at second 0. A graph the Time node does not reach draws
once, like any other.
Measured in the checks (step 28): a Step at 0.5 on u is red left of the middle and blue right
of it, on v red above and blue below; on a picture red on the left and green on the right,
sampling at 1 - u swaps the halves; u + 0.5 with Edge Repeat shows green on the left and,
past the border, red again on the right, with Mirror green on both sides; u + 0.8 reaches far
enough to separate the two remaining modes — with nothing set on the Output the right side is green
(Clamp), with Mirror it is red; with the clock held at
0.2 s and 0.7 s a Fract into a Step gives red and then blue; released, the pixel of a
Fract(Time) mix changes between two reads with nothing running.
Room around the picture
A shader draws where its wearer's rectangle is and nowhere else, so an outline, a glow or a shadow
is cut off at the border. Padding on the Output node gives it room: that many pixels of the
picture on every side. The picture stays where it is and as big as it is; UV simply runs on past
0 and 1 in the margin, 8 pixels of padding on a 32-pixel picture from -0.25 to 1.25.
What the margin reads is the Output's Edge, for Texture color, Texture alpha and unconnected
inputs just as for a Sample texture. For anything that should stand free around the picture that
is Transparent: outside the picture there is nothing, and only what the graph puts there shows.
With Clamp the border pixel goes on through the margin, which is rarely meant, so the Output says
"Padding with Clamp smears the border" while it is set that way. The shader still compiles.
The simplest case: a Sample texture reading at UV.u minus 0.25, its color and its alpha
both into the Output, shows the picture a quarter further right. Without padding it ends at the
sprite's right border; with Padding 8 on a 32-pixel picture and Edge Transparent all of it is
there, and left of it the sprite is empty. Take the alpha from the sample as well: the pixel's
own is 0 everywhere in the margin.
The pixel it counts in is the wearer's own:
- Sprite with a picture: a pixel of the frame, however big the sprite draws it.
- Sprite without one: a unit of its size.
- Nine-sliced sprite: a border pixel, drawn at the texture's nine-slice scale. The margin is a ring around the nine parts; the parts themselves do not move.
- Text: a unit of the text, whatever resolution the zoom had it rastered at.
- Particles: a pixel of the particle picture, around every particle; without a picture a particle's size counts as that many pixels.
The margin is drawn only. Clicking, the selection frame, the size rows and colliders keep to the rectangle without it. The preview in the Shader tab leaves room for the margin and shows it.
Measured in the checks (steps 28, 30, 31 and 37): with Padding 8 and Transparent on a 32-pixel picture the picture keeps its place and the margin stays the background; the picture sampled a quarter to the right ends at the rectangle without padding and is drawn past it with; with Clamp the border colour fills the margin and the Output carries the hint; a text's filled quad reaches 6 units past its rectangle and no further; particles grow about their centres; a sliced sprite gets a ring of 4 border pixels and keeps its 8-pixel border.
Softening the picture
Blur (#300) averages the pixels around a spot in one pass: with Direction Both a square of
(2r+1)*(2r+1) pixels, with Horizontal or Vertical one row or column of 2r+1. Its color and alpha
go where a Sample texture's would; for the plain blurred sprite, both into the Output.
- No dark seam. The average is taken over the premultiplied pixels and only then turned back
into a colour, so a transparent neighbour lowers the opacity and adds no black. Averaging the
colorof severalSample texturenodes by hand does add it. - Fractions. The outermost ring counts by the part of the radius that reaches it: 0.5 weighs
the neighbours half, 1 fully. A radius from a
Propertycan therefore be animated smoothly, and changing it compiles nothing again. - Radius 0 is the picture unchanged. Above 8 the radius stays at 8: that is 289 samples per pixel. Wider blurs and bloom are drawn in passes (see Drawing in passes).
- Past the border the picture continues as the Output's Edge says. For a soft edge that stands free around the sprite, set Transparent and give it Padding at least as wide as the radius.
- The pixel is the wearer's picture's: a pixel of the frame for a sprite, and of the rastered text for a text (which depends on the zoom with Linear). In a field shader a pixel is a unit of the frame: the field's picture has as many pixels as the frame covers on screen, but a radius of 4 reaches 4 units of the frame at every zoom.
- Kind: Box weighs every pixel alike; Gaussian weighs the middle more (sigma = radius / 2) and looks softer at the same radius and the same cost.
A glow lays the blurred shape behind the picture. Colour: Mix with a the glow colour, b
Texture color and t Texture alpha. Opacity: Mix with a the Blur's alpha, b 1 and t
Texture alpha. Both into the Output, with Edge Transparent and Padding for the glow to spread
into.
Measured in the checks (step 28), on a 32-pixel picture red on the left and green on the right, both Nearest: radius 0 draws the picture byte for byte; the last red pixel, softened horizontally through a Property, goes to 3/4, 2/3 and 5/9 red at 0.5, 1 and 4 with no compile in between; Vertical keeps it red, Gaussian keeps more red than Box; with Edge Transparent the first pixel stays red at 5/9 opacity; one white pixel blurred by 1 is a 3x3 spot of 1/9 each.
Drawing in passes
A Pass node (#301) cuts the graph in two. What feeds its color and alpha is drawn first,
into a picture of its own; its picture output then feeds the nodes that read a picture
(Texture color, Texture alpha, Sample texture, Blur, Texture size), and they read that
picture instead of the sprite's. A picture input left unconnected is the sprite's own picture, so
the original is always there beside any pass. A picture plugs only into a picture input.
A wide blur in two passes: Blur Horizontal into a Pass (color and alpha both), its
picture into a second Blur Vertical, that one into the Output. That is the same picture as
one Blur Both of the same radius, for 2*(2r+1) samples per pixel instead of (2r+1)*(2r+1).
Wider than 8: a Blur counts its radius in pixels of the picture it reads. Put an open Pass at
Half first (nothing into its color and alpha: it is the picture at half size), feed its
picture to the Blur Horizontal, and set the second Pass to Half as well. Both Blurs then
read pixels twice the size, and a radius of 8 reaches 16 pixels of the sprite's picture; Quarter
reaches 32. Give the Output a Padding as wide as that. With Nearest the shrinking Pass skips
every other pixel and thin details flicker; a Blur of 1 into it, instead of leaving it open,
smooths that out.
Bloom, the bright parts glowing over the picture:
Texture colorintoLuminance, that into aStep(itsedgeis the threshold, say 0.7). The Step timesTexture coloris the bright colour, the Step timesTexture alphaits opacity. Both into a Pass at Half.- That picture into a
BlurHorizontal (radius 8), both outputs into a second Pass at Half. - That picture into a
BlurVertical (radius 8): the glow, with itscolorandalpha. - Glow
colortimes glowalpha(times a strength),Added toTexture color, into the Output'scolor. For a glow beyond the sprite, the Output'salphaas in the glow above (Mixwithathe glow'salpha,b1,tTexture alpha), Edge Transparent and Padding.
What a pass's picture is:
- The frame is the sprite's picture and its Padding, the same rectangle the sprite draws
into. Padding is set once, at the Output, and holds for every pass;
Texture sizefed a pass counts the margin in. - Resolution Full has as many pixels as the sprite's picture, Half and Quarter a half and a quarter of them per side. A smaller picture is cheaper and blurs further at the same radius; it is read with the picture's own filter, so Linear smooths it and Nearest shows its pixels.
- Untinted. A pass sees the picture without the sprite's colour; the colour comes on when the last part (the one into the Output) reads a picture. A pass's picture added to the original is therefore tinted once, not twice. For a blur that makes no difference; for a threshold like the Step in Bloom it means the threshold looks at the untinted picture.
- Past its frame it continues as the Output's Edge says.
- Drawn when needed. A pass is drawn again when the picture, its size, the Padding or a
property changes; a still sprite draws its passes once. With
Timein the graph it is drawn every frame. Every wearer has pictures of its own. - Limits: up to 8 passes per graph (At most 8 passes at the node otherwise) and 2048 pixels per side. A pass that reads its own picture says Feeds itself. A corner has no picture, so a Pass cannot feed the Vertex output.
Who draws passes: a sprite (a sliced one over its unsliced picture), a text, a particle source and a field shader (over the field's picture), and the preview in the Shader tab. A particle source draws its passes once for all its particles, from the particles' picture, and the particle's own colour comes on at the end.
Measured in the checks (step 28): one white pixel on a 16-pixel picture, blurred by 2 across, a
Pass and blurred down, is the same 5x5 spot of 1/25 each that a Blur Both draws (difference
0.000); on a sprite tinted grey, the red half's pass picture added to the original comes out at
231/60/60, red once halved in each; two Passes in a row, the first the very program of an earlier
graph's only Pass, still show the picture and not white.
Shader functions
A shader function (#303) is a part of a shader graph kept as an asset of its own, so the same
calculation (a greyscale, a noise, a vignette, an outline of eight samples) is built once and used in
any number of shaders. + in the Assets panel → Shader function makes one; it opens in the
Shader tab like a shader. It has no Output, no wearer and no preview of its own: the panel beside
the graph says A function shows in the shaders calling it, and the readout counts its inputs and
outputs.
- Its ports are declared on its two ends: Function input says what a caller hands in, Function output what comes back. Select one and add ports in the inspector, each a Number or a Color; nothing else can be declared. Every port is at once a port of every node calling the function, in every shader: a new input grows those nodes, a renamed one keeps their edges, a removed one takes its edges along, and an undo takes all of it back.
- Calling it: every shader function is in the Shader tab's palette under its own name, and can be dragged in from the Assets panel. The node carries the function's name; an input left unconnected is the value typed on it. A double-click on the node opens the function.
- Set in, not called. The compiler copies the function's graph into the shader where the node
stands, as if its nodes had been placed there, so it costs exactly what the same nodes cost in place.
Calling a function twice copies it twice. The nodes that read the picture (
Texture color,Sample texture,Blur, ...) read the picture of whatever wears the calling shader, andTimeis the same clock. A Pass inside a function is a pass of the calling shader (a wide blur can be a function). - What it may hold: everything a shader holds except the Output, the Vertex output,
PropertyandColor property. Values come in through the inputs; the calling shader hangs its own properties on them, so it stays clear whose row a property is. Those nodes are not in a function's palette, and one pasted or left in says A function cannot hold .... The two ends are not in a shader's palette. - Changes reach every caller. Every shader calling a function compiles again when the function changes, however deep the call sits.
- Problems inside a function show on its own nodes in its tab, and at every call, in the function's words: In Grayscale: "level" needs a number, not a color. A function calling itself, directly or through another, says A function cannot call itself and is not set in; the palette does not offer such a call in the first place.
A function is part of the shader graph only: a behavior cannot call it, since a behavior runs on the processor and a shader on the graphics card.
Measured in the checks (step 28): a function "Grayscale" (a colour in, Luminance out as a colour),
called between Texture color and the Output, draws the same pixels as the same nodes in place, byte
for byte; a Multiply by 0.5 put into the function halves the sprite's grey without the shader being
touched; a double-click on the call opens the function, and a call of itself inside it is marked.
Moving the corners
Everything above decides what colour a pixel gets. A Vertex output node decides where the
picture is drawn: what feeds its x and y runs once for every corner of a grid laid over the
wearer and moves that corner, in pixels of the picture, x to the right and y down. The picture
is pinned to the corners, so it bends with them: a wave, a flag, a wobble. Between two corners the
move is a straight blend of theirs.
Whether the wearer is a plain rectangle or a grid is said in one place, the node's Subdivisions: the number of cells each way. 8 is an 8 by 8 grid with 81 corners, and 1 is the rectangle with its four. There is no switch beside it. A shader without a Vertex output node draws on the one rectangle all sprites share and costs nothing extra. The setting is the shader's, not the sprite's: how fine the grid has to be depends on the effect, so every wearer of the shader gets the same grid, whatever its size.
A number feeding x moves the whole picture. UV.v through a Step at 0.5, times 16, into x
moves only the lower half of a 32-pixel picture half its width to the right; with Subdivisions 1
there are no corners in between, and the whole picture leans instead. For a wave, feed y with
Sin of UV.u times a frequency plus Time, times the height in pixels.
What may feed it is what a corner can know: UV (the corner's own place on the picture), Time,
Property, Number, Texture size and the calculating nodes. Texture size is there the
picture the corner's pixels are counted in, the same numbers it gives a pixel. A corner has no
pixel, so Texture color, Texture alpha, Sample texture, Blur, Pass, Color and
Color property cannot feed it, nor a Texture size fed a Pass's picture; the Vertex output then
says "Not available per corner: …" and the shader does not compile. The same node may feed both
outputs: one Property can drive the bend and the colour.
Scaling about the middle, whatever the picture's size: x is
(UV.u - 0.5) * Texture size.width * (scale - 1) and y the same with UV.v and height. For a fade that grows from 1.2 to 1.0, the
scale is Mix of 1.2 and 1.0 by the fade's Property, and the same Property feeds the Output's
alpha. A scale is a straight move of every corner, so Subdivisions 1 is enough.
Who it applies to:
- Sprite and text: the grid, with the pixel counted as for Padding (a pixel of the frame, a unit of the text).
- Nine-sliced sprite and particles: not at all. A sliced sprite keeps its nine parts, a particle its four corners, and both draw as if the node were not there.
With Padding the grid covers the margin as well. Like the margin, the bend is drawn only: clicking, the selection frame, the size rows and colliders keep to the rectangle, and a wearer is drawn even when its rectangle is off screen, because its corners may reach in.
Measured in the checks (steps 28 and 30): a Vertex output fed 16 draws a 32-pixel picture half a
sprite to the right; the cut on v moves only the lower half with 8 subdivisions and leans the
upper half along with 1; half of Texture size.width moves it as far as 16 does; Texture alpha
into x is refused at the node; a text's filled quad stands 6 units to the right.
Wearing a shader
The sprite's Shader row is a picker over the project's shader assets (None is no shader: the
sprite draws in Pixi's batch like any other). The rows below it are the shader's Property and
Color property nodes, in node order; they are
ordinary sprite properties: overridable per placed object, reset (↺) and apply (↑) like any other,
readable and writable by a Properties node at runtime — a behavior can fade a sprite by writing
its brightness every frame. That node's + Property list (under Type: Sprite) offers the
parameters of every shader in the project, each naming the shader it comes from, because the sprite
it will be handed is only known while the game runs; writing a parameter the worn shader does not
have is stored and ignored. A value with no row (the shader was switched, the property renamed)
stays stored but marks nothing; it comes back with the shader.
A placed object may override the shader itself; it then shows that shader's rows. Switching the shader keeps the values of the old one's rows in the bag — they are not visible and do not count as deviations while their shader is not worn.
A particle source wears one the same way (see Particles), and there it is free: a field is a single draw call whether or not it wears a shader, so the batch that a shaded sprite gives up was never there to lose. The difference is what the fragment can see — a particle's picture, not its age or index — and that a source, having rows of its own graph as well, keeps a name the shader also declares: the uniform then stays on its default rather than taking the row.
A source can also wear one over its whole field (#170, the Field shader row, see
A shader over the whole field in Particles). There the shader draws a picture of
all the particles inside a fixed frame round the source, so its u/v span the frame and
Sample texture reaches across neighbouring particles: glow, blur and outlines of the whole cloud.
Unlike a shader per particle this is an extra pass, one per source.
A text object wears one the same way (#169), through a Shader row below Align. Its glyphs are
rasterised into a picture of their own and drawn on the sprite quad, so the graph sees exactly what a
sprite's graph sees: Texture color is the glyph pixel in the text's colour, Texture alpha the
glyph's coverage, and u/v run from 0 to 1 across the rectangle of the rendered text, line
height included. A new text, face or size is rasterised again and keeps the shader. Like a source,
a text keeps a name it already has (text, fontSize, align …): a shader parameter of that name
gets no row. In a Properties node the parameters are offered under Type: Text as they are under
Type: Sprite. A shaded text is one draw call, like a shaded sprite.
A camera wears one over its whole picture (#299): post-processing such as a vignette, a colour
grade, grey, scanlines, a red flash on a hit or a fade. The camera's Shader row sits under
Overlay, its parameters below it, overridable, animatable and in a Properties node under
Type: Camera like a sprite's. A camera keeps a name it already has (zoom, sees …). The world
the camera sees is drawn into a picture of its frame first, cleared to the scene's background,
and the shader draws that picture where the frame stands, so u/v run from 0 to 1 across the
camera's picture. Each camera wears its own, so a change of camera changes the effect, and an
overlay camera (#302) wears one over its own see-through picture before it is laid on the main
one: a red flash on the game camera leaves the HUD of an overlay camera alone. The row
Shader resolution, shown while a shader is picked, says how many pixels that picture has:
- Screen pixels (default): as many as the camera's picture covers on screen, so it stays sharp at any game scale.
- Camera pixels: View width x View height, scaled up to the game scale without smoothing, for pixel art: a scanline or a blur step per game pixel.
Either way a pixel counts as a camera pixel. Texture size is View width x View height
in both, and a Blur of 4 reaches 4 camera pixels at any game scale and any size of the window,
the same rule a field shader follows with its frame. Screen pixels only looks sharper. The frame
is the edge, so Padding and Vertex output do nothing there, as on a field. The Game tab, the
running game and the game link (play.html) all draw it. In the Scene tab the eye in the header
has a Shader switch on the Camera row, off by default: switched on, the main camera's shader
lies over its frame there, under the symbols, names and gizmos. That picture holds every render
layer, since the Scene tab shows all of them. Measured in the checks (step 63): a camera shader
that hands the picture through draws the game picture byte for byte at 100%, the right way up; a
flat red fills it to the corners while the overlay camera's square keeps its green; Texture size
reads 640 x 360 in both resolutions with the Game tab fitted to 670 x 377 pixels; and the Scene
tab shows the red only with the switch on.
What it costs, and where it stops
A shader runs in the sprite's own draw: the mesh is drawn once, with the graph's fragment in place
of Pixi's, so there is no intermediate texture and no extra pass. What it costs is the batch — a
sprite drawing with a shader of its own cannot share a draw call with the plain ones, so every
shaded sprite is one draw call. Measured with tools/perf-shaders.mjs on an Intel HD 630 (the GPU
CI uses as well), sprites of 48x48 inside a 640x360 camera, neutral values, three runs agreeing to
within 2 fps:
| sprites | 50 | 100 | 200 | 400 | 800 | 1600 | 3200 |
|---|---|---|---|---|---|---|---|
| all wearing the shader | 60 | 60 | 60 | 60 | 56 | 34 | 17 |
| the same scene, no shader | 60 | 60 | 60 | 60 | 60 | 39 | 21 |
Up to 400 shaded sprites nothing shows; at 800 the shader costs about 1.5 microseconds per sprite,
from 1600 on about 3 — and there the plain scene misses ticks by itself. For comparison, as a
filter pass per sprite (up to #77) the same counts measured 56 / 54 / 48 / 34 / 20 fps from 50 to
800: what cost then was the change of render target, not the pixels. The shader samples the
texture itself, so pixel art stays sharp: measured in play at a fractional camera scale (1.43
screen pixels per world unit), a Nearest texture keeps its hard border pixel for pixel with a
neutral shader on, and brightness 0.5 darkens every one of those pixels — the shader draws there,
and it does not resample. The node set is still
small — no channel split; each is one node in
src/core/shader/nodes.ts (a ShaderNodeDef with an emit) and one row in the table above. A
picture sampled past its border shows what Edge says, never the sprite's neighbour on the sheet: a
frame is a texture of its own. Without a shader the question never comes up — the quad's own
coordinates run 0 to 1 and never leave the picture — which is why the default is the mode that
matches what you see there: Clamp. Outside the sprite's rectangle a shader draws only as far as
its Padding reaches and its Vertex output moves the corners.
A grid has a price of its own, because every corner is computed and every wearer carries its own
copy of the grid. One run per column with tools/perf-shaders.mjs (GM_PERF_SUBDIVISIONS) on the
same HD 630, the same scene, a Vertex output with nothing feeding it, in frames per second:
| sprites | 200 | 400 | 800 | 1600 |
|---|---|---|---|---|
| no shader | 60 | 60 | 60 | 41 |
| shader, no Vertex output | 60 | 60 | 58 | 36 |
| Subdivisions 8 | 59 | 58 | 46 | 27 |
| Subdivisions 32 | 54 | 57 | 41 | 23 |
Up to 400 wearers a grid hardly shows; at 800 it costs a quarter of the frame rate. The 54 at 200 with 32 subdivisions is below the 57 at 400, so single values here are good to a few frames, not to one.
A field shader on a particle source (#170) is the one place a shader costs a pass: the source's
particles are drawn into a picture of its frame first. One run per row with
tools/perf-field-shaders.mjs on the same HD 630, sources spread over the 640x360 camera, a frame
of 256 units (256x256 pixels at that zoom), 50 particles a second each, a field shader that hands
the picture through unchanged, in frames per second:
| sources | 1 | 10 | 50 |
|---|---|---|---|
| no field shader | 60.1 | 60.0 | 59.2 |
| field shader | 60.0 | 60.0 | 57.6 |
Up to 10 sources nothing shows; at 50 the field shaders cost about 1.6 frames a second. The 60 Hz cap hides what a pass costs inside a frame that is not full, and a graph that samples the picture many times (a blur) adds its own price per pixel on top of the pass.
A Blur (#300) pays per pixel: (2r+1)*(2r+1) samples with Direction Both. One run per row with
GM_PERF_BLUR in both tools on the same HD 630, a shader of nothing but a Box Blur into the
Output, in frames per second. Without a shader the same scenes held 59.0 to 60.1.
| sprites of 48x48 | 100 | 400 | 800 |
|---|---|---|---|
| radius 2 | 59.8 | 58.4 | 55.6 |
| radius 4 | 53.1 | 51.4 | 36.3 |
| radius 8 | 46.6 | 24.4 | 14.4 |
| field shaders, 256x256 pixels each | 1 | 10 | 50 |
|---|---|---|---|
| radius 2 | 60.2 | 56.0 | 49.0 |
| radius 4 | 60.1 | 49.0 | 27.1 |
| radius 8 | 52.4 | 34.9 | 11.4 |
Radius 2 is cheap. From 4 on the samples set the price: a single field of 256x256 at radius 8 is 289 samples for each of its 65536 pixels and already costs 8 frames a second.
The same blur in two passes (#301: across, a Pass, down) is 2*(2r+1) samples per pixel, plus a
change of render target per pass. One run per row with GM_PERF_PASSES=1 in both tools, the same
HD 630 and scenes; the one-pass rows were measured again in the same sitting. Without a shader
the scenes held 58.3 to 60.2. Radius 16 is radius 8 on two passes at Half behind an open Pass at
Half (see Wider than 8), so three draws instead of two. On sprites the radius is tied to
Time, so their Pass is drawn every frame, as a field's always is; a still sprite draws it once and
then only pays the last part.
| sprites of 48x48 | 100 | 400 | 800 |
|---|---|---|---|
| radius 4, one pass | 53.1 | 50.7 | 35.5 |
| radius 8, one pass | 49.4 | 24.2 | 13.9 |
| radius 4, two passes | 59.1 | 45.6 | 24.1 |
| radius 8, two passes | 56.2 | 46.0 | 24.5 |
| radius 16, three passes at Half | 51.5 | 27.1 | 14.2 |
| field shaders, 256x256 pixels each | 1 | 10 | 50 |
|---|---|---|---|
| radius 4, one pass | 59.0 | 49.7 | 27.2 |
| radius 8, one pass | 54.5 | 35.0 | 11.4 |
| radius 4, two passes | 60.0 | 55.1 | 49.2 |
| radius 8, two passes | 59.9 | 51.3 | 41.3 |
| radius 16, three passes at Half | 59.9 | 55.6 | 44.4 |
On a large picture two passes pay off: 50 fields at radius 8 go from 11 to 41 frames a second, and radius 16 at Half costs only a little more than that. On small sprites the change of target costs more than the samples save: the radius hardly matters there, at radius 4 one pass is faster from 400 sprites on, and radius 16, with its third draw, is as slow at 800 sprites as radius 8 in one pass. Passes are for wide blurs and large pictures, not for every soft edge.
A camera shader (#299) is one pass for the whole view. Measured with tools/perf-camera-shader.mjs
on the same HD 630 in a 1920x1080 window, the Game tab at Fit (1310x679, the picture 1207x679 at
Screen pixels), plain sprites of 48x48 in the 640x360 camera, in frames per second. At that size
the editor holds well under 60 without any shader, and the scene alone moved by up to 5 frames a
second from one run to the next, so the hand-through rows are three runs each:
| sprites | 100 | 400 |
|---|---|---|
| no camera shader | 35.5 - 41.0 | 29.2 - 33.3 |
| hands through, Screen pixels | 35.0 - 41.5 | 31.1 - 34.1 |
| hands through, Camera pixels | 33.9 - 35.9 | 30.0 - 31.7 |
| Blur 4, Screen pixels (one run, its own scene 41.7 / 37.9) | 33.5 | 33.3 |
| Blur 4, Camera pixels (same run) | 33.9 | 33.6 |
A camera shader that hands the picture through costs less than the scene varies by itself. A Blur of 4 over the whole view cost about 4 to 8 frames a second in its one run, at either resolution.
In the code
src/core/shader/nodes.ts is the catalogue, src/core/shader/compile.ts turns a graph into the
fragment (every node reachable from the Output becomes one local per output, in dependency order;
every Property a uniform float u_<name>, every Color property a uniform vec3 u_<name>; the walk itself is src/core/graph/emitGraph.ts, shared with the
particle compiler). The type of a result has one source: liftedType in the catalogue is what
emit declares the local with, and liftedPortType (the group's agree, see Ports that carry
one and the same value in Behaviors) is the same rule for the port the editor draws.
A test runs every node with an open result through every mix of numbers and colours and compares
the two. src/viewport/shaders.ts wraps the result in a Pixi
Shader for the sprite's mesh (GraphShader, with a vertex stage of its own).
A Vertex output is a second walk of the same graph (ShaderVertex in compile.ts): its lines go
into the sprite's vertex stage (cornerVertex in shaders.ts), which reads the same uniforms, and
the grid is gridQuad in src/viewport/quadPadding.ts.
Blur writes no loop of its own: the loop is blur() in the prelude (BLUR_FN, only there when
a Blur is reached), and Kind and Direction are its arguments. A node with expensive work declares
it as prepare (a vec4 the compiler puts into one local), so color and alpha read one result
instead of blurring twice.
A Pass is where a walk stops (cut in emitGraph.ts): the compiler's passDrawer walks what feeds
it as a fragment of its own, drawing any Pass it meets in turn first, so CompiledShader.passes is
in drawing order and uPass<i> is the i-th picture. A pass's fragment has vColor as a constant 1,
which is what leaves it untinted. Every program reads a picture through texelAt(sampler, uv); a
pass's picture covers the Padding too, so a place on the sprite's picture becomes one on the pass's
through passUv and the uniform uPassPad. src/viewport/shaderPasses.ts (PassPictures) holds
one render texture per pass for each wearer, sized by passPixels in quadPadding.ts, and draws
them only when the key of picture, size, Padding and uniform values changed (a field's picture is a
render texture that changes under the same id, so it is drawn every time). Destroyed in that order:
the shader first, which lets go of the pictures, then the pictures; the other way round Pixi warns
that a texture was destroyed while still bound. Pixi builds the code that binds a program's
resources once, from the first shader that draws with it, and finds them by position after that;
since two graphs often share a pass's program, every shader declares all 8 pass slots and its
uniform group, even an empty one. Otherwise the second graph reads a placeholder and draws white.
Blur and Texture size count the picture's size through the uniform uPixelScale: 1 on every
wearer but a field, where it is the frame's units per pixel of its picture (unitsPerPixel in
src/viewport/fieldFrame.ts), and a camera, where it is camera pixels per pixel of its picture
(pixelScale in src/viewport/cameraPicture.ts).
A camera's shader is CameraShade (src/viewport/cameraShade.ts): every view keeps its masks and
objects in one container, which is drawn into the camera's picture with Pixi's render and a
transform from world units to that picture. The Game tab and the game link then take that container
off the stage and let the picture stand in for it; the Scene tab leaves it drawn, since it shows
more than the frame, and lays the picture over it below the symbols (shadingCamera and
keepsWorldAroundShader in WorldViewport).
A shader function (src/core/shader/functions.ts) never reaches the walk as a node:
inlineFunctions first replaces every call by a copy of the function's graph, its node ids prefixed
with the call's (<call>/<node>), wires what fed the call's inputs to what the Function input fed,
and what read its outputs to what fed the Function output; an unconnected port becomes a Number or
Color node with the typed value. Everything after that (passes, Time, the Edge cut, the corner
walk) sees a plain graph. A problem on a copied node is moved back onto the call
(problemsAtCalls). The function's own tab walks it from its Function output with any value at its
input (checkShaderFunction). The call's ports come from the function through GraphLookup
(shaderFunction), and the port commands mirror a change at a function's end into every call, as
they do for a behavior's Start and End.
src/viewport/ShaderLibrary.ts caches the compiled graph per asset, keyed by a fingerprint of what
the fragment depends on — node ids, types, params, declared ports, and which port feeds which, and
the same of every function it calls (functionsFingerprint) — so
moving, collapsing or folding compiles nothing again; a changed param or link does, once
(__gm.shaderCompiles() counts it for the checks). A sprite's picture is a Mesh
(a unit quad sized by its scale, symbols.ts), a sibling of the sprite's Graphics node under the
same parent: Pixi 8 lets only plain containers have children, and the node keeps just the hit area.
Two Pixi facts the code leans on: a mesh's texture is bound only for Pixi's own shader, so
GraphShader.wear hands it over itself; and Pixi caches whether a mesh batches and re-checks that
only in a frame without structure changes, so switching a shader on or off calls mesh.unload()
to drop that cache — otherwise a mesh that ever drew batched keeps drawing plain, shader ignored.
The fragment starts with #version 300 es: that line is what makes Pixi compile the program as
GLSL ES 3.00 (GlProgram looks for it in the fragment); without it texture() is only a
#define and textureSize does not exist. The Output's Edge is a second cut of the picture with
that address mode (TextureLibrary.get(ref, edge)), because WebGL takes the sampler state from the
texture source, not from a style the shader hands over. That cut is only made where it can show:
a graph whose Sample texture reads nothing but its own pixel draws with the plain sheet, whatever
the Edge says, so the common shader costs no second copy of the picture. Transparent needs none
either: no sampler state makes the outside empty, so the fragment cuts it itself (every read of the
picture goes through texelAt, which is the plain texture() in the other modes). Padding is
geometry (src/viewport/quadPadding.ts): the quad's corners and its UVs grow by the same fraction,
a sliced sprite gets a ring of spans around its parts (paddedSpans in nineSlice.ts), and for
particles, whose quads Pixi builds, the vertex stage does the same with a uPad uniform. A graph
with padding takes the Edge's cut of the picture even without a shifted sample, because every
pixel of the margin reads past the border. The sheet itself carries
mirror-repeat (SHEET_EDGE) and cannot carry clamp-to-edge: Pixi rewrites that to repeat on
every texture a Graphics fill draws with. Time is ShaderLibrary.now(), held still
by __gm.shaderTime(seconds) for the checks; a view that drew a Time shader redraws on every
editor tick (WorldViewport.animate). The rows come from
shaderParamDefs(graph) in src/core/properties/shaders.ts; schemaForAsset and
schemaForObject (PropertySchema.ts) append them to a sprite's schema, which is why both take
the project.
Using the editor
- Concepts
- Accounts
- Agents (MCP)
- Scenes
- Scene editing
- Behaviors
- Structs
- Colliders
- Physics
- Textures
- Vector art
- Pixel art
- Fonts
- Videos
- Sounds
- Instruments
- Patterns
- Songs
- Animations
- Curves
- Tiles
- Tilemaps
- State machines
- Shaders
- Particles
- Containers
- Compositions
- Keyboard
Working on the code