Sitemap

Monogame Spot Light!

8 min readSep 16, 2026

--

Press enter or click to view image in full size
Press enter or click to view image in full size

Another quick one today. Spotlights in MonoGame!

#define VS_SHADERMODEL vs_6_0
#define PS_SHADERMODEL ps_6_0

matrix World;
matrix View;
matrix Projection;

float3 LightPosition;
float3 LightDirection;
float3 LightColor;
float LightRadius;
float LightIntensity;
float3 AmbientColor;

float InnerConeCos;
float OuterConeCos;

Texture2D RoomTexture : register(t0);
SamplerState TextureSampler : register(s0);

struct VertexInput
{
float4 Position : POSITION0;
float3 Normal : NORMAL0;
float2 TexCoord : TEXCOORD0;
};

struct VertexOutput
{
float4 Position : SV_POSITION;
float4 WorldPos : TEXCOORD1;
float3 Normal : NORMAL0;
float2 TexCoord : TEXCOORD0;
};

VertexOutput MainVS(in VertexInput input)
{
VertexOutput output = (VertexOutput) 0;

float4 worldPos = mul(input.Position, World);
output.WorldPos = worldPos;

float4 viewPos = mul(worldPos, View);
output.Position = mul(viewPos, Projection);

output.Normal = normalize(mul(input.Normal, (float3x3) World));
output.TexCoord = input.TexCoord;

return output;
}

float4 MainPS(VertexOutput input) : SV_Target0
{
float distance = length(LightPosition - input.WorldPos.xyz);
float distAttenuation = 1.0f - saturate(distance / LightRadius);
distAttenuation *= distAttenuation;

float3 lightDir = normalize(LightPosition - input.WorldPos.xyz);
float nDotL = max(0, dot(input.Normal, lightDir));

float spotCos = dot(LightDirection, -lightDir);

float spotAttenuation = smoothstep(OuterConeCos, InnerConeCos, spotCos);

float3 finalLight = AmbientColor + (LightColor * LightIntensity * nDotL * distAttenuation * spotAttenuation);

float4 texColor = RoomTexture.Sample(TextureSampler, input.TexCoord);

return float4(texColor.rgb * finalLight, texColor.a);
}

technique SpotLightTechnique
{
pass P0
{
VertexShader = compile VS_SHADERMODEL MainVS();
PixelShader = compile PS_SHADERMODEL MainPS();
}
}

This is the shader for the lighting. It measures the distance between the light and the floor, then we use the distance to figure out how far that source is to the max radius and then calculates a smooth fall off curve.

using Microsoft.Xna.Framework;

namespace StarLiteFramework.Lighting
{
public class SpotLight
{
public Vector3 Position { get; set; }
public Vector3 Direction { get; set; }
public Vector3 DiffuseColor { get; set; }
public Vector3 AmbientColor { get; set; }
public float Intensity { get; set; } = 1.0f;
public float Radius { get; set; } = 100f;

// Cone angles in radians.
// Inner cone is full brightness, outer cone is where it fades to zero.
public float InnerConeAngle { get; set; }
public float OuterConeAngle { get; set; }

public SpotLight(Vector3 position, Vector3 direction, Vector3 diffuse, Vector3 ambient, float intensity, float radius, float innerConeAngle, float outerConeAngle)
{
Position = position;
Direction = Vector3.Normalize(direction);
DiffuseColor = diffuse;
AmbientColor = ambient;
Intensity = intensity;
Radius = radius;
InnerConeAngle = innerConeAngle;
OuterConeAngle = outerConeAngle;
}
}
}

The spotlight implementation itself. I included my camera class as it is used in this demo again:

using Microsoft.Xna.Framework;

namespace StarLiteFramework.Rendering
{
public class Camera3D
{
public Matrix ViewMatrix { get; private set; }
public Vector3 Forward { get; private set; }
public Vector3 Right { get; private set; }
public Vector3 Up { get; private set; }

public Camera3D()
{
Forward = Vector3.Forward;
Right = Vector3.Right;
Up = Vector3.Up;
UpdateMatrices();
}

/// <summary>
/// Updates the camera's view matrix based on a target rotation.
/// </summary>
public void Update(Quaternion targetRotation)
{
Matrix cameraRotation = Matrix.CreateFromQuaternion(targetRotation);
Forward = cameraRotation.Forward;
Right = cameraRotation.Right;
Up = cameraRotation.Up;

UpdateMatrices();
}

private void UpdateMatrices()
{
ViewMatrix = Matrix.CreateLookAt(Vector3.Zero, Forward, Up);
}

public Matrix GetProjectionMatrix(float aspectRatio, float dynamicNearPlane, float farPlane = 50000000f)
{
return Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4, aspectRatio, dynamicNearPlane, farPlane);
}
}
}

