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
Seven renderer implementation families implement the universal PBR draw parameters, plus a reduced CPU cross-check and one renderer that refuses. This table lists all 14 public identities; the last column is the separate question of shadow and environment sampling:
| Disposition | Renderer identities | Boundary | Shadow and environment sampling |
|---|---|---|---|
| Full PBR | DIRECTX9, DIRECTX11, OPENGLES3, OPENGL33, WEBGL2, SDL_GPU, VULKAN, WEBGPU, METAL (seven implementation families, nine identities) | Both rigid and skinned PBR paths exist and both KHR_materials_specular maps are sampled; glTF COLOR_0 multiplies the base colour. | Honoured on the three EasyGL identities and on SDL_GPU, VULKAN and WEBGPU, and on DIRECTX11 at feature level 11_0 and above; not on DIRECTX9 or METAL. |
| Reduced CPU cross-check | SOFTWARE | Consumes base-colour UV selection/transform and colour products, but has no metallic-roughness BRDF or lighting. | No. |
| Refuses the draw | FNA3D | A PbrEffect/SkinnedPbrEffect draw is refused by name; it has no metallic-roughness path and does not substitute a stock effect. | No. |
| No PBR pixels | HEADLESS, STUB, and the 2D-only SDL_RENDERER | HEADLESS validates and traces; STUB does nothing; the 2D-only renderer has no 3D pipeline. | No. |
These are implementation families as much as identities: EasyGL alone serves three of the identities above. A renderer outside the first row 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 list is pinned by a CNA test that audits every renderer directory mentioning the PBR normal-map parameter (eight directories: the eight full families; SOFTWARE is added separately). 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 DirectX rows rest on the code, on Wine/DXVK or cross-build evidence and, for DIRECTX11, on runs CNA records on one physical Windows 11 machine with an Intel GPU and on one manually dispatched GitHub windows-latest run — not on an automatic 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