Tutorial 114: PBR Materials with PbrEffect and SkinnedPbrEffect
What you’ll learn
- The
PbrEffectmaterial surface — seven maps, factors, alpha state, UV selectors and transforms. - How a PBR effect is lit: three directional lights, plus shadow receiving and image-based lighting — and which renderers honour them.
SkinnedPbrEffect, and how it differs fromSkinnedEffect.- The importer rule (unchanged since alpha.1): metallic-roughness remains PBR even with no maps or with vertex colour.
Before you start — Tutorial 32: BasicEffect for the stock-effect model these follow, and Tutorial 110 for getting a PBR-authored model in. Everything here is always compiled; none of it needs -DCNA_CNAEXT=ON.
PbrEffect and SkinnedPbrEffect implement glTF 2.0’s metallic-roughness material model against the specification’s own reference BRDF: a GGX normal distribution, Smith-Schlick-GGX visibility, and Schlick Fresnel. They are CNAEXT effects with the familiar stock-effect workflow — construct one, set properties, bind it, draw.
They are also unambiguously CNAEXT. Real XNA 4.0 predates the physically-based content pipeline entirely; there is no XNA or FNA equivalent to mirror, so nothing here is an attempt at API fidelity. The CNAEXT marker sits on the members that have no XNA equivalent rather than on the class declaration, and none of it depends on a build option. Tutorial 115 explains what the marker means for your build.
The material surface
Each of the seven texture slots comes as a getter, a raw-pointer setter and an owning setter that keeps the texture alive. The five core slots use the original property names; the two KHR_materials_specular slots carry the EXT suffix.
| Slot | Accessors | Owning setter |
|---|---|---|
| Base colour (albedo) | getTextureProperty() / setTextureProperty(Texture2D*) | SetOwnedTexture(shared_ptr<Texture2D>) |
| Normal (tangent space) | getNormalMapProperty() / setNormalMapProperty(Texture2D*) | SetOwnedNormalMap(...) |
| Metallic-roughness | getMetallicRoughnessMapProperty() / setMetallicRoughnessMapProperty(Texture2D*) | SetOwnedMetallicRoughnessMap(...) |
| Emissive | getEmissiveMapProperty() / setEmissiveMapProperty(Texture2D*) | SetOwnedEmissiveMap(...) |
| Occlusion | getOcclusionMapProperty() / setOcclusionMapProperty(Texture2D*) | SetOwnedOcclusionMap(...) |
| Specular strength | getSpecularMapEXTProperty() / setSpecularMapEXTProperty(Texture2D*) | SetOwnedSpecularMapEXT(...) |
| Specular colour | getSpecularColorMapEXTProperty() / setSpecularColorMapEXTProperty(Texture2D*) | SetOwnedSpecularColorMapEXT(...) |
The metallic-roughness map uses glTF’s own packing — green is roughness, blue is metallic. The occlusion map is read from the red channel, where 1 is fully lit and 0 fully occluded.
The scalar and vector factors multiply into those maps, and work perfectly well on their own when a slot is empty:
| Factor | Accessors | Default |
|---|---|---|
| Metallic | getMetallicFactorProperty() / setMetallicFactorProperty(float) | 1.0f |
| Roughness | getRoughnessFactorProperty() / setRoughnessFactorProperty(float) | 1.0f |
| Emissive | getEmissiveFactorProperty() / setEmissiveFactorProperty(const Vector3&) | Vector3::Zero |
| Base colour tint | getDiffuseColorProperty() / setDiffuseColorProperty(const Vector3&) | white |
| Opacity | getAlphaProperty() / setAlphaProperty(float) | 1.0f |
| Index of refraction | getIorEXTProperty() / setIorEXTProperty(float) | 1.5f |
| Specular strength | getSpecularFactorEXTProperty() / setSpecularFactorEXTProperty(float) | 1.0f |
| Specular colour | getSpecularColorFactorEXTProperty() / setSpecularColorFactorEXTProperty(Vector3) | white |
| Normal scale | getNormalScaleEXTProperty() / setNormalScaleEXTProperty(float) | 1.0f |
| Occlusion strength | getOcclusionStrengthEXTProperty() / setOcclusionStrengthEXTProperty(float) | 1.0f |
These are C++ methods, not C# properties. effect.MetallicFactor = 0.5f; does not compile — it is effect.setMetallicFactorProperty(0.5f);. CNA translates every XNA property into a getXProperty()/setXProperty() pair; ordinary methods keep their PascalCase names.
Putting it together, a hand-built brushed-metal material:
#include "Microsoft/Xna/Framework/Graphics/PbrEffect.hpp"
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
PbrEffect effect(device);
effect.setTextureProperty(&albedo);
effect.setNormalMapProperty(&normal);
effect.setMetallicRoughnessMapProperty(&metalRough);
effect.setMetallicFactorProperty(1.0f);
effect.setRoughnessFactorProperty(0.35f);
effect.setEmissiveFactorProperty(Vector3::Zero);
effect.setWorldProperty(world);
effect.setViewProperty(view);
effect.setProjectionProperty(projection);
effect.EnableDefaultLighting();
Use the SetOwned* variants when the effect should outlive your own reference to the texture — which is exactly what the model importers do, since the textures they decode have no other owner:
effect.SetOwnedNormalMap(std::shared_ptr<Texture2D>(std::move(decodedNormalMap)));
Alpha, sidedness and colour space
Beyond the maps and factors, the effect carries the rest of a glTF material’s state. Some of it is carried rather than applied: it travels with the material, and your draw code acts on it.
| State | Accessors | Behaviour |
|---|---|---|
| Alpha mode | getAlphaModeEXTProperty() / setAlphaModeEXTProperty(AlphaModeEXT) | Opaque (default), Mask (fragments below the cutoff are discarded) or Blend. For Blend the PBR effects emit straight RGB: select BlendState::NonPremultiplied and draw back to front. CNA preserves the part order and does not sort. |
| Alpha cutoff | getAlphaCutoffEXTProperty() / setAlphaCutoffEXTProperty(float) | Default 0.5f; used when the mode is Mask. |
| Double-sided | getDoubleSidedEXTProperty() / setDoubleSidedEXTProperty(bool) | Carried, not applied: Model::Draw does not change the device’s RasterizerState. For a double-sided material, set RasterizerState::CullNone yourself. |
| Texture colour space | get/setBaseColorTextureIsSrgbEXTProperty(), get/setEmissiveTextureIsSrgbEXTProperty() | Both default to true, glTF’s rule: the shader decodes the sample, never the factor. The specular-colour map has its own flag. |
| Output encoding | getEncodeOutputToSrgbEXTProperty() / setEncodeOutputToSrgbEXTProperty(bool) | Defaults to true, right for a plain UNORM back buffer. Turn it off when drawing into an sRGB target or when a later pass does its own tone mapping, or the transfer is applied twice. Alpha is never encoded. |
| UV sets and transforms | setTextureCoordinateSetEXTProperty(slot, set), setTextureTransformEXTProperty(slot, TextureTransformEXT) | Per-map UV selection and KHR_texture_transform; see Per-map UV channels and transforms. |
Lights, shadows and image-based lighting
PbrEffect implements IEffectMatrices, IEffectFog and IEffectLights, so its lighting and fog surface is the one you already know from BasicEffect: DirectionalLight0 through DirectionalLight2, setAmbientLightColorProperty(), EnableDefaultLighting(), and the four fog properties. Since alpha.1 it also implements IShadowReceiverEXT and accepts an ImageBasedLightEXT; those two are the new part of this section.
DirectionalLight& key = effect.getDirectionalLight0Property();
key.setEnabledProperty(true);
key.setDirectionProperty(Vector3::Normalize(Vector3(-0.5f, -1.0f, -0.3f)));
key.setDiffuseColorProperty(Vector3(1.0f, 0.96f, 0.9f));
effect.setAmbientLightColorProperty(Vector3(0.05f, 0.06f, 0.08f));
Without an environment — the default, and the only option on a renderer that ignores one — a metal reflects the analytic lights and the ambient term and nothing else, so a mirror-smooth metal sphere in an empty scene looks dark and flat rather than wrong. Give rough metals a little ambient and a fill light and they read correctly.
Image-based lighting
Both effects have setImageBasedLightEXT(const ImageBasedLightEXT&) and getImageBasedLightEXT(). The struct carries the three split-sum products — an Irradiance cube for diffuse light, a PrefilteredSpecular cube whose mip level is roughness, and a BrdfLut table — plus PrefilteredMipCount and an Intensity. IsValidEXT() is true only when all three textures are set; an invalid bundle is treated as no environment. The struct holds raw pointers, so the textures it names must outlive the effect’s draws.
#include "Microsoft/Xna/Framework/Graphics/ImageBasedLightEXT.hpp"
ImageBasedLightEXT ibl;
ibl.Irradiance = irradianceCube; // TextureCube*
ibl.PrefilteredSpecular = prefilteredCube; // TextureCube*, mip chain = roughness ramp
ibl.BrdfLut = brdfTable; // Texture2D*
ibl.PrefilteredMipCount = 5;
ibl.Intensity = 1.0f;
if (device.SupportsImageBasedLightingEXT()) // see the renderer table below
effect.setImageBasedLightEXT(ibl);
CNA does not generate these textures. Bake the irradiance cube, the prefiltered specular cube (one mip level per roughness step) and the BRDF lookup table with a tool of your choice or your own render passes, and load them as ordinary TextureCube and Texture2D content; ImageBasedLightEXT and the effect side that consumes them are always compiled.
An environment replaces the flat ambient colour; it does not add to it. While a valid ImageBasedLightEXT is set, PbrEffect zeroes its ambient colour, because the two stand for the same light. On a renderer that accepts the struct but does not shade from it you get an unlit-ambient surface rather than a double-lit one — the safer wrong picture, but still wrong. Set the environment only where SupportsImageBasedLightingEXT() is true and give the other renderers the flat ambient and fill light.
Shadows
IShadowReceiverEXT is the always-compiled receiving half of shadows, implemented by BasicEffect, SkinnedEffect, PbrEffect and SkinnedPbrEffect: setShadowMapEXT, setLightViewProjectionEXT, setShadowsEnabledEXT, setShadowDepthBiasEXT, setShadowFilterRadiusEXT, setShadowCascadesEXT (a ShadowCascadeStateEXT of up to four cascades) and setPunctualLightEXT (a PunctualLightEXT point or spot light with a cube or 2D shadow). CNA does not generate the maps: they come from a depth pass you write yourself, and Tutorial 59 shows both the pass and how to hand its result to these receivers. A shadow attenuates the direct lights only; the environment term is untouched.
Shadow sampling is honoured only where GraphicsDevice::SupportsShadowSamplingEXT() is true. Everywhere else the state is accepted and ignored, which gives an unshadowed image and no error.
One divergence from the other stock effects: setLightingEnabledProperty(false) is refused. A physically-based material with no lighting has no defined meaning, so the effect mirrors the same constraint real XNA’s SkinnedEffect already imposes.
SkinnedPbrEffect
SkinnedPbrEffect is the whole material surface above plus SkinnedEffect’s bone API — identical names, identical limits:
| Member | Value or signature |
|---|---|
MaxBones | 72 |
SetBoneTransforms | void SetBoneTransforms(const std::vector<Matrix>&) |
GetBoneTransforms | std::vector<Matrix> GetBoneTransforms(int count) const |
WeightsPerVertex | get/setWeightsPerVertexProperty(int) — 1, 2 or 4 |
Which means an AnimationPlayer drives it exactly as it drives a SkinnedEffect:
pbrSkinned.SetBoneTransforms(player.GetSkinTransforms());
See Tutorial 112 for the player, and Tutorial 57 for the bone limits in detail.
How the importer decides
You will usually not construct these effects yourself — the glTF importer picks one per mesh part. The choice follows the material model, not the presence of a particular texture, and this has held since alpha.1.
A primitive using glTF’s metallic-roughness model takes the PBR path. That includes the default material, factor-only materials and primitives carrying COLOR_0. A material explicitly declaring KHR_materials_unlit is the principal non-PBR case.
| Primitive | Effect | Vertex stride |
|---|---|---|
| Rigid PBR, one sampled UV set, no vertex colour | PbrEffect | 48 |
| Rigid PBR, two sampled UV sets or vertex colour | PbrEffect | 60 |
| Skinned PBR, one sampled UV set, no vertex colour | SkinnedPbrEffect | 68 |
| Skinned PBR, two sampled UV sets, no vertex colour | SkinnedPbrEffect | 76 |
| Skinned PBR with vertex colour | SkinnedPbrEffect | 80 |
PBR takes priority over the legacy dual-texture approximation. Vertex colour multiplies the PBR base colour in both rigid and skinned layouts; it no longer changes the selected material model.
Once the importer has chosen, the maps and factors are wired in for you, including KHR_materials_emissive_strength, whose multiplier is folded into the emissive factor before the emissive texture is applied. To adjust an imported material at runtime, cast and set. PbrEffect and SkinnedPbrEffect are separate classes, so cast for both (and note that a loop written for BasicEffect skips imported glTF parts entirely):
for (ModelMesh* mesh : model.getMeshesProperty()) {
for (ModelMeshPart* part : mesh->getMeshPartsProperty()) {
if (auto* pbr = dynamic_cast<PbrEffect*>(part->getEffectProperty())) {
pbr->setRoughnessFactorProperty(0.8f); // scuff the whole surface
} else if (auto* skinned = dynamic_cast<SkinnedPbrEffect*>(part->getEffectProperty())) {
skinned->setRoughnessFactorProperty(0.8f);
}
}
}
Factor-only and material state
Factor-only PBR was fixed in alpha.1, and still holds. A clean CAD export or procedural material with no texture maps remains a PbrEffect/SkinnedPbrEffect. Base colour, metallic, roughness, emissive and opacity factors survive, along with alpha mode/cutoff and double-sided state.
The same rule covers a primitive with no material object: glTF defines that case as its default metallic-roughness material, so CNA creates the corresponding PBR effect rather than silently substituting a different lighting model.
Renderer coverage
Six renderer implementation families — every family that draws pixels — implement the universal PBR draw parameters, and two renderers draw nothing. This table lists all 10 public identities; the last column is the separate question of shadow and environment sampling:
| Disposition | Renderer identities | Boundary | Shadow and environment sampling |
|---|---|---|---|
| Full PBR | OPENGLES3, OPENGL33, WEBGL2, SDL_GPU, WEBGPU, DIRECTX9, FNA3D, SOFTWARE (six implementation families, eight identities) | Both rigid and skinned PBR paths exist and both KHR_materials_specular maps are sampled; glTF COLOR_0 multiplies the base colour. DIRECTX9 shades with CNA’s Shader Model 3 HLSL, FNA3D with CNA’s PBR effects compiled for MojoShader, and SOFTWARE evaluates the material on the CPU. | Shadow maps on all six families (DIRECTX9 on a Shader Model 3 device with cube textures, FNA3D when its driver offers enough texture slots); image-based lighting on the EasyGL identities, SDL_GPU, WEBGPU, SOFTWARE and FNA3D, not on DIRECTX9. |
| No PBR pixels | HEADLESS, STUB | HEADLESS validates and traces; STUB does nothing. | No. |
These are implementation families as much as identities: EasyGL alone serves three of the identities above. Each renderer reports its own answer as PbrEffectShading and SkinnedPbrEffectShading in its capability profile (Tutorial 133); query it and test the concrete build rather than assuming a renderer is visually equivalent to another. The first row is pinned by CNA’s glTF PBR fallback-policy tests, whose inventory covers every renderer family that consumes PBR maps. That is a statement about code paths, not about pixels compared against a reference renderer.
The Windows Direct3D workflow (d3d-windows-ci.yml) is manually dispatched at this snapshot, so the DIRECTX9 entries rest on the code and on Wine/DXVK or cross-build evidence — not on an automatic or physical Windows run. See Tutorial 72 for the single- and multi-renderer modes, and verify PBR on the concrete renderer and driver you intend to ship.
Per-map UV channels and transforms
glTF lets every texture reference pick its own TEXCOORD set and apply an independent KHR_texture_transform. The importer carries the first two distinct authored sets into renderer-facing UV0/UV1, records a selector for each of the five core maps and both specular maps (setTextureCoordinateSetEXTProperty(slot, set)), and transports every map’s scale/rotation/translation independently (setTextureTransformEXTProperty(slot, TextureTransformEXT{Offset, Scale, Rotation})).
A material requesting a third distinct authored set exceeds the two-channel vertex layouts. CNA falls that map back to packed channel 0 and adds a stable diagnostic to Model::getGltfImportReportEXTProperty(); both direct glTF and converter-generated .cnj models expose the report.
Where to go next
- Tutorial 59: Shadow Mapping — render a shadow map and feed it to a
BasicEffectorPbrEffect. - Tutorial 115: CNAEXT — what the marker on these effects actually does.
- Tutorial 112: Skeletal Animation from glTF — driving
SkinnedPbrEffect. - Tutorial 37: Multiple Lights — the three-light convention in depth.
- Effects reference and the CNAEXT extensions reference