The main game class:

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using StarLiteFramework;
using StarLiteFramework.Lighting;
using System;

namespace RockGenerationDemo.Lighting
{
public class SpotLight_Game : Engine3D
{
private StarLiteFramework.Rendering.Camera3D _camera;
private SpotLight _spotLight;
private Vector3 _playerPosition;
private float _yaw;
private float _pitch;

private MouseState _previousMouseState;
private bool _isFirstFrame = true;

private VertexBuffer _roomVertexBuffer;
private IndexBuffer _roomIndexBuffer;

private Effect _effect;
private Texture2D _gridTexture;

private VertexBuffer _lightVertexBuffer;
private IndexBuffer _lightIndexBuffer;
private BasicEffect _lightEffect;

private VertexBuffer _coneVertexBuffer;
private IndexBuffer _coneIndexBuffer;
private int _coneTriangleCount;

public SpotLight_Game()
{
Content.RootDirectory = "Content";
IsMouseVisible = false;
}

protected override void Initialize()
{
_camera = new StarLiteFramework.Rendering.Camera3D();
_playerPosition = new Vector3(0, 0, 20f);

// init light
_spotLight = new SpotLight(
position: new Vector3(0, 15f, 0),
direction: Vector3.Down,
diffuse: new Vector3(1.0f, 0.85f, 0.7f),
ambient: new Vector3(0.05f, 0.05f, 0.05f),
intensity: 2.5f,
radius: 100f,
innerConeAngle: MathHelper.ToRadians(15),
outerConeAngle: MathHelper.ToRadians(25)
);

base.Initialize();
}

protected override void LoadContent()
{
if (spriteBatch == null)
spriteBatch = new SpriteBatch(GraphicsDevice);

BuildRoomGeometry();
BuildLightGeometry();

BuildConeGeometry();

_gridTexture = new Texture2D(GraphicsDevice, 2, 2);
_gridTexture.SetData(new[] { Color.DarkGray, Color.LightGray, Color.LightGray, Color.DarkGray });

_effect = Content.Load<Effect>("SpotLightShader");

_lightEffect = new BasicEffect(GraphicsDevice)
{
LightingEnabled = false,
VertexColorEnabled = true
};
}

private void BuildRoomGeometry()
{
float s = 50f;
float t = 10f;

var vertices = new VertexPositionNormalTexture[]
{
new VertexPositionNormalTexture(new Vector3(-s, -s, -s), Vector3.Up, new Vector2(0, 0)),
new VertexPositionNormalTexture(new Vector3( s, -s, -s), Vector3.Up, new Vector2(t, 0)),
new VertexPositionNormalTexture(new Vector3( s, -s, s), Vector3.Up, new Vector2(t, t)),
new VertexPositionNormalTexture(new Vector3(-s, -s, s), Vector3.Up, new Vector2(0, t)),

new VertexPositionNormalTexture(new Vector3(-s, s, -s), Vector3.Down, new Vector2(0, 0)),
new VertexPositionNormalTexture(new Vector3(-s, s, s), Vector3.Down, new Vector2(0, t)),
new VertexPositionNormalTexture(new Vector3( s, s, s), Vector3.Down, new Vector2(t, t)),
new VertexPositionNormalTexture(new Vector3( s, s, -s), Vector3.Down, new Vector2(t, 0)),

new VertexPositionNormalTexture(new Vector3(-s, -s, -s), Vector3.Backward, new Vector2(0, 0)),
new VertexPositionNormalTexture(new Vector3(-s, s, -s), Vector3.Backward, new Vector2(0, t)),
new VertexPositionNormalTexture(new Vector3( s, s, -s), Vector3.Backward, new Vector2(t, t)),
new VertexPositionNormalTexture(new Vector3( s, -s, -s), Vector3.Backward, new Vector2(t, 0)),

new VertexPositionNormalTexture(new Vector3(-s, -s, s), Vector3.Forward, new Vector2(0, 0)),
new VertexPositionNormalTexture(new Vector3( s, -s, s), Vector3.Forward, new Vector2(t, 0)),
new VertexPositionNormalTexture(new Vector3( s, s, s), Vector3.Forward, new Vector2(t, t)),
new VertexPositionNormalTexture(new Vector3(-s, s, s), Vector3.Forward, new Vector2(0, t)),

new VertexPositionNormalTexture(new Vector3(-s, -s, -s), Vector3.Right, new Vector2(0, 0)),
new VertexPositionNormalTexture(new Vector3(-s, -s, s), Vector3.Right, new Vector2(t, 0)),
new VertexPositionNormalTexture(new Vector3(-s, s, s), Vector3.Right, new Vector2(t, t)),
new VertexPositionNormalTexture(new Vector3(-s, s, -s), Vector3.Right, new Vector2(0, t)),

new VertexPositionNormalTexture(new Vector3(s, -s, -s), Vector3.Left, new Vector2(0, 0)),
new VertexPositionNormalTexture(new Vector3(s, s, -s), Vector3.Left, new Vector2(0, t)),
new VertexPositionNormalTexture(new Vector3(s, s, s), Vector3.Left, new Vector2(t, t)),
new VertexPositionNormalTexture(new Vector3(s, -s, s), Vector3.Left, new Vector2(t, 0))
};

short[] indices = new short[]
{
0, 1, 2, 0, 2, 3,
4, 5, 6, 4, 6, 7,
8, 9, 10, 8, 10, 11,
12, 13, 14, 12, 14, 15,
16, 17, 18, 16, 18, 19,
20, 21, 22, 20, 22, 23
};

_roomVertexBuffer = new VertexBuffer(GraphicsDevice, typeof(VertexPositionNormalTexture), vertices.Length, BufferUsage.WriteOnly);
_roomVertexBuffer.SetData(vertices);

_roomIndexBuffer = new IndexBuffer(GraphicsDevice, typeof(short), indices.Length, BufferUsage.WriteOnly);
_roomIndexBuffer.SetData(indices);
}

private void BuildLightGeometry()
{
float s = 0.5f;
Color c = Color.Orange;
var vertices = new VertexPositionColor[]
{
new VertexPositionColor(new Vector3(-s, -s, -s), c),
new VertexPositionColor(new Vector3( s, -s, -s), c),
new VertexPositionColor(new Vector3( s, -s, s), c),
new VertexPositionColor(new Vector3(-s, -s, s), c),
new VertexPositionColor(new Vector3(-s, s, -s), c),
new VertexPositionColor(new Vector3( s, s, -s), c),
new VertexPositionColor(new Vector3( s, s, s), c),
new VertexPositionColor(new Vector3(-s, s, s), c)
};

short[] indices = {
0,1,2, 0,2,3,
4,6,5, 4,7,6,
0,4,5, 0,5,1,
3,2,6, 3,6,7,
0,3,7, 0,7,4,
1,5,6, 1,6,2
};

_lightVertexBuffer = new VertexBuffer(GraphicsDevice, typeof(VertexPositionColor), vertices.Length, BufferUsage.WriteOnly);
_lightVertexBuffer.SetData(vertices);

_lightIndexBuffer = new IndexBuffer(GraphicsDevice, typeof(short), indices.Length, BufferUsage.WriteOnly);
_lightIndexBuffer.SetData(indices);
}

private void BuildConeGeometry()
{
int segments = 32;
var vertices = new VertexPositionColor[segments + 1];

// Multiply light color by a low alpha (0.2f) so it looks like soft dust/fog
Color tipColor = new Color(_spotLight.DiffuseColor) * 0.2f;

// The tip of the cone is at local origin
vertices[0] = new VertexPositionColor(Vector3.Zero, tipColor);

// Calculate how wide the bottom of the cone is at its max radius
float coneLength = _spotLight.Radius;
float coneRadius = coneLength * MathF.Tan(_spotLight.OuterConeAngle);

for (int i = 0; i < segments; i++)
{
float angle = (float)i / segments * MathHelper.TwoPi;
float x = coneRadius * MathF.Cos(angle);
float z = coneRadius * MathF.Sin(angle);

// Assuming spotLight direction is Vector3.Down, Y goes negative.
// The base of the cone fades perfectly to transparent
vertices[i + 1] = new VertexPositionColor(new Vector3(x, -coneLength, z), Color.Transparent);
}

short[] indices = new short[segments * 3];
for (int i = 0; i < segments; i++)
{
indices[i * 3] = 0; // Tip
indices[i * 3 + 1] = (short)(1 + i);
indices[i * 3 + 2] = (short)(1 + ((i + 1) % segments));
}

_coneTriangleCount = segments;

_coneVertexBuffer = new VertexBuffer(GraphicsDevice, typeof(VertexPositionColor), vertices.Length, BufferUsage.WriteOnly);
_coneVertexBuffer.SetData(vertices);

_coneIndexBuffer = new IndexBuffer(GraphicsDevice, typeof(short), indices.Length, BufferUsage.WriteOnly);
_coneIndexBuffer.SetData(indices);
}

protected override void Update(GameTime gameTime)
{
HandleInput((float)gameTime.ElapsedGameTime.TotalSeconds);

if (Keyboard.GetState().IsKeyDown(Keys.Escape))
QuitGame();

base.Update(gameTime);
}

private void HandleInput(float dt)
{
if (!IsActive) return;

var keyboardState = Keyboard.GetState();
var mouseState = Mouse.GetState();

if (_isFirstFrame)
{
_previousMouseState = mouseState;
_isFirstFrame = false;
return;
}

float deltaX = mouseState.X - _previousMouseState.X;
float deltaY = mouseState.Y - _previousMouseState.Y;

_yaw -= deltaX * 0.002f;
_pitch -= deltaY * 0.002f;
_pitch = MathHelper.Clamp(_pitch, -MathHelper.PiOver2 + 0.01f, MathHelper.PiOver2 - 0.01f);

Quaternion cameraRot = Quaternion.CreateFromYawPitchRoll(_yaw, _pitch, 0f);
_camera.Update(cameraRot);

int bounds = 50;
if (mouseState.X <= bounds || mouseState.Y <= bounds ||
mouseState.X >= Window.ClientBounds.Width - bounds ||
mouseState.Y >= Window.ClientBounds.Height - bounds)
{
int centerX = Window.ClientBounds.Width / 2;
int centerY = Window.ClientBounds.Height / 2;

Mouse.SetPosition(centerX, centerY);

_previousMouseState = new MouseState(
centerX, centerY,
mouseState.ScrollWheelValue,
mouseState.LeftButton, mouseState.MiddleButton,
mouseState.RightButton, mouseState.XButton1, mouseState.XButton2);
}
else
{
_previousMouseState = mouseState;
}

float moveSpeed = 35f * dt;
Vector3 moveDirection = Vector3.Zero;

if (keyboardState.IsKeyDown(Keys.W)) moveDirection += _camera.Forward;
if (keyboardState.IsKeyDown(Keys.S)) moveDirection -= _camera.Forward;
if (keyboardState.IsKeyDown(Keys.A)) moveDirection -= _camera.Right;
if (keyboardState.IsKeyDown(Keys.D)) moveDirection += _camera.Right;

if (keyboardState.IsKeyDown(Keys.Space)) moveDirection += Vector3.Up;
if (keyboardState.IsKeyDown(Keys.LeftShift)) moveDirection += Vector3.Down;

if (moveDirection.LengthSquared() > 0)
moveDirection.Normalize();

_playerPosition += moveDirection * moveSpeed;
}

protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.Black);

// draw demo room
GraphicsDevice.SetVertexBuffer(_roomVertexBuffer);
GraphicsDevice.Indices = _roomIndexBuffer;

GraphicsDevice.DepthStencilState = DepthStencilState.Default;
GraphicsDevice.BlendState = BlendState.Opaque;
GraphicsDevice.RasterizerState = RasterizerState.CullNone;

Matrix worldMatrix = Matrix.CreateTranslation(-_playerPosition);
Matrix projectionMatrix = _camera.GetProjectionMatrix(GraphicsDevice.Viewport.AspectRatio, 0.1f, 1000f);

_effect.Parameters["World"].SetValue(worldMatrix);
_effect.Parameters["View"].SetValue(_camera.ViewMatrix);
_effect.Parameters["Projection"].SetValue(projectionMatrix);

Vector3 translatedLightPos = Vector3.Transform(_spotLight.Position, worldMatrix);

_effect.Parameters["LightPosition"].SetValue(translatedLightPos);
_effect.Parameters["LightDirection"].SetValue(_spotLight.Direction);
_effect.Parameters["LightColor"].SetValue(_spotLight.DiffuseColor);
_effect.Parameters["LightRadius"].SetValue(_spotLight.Radius);
_effect.Parameters["LightIntensity"].SetValue(_spotLight.Intensity);
_effect.Parameters["AmbientColor"].SetValue(_spotLight.AmbientColor);

_effect.Parameters["InnerConeCos"].SetValue(MathF.Cos(_spotLight.InnerConeAngle));
_effect.Parameters["OuterConeCos"].SetValue(MathF.Cos(_spotLight.OuterConeAngle));

foreach (EffectPass pass in _effect.CurrentTechnique.Passes)
{
pass.Apply();

GraphicsDevice.Textures[0] = _gridTexture;
GraphicsDevice.SamplerStates[0] = SamplerState.PointWrap;

GraphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, 12);
}

