Tutorial 114: PBR Materials with PbrEffect and SkinnedPbrEffect
What you’ll learn
- The
PbrEffectmaterial surface — seven maps, factors, alpha state, UV selectors and transforms. - Why a PBR effect is still lit by three directional lights, and what that rules out.
SkinnedPbrEffect, and how it differs fromSkinnedEffect.- The alpha.1 importer rule: 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.
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. Tutorial 115 explains what that 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)));
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. In alpha.1 the choice follows the material model, not the presence of a particular texture.
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:
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
}
}
}
Factor-only and material state
Factor-only PBR is fixed in alpha.1. 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
Sixteen implementation families consume the universal alpha.1 PBR draw parameters:
| Disposition | Implementation families | Boundary |
|---|---|---|
| Full PBR | bgfx, Diligent, DirectX 9/11/12, EasyGL, IGL, LLGL, Magnum, Metal, OpenGL 2/4, SDL_GPU, Vulkan, WebGPU, Wicked | Both rigid and skinned PBR paths exist; WebGPU skinning is implemented. |
| Specular maps partial | Metal and Wicked | They consume the IOR/specular factors but not the two optional KHR_materials_specular maps. The other 14 full families sample both maps. |
| Reduced CPU cross-check | Software | Consumes base-colour UV selection/transform and colour products, but has no metallic-roughness BRDF or lighting. |
| Vertex-colour refusal | Metal and Wicked | Alpha.1 explicitly refuses the coloured stride-60/80 PBR draw instead of accepting it with wrong colour semantics. |
These are implementation families, not public identity counts: EasyGL alone serves five renderer identities. A renderer outside the table is not evidence of equivalent PBR support; query its capabilities and test the concrete build rather than assuming a generic fallback is visually correct.
The tag's Windows Direct3D workflow lanes are manually dispatched; alpha.1 has no automatic MinGW/Wine lane. 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. Alpha.1 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, and transports every map’s scale/rotation/translation independently.
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 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