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.
- What this snapshot adds beyond three lights: one shadow-capable point or spot light, and custom shaders for many lights.
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 renderer (SDL_RENDERER) throws on 3D calls by default, and STUB reports no 3D capability at all.
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/, captured under Wine + DXVK on Linux), against which CNA's DIRECTX9 renderer is diffed at --tolerance 0 by hand, outside CI.
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.getTranslationProperty();
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:
// Load<Model> returns the Model by value (see Tutorial 35), so use '.' not '->'.
Model indoorModel = getContentProperty().Load<Model>("models/indoor");
Model outdoorModel = getContentProperty().Load<Model>("models/outdoor");
// Assign different effect instances per mesh part. setEffectProperty takes a raw
// Effect* and does not take ownership: keep indoorEffect / outdoorEffect alive
// (for example as members) for as long as the models draw with them.
indoorModel.getMeshesProperty()[0]->getMeshPartsProperty()[0]->setEffectProperty(&indoorEffect);
outdoorModel.getMeshesProperty()[0]->getMeshPartsProperty()[0]->setEffectProperty(&outdoorEffect);
// Draw — each part uses its own lighting config
indoorModel.Draw(world, view, proj);
outdoorModel.Draw(world, view, proj);
Model::Draw requires every part’s effect to implement IEffectMatrices and throws InvalidOperationException otherwise; BasicEffect does. Note also that models imported from glTF are given PbrEffect/SkinnedPbrEffect unless the material is unlit, so a dynamic_cast<BasicEffect*> on a glTF part usually fails; assign your own BasicEffect as above, or downcast to IEffectLights for lighting.
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:
// envEffect_ is a std::unique_ptr<EnvironmentMapEffect> member, so it outlives the model
envEffect_ = std::make_unique<EnvironmentMapEffect>(gd);
envEffect_->setEnvironmentMapProperty(skyCubeMap); // a TextureCube*
envEffect_->setEnvironmentMapAmountProperty(0.4f); // 40% reflection blend
envEffect_->setFresnelFactorProperty(1.0f);
envEffect_->EnableDefaultLighting(); // it has the three directional lights too
mesh->getMeshPartsProperty()[0]->setEffectProperty(envEffect_.get()); // not owned by the part
EnvironmentMapEffect implements IEffectLights itself: it carries the same three directional lights, EnableDefaultLighting() and setLightingEnabledProperty as BasicEffect, so a reflective part needs no second pass for its directional lighting. Assign it only to the reflective parts (glass, metal) and leave the rest on BasicEffect.
One Shadow-Capable Point or Spot Light (CNAEXT)
The three directional slots are still the whole XNA-compatible story, but this snapshot adds one positional light on top of them. BasicEffect, SkinnedEffect, PbrEffect and SkinnedPbrEffect accept a single PunctualLightEXT (a point or a spot light, with optional shadow) through setPunctualLightEXT. These members are always compiled; they do not need CNA_CNAEXT. The budget is one per draw, deliberately: each extra shadowed light is another full shadow-generation pass, and six of them for a point light.
#include "Microsoft/Xna/Framework/Graphics/PunctualLightEXT.hpp"
PunctualLightEXT lamp;
lamp.Kind = PunctualLightKindEXT::Point; // or Spot (then set Direction, InnerAngle, OuterAngle)
lamp.Position = Vector3(0.0f, 2.0f, 1.0f); // world space
lamp.DiffuseColor = Vector3(1.0f, 0.8f, 0.5f);
lamp.Range = 12.0f; // nothing is lit past this distance
// ShadowCube / ShadowMap left null: the light is lit but casts no shadow.
effect_->setPunctualLightEXT(lamp);
The light is only honoured by renderers whose shaders implement it: the three GL-family identities (OPENGLES3, OPENGL33, WEBGL2), VULKAN, SDL_GPU and WEBGPU. On the others the state is accepted and ignored, which shows up as an unlit result rather than an error. CNA does not produce the shadow cube or spot map for you: render it yourself (a depth pass into a render target, as in Tutorial 59) and hand it over through ShadowCube or ShadowMap and ShadowViewProjection. What CNA provides is the receiving side shown here. See the PunctualLightEXT header for the meaning of every field.
Custom Shader for More Lights
For a light model of your own, write a custom shader. The XNA-shaped route is a compiled Effect Framework .xnb (see below); the CNA-native route is ShaderEffect (CNAEXT), whose source is per renderer dialect (GLSL desktop/ES, HLSL, WGSL, SPIR-V, …). 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, load the XNB as getContentProperty().Load<std::shared_ptr<Effect>>(...) and set reflected parameters through effect->getParametersProperty()["lightCount"]->SetValue(...) (operator[](name) returns an EffectParameter*). This needs a renderer with a compiled-effect runtime: FNA3D always, and 10 more identities in 8 more families (the three GL identities, SDL_GPU, VULKAN, WEBGPU, SOFTWARE, DIRECTX9, DIRECTX11, METAL) but only when the matching CNA_*_COMPILED_EFFECTS build option was switched on (all default OFF). A default configuration reports CompiledEffects true on FNA3D only. For renderer-native source such as the GLSL excerpt above, use ShaderEffect and its SetUniformXxx() methods instead (with array setters such as SetUniformVec3Array and SetUniformMat4Array for light arrays).
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);
}
// No gd.Present(): Game presents in EndDraw, after Draw() returns.
}
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;
// The corners above run counter-clockwise as seen from outside, but XNA
// front faces are CLOCKWISE (the default rasteriser state culls counter-
// clockwise triangles), so emit each triangle in the reverse order.
idx[b+0]=v; idx[b+1]=v+2; idx[b+2]=v+1;
idx[b+3]=v; idx[b+4]=v+3; idx[b+5]=v+2;
};
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.
- This snapshot adds one shadow-capable point or spot light (
setPunctualLightEXT, always compiled). For any other light model write aShaderEffector a compiled effect with a light array. - Keep draw call count low on mobile and low-end targets — each state change has CPU cost.