Tutorial 55: DualTextureEffect for Lightmaps

CNA Tutorials  ·  Built-in Effects

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_texcoord1 from TextureCoordinate in 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_texcoord2 from TextureCoordinate2). 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:

  • PositionVector3, world-space (or object-space) vertex position.
  • TextureCoordinateVector2, UV coordinates for the albedo texture (first UV channel).
  • TextureCoordinate2Vector2, 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:

  1. 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.
  2. 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.
  3. Export assets — export the mesh with both UV channels to OBJ or FBX. Export the baked lightmap PNG.
  4. Import into CNA — the content pipeline ingests the mesh and preserves the second UV channel as TextureCoordinate2 in the vertex data. Load the lightmap texture normally via Content.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();
    }
};