// Setup matrices for light box and cone
_lightEffect.World = Matrix.CreateTranslation(_spotLight.Position) * worldMatrix;
_lightEffect.View = _camera.ViewMatrix;
_lightEffect.Projection = projectionMatrix;

// draw light source box -- just for demo
GraphicsDevice.SetVertexBuffer(_lightVertexBuffer);
GraphicsDevice.Indices = _lightIndexBuffer;

foreach (EffectPass pass in _lightEffect.CurrentTechnique.Passes)
{
pass.Apply();
GraphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, 12);
}

// draw volumetric light cone
// additive blending makes overlapping light "brighter"
GraphicsDevice.BlendState = BlendState.Additive;
// reads depth so it hides behind walls, but disable depth write so it doesn't block other things
GraphicsDevice.DepthStencilState = DepthStencilState.DepthRead;

GraphicsDevice.SetVertexBuffer(_coneVertexBuffer);
GraphicsDevice.Indices = _coneIndexBuffer;

foreach (EffectPass pass in _lightEffect.CurrentTechnique.Passes)
{
pass.Apply();
GraphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, _coneTriangleCount);
}

// reset before next frame
GraphicsDevice.BlendState = BlendState.Opaque;
GraphicsDevice.DepthStencilState = DepthStencilState.Default;

