PBR shader look okay?

Hey everyone,

I’ve been working on a PBR shader the last few months and have gotten a good ways with it I think. I’m just not sure if I have actually got a robust solution, and I’m getting PBR working with my terrain system and feel like the screenshot below is just a little to “blue” from IBL. You can see the left of the terrain has a blue tint compared to the right. I tend to believe that the camera is facing the directional light there… I don’t know. It just feels washed out to me, but might be correct.

Here’s the same PBR lighting on the helmet model (different shader code but same lighting functions)

Hoping some of you guys might have some tips or may see something not right in the code.

Here’s the full vert and frag code for the terrain.

//Vertex Shader
#version 450
#extension GL_ARB_separate_shader_objects : enable
#extension GL_ARB_shader_draw_parameters : enable
#extension GL_ARB_bindless_texture : enable


#define BUFFER_BINDING_CAMERA_SSBO 0
#define BUFFER_BINDING_ENTITY_SSBO 1
#define BUFFER_BINDING_MESH_SSBO 2
#define BUFFER_BINDING_MATERIAL_SSBO 3
#define BUFFER_BINDING_FRAMEBUFFER_SSBO 4
#define BUFFER_BINDING_LIGHTS_SSBO 5
#define BUFFER_BINDING_INSTANCE_SSBO 6
#define BUFFER_BINDING_VOXEL_OBJECT_SSBO 7
#define BUFFER_BINDING_PROBE_SSBO 8

//Shader master starts from 8.  This needs to be manually changed if more enigne values are added here.

layout(location = 0) uniform int CameraID;
#ifdef DOUBLE_FLOAT
//layout(location = 1) uniform dvec3 WorldOffset;
#endif

struct Camera {
    mat4 view;
	mat4 projection;
};

layout(std430, binding = BUFFER_BINDING_CAMERA_SSBO) buffer CameraBuffer {
    Camera cameras[];
};

mat4 CameraMatrix;
mat4 CameraInverseMatrix;

vec3 ExtractCameraPosition(in int CameraID) {
	Camera cam = cameras[CameraID];
	CameraInverseMatrix = inverse(cam.view);
	return CameraInverseMatrix[3].xyz;
}
vec3 CameraPosition = ExtractCameraPosition(CameraID);


//Shoud only be used in a vertex shader due to : uint id = gl_BaseInstanceARB + gl_InstanceID;

struct Mesh {
	uint entityID;
	uint materialID;
};

layout(std430, binding = BUFFER_BINDING_MESH_SSBO) readonly buffer MeshBuffer {
    Mesh instanceInfo[];
};

uint EntityID;
uint MaterialID;

uint ExtractInstanceInfo() {
	uint id = gl_BaseInstanceARB + gl_InstanceID;
	EntityID = instanceInfo[id].entityID;
	MaterialID = instanceInfo[id].materialID;
	
	return id;
}
uint MeshID = ExtractInstanceInfo();

//Entities
//Structs are auto padded to 16 bytes
struct Entity {
	mat4 matrix;
	mat4 normal_matrix;
	vec4 color;
};

layout(std430, binding = BUFFER_BINDING_ENTITY_SSBO) readonly buffer EntityBuffer {
    Entity entities[];
};
layout(location = 0) in vec3 in_vPosition;
layout(location = 1) in vec3 in_vNormal;
layout(location = 2) in vec3 in_vTangent;
layout(location = 3) in vec3 in_vBitangent;
layout(location = 4) in vec4 in_vCoords;
layout(location = 5) in vec3 in_vColor;
#define TEXTURE_DIFFUSE 0
#define TEXTURE_NORMAL 1
#define TEXTURE_TANGENT 2
#define TEXTURE_BITANGENT 3
#define TEXTURE_AO 4
#define TEXTURE_SLOT_ALBEDO 0
#define TEXTURE_SLOT_NORMAL 1
#define TEXTURE_SLOT_METALLIC_ROUGHNESS_AO 2
#define TEXTURE_SLOT_EMISSIVE 3
#define TEXTURE_SLOT_DISPLACMENT 4

