Tutorial 154: Cascaded Shadow Maps
What you’ll learn: why one shadow map is not enough for a long view; the CascadedShadowMap update/begin/end/applyToReceiver sequence (needs CNA_CNAEXT=ON); receiving cascades on a BasicEffect; and which renderers can generate and sample them.
Experimental layer, pinned snapshot. Shadow generation is part of the CNAEXT engine layer, which exists only when CNA is configured with -DCNA_CNAEXT=ON (off by default, and built by no CI workflow; see the engine-layer reference). Shadow receiving is always compiled. This tutorial targets CNA snapshot 009d40f5 on the next branch (clone with git clone -b next; sharp-runtime must be its next branch too). The program below was syntax-checked against that snapshot's headers with -DCNA_CNAEXT; we did not build or run it, so the description of the picture comes from CNA's own cascaded-shadow test, not from a screenshot.
Before you start — Tutorial 02 and Tutorial 03 (a working CNA project), Tutorial 32 (BasicEffect and its lights) and Tutorial 115 (what CNA_CNAEXT gates). Tutorial 59 builds a single shadow map by hand with your own shaders; this one uses the engine layer to do it for a large outdoor view. You need a 3D renderer that generates and samples shadows; see Which renderers can do it.
A single shadow map stretched over a long view spends most of its texels on ground the player will never look at closely, so near shadows come out blocky. Cascaded shadow maps split the camera's view frustum by distance and fit a separate map to each slice: fine texels near the camera, coarse texels far away. The price is that the shadow casters are drawn once per cascade. In this tutorial you light a long strip of ground and a row of pillars with one sun and three cascades, and slide the camera up and down the strip so the cascade boundaries move across the scene.
How CNA's cascades work
CNA::Graphics::CascadedShadowMap owns one wide RenderTarget2D — an atlas with one square tile per cascade — because CNA's renderer interface has no array-texture concept. Its use is app-driven, in four calls per frame:
| Call | What it does |
|---|---|
CascadedShadowMap(device, quality, cascadeCount) | Creates the atlas and the caster effect. cascadeCount must be 2 to 4 (kMaxCascades is 4). Each cascade gets the resolution a single ShadowMap of that ShadowQuality would get: 512 (Low, Disabled), 1024 (Medium), 2048 (High) or 4096 (Ultra) texels. The atlas is cascadeCount tiles wide, a float target where the renderer has one and 8-bit otherwise. |
update(sun, cameraView, cameraProjection) | Once per frame, before the first begin. Recovers the camera's near and far planes from the projection matrix, splits that range into slices (a blend of uniform and logarithmic splits, setSplitLambda, default 0.75), and fits a bounding sphere to each slice so the fitted volume does not change as the camera turns. Each cascade's centre is snapped to whole texels to stop edges crawling. |
begin(i) … end() | Binds the atlas, clears it (once per frame, on the first begin), sets the viewport to cascade i's tile, and applies the caster effect with an identity world matrix. Draw your casters between the two calls. |
applyToReceiver(effect) | Gives a receiving effect the atlas, the per-cascade matrices and split distances, the camera the cascades were fitted to, the cross-fade band and the filter radius, all together, so they cannot be set inconsistently. Throws std::logic_error if update has not run. |
The receiver picks a cascade per pixel from its view-space depth. setBlendBand(width) (view-depth units, default 0) cross-fades neighbouring cascades, because a hard switch draws a straight line across the ground where two cascades disagree about where a shadow edge is. setDebugTintEnabled(true) tints each cascade differently so you can see the split.
Which renderers can do it
Two separate questions decide whether you see shadows, and the program asks both:
- Can the renderer generate them?
CascadedShadowMap::isSupported(). It needsThreeD,CustomEffectsand a shader variant the renderer takes (the engine ships the caster in GLSL ES, desktop GLSL, SPIR-V and WGSL). That is the EasyGL family,OPENGL4,VULKAN,WEBGPU, andSDL_GPUonly in builds with libshaderc. Where it is false the object still works and the frame renders unshadowed. - Can the renderer sample them?
GraphicsDevice::SupportsShadowSamplingEXT(), true on the same set:OPENGLES2,OPENGLES3,OPENGL33,WEBGL1,WEBGL2,OPENGL4,VULKAN,SDL_GPU,WEBGPU— nine of the 25 identities. On the others (DIRECTX9,DIRECTX11,DIRECTX12,METAL,FNA3D,SOFTWARE,PORTABLEGL,HEADLESS,STUBand the seven 2D-only renderers) a receiving effect accepts the shadow state and ignores it: an unshadowed image, no error.
Four effects are receivers, through the always-compiled IShadowReceiverEXT interface: BasicEffect, SkinnedEffect, PbrEffect and SkinnedPbrEffect. AlphaTestEffect, DualTextureEffect and EnvironmentMapEffect are not. The effect must be lit: an unlit draw accepts the state and ignores it. The two-ES-2-generation profiles (OPENGLES2, WEBGL1) report both answers true, but the engine's ES shader payloads are written for GLSL ES 3.00 and we did not verify them on a live ES 2.0 or WebGL 1 context.
Set up the project
Use the layout from Tutorial 03, with the engine layer switched on before CNA is added:
cmake_minimum_required(VERSION 3.20)
project(CascadeDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CNA_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../cna")
# A renderer that generates and samples shadows (see above), and the engine layer.
set(CNA_GRAPHICS_RENDERER "OPENGLES3" CACHE STRING "CNA graphics renderer")
set(CNA_CNAEXT ON CACHE BOOL "Build the CNA::Graphics engine layer")
add_subdirectory(${CNA_DIR} ${CMAKE_BINARY_DIR}/cna)
add_executable(CascadeDemo main.cpp)
target_link_libraries(CascadeDemo PRIVATE CNA)
Without CNA_CNAEXT the include below succeeds and CNA::Graphics::CascadedShadowMap is “not a member”: every engine-layer header is empty when the option is off.
Step 1: a scene
The scene is two kinds of geometry, both in world space: casters (six pillars marching away down the strip) and the whole scene the camera sees (the same pillars plus a ground quad). The caster pass applies an identity world matrix, so a caster's vertices must already be in world space; if your objects have their own world matrices you would bake them into the vertices for this pass.
namespace
{
using Vertices = std::vector<VertexPositionNormalTexture>;
void AppendQuad(Vertices& out, const Vector3& a, const Vector3& b, const Vector3& c,
const Vector3& d, const Vector3& normal)
{
const Vector2 uv(0.0f, 0.0f);
out.push_back(VertexPositionNormalTexture(a, normal, uv));
out.push_back(VertexPositionNormalTexture(b, normal, uv));
out.push_back(VertexPositionNormalTexture(c, normal, uv));
out.push_back(VertexPositionNormalTexture(a, normal, uv));
out.push_back(VertexPositionNormalTexture(c, normal, uv));
out.push_back(VertexPositionNormalTexture(d, normal, uv));
}
// Six quads: a box centred at `centre`, `half` units from centre to face.
void AppendBox(Vertices& out, const Vector3& centre, float hx, float hy, float hz)
{
const auto p = [&](float x, float y, float z) {
return Vector3(centre.X + x * hx, centre.Y + y * hy, centre.Z + z * hz);
};
AppendQuad(out, p(-1, 1, -1), p(1, 1, -1), p(1, 1, 1), p(-1, 1, 1), Vector3(0, 1, 0));
AppendQuad(out, p(-1, -1, 1), p(1, -1, 1), p(1, -1, -1), p(-1, -1, -1), Vector3(0, -1, 0));
AppendQuad(out, p(-1, -1, 1), p(-1, 1, 1), p(-1, 1, -1), p(-1, -1, -1), Vector3(-1, 0, 0));
AppendQuad(out, p(1, -1, -1), p(1, 1, -1), p(1, 1, 1), p(1, -1, 1), Vector3(1, 0, 0));
AppendQuad(out, p(-1, -1, 1), p(1, -1, 1), p(1, 1, 1), p(-1, 1, 1), Vector3(0, 0, 1));
AppendQuad(out, p(1, -1, -1), p(-1, -1, -1), p(-1, 1, -1), p(1, 1, -1), Vector3(0, 0, -1));
}
constexpr float kNear = 1.0f;
constexpr float kFar = 140.0f;
}
Everything else in main.cpp is included at the top: CascadedShadowMap.hpp, DirectionalLightEXT.hpp, ShadowQuality.hpp, BasicEffect.hpp and the usual math and graphics headers (the complete file is at the end).
Step 2: the sun, the cascades and a receiving effect
DirectionalLightEXT is the engine layer's light description; its Direction is the way the light travels, so (-0.35, -1, -0.2) is a sun nearly overhead. Build the cascade set once, ask both questions, and configure a BasicEffect with lighting on, texture off and one directional light pointing the same way as the sun — the configuration CNA's own cascade test uses:
sun_.Direction = Vector3(-0.35f, -1.0f, -0.2f);
cascades_ = std::make_unique<CascadedShadowMap>(device, ShadowQuality::Medium, 3);
cascades_->setBlendBand(4.0f); // cross-fade width between cascades, in view-depth units
// Generation and receiving are separate questions; ask both.
useShadows_ = cascades_->isSupported() && device.SupportsShadowSamplingEXT();
effect_ = std::make_unique<BasicEffect>(device);
effect_->setLightingEnabledProperty(true);
effect_->setTextureEnabledProperty(false);
effect_->setDiffuseColorProperty(Vector3(0.85f, 0.85f, 0.8f));
effect_->setAmbientLightColorProperty(Vector3(0.15f, 0.15f, 0.18f));
effect_->setSpecularColorProperty(Vector3::Zero);
auto& key = effect_->getDirectionalLight0Property();
key.setEnabledProperty(true);
key.setDirectionProperty(sun_.Direction);
key.setDiffuseColorProperty(Vector3(1.0f, 0.97f, 0.9f));
key.setSpecularColorProperty(Vector3::Zero);
effect_->getDirectionalLight1Property().setEnabledProperty(false);
effect_->getDirectionalLight2Property().setEnabledProperty(false);
effect_->setWorldProperty(Matrix::getIdentityProperty());
effect_->setProjectionProperty(Matrix::CreatePerspectiveFieldOfView(
MathHelper::PiOver4, 960.0f / 540.0f, kNear, kFar));
}
Three cascades at ShadowQuality::Medium is a 3072 × 1024 atlas. Try two or four; they trade memory and caster draw calls against resolution.
Step 3: the frame
Every frame does the same four things in order. The whole pass is skipped when shadows are unavailable, so you neither spend the draw calls nor rely on a caster effect that does not exist:
void Draw(const GameTime&) override
{
auto& device = getGraphicsDeviceProperty();
const Matrix view = Matrix::CreateLookAt(Vector3(0.0f, 12.0f, cameraZ_),
Vector3(0.0f, 0.0f, cameraZ_ - 85.0f),
Vector3(0.0f, 1.0f, 0.0f));
const Matrix projection = Matrix::CreatePerspectiveFieldOfView(
MathHelper::PiOver4, 960.0f / 540.0f, kNear, kFar);
device.setRasterizerStateProperty(RasterizerState::CullNone);
device.setDepthStencilStateProperty(DepthStencilState::Default);
device.setBlendStateProperty(BlendState::Opaque);
if (useShadows_)
{
// 1. Fit the cascades to this frame's camera and sun.
cascades_->update(sun_, view, projection);
// 2. Draw the casters once per cascade. begin() binds the atlas tile and applies the
// caster effect, which uses an identity world matrix -- so the casters' vertices
// must already be in world space, as they are here.
for (int i = 0; i < cascades_->getCascadeCount(); ++i)
{
cascades_->begin(i);
device.DrawUserPrimitives(PrimitiveType::TriangleList, casters_.data(), 0,
static_cast<int>(casters_.size()) / 3);
cascades_->end();
}
// 3. Hand the atlas, matrices and splits to the receiving effect in one call.
cascades_->applyToReceiver(*effect_);
}
effect_->setShadowsEnabledEXT(useShadows_);
// 4. The ordinary lit pass.
effect_->setViewProperty(view);
device.Clear(Color::CornflowerBlue);
effect_->Apply();
device.DrawUserPrimitives(PrimitiveType::TriangleList, scene_.data(), 0,
static_cast<int>(scene_.size()) / 3);
}
Points to notice:
update()first, every frame. It is what turns this frame's camera and sun into cascade matrices. Callingbegin()orapplyToReceiver()before the firstupdate()throwsstd::logic_error.- The casters are drawn
getCascadeCount()times. That is the contract of cascades. A game would normally cull the casters per cascade — the near cascades need only nearby objects. - Draw only geometry between
begin()andend(). Do not clear, change the render target, orApply()your own effects:begin()has already applied the caster effect. (CNA's showcase test records the mistake this avoids: a caster callback that applied the scene's effects filled the shadow map with shaded colours, and the receiver then sampled the map it was being drawn into.) - Turn shadows off on the effect too.
setShadowsEnabledEXT(useShadows_)stops the receiver sampling a map that holds nothing. The receiving effect and the generating pass are configured separately. - No
Present().Gamepresents inEndDraw. - The rasteriser is set to
CullNoneso the tutorial does not depend on the winding of its hand-written quads; CNA's own example does the same.
The complete program
Everything above, assembled. It requests the HiDef profile when the adapter offers it, as CNA's engine-layer examples do: the default Reach profile refuses several things the engine layer wants, float render targets among them (Tutorial 152 covers Reach and HiDef). Save it as main.cpp:
#include "CNA/Graphics/CascadedShadowMap.hpp"
#include "CNA/Graphics/DirectionalLightEXT.hpp"
#include "CNA/Graphics/ShadowQuality.hpp"
#include "CNA/GraphicsCapability.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GameTime.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/MathHelper.hpp"
#include "Microsoft/Xna/Framework/Matrix.hpp"
#include "Microsoft/Xna/Framework/Vector2.hpp"
#include "Microsoft/Xna/Framework/Vector3.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/BlendState.hpp"
#include "Microsoft/Xna/Framework/Graphics/DepthStencilState.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsAdapter.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDevice.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsProfile.hpp"
#include "Microsoft/Xna/Framework/Graphics/PrimitiveType.hpp"
#include "Microsoft/Xna/Framework/Graphics/RasterizerState.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionNormalTexture.hpp"
#include <cmath>
#include <memory>
#include <vector>
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using CNA::Graphics::CascadedShadowMap;
using CNA::Graphics::DirectionalLightEXT;
using CNA::Graphics::ShadowQuality;
namespace
{
using Vertices = std::vector<VertexPositionNormalTexture>;
void AppendQuad(Vertices& out, const Vector3& a, const Vector3& b, const Vector3& c,
const Vector3& d, const Vector3& normal)
{
const Vector2 uv(0.0f, 0.0f);
out.push_back(VertexPositionNormalTexture(a, normal, uv));
out.push_back(VertexPositionNormalTexture(b, normal, uv));
out.push_back(VertexPositionNormalTexture(c, normal, uv));
out.push_back(VertexPositionNormalTexture(a, normal, uv));
out.push_back(VertexPositionNormalTexture(c, normal, uv));
out.push_back(VertexPositionNormalTexture(d, normal, uv));
}
// Six quads: a box centred at `centre`, `half` units from centre to face.
void AppendBox(Vertices& out, const Vector3& centre, float hx, float hy, float hz)
{
const auto p = [&](float x, float y, float z) {
return Vector3(centre.X + x * hx, centre.Y + y * hy, centre.Z + z * hz);
};
AppendQuad(out, p(-1, 1, -1), p(1, 1, -1), p(1, 1, 1), p(-1, 1, 1), Vector3(0, 1, 0));
AppendQuad(out, p(-1, -1, 1), p(1, -1, 1), p(1, -1, -1), p(-1, -1, -1), Vector3(0, -1, 0));
AppendQuad(out, p(-1, -1, 1), p(-1, 1, 1), p(-1, 1, -1), p(-1, -1, -1), Vector3(-1, 0, 0));
AppendQuad(out, p(1, -1, -1), p(1, 1, -1), p(1, 1, 1), p(1, -1, 1), Vector3(1, 0, 0));
AppendQuad(out, p(-1, -1, 1), p(1, -1, 1), p(1, 1, 1), p(-1, 1, 1), Vector3(0, 0, 1));
AppendQuad(out, p(1, -1, -1), p(-1, -1, -1), p(-1, 1, -1), p(1, 1, -1), Vector3(0, 0, -1));
}
constexpr float kNear = 1.0f;
constexpr float kFar = 140.0f;
}
class CascadeGame final : public Game
{
public:
CascadeGame() : graphics_(this)
{
if (GraphicsAdapter::getDefaultAdapterProperty().IsProfileSupported(GraphicsProfile::HiDef))
graphics_.setGraphicsProfileProperty(GraphicsProfile::HiDef);
graphics_.setPreferredBackBufferWidthProperty(960);
graphics_.setPreferredBackBufferHeightProperty(540);
}
protected:
void LoadContent() override
{
auto& device = getGraphicsDeviceProperty();
// Casters: a row of pillars marching away down the strip.
for (int i = 0; i < 6; ++i)
AppendBox(casters_, Vector3((i % 2 == 0) ? -6.0f : 6.0f, 4.0f, 30.0f - 22.0f * i),
1.5f, 4.0f, 1.5f);
// Everything the camera sees: the ground strip plus the same pillars.
AppendQuad(scene_, Vector3(-40, 0, -120), Vector3(40, 0, -120), Vector3(40, 0, 60),
Vector3(-40, 0, 60), Vector3(0, 1, 0));
scene_.insert(scene_.end(), casters_.begin(), casters_.end());
sun_.Direction = Vector3(-0.35f, -1.0f, -0.2f);
cascades_ = std::make_unique<CascadedShadowMap>(device, ShadowQuality::Medium, 3);
cascades_->setBlendBand(4.0f); // cross-fade width between cascades, in view-depth units
// Generation and receiving are separate questions; ask both.
useShadows_ = cascades_->isSupported() && device.SupportsShadowSamplingEXT();
effect_ = std::make_unique<BasicEffect>(device);
effect_->setLightingEnabledProperty(true);
effect_->setTextureEnabledProperty(false);
effect_->setDiffuseColorProperty(Vector3(0.85f, 0.85f, 0.8f));
effect_->setAmbientLightColorProperty(Vector3(0.15f, 0.15f, 0.18f));
effect_->setSpecularColorProperty(Vector3::Zero);
auto& key = effect_->getDirectionalLight0Property();
key.setEnabledProperty(true);
key.setDirectionProperty(sun_.Direction);
key.setDiffuseColorProperty(Vector3(1.0f, 0.97f, 0.9f));
key.setSpecularColorProperty(Vector3::Zero);
effect_->getDirectionalLight1Property().setEnabledProperty(false);
effect_->getDirectionalLight2Property().setEnabledProperty(false);
effect_->setWorldProperty(Matrix::getIdentityProperty());
effect_->setProjectionProperty(Matrix::CreatePerspectiveFieldOfView(
MathHelper::PiOver4, 960.0f / 540.0f, kNear, kFar));
}
void Update(GameTime& gameTime) override
{
// Slide the camera up and down the strip so the cascade boundaries move across the ground.
const float t = static_cast<float>(gameTime.getTotalGameTimeProperty().getTotalSecondsProperty());
cameraZ_ = 45.0f - 25.0f * (0.5f + 0.5f * std::sin(t * 0.4f));
}
void Draw(const GameTime&) override
{
auto& device = getGraphicsDeviceProperty();
const Matrix view = Matrix::CreateLookAt(Vector3(0.0f, 12.0f, cameraZ_),
Vector3(0.0f, 0.0f, cameraZ_ - 85.0f),
Vector3(0.0f, 1.0f, 0.0f));
const Matrix projection = Matrix::CreatePerspectiveFieldOfView(
MathHelper::PiOver4, 960.0f / 540.0f, kNear, kFar);
device.setRasterizerStateProperty(RasterizerState::CullNone);
device.setDepthStencilStateProperty(DepthStencilState::Default);
device.setBlendStateProperty(BlendState::Opaque);
if (useShadows_)
{
// 1. Fit the cascades to this frame's camera and sun.
cascades_->update(sun_, view, projection);
// 2. Draw the casters once per cascade. begin() binds the atlas tile and applies the
// caster effect, which uses an identity world matrix -- so the casters' vertices
// must already be in world space, as they are here.
for (int i = 0; i < cascades_->getCascadeCount(); ++i)
{
cascades_->begin(i);
device.DrawUserPrimitives(PrimitiveType::TriangleList, casters_.data(), 0,
static_cast<int>(casters_.size()) / 3);
cascades_->end();
}
// 3. Hand the atlas, matrices and splits to the receiving effect in one call.
cascades_->applyToReceiver(*effect_);
}
effect_->setShadowsEnabledEXT(useShadows_);
// 4. The ordinary lit pass.
effect_->setViewProperty(view);
device.Clear(Color::CornflowerBlue);
effect_->Apply();
device.DrawUserPrimitives(PrimitiveType::TriangleList, scene_.data(), 0,
static_cast<int>(scene_.size()) / 3);
}
private:
GraphicsDeviceManager graphics_;
Vertices casters_;
Vertices scene_;
DirectionalLightEXT sun_;
std::unique_ptr<CascadedShadowMap> cascades_;
std::unique_ptr<BasicEffect> effect_;
bool useShadows_ = false;
float cameraZ_ = 45.0f;
};
int main()
{
CascadeGame game;
game.Run();
return 0;
}
Build and run
cd my-cascade-demo
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(nproc)
./build/CascadeDemo
What to expect, from what CNA's cascade example asserts: the casters cast shadows through the atlas at two, three and four cascades; with the debug tint on, the frame bands in depth order with the nearest cascade nearest the camera; and with shadows off the same scene is uniformly lit. So you should see six pillars, each with a long shadow on the ground to the side and slightly toward the camera, resolved with finer texels near the camera than far away; because each cascade's fitted volume is snapped to whole shadow-map texels, the edges are designed not to crawl as the camera slides. On a renderer that reports useShadows_ == false you get the same lit scene with no shadows.
Tuning and experiments
| Try | Effect |
|---|---|
cascades_->setDebugTintEnabled(true) | Tints each cascade so you can see the boundaries move as the camera slides. |
cascades_->setBlendBand(0.0f) vs 8.0f | Hard switch (a visible line at each split) vs a wider cross-fade. |
cascades_->setSplitLambda(0.0f) vs 1.0f | Uniform splits (near cascades wasted on distance) vs logarithmic splits (far cascades starved). The default is 0.75. |
ShadowQuality::High or a cascade count of 2 or 4 | Resolution and memory against caster draw calls. |
getSplitDistance(i), selectCascade(viewDepth) | Read back where the splits landed, for a debug overlay or to cull casters per cascade. |
Swap BasicEffect for PbrEffect | applyToReceiver takes any IShadowReceiverEXT; the PBR effects receive the same cascades. |
Beyond one sun
- A single directional map.
CNA::Graphics::ShadowMap(begin(light, sceneBounds)/end()) is the one-map version, and the only oneRenderPipeline::setShadowScene()can drive for you. Cascades, spot and point maps are used by callingbegin/endyourself. - Spot and point lights.
SpotShadowMapandCubeShadowMapgenerate the maps; aPunctualLightEXThanded tosetPunctualLightEXT()carries the light and its shadow to the receiving effect. - Screen-space contact shadows.
ContactShadowPass, part of the post-processing chain. - Image-based lighting pairs with shadows: a shadow removes only the direct light and leaves the environment term. See Tutorial 153.
Limits
- Nine of the 25 renderer identities generate and sample shadows through the engine layer; on the rest the frame is unshadowed, silently, unless you check
isSupported()andSupportsShadowSamplingEXT()as this program does. - The engine layer is experimental, off by default, not built by any CI workflow, and its revision counter (18) is not an ABI promise.
- Cascades are drawn by your code, once per cascade; the pipeline does not draw them for you.
ShadowMapexposes a skinned caster (applySkinnedCaster), whereasCascadedShadowMapexposes only its rigid caster effect (getCasterEffect()); this tutorial uses rigid geometry. - We did not build or run this program. The API calls are those of CNA's own
cnaext_csm_test, and the whole file compiles (syntax only) against the snapshot headers.
Where to go next
- Tutorial 153: Image-based lighting — the other receiving-side feature.
- Tutorial 59: Shadow Mapping — the algorithm underneath, written by hand.
- Tutorial 116 — the
RenderPipelineyou would put this scene in. - Tutorial 115: CNAEXT and the CNAEXT engine-layer reference.