Tutorial 114: PBR Materials with PbrEffect and SkinnedPbrEffect
What you’ll learn
- The full
PbrEffectmaterial surface — four maps, three factors, and the owning setters. - Why a PBR effect is still lit by three directional lights, and what that rules out.
SkinnedPbrEffect, and how it differs fromSkinnedEffect.- The exact rule the importer uses to choose PBR — and the material shape that silently misses it.
Before you start — Tutorial 32: BasicEffect for the stock-effect model these follow, and Tutorial 110 for getting a PBR-authored model in.
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 ordinary CNA stock effects — 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. Tutorial 115 explains what that marker means for your build.
The material surface
Each of the five texture slots comes as a triple: a getter, a raw-pointer setter, and an owning setter that takes shared ownership so the effect keeps the texture alive for you.
| 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(...) |
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 |
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)));
Lit by three directional lights, not an environment
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.
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));
There is no image-based lighting. No irradiance map, no prefiltered environment, no BRDF lookup table. Metal reflects the analytic lights and the ambient term, and nothing else — so a mirror-smooth metal sphere in an empty scene will look dark and flat rather than wrong. Give rough metals a little ambient and a fill light and they read correctly.
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 rule is narrower than "the material has a pbrMetallicRoughness block", because glTF gives every material one of those, including trivially simple ones that BasicEffect already handles well.
A primitive takes the PBR path when it is not vertex-coloured and its material carries a normal map or a metallic-roughness map. Either texture is enough; neither means no PBR.
| Primitive | Effect | Vertex stride |
|---|---|---|
| Unskinned, has a normal or metallic-roughness map | PbrEffect | 48 |
| Skinned, has a normal or metallic-roughness map | SkinnedPbrEffect | 68 |
| Skinned, without either map | SkinnedEffect | — |
| Unskinned, base colour plus occlusion texture | DualTextureEffect | — |
| Anything else | BasicEffect | — |
PBR takes priority over the dual-texture path when both would apply, since a material carrying both an occlusion map and a normal map is unambiguously authored for real PBR rather than for the occlusion-as-lightmap approximation. Vertex colour and PBR are mutually exclusive — no PBR shader currently reads a vertex colour stream.
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:
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
}
}
}
The factor-only trap
A glTF material that sets base colour, metallic and roughness as plain numbers with no texture maps at all does not take the PBR path. It falls through to BasicEffect, and its base colour factor, alpha mode, alpha cutoff and double-sidedness do not survive the import.
This is a known defect, tracked in CNA’s own glTF conformance harness, not a design decision — the fixture that proves it is part of the permanent corpus. It bites exactly the content you would least expect to be fragile: clean, procedurally-authored assets, CAD exports, greybox blockouts, anything coloured by material rather than by texture.
Two ways around it, both cheap:
- Give the material a texture. Even a 4×4 flat normal map is enough to trip the rule and take the whole material down the PBR path with its factors intact.
- Set the factors after loading. Walk the mesh parts as shown above and apply the values yourself. You will be assigning them onto a
BasicEffect, so you get its lighting model rather than the PBR one — fine for flat-shaded content, wrong for anything metallic.
The same trap is noted from the loading side in Tutorial 110, and from the converter’s side in Tutorial 111, because it is visible from all three.
Renderer coverage
PBR is consumed by 16 renderers, each with its own pixel tests. The shader work is genuinely per renderer — a hand-derived BRDF on some, golden-image comparison against the reference implementation on others — so coverage is uneven in a way worth knowing about:
| Renderer group | PbrEffect | SkinnedPbrEffect |
|---|---|---|
| EasyGL (the five GL profile identities), Vulkan, bgfx, SDL_GPU | Real shader | Real shader |
| DIRECTX9, DIRECTX11, DIRECTX12 | Real shader | Real shader |
| WebGPU | Real shader | No skinning path yet |
| 2D-only and non-shader renderers | Fallback | Fallback |
The last row matters more than it looks. Binding a PbrEffect on a renderer with no PBR shader is not an error — the effect is accepted and the geometry is drawn through that renderer’s fallback path, untextured and unlit. Your scene renders; it just does not look right. If you are chasing a "why is everything flat grey" bug, check which renderer the build selected before you check the material.
The Windows DirectX renderers are routinely verified through Wine with DXVK and vkd3d-proton on Linux rather than on real Windows hardware. See Tutorial 72 for how a renderer is chosen, and remember that exactly one is compiled into any given build.
One shared UV channel
glTF lets every texture reference pick its own TEXCOORD set independently. PbrEffect and SkinnedPbrEffect sample every map from a single shared UV channel — the one belonging to the base colour texture.
A material whose normal or occlusion map points at a different TEXCOORD set will therefore be sampled with the wrong coordinates. The offline converter detects this and prints a warning naming the primitive; the runtime path cannot warn you at all. If a lightmap or a detail normal comes out scrambled, this is the first thing to check.
Where to go next
- 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