struct Material {
	vec4 diffuseColor;
	float roughness;
	float metallic;
	float displacment_strength;
	uvec2 textureHandle[16];
};

layout(std430, binding = 3) readonly buffer MaterialBlock { Material materials[]; };

layout(location = 0) out vec3 out_vNormal;
layout(location = 1) out vec4 out_vCoords;
layout(location = 2) out vec4 out_vColor;
layout(location = 3) flat out uint out_MaterialIndex;
layout(location = 4) flat out uint out_EntityIndex;
layout(location = 5) flat out uint out_MeshIndex;
layout(location = 6) out vec3 out_WorldPosition;
layout(location = 7) out vec3 out_CameraPosition;
layout(location = 8) out mat3 out_TBN;


void main() {
	Entity entity = entities[EntityID];
	Material material = materials[MaterialID];
	
	vec3 vertex_position = in_vPosition;
	
	//mat4 model_matrix = entity.matrix;
	
	Camera camera = cameras[CameraID];
	mat4 proj_matrix = camera.projection;
	mat4 view_matrix = camera.view;
	
	//Can we calculate the normal_matrix once per model?
	//-mat3 normal_matrix = transpose(inverse(mat3(entity.matrix)));
	mat3 normal_matrix = mat3(entity.matrix);
	vec3 T = normalize(vec3(normal_matrix * vec3(1,0,0)));
	vec3 B = normalize(vec3(normal_matrix * vec3(0,0,1)));
	vec3 N = normalize(vec3(normal_matrix * vec3(0,1,0)));
	out_TBN = mat3(T, B, N);
	
	out_CameraPosition = inverse(view_matrix)[3].xyz;
	out_WorldPosition = vec3(entity.matrix * vec4(vertex_position, 1.0f));
	out_EntityIndex = EntityID;
	//out_EntityColor = entity.color;
	out_MeshIndex = MeshID;
	out_MaterialIndex = MaterialID;
	out_vNormal = N;
	out_vColor = vec4(in_vColor, 1.0f);
	out_vCoords = in_vCoords;
    gl_Position = proj_matrix * view_matrix * vec4(out_WorldPosition, 1.0f);
}
//Fragment Shader
#version 460
#extension GL_ARB_separate_shader_objects : enable
#extension GL_ARB_bindless_texture : enable

layout(location = 0) in vec3 in_vNormal;
layout(location = 1) in vec4 in_vCoords;
layout(location = 2) in vec4 in_vColor;
layout(location = 3) flat in uint in_MaterialIndex;
layout(location = 4) flat in uint in_EntityIndex;
layout(location = 5) flat in uint in_MeshIndex;
layout(location = 6) in vec3 in_WorldPosition;
layout(location = 7) in vec3 in_CameraPosition;
layout(location = 8) in mat3 in_TBN;

#define TEXTURE_DIFFUSE 0
#define TEXTURE_NORMAL 1
#define TEXTURE_TANGENT 2
#define TEXTURE_BITANGENT 3
#define TEXTURE_AO 4
#define TEXTURE_SLOT_ALBEDO 0
#define TEXTURE_SLOT_NORMAL 1
#define TEXTURE_SLOT_METALLIC_ROUGHNESS_AO 2
#define TEXTURE_SLOT_EMISSIVE 3
#define TEXTURE_SLOT_DISPLACMENT 4


struct Material {
	vec4 diffuseColor;
	float roughness;
	float metallic;
	float displacment_strength;
	uvec2 textureHandle[16];
};

layout(std430, binding = 3) readonly buffer MaterialBlock { Material materials[]; };

struct VoxelTerrainLayer {
	uint material_index;
	float min_height;
	float max_height;
	float min_slope;
	float max_slope;
	float min_angle;
	float max_angle;
	float transition;
};




