Table of Contents

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 ShaderManager throws an InvalidOperationException.
  • Submitting a shape in a pass that the material's AllowPasses does not include throws the same exception.
  • Creating a shape with a sprite material, such as Material.Lit, throws an ArgumentException.

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, a sampler2D: the sprite's texture.
  • uScreenSize, a vec2: the size of what is being drawn into, in pixels.
  • uProjection, a mat4: the identity, since aPos is 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),
});
  • SetGlobalAmbientColor sets the light every lit sprite starts from. It is grey, at 30 out of 255, by default, which is dark.
  • AddPointLight adds a point light. Its Position is 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. Color is its colour, and Intensity and Radius set how bright it is and how far it reaches. Its light falls off with distance and reaches nothing beyond Radius. 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).
  • Pulse makes a light pulse: LightPulse(Frequency, Amplitude, RadiusAmplitude, Offset). Frequency is pulses a second, Amplitude is how far Intensity moves either way, RadiusAmplitude moves Radius by that multiple of the same swing, and Offset shifts 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.
  • Cone makes a point light a spotlight, as described below.
  • AddPointLight returns a PointLightHandle. UpdatePointLight(handle, light) changes that light in place (for one that moves, or that the game flickers by hand), and RemovePointLight(handle) takes it away; both return false once the light is gone. ClearPointLights removes every light. A game that works out its lights afresh each frame can call ClearPointLights and add them all again, and ignore the handles. PointLights lists 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):

  • Direction is the way the cone points. It is in the same coordinates as Position, so (0, 1) points down the screen. Zero makes the light an ordinary point light.
  • Angle is the cone's full width in degrees. 360 or more is the whole circle, so an ordinary point light whatever its Direction.
  • Softness fades 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.