Shaders - wildware-uk/composegl GitHub Wiki
Shaders
Blur a pause menu, outline the thing you are aiming at, dissolve a panel when it closes β or write your own GLSL and bind it to any widget.
There is no privileged API here. The effects that ship are written against the same thing your shader is.
The ones that ship
They live in composegl-effects, a separate module so that a game which wants none
of them carries none of them.
implementation("dev.wildware.composegl:composegl-effects:0.6.0")
Panel(Modifier.blur(radius = 8f)) { PausedMenu() }
Image(portrait, Modifier.outline(Colour.rgb(0x5B8DEF), width = 2f))
Panel(Modifier.colourGrade(saturation = 0f, brightness = 0.6f)) { DeadScreen() }
Panel(Modifier.dissolve(progress)) { ClosingTerminal() }
| what it does | |
|---|---|
blur(radius) |
a two-pass gaussian, horizontal then vertical |
outline(colour, width) |
a ring of colour round whatever is not transparent |
colourGrade(brightness, contrast, saturation, tint) |
the whole knob set, per pixel |
dissolve(progress, scale, softness, edge) |
eats it away in patches, optionally burning the rim |
Each also exists as a plain ShaderEffect, if you want it somewhere other than a
modifier chain:
val fade = blur(radius = 6f, axis = Axis.Horizontal)
How it works
Modifier.effect(...) draws the widget and everything under it into an offscreen
picture, then draws that picture through your shader.
Two of them in a chain compose, in the order written β the second works on the first one's answer:
Modifier.effect(blur(8f, Axis.Horizontal)).effect(blur(8f, Axis.Vertical))
That is exactly what Modifier.blur(8f) is.
It costs a picture and a draw call, every frame it is on screen. Put it on the panel, not on each of the forty things in the panel.
A backend with no offscreen drawing, or no shaders, draws the widget plainly and says nothing. An effect degrades to no effect, never to a missing widget.
Writing your own
A shader is text β a fragment shader in the old dialect (varying, texture2D,
gl_FragColor), which compiles on a desktop driver and on a phone without being
written twice. The backend adds the #version and precision lines; do not write
them.
val scanlines = ShaderSource(
name = "scanlines",
fragment = """
uniform float u_spacing;
uniform float u_depth;
void main() {
vec4 picture = texture2D(u_texture, v_texCoord);
float line = mod(v_texCoord.y * u_size.y, u_spacing) / u_spacing;
float dim = 1.0 - u_depth * step(0.5, line);
gl_FragColor = picture * dim * u_alpha;
}
""",
)
fun scanlines(spacing: Float = 3f, depth: Float = 0.25f) = ShaderEffect(
source = scanlines,
uniforms = mapOf(
"u_spacing" to Uniform.Number(spacing),
"u_depth" to Uniform.Number(depth),
),
)
// β¦then
Panel(Modifier.effect(scanlines())) { Terminal() }
What is declared for you
| name | what it is |
|---|---|
v_texCoord |
where in the picture this pixel is, 0 to 1 |
u_texture |
the picture: what the interface drew, before the effect |
u_textureSize |
its size in real pixels β a blur's step is 1.0 / u_textureSize. Under a still Modifier.scale on a widget above it the picture is taken at the widget's own size and shrunk or grown on the way down, so a step in these pixels shrinks and grows with the panel too |
u_size |
the area the effect covers, in the units it was drawn in β before a still Modifier.scale on a widget above it or a PanZoomCanvas zoom, so a step measured in it grows and shrinks with them, as the bleed does. A scale on the effect's own widget is not taken off: that size is the scaled one |
u_alpha |
the opacity in force, which your last line should multiply by |
Declare only your own uniforms on top of those, and none whose name starts cg_: those
are the toolkit's own. The picture is often the corner of a bigger texture the canvas
keeps for reuse, so every texture2D you write is quietly sent through cg_picture,
which finds your picture there. v_texCoord still runs 0 to 1 over your picture, and
a read past its edge still gets the edge, whatever lies beyond.
Uniforms are typed
Uniform.Number(1.5f)
Uniform.Vector2(x, y)
Uniform.Vector3(r, g, b)
Uniform.Vector4(r, g, b, a)
Uniform.Whole(4)
Uniform.Flag(true)
Uniform.of(Colour.rgb(0x5B8DEF)) // a vec4, NOT premultiplied
Passing a size where a colour was meant is a Kotlin compile error rather than a wrong picture on a screen. A name your shader does not declare is ignored β drivers throw away a uniform nothing uses.
Premultiplied alpha
What you sample already has its colours multiplied by its own opacity, and what you write must be the same. That is what makes a soft edge blend without a dark halo round it.
In practice: multiply your whole answer by its own alpha, and by u_alpha. To work
on the colour rather than what is stored, divide it out first:
vec3 colour = picture.a > 0.0 ? picture.rgb / picture.a : vec3(0.0);
// β¦do something to `colour`β¦
gl_FragColor = vec4(colour * picture.a, picture.a) * u_alpha;
Light is the exception. A glow drawn with BlendMode.Additive over a clear part of the
picture adds colour and no opacity, so it arrives with an alpha of nought and the
division above loses it. A step that only multiplies or mixes the colour can work on
what is stored and keeps it β the shipped colourGrade and dissolve do:
vec3 graded = mix(vec3(dot(picture.rgb, vec3(0.299, 0.587, 0.114))), picture.rgb, u_saturation);
gl_FragColor = vec4(graded, picture.a) * u_alpha; // light stays light
Uniform.of(colour) is deliberately not premultiplied: a shader that wants a
hue to tint by wants the hue, and one that wants a colour to blend towards wants it
multiplied, and only the shader knows which.
Bleed
An effect that reaches outside the widget has to say so, or the spread is cut off square at the edge:
ShaderEffect(source = glowShader, uniforms = β¦, bleed = 12f)
It costs a bigger picture, which is why it is zero unless asked for. blur sets it
to its radius, outline to its width, colourGrade and dissolve to nothing.
When it does not compile
The backend throws, with the driver's own message and your shader's name in it. A
shader that does not compile is a mistake in the source rather than a condition to
recover from β and a silent black rectangle is the hardest bug in this toolkit to
find.
Drawing to a layer yourself
Under the modifier is one canvas call, if you want it directly:
val picture = canvas.layer(bounds) { drawTheThing() }
if (picture != null) canvas.drawLayer(picture, bounds, effect)
else drawTheThing() // no offscreen drawing here: draw it plainly
The picture belongs to the canvas and is reused; it is good until the end of the frame and must not be kept past it.