Materials, effects and lighting
A material says how a sprite or shape is drawn. A full-screen effect blends two images across the whole screen, for transitions and similar work. Lighting brightens lit sprites near point lights, and sets the ambient light they are drawn in. Their shaders are GLSL. Shaders in the game's own files are compiled when the game builds, as Shaders explains.
Built-in materials
A sprite is drawn with one of these materials, set with its material option:
| Material | Used for |
|---|---|
Material.Unlit |
The default. The sprite is drawn as it is, in any pass. |
Material.Lit |
The sprite is lit by the ambient colour and by point lights. |
Material.Veil |
A sprite laid over a lit scene that the lights clear rather than brighten: mist, smoke, a colour wash. |
A shape is drawn with Material.Shape, the default, which lighting does not touch. A shape can also
use a material of its own, as described below.
Material.Lit and Material.Veil are for the scene pass only, which is where Draw runs.
Custom shape materials
A custom material is a pair of shaders and a name. The shaders are GLSL files, embedded in the game and compiled at build:
<ItemGroup>
<EmbeddedResource Include="Shaders\*" />
<BSGameLibShader Include="Shaders\*.vert;Shaders\*.frag" />
</ItemGroup>
The shader pair for a material that draws stripes:
// Stripes.vert
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec4 aColor;
out vec4 vertexColor;
uniform mat4 uProjection;
void main()
{
vertexColor = aColor;
gl_Position = uProjection * vec4(aPos, 0.0, 1.0);
}
// Stripes.frag
#version 330 core
in vec4 vertexColor;
out vec4 fragColor;
void main()
{
float band = mod(floor((gl_FragCoord.x + gl_FragCoord.y) / 8.0), 2.0);
fragColor = band < 1.0 ? vertexColor : vec4(0.0, 0.0, 0.0, 1.0);
}
A shape shader gets each vertex's position as aPos at location 0, and its colour as aColor at
location 1. It multiplies the position by uProjection, as the built-in shape shader does. The
renderers set uProjection up so that this gives the right position on each of them.
Register the material with the game's ShaderManager, giving it a name, the passes it may be used
in, and the code that loads its shader:
private static readonly Material Stripes = Material.Create("mygame.stripes");
protected override void Initialize()
{
var assembly = typeof(MyGame).Assembly;
ShaderManager.EnsureMaterial(Stripes, builder => builder
.WithName("mygame.stripes")
.AllowPasses(RenderPassTag.Scene | RenderPassTag.UI)
.WithShader(() => ShaderManager.LoadEmbedded(
"mygame.stripes",
assembly,
"MyGame.Shaders.Stripes.vert",
"MyGame.Shaders.Stripes.frag")));
base.Initialize();
}
The resource names are the game's root namespace followed by the folder and file name, as the
compiler embeds them. EnsureMaterial returns the existing registration if the material is already
there, and RegisterMaterial replaces it.
Then draw with it like any other shape:
encoder.SubmitShape(ShapeDrawOptions.RectangleFilled(new Rectangle(20, 20, 160, 80), Color.Gold, material: Stripes));
Some mistakes throw an exception:
- Submitting a shape whose material was never registered with
ShaderManagerthrows anInvalidOperationException. - Submitting a shape in a pass that the material's
AllowPassesdoes not include throws the same exception. - Creating a shape with a sprite material, such as
Material.Lit, throws anArgumentException.
Custom sprite materials
A sprite can be drawn with a custom material too, registered the same way and passed as the
sprite's material option. This works on Vulkan only so far: on OpenGL the sprite is drawn unlit,
and the renderer logs an error naming the material, once.
The sprite renderer gives the vertex shader four inputs for each corner:
layout (location = 0) in vec2 aPos; // the corner in clip space, already projected
layout (location = 1) in vec2 aTexCoord; // the texture coordinate, 0 to 1
layout (location = 2) in vec4 aColor; // the sprite's tint, 0 to 1 per channel
layout (location = 3) in vec2 aWorldPos; // the corner in the logical pixels sprites are drawn in
aPos is already where the corner goes, so the vertex shader passes it straight to gl_Position;
aWorldPos is there for effects that depend on where on the scene a pixel is, such as lighting.
The renderer sets these uniforms when it draws with the material:
uTexture, asampler2D: the sprite's texture.uScreenSize, avec2: the size of what is being drawn into, in pixels.uProjection, amat4: the identity, sinceaPosis already projected. A shader may leave it out.
If the shader has a colour uniform named uColorMultiplier, uColor, uTintColor or uTint
(the first of these it has), every sprite's tint is multiplied by its value. Anything else the
shader declares is the game's to set, through the material's shader
(ShaderManager.GetMaterialShader(material)). To light the material, see
Lighting a custom shader.
As with shapes, submitting a sprite whose material isn't registered, or in a pass its
AllowPasses doesn't include, throws an InvalidOperationException. If the material's shader
fails to load or to be set up, the sprite is drawn with the engine's own sprite shader instead and
the renderer logs an error with the reason, once per material.
Shaders given as text
ShaderManager.LoadShader(name, vertexSource, fragmentSource) takes the GLSL as a string, which
may be read from a file or made at run time. The OpenGL renderer compiles it when it is loaded. The
Vulkan renderer needs a compiler for it, which is the
BSGameLib.Game.ShaderCompiler package, as Shaders explains. Without one, the Vulkan
renderer logs the problem and draws without the shader.
Full-screen effects
A full-screen effect draws one image over another across the whole screen, with a progress from 0
to 1 that blends between them. It is the tool for transitions such as a wipe. Get the factory from
the services, and create the effect from its two shaders:
private IFullScreenEffect _wipe;
protected override void Initialize()
{
_wipe = GetService<IFullScreenEffectFactory>().Create(
ReadShader("MyGame.Shaders.Wipe.vert"),
ReadShader("MyGame.Shaders.Wipe.frag"));
base.Initialize();
}
private static string ReadShader(string resourceName)
{
using var stream = typeof(MyGame).Assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"{resourceName} is not embedded.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
Then, in Draw, the effect draws between two textures. Each frame, give it a progress from 0 to 1:
using var encoder = Graphics2D.Begin();
_wipe.Render(encoder, _oldScene, _newScene, _progress);
Its shaders follow a fixed layout. The vertex shader takes aPos at location 0, a position on a
0 to 1 square that covers the screen, and aTexCoord at location 1. The fragment shader reads two
samplers, uFromTexture and uToTexture, and a float uProgress:
// Wipe.vert
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoord;
out vec2 vTexCoord;
void main()
{
vTexCoord = aTexCoord;
gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0);
}
// Wipe.frag
#version 330 core
in vec2 vTexCoord;
out vec4 fragColor;
uniform sampler2D uFromTexture;
uniform sampler2D uToTexture;
uniform float uProgress;
void main()
{
float t = smoothstep(uProgress - 0.05, uProgress + 0.05, vTexCoord.x);
fragColor = mix(texture(uFromTexture, vTexCoord), texture(uToTexture, vTexCoord), t);
}
An effect always takes two textures. To apply one image, pass it twice. For other post-processing, use render passes, as Render passes describes. Dispose the effect when the game no longer needs it.
Lighting
ILightingManager is in the services, and it holds the lighting for every lit draw:
var lighting = GetService<ILightingManager>();
lighting.SetGlobalAmbientColor(Color.FromRgba(40, 40, 60, 255));
// A lamp: a point light that reaches 200 pixels in every direction.
lighting.AddPointLight(new PointLight(new Vector2(320, 180), Color.FromRgba(255, 200, 120, 255), 1.5f, 200f));
// A spotlight: 40 degrees wide, pointing down the screen, fading out over its last 8 degrees.
lighting.AddPointLight(new PointLight(new Vector2(480, 40), Color.FromRgba(255, 255, 255, 255), 2f, 400f)
{
Cone = new LightCone(new Vector2(0f, 1f), Angle: 40f, Softness: 8f),
});
// A torch that flickers: its brightness swings 0.3 either way, three times a second.
var torch = lighting.AddPointLight(new PointLight(new Vector2(100, 300), Color.FromRgba(255, 160, 60, 255), 1.2f, 150f)
{
Pulse = new LightPulse(Frequency: 3f, Amplitude: 0.3f),
});
SetGlobalAmbientColorsets the light every lit sprite starts from. It is grey, at 30 out of 255, by default, which is dark.AddPointLightadds a point light. ItsPositionis where it is, in the logical pixels sprites are drawn in: the ones a sprite's destination rectangle uses. So a light placed at a sprite's corner lights that corner whatever the window's pixel size is.Coloris its colour, andIntensityandRadiusset how bright it is and how far it reaches. Its light falls off with distance and reaches nothing beyondRadius. A light adds its colour's red, green and blue to a lit sprite and leaves the sprite's own transparency alone; the colour's alpha is how strongly the light clears a veil (below).Pulsemakes a light pulse:LightPulse(Frequency, Amplitude, RadiusAmplitude, Offset).Frequencyis pulses a second,Amplitudeis how farIntensitymoves either way,RadiusAmplitudemovesRadiusby that multiple of the same swing, andOffsetshifts the pulse by that many seconds. The pulse follows the game's total time (IGameTime.TotalTime), not when the light was added, so lights with the same frequency and offset pulse together, and a light added again every frame pulses smoothly. The engine works out each pulse's place in its cycle in double precision, so a pulse keeps its rhythm however long the game has been running.Conemakes a point light a spotlight, as described below.AddPointLightreturns aPointLightHandle.UpdatePointLight(handle, light)changes that light in place (for one that moves, or that the game flickers by hand), andRemovePointLight(handle)takes it away; both return false once the light is gone.ClearPointLightsremoves every light. A game that works out its lights afresh each frame can callClearPointLightsand add them all again, and ignore the handles.PointLightslists the lights, in the order they were added.SetGreyscale(amount)drains colour from lit sprites before they are lit, from 0, which leaves them alone, to 1, which makes them grey.
Spotlights
A point light lights every pixel within its Radius by default. Give it a Cone, and it lights only
inside a cone from its Position. LightCone(Direction, Angle, Softness):
Directionis the way the cone points. It is in the same coordinates asPosition, so(0, 1)points down the screen. Zero makes the light an ordinary point light.Angleis the cone's full width in degrees. 360 or more is the whole circle, so an ordinary point light whatever itsDirection.Softnessfades the light out over that many degrees just inside the cone's edge, so the edge is soft rather than hard. The default, 0, gives a hard edge.
Inside the cone the light falls off with distance as it does for any point light, and a pixel outside the cone gets none of it. The example above lights a 40 degree cone reaching 400 pixels down the screen. A veil is cleared inside the cone in the same way.
Lights act only on sprites drawn with Material.Lit, and on those drawn with Material.Veil. An
unlit sprite and a shape are not affected. Make a sprite lit with its material option:
encoder.SubmitSprite(new SpriteDrawOptions(_wall, new Rectangle(40, 300, 200, 60), material: Material.Lit));
A veil is a lit sprite that the lights clear: it keeps the ambient colour and grows more transparent where a point light falls on it.
Each draw uses at most 32 point lights. If more are added, the first 32 in the list are used.
Lighting a custom shader
ILightingManager.ConfigureShader(shader, totalSeconds) sets the lighting on a shader: the ambient
colour, the greyscale amount and the first 32 point lights. Material.Lit and Material.Veil
sprites get it automatically. A custom material that should be lit declares the same uniforms as
the engine's sprite shader, and the game calls ConfigureShader on the material's shader once a
frame, before drawing with it:
lighting.ConfigureShader(ShaderManager.GetMaterialShader(Glow), gameTime.TotalTime.TotalSeconds);
The uniforms it sets, which are part of the engine's API:
uniform vec4 uGlobalAmbientColor; // the ambient colour, 0 to 1 per channel
uniform float uGreyscale; // 0 to 1
struct PointLight {
vec2 Position; // in the logical pixels sprites are drawn in
vec4 Color; // rgb is the light's colour; a is its strength against veils
float Radius; // pulse already applied
float Intensity; // pulse already applied
vec2 Direction; // a spotlight's unit direction, or zero for no cone
float ConeEdgeCos; // cosine of half the cone's angle
float ConeFadeCos; // cosine of where the fade inside the edge begins
};
uniform PointLight uPointLights[32];
uniform int uNumPointLights; // how many of uPointLights are in use
Pulses and cones are worked out before they reach the shader: Radius and Intensity are this
frame's values, and a cone arrives as the cosines a fragment shader compares against, so the shader
needs no trigonometry.