How to Define and Apply PBR Materials Using the pbr_system in Equilibrium Engine

The pbr_system bridges C-side material data with GLSL shaders by binding Material components to PBRShader components via bind_material(), which uploads uniform factors and texture handles each frame before GPU evaluation.

The Equilibrium Engine provides a data-driven physically based rendering pipeline that separates material asset definitions from shader execution. Understanding how to define and apply PBR materials using the pbr_system enables developers to leverage glTF 2.0 assets or manual material creation while ensuring correct uniform binding and texture sampling in both forward and deferred rendering paths.

Architecture Overview

The pbr_system operates across four distinct layers that connect scene data to GPU execution:

Scene Data Layer – Defined in scene_components.h, the Material struct stores raw factor values (base color, metallic, roughness, normal scale, occlusion strength, emissive) and texture handles for each entity.

Renderer Components Layer – The PBRShader struct in renderer_components.h maintains shader program handles, uniform locations for factor uploads, default texture references, and the albedo LUT for multiple-scattering correction.

PBR System Layer – Implemented in pbr_system.c, this layer provides bind_material() and set_texture_or_default() to synchronize Material data with shader uniforms each frame.

Shader Execution Layer – The pbr.sh library defines the PBRMaterial struct and evaluation functions (pbrMaterial, pbrInitMaterial) that compute final material properties from bound textures and uniforms.

Defining PBR Materials

Materials can originate from glTF assets or manual component initialization.

Manual Material Creation

To define a material programmatically, create an entity and populate the Material component:

/* Create entity and attach Material component */
ecs_entity_t entity = ecs_new(world, 0);
Material *mat = ecs_emplace(world, entity, Material);

/* Configure base color texture and factor */
mat->base_color_texture = bgfx_create_texture_2d(
    1024, 1024, false, 1,
    BGFX_TEXTURE_FORMAT_RGBA8,
    BGFX_TEXTURE_NONE,
    bgfx_make_ref(image_data_basecolor, image_size));
mat->base_color_factor = (vec4){1.0f, 1.0f, 1.0f, 1.0f};

/* Configure metallic-roughness */
mat->metallic_roughness_texture = bgfx_create_texture_2d(...);
mat->metallic_factor = 1.0f;
mat->roughness_factor = 0.5f;

/* Configure normal mapping */
mat->normal_texture = bgfx_create_texture_2d(...);
mat->normal_scale = 1.0f;

/* Configure occlusion and emissive (optional) */
mat->occlusion_texture = BGFX_INVALID_HANDLE;
mat->occlusion_strength = 1.0f;
mat->emissive_texture = BGFX_INVALID_HANDLE;
mat->emissive_factor = (vec3){0.0f, 0.0f, 0.0f};

Loading from glTF Assets

For glTF 2.0 files, cgltf_utils.c automatically parses pbrMetallicRoughness properties into the Material component (lines 242-269). The loader populates texture references and factor values directly from the glTF JSON and binary buffers, eliminating manual configuration.

The PBR System Workflow

Once materials are defined, the pbr_system binds them to shaders during rendering.

PBRShader Component Initialization

The renderer creates a PBRShader component containing uniform handles and default textures:

ECS_OBSERVER(world, InitializePBRShader, EcsOnSet, renderer.components.PBRShader);
ECS_SYSTEM(world, UpdatePBRShader, OnRender, renderer.components.PBRShader);

InitializePBRShader generates uniform handles for base_color_factor_uniform, metallic_roughness_normal_occlusion_factor_uniform, emissive_factor_uniform, and has_textures_uniform. It also creates a default 1×1 white texture and the albedo LUT for multiple-scattering correction.

Binding Materials with bind_material

During the draw loop, the system calls bind_material() (implemented in pbr_system.c, lines 29-66):

/* In the mesh draw loop */
uint64_t material_id = bind_material(pbr_shader, material);
bgfx_set_state(state, material_id);
bgfx_submit(view_id, pbr_shader->program, 0, 0);

