NervLand DevLog #28: Textured PBR workflow

Hello my friends! In this new episode, we continue with our experiments on PBR: now that we have introduced support for IBL in our workflow, the next step will be to load the metallic/roughness data from textures. And in this process we will also use a few additional textures for the Albedo, Normals and Ambient Occlusion maps: all these together should result in a pretty advanced rendering result, so I'm really excited to get this example on rails 🤩

As usual, we start from the previous example file pbr_ibl.cpp to create our new pbr_texture.cpp version. And before adding more content in there we should try to optimize and simplify the usage of what we already have. So let's see what we can do.

First, the case of the BRDF 2D LUT: assuming we keep the size of this texture fixed, it can be generated only once, and used for any PBR context.

⇒ Now I have a dedicated function for that:

auto generate_pbr_brdf_lut(U32 width, U32 height) -> Texture {
    // create a render pass to generate the texture:

    const RenderPassDesc& desc = {.width = (U32)width,
                                  .height = (U32)height,
                                  .with_depth = false,
                                  .color_clear_value = {0.0F, 0.0F, 0.0F, 1.0F},
                                  .render_to_texture = true,
                                  //   .format = TextureFormat::RG32Float,
                                  .format = TextureFormat::RG8Unorm,
                                  .with_blend = false};

    auto rpass = create_ref_object<WGPURenderPass>(desc);

    rpass->render_node(0, "base/gen_brdf_lut", DrawCall{6});


    // return the generated texture:
    return rpass->get_attachment_texture(0);

Next we do the same thing with the irradiance cubemap generation and try to externalize this: OK we now have the function generate_pbr_irradiance_cubemap(…).

Same for prefiltered env, we now have the helper function generate_pbr_filtered_env_cubemap(…)

Finally, introduced another function to encapsulate the construction of the 3 required IBL textures:

    // const char* tname = "tests/cmaps/yokohama_rgba";
    const char* tname = "tests/cmaps/bridge2";
    auto ibl = generate_pbr_ibl_context(tname);

With the IBLContext struct defined as:

struct IBLContext {
    wgpu::Texture brdfLut;
    wgpu::Texture irradiance;
    wgpu::Texture filteredEnv;

Next, I think I could use a single linear sampler instead of 3 in the bind groups defined below:

    rpass1.render_node(std_vfmt_ipnu, "tests/pbr_ibl", "tests/sphere", "camera",
                       makeUBO(mat), matU.as_frag_ubo(), sceneU.as_frag_ubo(),
                       "frame", BindLinearSampler, texCube(ibl.filteredEnv),
                       BindLinearSampler, tex2d(brdfTex), BindLinearSampler,
    rpass1.render_node(std_vfmt_ipn, "tests/pbr_ibl", "tests/teapot", "camera",
                       makeUBO(mat2), matU.as_frag_ubo(), sceneU.as_frag_ubo(),
                       "frame", BindLinearSampler, texCube(ibl.filteredEnv),
                       BindLinearSampler, tex2d(brdfTex), BindLinearSampler,

So now I'm rather using the following BindGroup structure (and this works just fine of course):

    rpass1.render_node(std_vfmt_ipnu, "tests/pbr_texture", "tests/sphere",
                       "camera", makeUBO(mat), matU.as_frag_ubo(),
                       sceneU.as_frag_ubo(), BindLinearSampler,
                       tex2d(ibl.brdfLut), texCube(ibl.irradiance),
    rpass1.render_node(std_vfmt_ipn, "tests/pbr_texture", "tests/teapot",
                       "camera", makeUBO(mat2), matU.as_frag_ubo(),
                       sceneU.as_frag_ubo(), BindLinearSampler,
                       tex2d(ibl.brdfLut), texCube(ibl.irradiance),

Hmmm, first time I try to load this Cerberus model, and it seems there is something going wrong in my gltf loader implementation as I get a crash on this error:

2023-08-08 16:10:35.504447 [DEBUG] Loading nodes...
2023-08-08 16:10:35.504453 [DEBUG] Creating GLTF node Cerberus00_Fixed.001
2023-08-08 16:10:35.504456 [FATAL] Invalid parent node 1 for 0
2023-08-08 16:10:35.504460 [FATAL] A fatal error occured


Found it: when loading the GLTF nodes it might happen that a parent node is described after a child node, so we need to take this into account and load the parrent first in that case:

    if (src_node->parent != nullptr) {
        // Compute the parent index:
        U64 pidx = (U64)(src_node->parent - data->nodes);
        auto* parent = model.get_node(pidx);
        // It could be that this parent node is not added yet, in that case we
        // should add it first:
        if (parent == nullptr) {
            logDEBUG("Loading missing parent node {}...", pidx);
            gltf_model_load_node(model, pidx, data);
            parent = model.get_node(pidx);
        // Parent must be valid now:
        NVCHK(parent != nullptr, "Invalid parent node {} for {}", pidx, index);

But now, the Cerberus model will still not display, and I have a validation error:

2023-08-08 16:29:51.055108 [ERROR] Dawn: Validation error: Size (201246) is not a multiple of 4.
 - While [Failed to format error: "calling %s.WriteBuffer(%s, %s, (%d bytes))"]

Lesson: to write data to a gpu buffer the source data sie must a a multiple of 4 bytes.

Added support to align the index/vertex buffer to 4 bytes in gltf model class:

    // Align the index buffer to 4 bytes:
    U32 aligned_ibuf_size = get_aligned_element_size(ibuf_size, 4);
    if (aligned_ibuf_size != ibuf_size) {
        logWARN("Aligning index buffer size to 4 bytes: {} -> {}", ibuf_size,
        ibuf_size = aligned_ibuf_size;

And now I can get the naked model loaded:

Updated the pbr_texture shader to make use of the roughness, metallic, albedo, normal and ao texture for this model and here is the final display I get, (which is pretty nice! 😍)

With the introduction of textures to control the roughness/metallic aspect, our current IMGUI interface is not very usefull anymore. But what we could try instead is to provide support to select different environment maps from this GUI!

Note: to quickly change the IBLContext I think the easiest option is to copy the cubemap textures from some reference IBL context into the “current context texture” used in the constructed BindGroup (I might be wrong on this point ?). So I added this method to copy equivalent textures:

auto WGPUCommandBuilder::copy_texture_to_texture(wgpu::Texture src,
                                                 wgpu::Texture dst,
                                                 I32 mip_level)
    -> WGPUCommandBuilder& {

    // Textures should match to call this method:
    U32 width = src.GetWidth();
    U32 height = src.GetHeight();
    U32 nlayers = src.GetDepthOrArrayLayers();
    U32 nmips = src.GetMipLevelCount();

    NVCHK(width == dst.GetWidth(), "Mismatch in textures width");
    NVCHK(height == dst.GetHeight(), "Mismatch in textures height");
    NVCHK(nlayers == dst.GetDepthOrArrayLayers(),
          "Mismatch in textures nlayers");
    NVCHK(nmips == dst.GetMipLevelCount(), "Mismatch in textures mip levels");

    ImageCopyTexture srcDesc{.texture = std::move(src)};
    ImageCopyTexture dstDesc{.texture = std::move(dst)};

    Extent3D csize{width, height, nlayers};

    if (mip_level == -1) {
        // Copy all the mip levels:
        for (U32 i = 0; i < nmips; ++i) {
            srcDesc.mipLevel = i;
            dstDesc.mipLevel = i;
            csize.width = maximum(width / (1 << i), 1U);
            csize.height = maximum(height / (1 << i), 1U);
            _encoder.CopyTextureToTexture(&srcDesc, &dstDesc, &csize);
    } else {
        // Copy a single mip level:
        srcDesc.mipLevel = mip_level;
        dstDesc.mipLevel = mip_level;
        csize.width = maximum(width / (1 << mip_level), 1U);
        csize.height = maximum(height / (1 << mip_level), 1U);
        _encoder.CopyTextureToTexture(&srcDesc, &dstDesc, &csize);

    return *this;

Arrff… except that our textures now need to support the CopySrc/CopyDst usages of course 😅:

2023-08-09 10:56:16.262824 [ERROR] Dawn: Validation error: [Texture] usage (TextureUsage::(TextureBinding|RenderAttachment)) doesn't include TextureUsage::CopySrc.
 - While encoding [CommandEncoder].CopyTextureToTexture([Texture], [Texture], [Extent3D width:64, height:64, depthOrArrayLayers:6]).

2023-08-09 10:56:16.262835 [ERROR] Dawn: Validation error: [Invalid CommandBuffer] is invalid.
 - While calling [Queue].Submit([[Invalid CommandBuffer]])

⇒ Refactored the generate_pbr_ibl_context() function to now take an IBLCreateInfo struct as parameter:

struct IBLCreateInfo {
    wgpu::Texture envmap{nullptr};
    const char* envmapFile{nullptr};
    U32 irradianceSize{NV_PBR_IRRADIANCE_CUBE_DIM};
    U32 filteredEnvSize{NV_PBR_PREFILTERED_CUBE_DIM};
    wgpu::TextureUsage usage{wgpu::TextureUsage::RenderAttachment |

And now we can easily change the environment map using the IMGUI interface, perfect 😎!

See this tweet for a demo:

  • blog/2023/0810_nvl_dev28_textured_pbr.txt
  • Last modified: 2023/08/10 10:35
  • (external edit)