Tutorial 37: Multiple Light Sources
What you’ll learn
- Why
BasicEffectstops at three directional lights. - Faking point lights, and splitting a scene across draw calls with different effects.
- When to give up and write a custom shader instead.
Before you start — Tutorial 32: BasicEffect and 3D Lighting — this starts from the three-light rig set up there. Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the 2D-only renderers (SDL_RENDERER, DIRECT2D, CANVAS, HTML_DOM, SKIA, BLEND2D, FREEDIRECT, DIRECTX1, GDI, SVG_DOM, OPENVG, NANOVG, PIXIJS) throw on 3D calls.
BasicEffect supports exactly three directional lights. This tutorial shows how to configure them for cinematic three-point lighting, how to approximate point lights, and when to reach for a custom shader instead.
XNA 3-Directional-Light Limit
BasicEffect mirrors the original XNA design: it exposes three directional light slots (DirectionalLight0, DirectionalLight1, DirectionalLight2). Directional lights have a direction vector but no position — they model an infinitely distant source like the sun. Every vertex in the scene is lit identically regardless of its world position.
This design maps efficiently onto shader constant buffers and was a deliberate hardware-era trade-off. For many 3D games it is sufficient; the three-point lighting rig (key + fill + rim) has been a cinema industry standard for decades.
// Enable all three lights and set directions
effect.DirectionalLight0.setEnabledProperty(true);
effect.DirectionalLight0.setDirectionProperty(Vector3::Normalize({-1, -1, -0.5f}));
effect.DirectionalLight0.setDiffuseColorProperty({1.0f, 0.9f, 0.7f}); // warm sun
effect.DirectionalLight1.setEnabledProperty(true);
effect.DirectionalLight1.setDirectionProperty(Vector3::Normalize({ 1, 0, -1}));
effect.DirectionalLight1.setDiffuseColorProperty({0.3f, 0.4f, 0.6f}); // cool fill
effect.DirectionalLight2.setEnabledProperty(true);
effect.DirectionalLight2.setDirectionProperty(Vector3::Normalize({ 0, 1, 1}));
effect.DirectionalLight2.setDiffuseColorProperty({0.5f, 0.5f, 0.5f}); // rim
All three directional light slots active on a flat, front-facing textured quad. Because the quad has one normal and directional lights have no falloff, the three contributions sum to a single uniform value across the whole face — the limitation this tutorial is about. This is genuine Microsoft XNA 4.0 runtime output from CNA's oracle corpus (tools/xna-oracle/), which CNA's DIRECTX9 renderer is diffed against at --tolerance 0.
Simulating Point Lights (BasicEffect Workaround)
A point light emits in all directions from a single position in world space. BasicEffect has no native point-light support, but you can approximate one per object by computing the direction vector from the light position to the object's centre and updating DirectionalLight0 before each draw call:
// Per-object light direction update
Vector3 lightPos = {3, 4, 2};
Vector3 objectCentre = world.Translation();
Vector3 toLight = Vector3::Normalize(lightPos - objectCentre);
effect.DirectionalLight0.setDirectionProperty(-toLight); // direction points away from light
effect.setWorldProperty(world);
DrawObject();
This is not physically correct — surfaces far from the light receive the same apparent intensity as close ones — but it is visually convincing for small scenes where objects do not span large world distances relative to the light.
Multiple Draw Calls with Different Effects
If objects truly need independent lighting (e.g., an outdoor scene and an indoor area rendered in the same frame), create multiple BasicEffect instances with different light configurations and assign each to the relevant ModelMeshPart:
// Assign different effect instances per mesh part
indoorModel->Meshes[0].MeshParts[0].Effect = indoorEffect;
outdoorModel->Meshes[0].MeshParts[0].Effect = outdoorEffect;
// Draw — each part uses its own lighting config
indoorModel->Draw(world, view, proj);
outdoorModel->Draw(world, view, proj);
Performance note: Effect state changes are relatively cheap in CNA, but each draw call has fixed CPU overhead. On mobile and low-end targets, keep the number of distinct effects and draw calls small. Batch geometry with the same lighting into a single draw call where possible.
EnvironmentMapEffect for Ambient
CNA includes EnvironmentMapEffect for cube-map based reflective ambient lighting. It reads from a TextureCube to give surfaces a sense of the surrounding environment without tracing rays:
auto envEffect = std::make_unique<EnvironmentMapEffect>(gd);
envEffect->setEnvironmentMapProperty(skyCubeMap);
envEffect->setEnvironmentMapAmountProperty(0.4f); // 40% reflection blend
envEffect->setFresnelFactorProperty(1.0f);
mesh.MeshParts[0].Effect = std::move(envEffect);
Combine with BasicEffect for primary directional lighting — render the mesh twice or assign EnvironmentMapEffect only to reflective parts (glass, metal).
Custom Shader for More Lights
For scenes requiring true point lights, spotlights, or more than three light sources, write a custom GLSL/HLSL effect using CNA's Effect class. The fragment shader loops over a light array stored in a uniform buffer:
// GLSL fragment shader excerpt (multi-light forward rendering)
// uniform PointLight lights[8];
// ...
// vec3 result = ambientColor * albedo;
// for (int i = 0; i < lightCount; ++i) {
// vec3 L = normalize(lights[i].position - fragPos);
// float dist = length(lights[i].position - fragPos);
// float attenuation = 1.0 / (1.0 + 0.09*dist + 0.032*dist*dist);
// result += max(dot(N, L), 0.0) * lights[i].color * albedo * attenuation;
// }
// fragColor = vec4(result, 1.0);
For XNA/FNA D3D9 Effect Framework bytecode on a capable renderer build, load the XNB as content.Load<std::shared_ptr<Effect>>(...) and set reflected parameters through effect->getParametersProperty()["lightCount"]. For renderer-native source such as the GLSL excerpt above, use ShaderEffect and its SetUniformXxx() methods instead.
Full Example: Three-Point Lighting Rig
This demo implements classic three-point lighting: a warm yellow key light (simulated sun), a cool blue fill, and a white rim light from behind. The cube rotates slowly; pressing D animates the sun direction to simulate time of day.
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/IndexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionNormalTexture.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;
class ThreeLightGame final : public Game {
public:
ThreeLightGame() : graphics_(this) {
graphics_.setPreferredBackBufferWidthProperty(800);
graphics_.setPreferredBackBufferHeightProperty(600);
}
protected:
void LoadContent() override {
effect_ = std::make_unique<BasicEffect>(getGraphicsDeviceProperty());
effect_->setLightingEnabledProperty(true);
// Start from default and override
effect_->EnableDefaultLighting();
// Key light — warm yellow sun
effect_->DirectionalLight0.setEnabledProperty(true);
effect_->DirectionalLight0.setDiffuseColorProperty({1.0f, 0.9f, 0.6f});
effect_->DirectionalLight0.setSpecularColorProperty({1.0f, 1.0f, 0.8f});
// Fill light — cool blue, dimmer
effect_->DirectionalLight1.setEnabledProperty(true);
effect_->DirectionalLight1.setDiffuseColorProperty({0.2f, 0.3f, 0.5f});
effect_->DirectionalLight1.setDirectionProperty(Vector3::Normalize({1, 0.5f, 0.5f}));
// Rim light — white, from behind
effect_->DirectionalLight2.setEnabledProperty(true);
effect_->DirectionalLight2.setDiffuseColorProperty({0.4f, 0.4f, 0.4f});
effect_->DirectionalLight2.setDirectionProperty(Vector3::Normalize({0, 1, 1}));
effect_->setAmbientLightColorProperty({0.05f, 0.05f, 0.08f});
BuildCube();
}
void Update(GameTime& gt) override {
auto kb = Keyboard::GetState();
if (kb.IsKeyDown(Keys::Escape)) Exit();
float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
rotation_ += dt * 0.4f;
// D key: animate sun (time-of-day rotation around Y axis)
if (kb.IsKeyDown(Keys::D))
dayAngle_ += dt * 0.5f;
float sy = std::sin(dayAngle_), cy = std::cos(dayAngle_);
sunDir_ = Vector3::Normalize({-cy, -0.7f, -sy});
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color(20, 20, 30, 255));
effect_->DirectionalLight0.setDirectionProperty(sunDir_);
effect_->setWorldProperty(Matrix::CreateRotationY(rotation_));
effect_->setViewProperty(Matrix::CreateLookAt(
{0, 1.5f, 3}, Vector3::Zero, Vector3::Up));
effect_->setProjectionProperty(Matrix::CreatePerspectiveFieldOfView(
MathHelper::PiOver4, 800.0f / 600.0f, 0.1f, 100.0f));
gd.SetVertexBuffer(vb_.get());
gd.SetIndexBuffer(ib_.get());
for (auto& pass : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
pass.Apply();
gd.DrawIndexedPrimitives(PrimitiveType::TriangleList,
0, 0, 24, 0, 12);
}
gd.Present();
}
private:
void BuildCube() {
using V = VertexPositionNormalTexture;
V verts[24];
uint16_t idx[36];
auto face = [&](int f, Vector3 n, Vector3 up, Vector3 right) {
Vector3 c = n * 0.5f;
verts[f*4+0] = { c - right*0.5f + up*0.5f, n, Vector2(0, 0) };
verts[f*4+1] = { c + right*0.5f + up*0.5f, n, Vector2(1, 0) };
verts[f*4+2] = { c + right*0.5f - up*0.5f, n, Vector2(1, 1) };
verts[f*4+3] = { c - right*0.5f - up*0.5f, n, Vector2(0, 1) };
int b = f*6, v = f*4;
idx[b+0]=v; idx[b+1]=v+1; idx[b+2]=v+2;
idx[b+3]=v; idx[b+4]=v+2; idx[b+5]=v+3;
};
face(0, Vector3::Forward, Vector3::Up, Vector3::Right);
face(1, Vector3::Backward, Vector3::Up, Vector3::Left);
face(2, Vector3::Left, Vector3::Up, Vector3::Forward);
face(3, Vector3::Right, Vector3::Up, Vector3::Backward);
face(4, Vector3::Up, Vector3::Backward, Vector3::Right);
face(5, Vector3::Down, Vector3::Forward, Vector3::Right);
auto& gd = getGraphicsDeviceProperty();
vb_ = std::make_unique<VertexBuffer>(gd,
VertexPositionNormalTexture::getVertexDeclarationStatic(), 24, BufferUsage::WriteOnly);
vb_->SetData(verts, 24);
ib_ = std::make_unique<IndexBuffer>(gd,
IndexElementSize::SixteenBits, 36, BufferUsage::WriteOnly);
ib_->SetData(idx, 36);
}
GraphicsDeviceManager graphics_;
std::unique_ptr<BasicEffect> effect_;
std::unique_ptr<VertexBuffer> vb_;
std::unique_ptr<IndexBuffer> ib_;
float rotation_ = 0.0f;
float dayAngle_ = 0.3f;
Vector3 sunDir_ = Vector3::Normalize({-1, -1, -0.5f});
};
int main() { ThreeLightGame g; g.Run(); }
Key Points
BasicEffecthas exactly three directional light slots; directional lights have no world position.- Call
EnableDefaultLighting()as a starting point, then override individual light colours and directions. - Approximate point lights per-object by computing a direction vector each draw call.
- For more than three lights, write a custom
Effectwith a light array in the fragment shader. - Keep draw call count low on mobile and low-end targets — each state change has CPU cost.