float MacroVariation( in Material material , in int texture_slot , in vec2 coords , in float strength ) {
	return mix( ( 1.0f - strength ) , 1.0f , ( ( texture( sampler2D( material.textureHandle[ texture_slot ] ) , ( coords * 0.2134f ) ).x + 0.5f ) * ( ( texture( sampler2D( material.textureHandle[ texture_slot ] ) , ( coords * 0.05341f ) ).x + 0.5f ) * ( texture( sampler2D( material.textureHandle[ texture_slot ] ) , ( coords * 0.002f ) ).x + 0.5f ) ) ) );
}




vec4 SampleDiffuse( Material material , in vec2 coords ) {
	return ( material.diffuseColor * ( texture( sampler2D( material.textureHandle[ 0 ] ) , coords ) * MacroVariation( material , 0 , coords , 1.0f ) ) );
}




vec3 SampleNormal( in Material material , in vec2 coords ) {
	vec4 node22_pixel = texture( sampler2D( material.textureHandle[ 1 ] ) , coords );
	return vec3( ( ( node22_pixel.x * 2.0f ) - 1.0f ) , ( ( node22_pixel.z * 2.0f ) - 1.0f ) , ( ( node22_pixel.y * 2.0f ) - 1.0f ) );
}




void SampleLayer( in VoxelTerrainLayer layer , in vec2 coords , in float slope , out vec3 normal , out vec4 color , out float blend ) {
	normal = SampleNormal(materials[ layer.material_index ] , coords);
	blend = smoothstep( ( layer.max_slope + 0.001f ) , layer.min_slope , slope );
	if ( blend > 0.0f ) {
		color = ( materials[ layer.material_index ].diffuseColor * SampleDiffuse(materials[ layer.material_index ] , coords) );
		
	}
}


#define BUFFER_BINDING_CAMERA_SSBO 0
#define BUFFER_BINDING_ENTITY_SSBO 1
#define BUFFER_BINDING_MESH_SSBO 2
#define BUFFER_BINDING_MATERIAL_SSBO 3
#define BUFFER_BINDING_FRAMEBUFFER_SSBO 4
#define BUFFER_BINDING_LIGHTS_SSBO 5
#define BUFFER_BINDING_INSTANCE_SSBO 6
#define BUFFER_BINDING_VOXEL_OBJECT_SSBO 7
#define BUFFER_BINDING_PROBE_SSBO 8

//Shader master starts from 8.  This needs to be manually changed if more enigne values are added here.


layout(location = 0) uniform int CameraID;
#ifdef DOUBLE_FLOAT
layout(location = 1) uniform dvec3 WorldOffset;
#endif


struct Camera {
    mat4 view;
	mat4 projection;
};

layout(std430, binding = BUFFER_BINDING_CAMERA_SSBO) buffer CameraBuffer {
    Camera cameras[];
};

mat4 CameraMatrix;
mat4 CameraInverseMatrix;

vec3 ExtractCameraPosition(in int CameraID) {
	Camera cam = cameras[CameraID];
	CameraInverseMatrix = inverse(cam.view);
	return CameraInverseMatrix[3].xyz;
}
vec3 CameraPosition = ExtractCameraPosition(CameraID);


vec2 DistanceCoords( in vec2 coords , in vec3 world_position , in float uv_scale , in float step_size ) {
	return ( coords / ( uv_scale * ( floor( ( distance(world_position, CameraPosition) / step_size ) ) + 1.0f ) ) );
}



uniform float object_size = 1.0f;
uniform int total_material_layers = 0;

void VoxelTerrainInput( out float size , out float height , out float detail , out float noise , out float ao , out vec3 normal , out vec3 tangent , out vec3 bi_tangent , out vec2 object_uv , out vec2 tile_uv , out int layer_count , out uint layers ) {
	size = object_size;
	vec4 node23_tex_sample = texture( sampler2D( materials[ in_MaterialIndex ].textureHandle[ TEXTURE_DIFFUSE ] ) , ( in_WorldPosition.xz / object_size ) );
	height = node23_tex_sample.x;
	detail = node23_tex_sample.y;
	noise = node23_tex_sample.z;
	ao = node23_tex_sample.w;
	normal = SampleNormal(materials[ in_MaterialIndex ] , ( in_WorldPosition.xz / object_size ));
	tangent = texture( sampler2D( materials[ in_MaterialIndex ].textureHandle[ TEXTURE_TANGENT ] ) , ( in_WorldPosition.xz / object_size ) ).xyz;
	bi_tangent = texture( sampler2D( materials[ in_MaterialIndex ].textureHandle[ TEXTURE_BITANGENT ] ) , ( in_WorldPosition.xz / object_size ) ).xyz;
	object_uv = ( in_WorldPosition.xz / object_size );
	tile_uv = in_WorldPosition.xz;
	layer_count = total_material_layers;
}