base.Draw(gameTime);
}
}
}

To help unpack some of this a little bit, the BuildRoomGeometry, BuildLightGeometry and BuildConeGeometry are all just creating a basic procedural geometry. Handle input is just handling the movement of the camera and getting keyboard and mouse inputs.

Get Matt Perley’s stories in your inbox

Join Medium for free to get updates from this writer.

The draw function is doing a lot, however, its pretty straight forward. A lot of it is just the objects being drawn. Here we set up the shader parameters:

Matrix worldMatrix = Matrix.CreateTranslation(-_playerPosition);
Matrix projectionMatrix = _camera.GetProjectionMatrix(GraphicsDevice.Viewport.AspectRatio, 0.1f, 1000f);

_effect.Parameters["World"].SetValue(worldMatrix);
_effect.Parameters["View"].SetValue(_camera.ViewMatrix);
_effect.Parameters["Projection"].SetValue(projectionMatrix);

Vector3 translatedLightPos = Vector3.Transform(_spotLight.Position, worldMatrix);

_effect.Parameters["LightPosition"].SetValue(translatedLightPos);
_effect.Parameters["LightDirection"].SetValue(_spotLight.Direction);
_effect.Parameters["LightColor"].SetValue(_spotLight.DiffuseColor);
_effect.Parameters["LightRadius"].SetValue(_spotLight.Radius);
_effect.Parameters["LightIntensity"].SetValue(_spotLight.Intensity);
_effect.Parameters["AmbientColor"].SetValue(_spotLight.AmbientColor);

_effect.Parameters["InnerConeCos"].SetValue(MathF.Cos(_spotLight.InnerConeAngle));
_effect.Parameters["OuterConeCos"].SetValue(MathF.Cos(_spotLight.OuterConeAngle));

Everything else is simply setting up the parameters required to feed the shader, draw the objects, etc.

I should probably mention now as well, I am using Monogame native and their new desktopvk. It has been a much smoother experience than opengl, I got pretty far using it, but vulkan just works better for me at this point.

As usual, I hope this is helpful to someone looking to use monogame for 3D games and applications! It is a great tool and I hope to see more people using it in the future! Until next time, please take care.

--

--

Matt Perley
Matt Perley

Written by Matt Perley

Business owner. Game developer. Software engineer. I make AI, games, apps and headaches.