Skip to content

Commit bfcb0ce

Browse files
Added animations part 4, shaders
1 parent f06dca7 commit bfcb0ce

File tree

2 files changed

+188
-7
lines changed

2 files changed

+188
-7
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,27 @@
11
# Part 4: Shader Animations
2+
3+
In the previous sections of these animation guides we've looked at hand coding animations, abstracting over them with signals, and composing complicated presentations using timelines.
4+
5+
In this section, in pursuit of performance, we going to come full circle back to hand coded animations, but this time, in the form of shaders.
6+
7+
This part of our tour isn't going to cover everything there is to know about shaders. Shaders in Indigo are typically written using our shader library, [Ultraviolet](https://github.com/PurpleKingdomGames/ultraviolet), which has it's own [documentation site](https://ultraviolet.indigoengine.io/), complete with many Indigo based examples.
8+
9+
As always the complete source code for this demo is available from the link on this page, if you want to see how the whole thing is put together. Therefore in this discussion, we'll be focusing on the reasons for using shader based animations, and the nuts and bolts of how one works.
10+
11+
## The need for speed
12+
13+
If timeline animations are designed for friendly usability at the expense of performance overhead, shaders are the opposite. Super efficient, massively parallel, and really powerful; Shaders are a low level way to describe the graphics you want to draw into a region of the screen.
14+
15+
Whenever you draw literally anything with Indigo - whether you know it or not - a shader is doing the work. So if you need complex graphics, animations, and performance: Shaders might be the answer.
16+
17+
It must also be said that while they can be complicated and contain a fair bit of maths, shader programming is very good fun once you get into it!
18+
19+
## Shaders, hand coding, and signals
20+
21+
Shaders are written in procedural code. Scala if you're using Ultraviolet, otherwise you'll be using a language called GLSL.
22+
23+
Despite that, philosophically, programming a shader is more like coding with signals than it is the hand coded solution we saw in part 1.
24+
25+
A fragment shader is not unlike a pure function that runs for every pixel of an image, and decides what color it is. It has to be pure, since it will be run in parallel, and like a pure function (or indeed a signal), it operates on some initial parameters (such as the running time) to generate its output, but can otherwise be considered stateless.*
26+
27+
> (* You can add your own input data to a shader using UBOs, and those will typically be based on game model state. However, shaders in Indigo _do not_ have a way to generate, store, look up, or share their own state.)
Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,147 @@
11
package indigoexamples
22

33
import indigo.*
4+
import indigo.syntax.*
45
import generated.Config
56
import generated.Assets
7+
import ultraviolet.syntax.*
68

79
import scala.scalajs.js.annotation.*
810

11+
/** ## Animating with shaders
12+
*
13+
* In this example we're going to write a shader for a little bouncing yellow box, the kind that
14+
* could be used to show the currently highlighted grid square in a grid based rpg or strategy
15+
* game.
16+
*
17+
* Let's go through the code:
18+
*/
19+
20+
object CustomShader:
21+
22+
val shader: ShaderProgram =
23+
UltravioletShader.entityFragment(
24+
ShaderId("custom shader"),
25+
EntityShader.fragment[FragmentEnv](fragment, FragmentEnv.reference)
26+
)
27+
28+
inline def fragment: Shader[FragmentEnv, Unit] =
29+
Shader[FragmentEnv] { env =>
30+
import ultraviolet.sdf.*
31+
32+
def sdBox: (vec2, vec2) => Float =
33+
(p, b) => box(p, b)
34+
35+
def fragment(color: vec4): vec4 =
36+
37+
/** As usual, animation is about the running time of the game, and in our case we need to
38+
* speed time up in order to get the bounce speed we're after.
39+
*/
40+
// ```scala
41+
val time: Float = env.TIME * 4.0f
42+
// ```
43+
44+
/** Next we're going to set up some constants / values / parameters. Yes, in this example
45+
* these are hardcoded values. On the other hand, they're also relative values (shaders
46+
* operate in 0.0 to 1.0 coordinate space), and if we wanted to we could pass them in using
47+
* a UBO, but that isn't needed for this example.
48+
*/
49+
// ```scala
50+
val halfStrokeWidth: Float = 0.02f
51+
val halfGlowWidth: Float = 0.07f
52+
val minDistance: Float = 0.3f
53+
val distMultiplier: Float = 0.05f
54+
// ```
55+
56+
/** In order to describe the box shape, we're going to use something called an SDF (Signed
57+
* Distance Function) that will tell us whether this pixel (fragment) is outside the box (>
58+
* 0), on the edge of the box (~= 0), or inside the box (< 0).
59+
*
60+
* We're using one of Ultraviolets built in helper functions to do that, and it requires
61+
* two parameters:
62+
*
63+
* 1. The position of this pixel (i.e. the UV coordinate), re-centered around the origin
64+
* (0,0).
65+
* 2. The 'halfsize' of the box (can be a rectangle or a square).
66+
*
67+
* What's important about the halfsize here is that this is the thing we're animating!
68+
* We're using a sin wave, using the current time as the argument, pushing out the value by
69+
* a minimum distance. This produces an SDF distance value for a box that is bouncing /
70+
* changing size.
71+
*
72+
* > The argument to `sin` _should_ be an angle, but we're cheating a bit and using the
73+
* running time in seconds, which more or less does what we want.
74+
*/
75+
// ```scala
76+
val halfsize = vec2(minDistance + abs(sin(time) * distMultiplier))
77+
val sdf = sdBox(env.UV - 0.5f, halfsize)
78+
// ```
79+
80+
/** The SDF value is a smooth gradiant, so to make it a hard 'frame' (like a picture frame),
81+
* we need to do two things to it:
82+
*
83+
* 1. We need to make it 'annular', so that the values inside and outside the shader are
84+
* positive, and it ends up looking like a top down view of a square crater.
85+
* 2. We need to use a `step` function to swap out the gradient for a hard edge.
86+
*/
87+
// ```scala
88+
val frame = 1.0f - step(0.0f, abs(-sdf) - halfStrokeWidth)
89+
// ```
90+
91+
/** Time for some color! Colors in this example are represented as `vec3`s, where (x, y, z)
92+
* are equivalent to (red, green, blue).
93+
*
94+
* The main color (`col`) is yellow, i.e. full red, full green, no blue.
95+
*
96+
* Note that we're calculating the colors separately from the alphas, this is important.
97+
*/
98+
// ```scala
99+
val col = vec3(1.0, 1.0, 0.0f)
100+
// ```
101+
102+
/** We know that the color is going to be yellow, but what we need to do now is work out the
103+
* alpha of all that yellow. Obviouly, the alpha will be 0.0 outside the frame, and 1.0
104+
* inside the middle of the frame, but we also want a little glow effect, which is really
105+
* just a gradiant ramp in the alpha channel.
106+
*
107+
* The glow amount (alpha of the glow) is roughly calculated by looking at the current SDF
108+
* value, and saying that if the value is between an upper and lower bound, than
109+
* 'smoothstep' that value.
110+
*
111+
* Smoothstepping is a process of interpolating between the upper and lower bound values,
112+
* to produce a value between 0.0 and 1.0. For example, if the lower bound was 10, and the
113+
* upper bound was 20, and the value was 15, we'd get a result of 0.5. It isn't quite as
114+
* simple as that that because of the 'smooth' part. The value produced isn't a linear
115+
* interpolation, its eased in and out based on an S curve.
116+
*
117+
* The final alpha is the glow amount, knocked back by 50%, combined with the frame value
118+
* which you may recall was either 0.0 or 1.0.
119+
*
120+
* This can mean that by our process, a pixel could have an alpha value > 1.0, but since
121+
* that's unrepresentable, it doesn't matter for our purposes and for all intents and
122+
* purposes, the value will be clamped to a 0.0 to 1.0 range.
123+
*/
124+
// ```scala
125+
val glowAmount = smoothstep(0.95f, 1.05f, 1.0f - (abs(sdf) - halfGlowWidth))
126+
val alpha = (glowAmount * 0.5f) + frame
127+
// ```
128+
129+
/** Finally we're going to return the pixel colour, a `vec4`, i.e. (red, green, blue,
130+
* alpha).
131+
*
132+
* The thing to note here is that its been constructed with the vec3 colour value and the
133+
* alpha, i.e. `vec4(vec3(r, g, b), a)` but curiously, the colour has been multiplied by
134+
* the alpha.
135+
*
136+
* This is not an error! It's to do with something called pre-multiplied alpha, and is
137+
* essential for the colours to come out looking right. (See the Ultraviolet docs for more
138+
* details.)
139+
*/
140+
// ```scala
141+
vec4(col * alpha, alpha)
142+
// ```
143+
}
144+
9145
@JSExportTopLevel("IndigoGame")
10146
object AnimationPart4 extends IndigoSandbox[Unit, Unit]:
11147

@@ -17,7 +153,7 @@ object AnimationPart4 extends IndigoSandbox[Unit, Unit]:
17153

18154
val fonts: Set[FontInfo] = Set()
19155
val animations: Set[Animation] = Set()
20-
val shaders: Set[ShaderProgram] = Set()
156+
val shaders: Set[ShaderProgram] = Set(CustomShader.shader)
21157

22158
def setup(assetCollection: AssetCollection, dice: Dice): Outcome[Startup[Unit]] =
23159
Outcome(Startup.Success(()))
@@ -28,14 +164,33 @@ object AnimationPart4 extends IndigoSandbox[Unit, Unit]:
28164
def updateModel(context: Context[Unit], model: Unit): GlobalEvent => Outcome[Unit] =
29165
_ => Outcome(model)
30166

167+
val lightBlueGrey = RGBA.fromHexString("#9badb7")
168+
val darkBlueGrey = RGBA.fromHexString("#3f3f74")
169+
val darkPurple = RGBA.fromHexString("#76428a").mix(RGBA.Black)
170+
171+
val tileSize = Size(64)
172+
173+
val gridSquare =
174+
Shape.Box(
175+
Rectangle(tileSize),
176+
Fill.RadialGradient(Point(16), 32, lightBlueGrey, darkBlueGrey),
177+
Stroke(6, darkPurple)
178+
)
179+
180+
val offset = Point(10)
181+
182+
val grid =
183+
(0 to 2).flatMap { y =>
184+
(0 to 2).map { x =>
185+
gridSquare.moveTo(Point(x, y) * tileSize.toPoint).moveBy(offset)
186+
}
187+
}.toBatch
188+
31189
def present(context: Context[Unit], model: Unit): Outcome[SceneUpdateFragment] =
32190
Outcome(
33191
SceneUpdateFragment(
34-
Shape
35-
.Circle(
36-
Circle(Point.zero, 50),
37-
Fill.Color(RGBA.Red),
38-
Stroke(2, RGBA.White)
39-
)
192+
grid :+
193+
BlankEntity(tileSize + Size(10), ShaderData(CustomShader.shader.id))
194+
.moveTo(Point(64) + offset + Point(-5))
40195
)
41196
)

0 commit comments

Comments
 (0)