Tutorial 55: DualTextureEffect for Lightmaps
Lightmapping is one of the oldest and most effective techniques for delivering high-quality static lighting at minimal runtime cost. The idea is to precompute all indirect illumination, soft shadows, and ambient occlusion offline, bake the result into a second texture (the lightmap), and then multiply it into the diffuse albedo during rendering. CNA's built-in DualTextureEffect implements exactly this pipeline in a single shader pass, supporting two independent UV channels, optional per-vertex colour modulation, and fog.
DualTextureEffect Overview
DualTextureEffect combines two Texture2D objects in a single fragment shader pass. The first texture is the surface's diffuse albedo — the colour of the material under white light. The second texture is the precomputed lightmap. The fragment shader multiplies them together:
// Core of DualTextureEffect fragment shader (simplified)
vec4 albedo = texture(u_texture, v_texcoord1);
vec4 lightmap = texture(u_texture2, v_texcoord2);
fragColor = albedo * lightmap * u_diffuseColor;
This multiplication is physically motivated: a surface that reflects a fraction of incoming light, when lit by a certain intensity, outputs the product of the two. Because the lightmap stores pre-integrated radiance (often encoded in a gamma-compressed format), the multiplication should happen in linear colour space for physical correctness — CNA applies gamma decoding automatically when the textures are created with SurfaceFormat::Color and the effect's linear-space flag is enabled.
The key performance advantage of DualTextureEffect over a per-pixel lighting effect is that there are no light direction calculations at runtime. The GPU simply samples two textures and multiplies — this is an extremely fast path even on low-end mobile GPUs.
Texture and Texture2 Properties
The two textures are set via separate setter methods:
setTexture(Texture2D*)— the primary (albedo) texture. Sampled using the first UV channel (v_texcoord1fromTextureCoordinatein the vertex struct). This should be a tiling, detail-rich albedo map.setTexture2(Texture2D*)— the secondary (lightmap) texture. Sampled using the second UV channel (v_texcoord2fromTextureCoordinate2). This is a unique (non-tiling) atlas per mesh or per room, produced by an offline lightmapper.
Both textures use their own independent UV sets stored in the vertex buffer. The albedo UV typically tiles across the surface (a brick texture repeated many times), while the lightmap UV covers the [0, 1] range exactly once across the entire mesh, ensuring each texel in the lightmap corresponds to a unique surface point.
dualEffect_->setTexture(diffuseTex_.get()); // albedo (tiling)
dualEffect_->setTexture2(lightmapTex_.get()); // lightmap (unique UV)
VertexPositionDualTexture
VertexPositionDualTexture is the vertex struct required by DualTextureEffect. It carries three fields:
Position—Vector3, world-space (or object-space) vertex position.TextureCoordinate—Vector2, UV coordinates for the albedo texture (first UV channel).TextureCoordinate2—Vector2, UV coordinates for the lightmap (second UV channel).
The stride is 28 bytes: 12 bytes for position + 8 bytes for UV1 + 8 bytes for UV2. The vertex declaration is registered with the GraphicsDevice automatically when you use VertexPositionDualTexture::VertexDeclaration:
VertexPositionDualTexture verts[] = {
// { Position, UV1 (albedo), UV2 (lightmap) }
{ Vector3(-5, 0, -5), Vector2(0, 4), Vector2(0, 0) },
{ Vector3( 5, 0, -5), Vector2(4, 4), Vector2(1, 0) },
{ Vector3( 5, 0, 5), Vector2(4, 0), Vector2(1, 1) },
{ Vector3(-5, 0, 5), Vector2(0, 0), Vector2(0, 1) },
};
// Note: albedo UV uses scale 4 for a 4x repeat; lightmap UV is [0,1]
auto vb = std::make_unique<VertexBuffer>(
gd,
VertexPositionDualTexture::VertexDeclaration,
4,
BufferUsage::WriteOnly
);
vb->SetData(verts, 4);
When VertexColorEnabled is true, use VertexPositionColorDualTexture instead, which adds a Color field between Position and TextureCoordinate.
Baked Lightmap Workflow
Creating a lightmap involves an offline rendering step. The typical workflow for a CNA project:
- Model your scene in Blender (or any DCC tool). Ensure all static geometry has a second UV channel (UV2) that is "unwrapped for lightmapping" — no overlapping faces, each polygon occupies a unique region of [0,1] texture space.
- Bake the lightmap using Blender's Cycles bake system (or a dedicated lightmapper such as The Lightmapper, Bakery, or xatlas + custom ray tracer). Bake to an RGBA image at 1024×1024 or 2048×2048 depending on scene scale. Save as PNG.
- Export assets — export the mesh with both UV channels to OBJ or FBX. Export the baked lightmap PNG.
- Import into CNA — the content pipeline ingests the mesh and preserves the second UV channel as
TextureCoordinate2in the vertex data. Load the lightmap texture normally viaContent.Load<Texture2D>.
A common mistake is to use overlapping UVs in the lightmap channel (the same UV layout as the albedo, which tiles). This causes every surface that shares the same albedo UV region to also share the same lightmap texel, producing incorrect lighting where every face appears to have identical shadow patterns. Always create a dedicated non-overlapping UV unwrap for the lightmap channel.
Combining Diffuse and Lightmap
The fragment-level combination used by DualTextureEffect is a simple multiply. In mathematical terms:
// Inside DualTextureEffect — both textures sampled and multiplied
vec4 c1 = texture(u_texture, v_texcoord1);
vec4 c2 = texture(u_texture2, v_texcoord2);
vec4 result = c1 * c2 * u_diffuseColor;
// Optional vertex colour modulation
#ifdef VERTEX_COLOR_ENABLED
result *= v_vertexColor;
#endif
fragColor = result;
The lightmap values are typically in the range [0, 1], where 0 represents full shadow and 1 represents maximum direct illumination. Values can exceed 1 if the lightmap is encoded in HDR format (SurfaceFormat::HdrBlendable), which requires tone mapping before display. For LDR pipelines, clamp the lightmap to [0, 1] during export from the baking tool.
A common refinement is to store ambient occlusion in the lightmap's alpha channel and multiply it separately, giving you AO as a distinct scalar at no additional texture cost.
Limitations
DualTextureEffect provides static lighting only. The lightmap is baked once offline. Dynamic objects (characters, moving platforms, projectiles) cannot participate in lightmapped lighting — they require a separate dynamic lighting effect. Moving static geometry invalidates the bake.
- No dynamic lights: Point lights, spotlights, and other dynamic light sources are not reflected by the lightmap unless you re-bake. For mixed static + dynamic lighting use a combination of lightmaps (for static light) and a custom effect with runtime light evaluation (for dynamic light).
- Re-bake on geometry change: If a wall moves by even one unit, the lightmap shadows become incorrect. Lightmaps are appropriate for fully static environments.
- Memory overhead: A 2048×2048 RGBA lightmap consumes 16 MB of VRAM. Large open worlds require lightmap atlasing across multiple textures.
- No animated lightmaps: You cannot smoothly interpolate between two baked lightmaps at runtime within
DualTextureEffect— that requires a custom effect with two samplers and a lerp uniform.
Complete Example: Lightmapped Room
class LightmapGame final : public Game {
std::unique_ptr<DualTextureEffect> dualEffect_;
std::unique_ptr<Texture2D> diffuseTex_;
std::unique_ptr<Texture2D> lightmapTex_;
std::unique_ptr<VertexBuffer> roomVB_;
std::unique_ptr<IndexBuffer> roomIB_;
int indexCount_ = 0;
Camera camera_;
void LoadContent() override {
auto& gd = getGraphicsDeviceProperty();
diffuseTex_ = Content.Load<Texture2D>("textures/brick_albedo");
lightmapTex_ = Content.Load<Texture2D>("textures/room_lightmap");
dualEffect_ = std::make_unique<DualTextureEffect>(gd);
dualEffect_->setTexture(diffuseTex_.get());
dualEffect_->setTexture2(lightmapTex_.get());
dualEffect_->setVertexColorEnabled(false);
dualEffect_->setDiffuseColor(Vector3(1.0f, 1.0f, 1.0f));
// Enable fog for depth cueing
dualEffect_->setFogEnabled(true);
dualEffect_->setFogColor(Vector3(0.1f, 0.1f, 0.12f));
dualEffect_->setFogStart(15.0f);
dualEffect_->setFogEnd(50.0f);
BuildRoomGeometry(gd);
}
void BuildRoomGeometry(GraphicsDevice& gd) {
// Six-sided room: floor, ceiling, four walls
// Each face has albedo UVs tiling 4x and unique lightmap UVs
std::vector<VertexPositionDualTexture> verts;
std::vector<uint16_t> indices;
auto addQuad = [&](Vector3 a, Vector3 b, Vector3 c, Vector3 d,
float uvScale, Vector2 lm0, Vector2 lm1,
Vector2 lm2, Vector2 lm3) {
uint16_t base = (uint16_t)verts.size();
verts.push_back({ a, Vector2(0, uvScale), lm0 });
verts.push_back({ b, Vector2(uvScale,uvScale), lm1 });
verts.push_back({ c, Vector2(uvScale,0 ), lm2 });
verts.push_back({ d, Vector2(0, 0 ), lm3 });
indices.insert(indices.end(),
{ base, (uint16_t)(base+1), (uint16_t)(base+2),
base, (uint16_t)(base+2), (uint16_t)(base+3) });
};
// Floor (y=0), lightmap occupies top-left quarter of the atlas
addQuad(
{-5,0,-5}, { 5,0,-5}, { 5,0, 5}, {-5,0, 5}, 4.0f,
{0,0}, {0.5f,0}, {0.5f,0.5f},{0,0.5f}
);
// ... add remaining faces similarly ...
indexCount_ = (int)indices.size();
roomVB_ = std::make_unique<VertexBuffer>(
gd, VertexPositionDualTexture::VertexDeclaration,
(int)verts.size(), BufferUsage::WriteOnly);
roomVB_->SetData(verts.data(), (int)verts.size());
roomIB_ = std::make_unique<IndexBuffer>(
gd, IndexElementSize::SixteenBits,
(int)indices.size(), BufferUsage::WriteOnly);
roomIB_->SetData(indices.data(), (int)indices.size());
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color::Black);
dualEffect_->setWorld(Matrix::Identity);
dualEffect_->setView(camera_.View());
dualEffect_->setProjection(camera_.Projection());
gd.setVertexBuffer(*roomVB_);
gd.setIndexBuffer(*roomIB_);
for (auto& pass : dualEffect_->getCurrentTechnique().Passes) {
pass.Apply();
gd.DrawIndexedPrimitives(
PrimitiveType::TriangleList, 0, 0,
indexCount_ / 3);
}
gd.Present();
}
};