float GetSlope( in vec3 normal , in vec3 up ) {
	return clamp(dot( normal , up ), 0.0f, 1.0f);
}



const int node38_array_size = 32;
layout(std140, binding = 8) uniform material_layers_block {
	VoxelTerrainLayer material_layers[32];
};
out vec4 FragColor;

void main() {
	float node29_size;
	float node29_height;
	float node29_detail;
	float node29_noise;
	float node29_ao;
	vec3 node29_normal;
	vec3 node29_tangent;
	vec3 node29_bi_tangent;
	vec2 node29_object_uv;
	vec2 node29_tile_uv;
	int node29_layer_count;
	uint node29_layers;
	VoxelTerrainInput( node29_size , node29_height , node29_detail , node29_noise , node29_ao , node29_normal , node29_tangent , node29_bi_tangent , node29_object_uv , node29_tile_uv , node29_layer_count , node29_layers );
	vec4 node13_variable = vec4( 1.0f, 1.0f, 1.0f, 0.0f );
	
	vec3 node6_normal;
	vec4 node6_color;
	float node6_blend;
	
	for( int node35_index = 0; node35_index < node29_layer_count; node35_index++ ) {
		SampleLayer( material_layers[node35_index] , DistanceCoords( node29_tile_uv , in_WorldPosition , 1.0f , 32.0f ) , GetSlope(node29_normal , vec3( 0.0f, 1.0f, 0.0f )) , node6_normal , node6_color , node6_blend );
		if ( node35_index == 0 ) {
			node6_blend = 1.0f;
			
		}
		node13_variable = mix( node13_variable , node6_color , node6_blend );
	}
	
	FragColor = node13_variable;
}
1 Like

ITS ALL ABOUT THE LUTS!!

TL;DR;

It looks like your terrain and helmet are getting a bit washed out because the specular contribution isn’t pulling its weight. A few things you might check:

  • Specular/metallic map values – make sure they’re not clamped too low. Dielectrics should have a low F₀ (~0.04), but metals should be close to 1. If the texture is too dark, highlights will vanish.
  • IBL prefiltering – environment maps need a prefiltered mip chain so roughness can drive the LOD. Without it, reflections smear and the sky tint dominates.
  • BRDF terms – confirm you’re using GGX/Trowbridge‑Reitz for the normal distribution, Smith‑Schlick for geometry, and Fresnel‑Schlick for reflectance. Missing or mis‑scaled terms make specular look flat.
  • Energy conservation – scale diffuse by (1 – F). Otherwise diffuse overwhelms specular and everything looks dull.
  • Tonemapping/exposure – raw HDR skyboxes can push too much blue into terrain. Filmic or ACES tonemapping helps balance highlights and prevent color washout.

If you implement those checks, you should see crisper highlights, more natural color balance, and specular reflections that feel less “dull.”


Fresnel-Schlick Approximation

vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
    return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}

IBL Prefilter Sampling

// prefiltered environment map with mip chain
vec3 prefilteredColor = textureLod(envMap, R, roughness * maxMipLevels).rgb;

// BRDF LUT for split-sum approximation
vec2 envBRDF = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;

vec3 specular = prefilteredColor * (F * envBRDF.x + envBRDF.y);

These show the Fresnel term and roughness-based LOD sampling that prevent dull specular.

Minimal ShaderToy Example

// Chrome sphere with exaggerated LUT toggle every 3 seconds

vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
    return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}