The bind_material function performs three critical operations:

  1. Uniform Upload – Sets factor uniforms for base color, packed metallic-roughness-normal-occlusion, and emissive values.
  2. Texture Validation – Packs a bit-field into has_textures_uniform indicating which of the five PBR texture slots (base color, metallic-roughness, normal, occlusion, emissive) contain valid data.
  3. Sampler Binding – Calls set_texture_or_default() to bind each texture to its corresponding sampler slot (PBR_BASECOLOR, PBR_METALROUGHNESS, PBR_NORMAL, PBR_OCCLUSION, PBR_EMISSIVE), falling back to the default white texture when handles are invalid.

Shader-Side Material Evaluation

After binding, the GPU executes PBR logic defined in pbr.sh. The fragment shader (fs_forward.sc or fs_deferred_geometry.sc) retrieves the material:

#include "pbr.sh"

void main()
{
    // v_texcoord0 passed from vertex shader
    PBRMaterial mat = pbrMaterial(v_texcoord0);
    
    // Lighting evaluation
    vec3 color = BRDF(viewDir, lightDir, normal, NoV, NoL, mat);
    gl_FragColor = toneMap(color);
}

The pbrMaterial function (lines 36-53) constructs the material by sampling bound textures when the corresponding bit in has_textures_uniform is set, otherwise falling back to uniform factors. It then calls pbrInitMaterial (lines 57-74) to precompute diffuseColor, Fresnel reflectance F0, and remapped roughness a (roughness²) for the BRDF evaluation.

Key Source Files

File Role
scene_components.h Declares Material struct with texture handles and factor values
renderer_components.h Declares PBRShader with uniform handles and default textures
pbr_system.h Public API header declaring bind_material()
pbr_system.c Implements material binding, uniform uploads, and texture fallback logic
pbr.sh GLSL library defining PBRMaterial and evaluation functions
fs_forward.sc Forward rendering fragment shader consuming PBR materials
fs_deferred_geometry.sc Deferred rendering geometry pass fragment shader
cgltf_utils.c glTF 2.0 loader parsing pbrMetallicRoughness into Material
forward_renderer_system.c / deferred_renderer_system.c Create PBRShader components and invoke the PBR system

Summary

  • The pbr_system connects CPU-side Material components to GPU shaders through the bind_material() function in pbr_system.c.
  • Material definition happens either manually by filling the Material struct in scene_components.h or automatically via cgltf_utils.c for glTF assets.
  • Shader binding uploads factor uniforms, packs texture availability bits into has_textures_uniform, and binds samplers to slots like PBR_BASECOLOR and PBR_METALROUGHNESS.
  • GPU evaluation uses pbr.sh functions to sample textures or fall back to uniforms, then precomputes BRDF inputs through pbrInitMaterial.

Frequently Asked Questions

What is the difference between the Material and PBRShader components?

The Material component defined in scene_components.h stores per-entity material data including texture handles and factor values on the CPU side. The PBRShader component defined in renderer_components.h stores shader program handles, uniform locations, and default textures that are shared across multiple entities. The pbr_system bridges these components during rendering by copying material data into shader uniforms.

How does the system handle missing textures?

When a texture handle is invalid (such as BGFX_INVALID_HANDLE), the set_texture_or_default() function in pbr_system.c binds a default 1×1 white texture instead. Simultaneously, the has_textures_uniform bit-field is updated to indicate which slots contain valid data, allowing the shader code in pbr.sh to fall back to uniform factor values when the corresponding bit is not set.

Can I use the pbr_system with both forward and deferred rendering?

Yes, the pbr_system is pipeline-agnostic. Both forward_renderer_system.c and deferred_renderer_system.c create PBRShader components and invoke bind_material() during their respective draw loops. The shader-side code in pbr.sh is included by both fs_forward.sc and fs_deferred_geometry.sc, ensuring consistent material evaluation across rendering paths.

Where does the multiple-scattering albedo LUT get initialized?

The albedo LUT used for multiple-scattering correction is created during PBRShader initialization in the renderer systems. The InitializePBRShader observer generates this LUT texture and stores its handle in the PBRShader component alongside uniform handles and the default white texture. This initialization occurs once when the shader component is first created, not per-material or per-frame.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →