Starfield shader
The purple background on the screen is starting to feel a bit boring. Instead we’ll add something more interesting. We’ll use a pixel shader to display a moving starfield in the background. How to implement a shader is outside the scope of this guide, so we’ll use one that has already been prepared for us.
In short, a shader is a small program that runs on the GPU of the computer.
They are written in a C-like programming language called GLSL. The shader is
made up of two parts, a vertex shader and a fragment shader. The vertex shader
converts from coordinates in a 3D environment to the 2D coordinates of the
screen. Whereas the fragment shader is run for every pixel on the screen to
set the variable gl_FragColor
to define the color that pixel should have.
Since our game is entirely in 2D, the vertex shader won’t do anything other
than setting the position.
Implementation
Shaders
At the top of the main.rs
file we’ll add a vertex shader, the fragment
shader will be loaded from a file that we will add later. We’ll use the Rust
macro include_str!()
to read the file as a &str
at compile time. The
vertex shader is so short that it can be added directly in the Rust source
code.
The most important line in the vertex shader is the line that sets
gl_Position
. For simplicity’s sake we’ll also set the iTime
variable that
is used by the fragment shader from _Time.x
. It would also be possible to
use _Time
directly in the fragment shader, but it would mean we have
to change it slightly.
const FRAGMENT_SHADER: &str = include_str!("starfield-shader.glsl");
const VERTEX_SHADER: &str = "#version 100
attribute vec3 position;
attribute vec2 texcoord;
attribute vec4 color0;
varying float iTime;
uniform mat4 Model;
uniform mat4 Projection;
uniform vec4 _Time;
void main() {
gl_Position = Projection * Model * vec4(position, 1);
iTime = _Time.x;
}
";
Initialize the shader
In the main()
function, above the loop, we need to setup a few variables to
be able to use the shader. We start by adding the variable
direction_modifier
that will be used to change the direction of the stars
horizontally, depending on whether the circle is moved left or right. After
that we create a render_target
to which the shader will be rendered.
Now we can create a Material
with the vertex shader and the fragment shader
using the enum ShaderSource::Glsl
.
In the parameters we’ll also setup two uniforms for the shader that are global
variables that we can set for every frame. The uniform iResolution
will
contain the size of the window and direction_modifier
is used to control the
direction of the stars.
let mut direction_modifier: f32 = 0.0;
let render_target = render_target(320, 150);
render_target.texture.set_filter(FilterMode::Nearest);
let material = load_material(
ShaderSource::Glsl {
vertex: VERTEX_SHADER,
fragment: FRAGMENT_SHADER,
},
MaterialParams {
uniforms: vec![
("iResolution".to_owned(), UniformType::Float2),
("direction_modifier".to_owned(), UniformType::Float1),
],
..Default::default()
},
)
.unwrap();
Macroquad will automatically add some uniforms to all shaders. The available
uniforms are _Time
, Model
, Projection
, Texture
, and _ScreenTexture
.
Draw the shader
It’s now time to change the purple background to our new starfield. Change the
line clear_background(DARKPURPLE);
to the code below.
The first thing we need to do is to set the window resolution to the material
uniform iResolution
. We’ll also set the direction_modifier
uniform to the
same value as the corresponding variable.
After this we’ll use the function gl_use_material()
to use the material.
Finally we can use the function draw_texture_ex()
to draw the texture from
our render_target
on the background of the screen. Before we continue we’ll
restore the shader with the function gl_use_default_material()
so that it
won’t be used when drawing the rest of the game.
clear_background(BLACK);
material.set_uniform("iResolution", (screen_width(), screen_height()));
material.set_uniform("direction_modifier", direction_modifier);
gl_use_material(&material);
draw_texture_ex(
&render_target.texture,
0.,
0.,
WHITE,
DrawTextureParams {
dest_size: Some(vec2(screen_width(), screen_height())),
..Default::default()
},
);
gl_use_default_material();
Controlling the stars
When the player holds down the left or right arrow key we’ll add or subtract a
value from the variable direction_modifier
so that the shader can control
the movement of the stars. Remember to multiply the value with delta_time
so
that the change is relative to framerate, just like when doing the movement.
if is_key_down(KeyCode::Right) {
circle.x += MOVEMENT_SPEED * delta_time;
direction_modifier += 0.05 * delta_time;
}
if is_key_down(KeyCode::Left) {
circle.x -= MOVEMENT_SPEED * delta_time;
direction_modifier -= 0.05 * delta_time;
}
Create the shader file
Now create a file with the name starfield-shader.glsl
in the src
directory
to contain the fragment shader and add the following code:
#version 100
// Starfield Tutorial by Martijn Steinrucken aka BigWings - 2020
// countfrolic@gmail.com
// License Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
// From The Art of Code: https://www.youtube.com/watch?v=rvDo9LvfoVE
precision highp float;
varying vec4 color;
varying vec2 uv;
varying float iTime;
uniform vec2 iResolution;
uniform float direction_modifier;
#define NUM_LAYERS 4.
mat2 Rot(float a) {
float s = sin(a), c = cos(a);
return mat2(c, -s, s, c);
}
float Star(vec2 uv, float flare) {
float d = length(uv);
float m = .05 / d;
float rays = max(0., 1. - abs(uv.x * uv.y * 1000.));
m += rays * flare;
uv *= Rot(3.1415 / 4.);
rays = max(0., 1. - abs(uv.x * uv.y * 1000.));
m += rays * .3 * flare;
m *= smoothstep(1., .2, d);
return m;
}
float Hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
vec3 StarLayer(vec2 uv) {
vec3 col = vec3(0);
vec2 gv = fract(uv) - .5;
vec2 id = floor(uv);
float t = iTime * 0.1;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 offs = vec2(x, y);
float n = Hash21(id + offs); // random between 0 and 1
float size = fract(n * 345.32);
float star = Star(gv - offs - vec2(n, fract(n * 42.)) + .5, smoothstep(.9, 1., size) * .6);
vec3 color = sin(vec3(.8, .8, .8) * fract(n * 2345.2) * 123.2) * .5 + .5;
color = color * vec3(0.25, 0.25, 0.20);
star *= sin(iTime * 3. + n * 6.2831) * .5 + 1.;
col += star * size * color;
}
}
return col;
}
void main()
{
vec2 uv = (gl_FragCoord.xy - .5 * iResolution.xy) / iResolution.y;
float t = iTime * .02;
float speed = 3.0;
vec2 direction = vec2(-0.25 + direction_modifier, -1.0) * speed;
uv += direction;
vec3 col = vec3(0);
for (float i = 0.; i < 1.; i += 1. / NUM_LAYERS) {
float depth = fract(i+t);
float scale = mix(20., .5, depth);
float fade = depth * smoothstep(1., .9, depth);
col += StarLayer(uv * scale + i * 453.2) * fade;
}
gl_FragColor = vec4(col, 1.0);
}
If you want to know how the shader works you can watch the video Shader Coding: Making a starfield by The Art of Code.
Our starfield is now done and the game is starting to look like it takes place in outer space.