float DistributionGGX(vec3 N, vec3 H, float roughness)
{
    float a = roughness * roughness;
    float a2 = a * a;
    float NdotH = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;
    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    return a2 / (3.14159 * denom * denom);
}

float GeometrySchlickGGX(float NdotV, float roughness)
{
    float r = (roughness + 1.0);
    float k = (r * r) / 8.0;
    return NdotV / (NdotV * (1.0 - k) + k);
}

float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness)
{
    float NdotV = max(dot(N, V), 0.0);
    float NdotL = max(dot(N, L), 0.0);
    return GeometrySchlickGGX(NdotV, roughness) * GeometrySchlickGGX(NdotL, roughness);
}

vec2 BRDF_LUT(float NdotV, float roughness)
{
    float bias = 0.5 * roughness;
    float scale = 1.0 - bias;
    return vec2(scale, bias);
}

vec3 fakeEnvironment(vec3 R)
{
    // Simple gradient sky: blue up, gray down
    float t = R.y * 0.5 + 0.5;
    return mix(vec3(0.3), vec3(0.6, 0.8, 1.0), t);
}

vec3 renderSphere(vec2 uv, bool useLUT)
{
    float r = length(uv);
    if (r > 0.8) return fakeEnvironment(normalize(vec3(uv, 0.0))); // background

    // Sphere normal
    vec3 N = normalize(vec3(uv, sqrt(0.8*0.8 - r*r)));
    vec3 V = normalize(vec3(0.0, 0.0, 1.0));
    vec3 R = reflect(-V, N);

    // Chrome material
    float roughness = 0.05;
    float metallic  = 1.0;
    vec3 albedo     = vec3(1.0); // neutral chrome
    vec3 F0 = mix(vec3(0.04), albedo, metallic);

    // Single light
    vec3 L = normalize(vec3(0.5, 0.5, 1.0));
    vec3 H = normalize(V + L);

    float D = DistributionGGX(N, H, roughness);
    float G = GeometrySmith(N, V, L, roughness);
    vec3 F  = fresnelSchlick(max(dot(H, V), 0.0), F0);

    vec3 numerator = D * G * F;
    float denominator = 4.0 * max(dot(N,V),0.0) * max(dot(N,L),0.0) + 0.001;
    vec3 specular = numerator / denominator;

    float NdotL = max(dot(N, L), 0.0);

    // Exaggerate LUT difference
    if (useLUT) {
        vec2 envBRDF = BRDF_LUT(max(dot(N,V),0.0), roughness);
        specular *= (envBRDF.x + envBRDF.y) * vec3(1.0, 0.6, 0.2); // warm tint
    } else {
        specular *= vec3(0.6, 0.8, 1.0); // cool tint
    }

    vec3 color = specular * NdotL;

    // Add ambient reflection from environment
    color += fakeEnvironment(R) * F0 * 0.5;

    return clamp(color, 0.0, 10.0);
}

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    vec2 uv = (fragCoord.xy / iResolution.xy) * 2.0 - 1.0;
    uv.x *= iResolution.x / iResolution.y;

    // Toggle LUT every 3 seconds
    bool useLUT = mod(iTime, 6.0) < 3.0;

    // Render single chrome sphere
    vec3 color = renderSphere(uv, useLUT);

    // Tonemap + gamma
    color = color / (color + vec3(1.0));
    color = pow(color, vec3(1.0/2.2));

    // Top bar indicator
    vec2 fc = fragCoord.xy / iResolution.xy;
    if (fc.y > 0.95) {
        if (useLUT) color += vec3(1.0, 0.5, 0.0); // LUT ON
        else        color += vec3(0.0, 1.0, 1.0); // LUT OFF
    }

    fragColor = vec4(color, 1.0);
}

GL;HC;

~p3nGu1nZz

1 Like

Thanks for the reply! I found a few issues with my implementation. I think I was over exposing which was probably the main issue. All the other core lighting functions seem fine.

1 Like

Hazzah, i am happy you solved it, those lighting issues can be a real bugger at times.

happy coding :slight_smile:

p3n

1 Like