Texture Blending 2(With Godot)
This is a direct continuation of the previous post.
I created a plane mesh instance which I exported to Blender for subdividing and UV coloring. We will be using the colors as weights for both blending and layering.
I found Blender to be a bit troublesome for manipulating the colors of individual vertices, perhaps I’ll create a tool for the Godot editor in the future to easily handle this.
On the edges will be 100% our R texture, and 100% our G texture in the middle. Inbetween I added yellow vertices for a nice blend of an overlapping RG layer.
shader_type spatial;
render_mode blend_mix,depth_draw_opaque,cull_back,diffuse_burley,specular_schlick_ggx;
// Macro to define a uniform group for a material layer.
// Each layer (R, G, B) will have its own set of uniforms for various material properties.
#define DEFINE_UNIFORM_GROUP(suffix) \
group_uniforms Material##suffix; \
uniform vec4 albedo##suffix : source_color = vec4(1.0); \
uniform sampler2D texture_albedo##suffix : source_color,filter_linear_mipmap,repeat_enable; \
uniform float roughness##suffix: hint_range(0,1) = 1.0; \
uniform sampler2D texture_metallic##suffix: hint_default_white,filter_linear_mipmap,repeat_enable; \
uniform vec4 metallic_texture_channel##suffix; \
uniform sampler2D texture_roughness##suffix: hint_roughness_r,filter_linear_mipmap,repeat_enable; \
uniform float specular##suffix = 0.5; \
uniform float metallic##suffix = 0.0; \
uniform sampler2D texture_normal##suffix: hint_normal,filter_linear_mipmap,repeat_enable; \
uniform float normal_scale##suffix: hint_range(-16,16) = 1.0; \
uniform sampler2D texture_ambient_occlusion##suffix: hint_default_white, filter_linear_mipmap,repeat_enable; \
uniform vec4 ao_texture_channel##suffix = vec4(1.0, 0.0, 0.0, 0.0); \
uniform float ao_light_affect##suffix = 0.0; \
uniform sampler2D texture_heightmap##suffix: hint_default_black,filter_linear_mipmap,repeat_enable; \
uniform float heightmap_scale##suffix = 5.0; \
uniform vec2 heightmap_flip##suffix; \
group_uniforms;
// Define uniforms for three material layers: R, G, and B.
DEFINE_UNIFORM_GROUP(R)
DEFINE_UNIFORM_GROUP(G)
//DEFINE_UNIFORM_GROUP(B)
uniform vec3 uv1_scale = vec3(1.0);
uniform vec3 uv1_offset;
uniform vec3 uv2_scale = vec3(1.0);
uniform vec3 uv2_offset;
// Uniform to control the sharpness of the blend.
uniform float BlendSharpness: hint_range(1.0,10.0) = 3.0;
uniform sampler2D texture_noise;
void vertex() {
UV=UV*uv1_scale.xy+uv1_offset.xy;
}
struct MaterialProperties {
vec4 albedo;
float roughness;
vec4 metallic_texture_channel;
float specular;
float metallic;
float normal_scale;
vec4 ao_texture_channel;
float ao_light_affect;
float heightmap_scale;
vec2 heightmap_flip;
vec2 uv;
vec3 vertex;
vec3 normal;
vec3 tangent;
vec3 binormal;
};
struct ProcessMaterialOut {
vec3 albedo;
float metallic;
float roughness;
float specular;
vec3 normal_map;
float normal_map_depth;
float ao;
float ao_light_affect;
float depth;
float height;
};
// Function to process material properties and textures for a layer.
// Applies texture sampling and calculations to derive final material attributes.
ProcessMaterialOut process_material(MaterialProperties props, sampler2D texture_albedo, sampler2D texture_heightmap,
sampler2D texture_metallic, sampler2D texture_roughness, sampler2D texture_normal,
sampler2D texture_ambient_occlusion) {
ProcessMaterialOut p_out;
vec2 base_uv;
{
vec3 view_dir = normalize(normalize(-props.vertex) * mat3(props.tangent * props.heightmap_flip.x, -props.binormal * props.heightmap_flip.y, props.normal));
float height = texture(texture_heightmap, props.uv).r;
float depth = 1.0 - height;
p_out.depth = depth;
p_out.height = height;
vec2 ofs = props.uv - view_dir.xy * depth * props.heightmap_scale * 0.01;
base_uv=ofs;
}
vec4 albedo_tex = texture(texture_albedo,base_uv);
p_out.albedo = props.albedo.rgb * albedo_tex.rgb;
float metallic_tex = dot(texture(texture_metallic,base_uv), props.metallic_texture_channel);
p_out.metallic = metallic_tex * props.metallic;
vec4 roughness_texture_channel = vec4(1.0,0.0,0.0,0.0);
float roughness_tex = dot(texture(texture_roughness,base_uv),roughness_texture_channel);
p_out.roughness = roughness_tex * props.roughness;
p_out.specular = props.specular;
p_out.normal_map = texture(texture_normal,base_uv).rgb;
p_out.normal_map_depth = props.normal_scale;
p_out.ao = dot(texture(texture_ambient_occlusion,base_uv),props.ao_texture_channel);
p_out.ao_light_affect = props.ao_light_affect;
return p_out;
}
// Utility function to reconstruct the Z component of a normal vector from its X and Y components.
float reconstructZ(vec3 norm) {
return sqrt(max(0.0, 1.0 - dot(norm.xy, norm.xy)));
}
// Macro to initialize material properties for a layer based on the defined uniforms.
#define DEFINE_MATERIAL_PROPERTIES(suffix) \
MaterialProperties mat##suffix; \
mat##suffix.albedo = albedo##suffix; \
mat##suffix.roughness = roughness##suffix; \
mat##suffix.metallic_texture_channel = metallic_texture_channel##suffix; \
mat##suffix.specular = specular##suffix; \
mat##suffix.metallic = metallic##suffix; \
mat##suffix.normal_scale = normal_scale##suffix; \
mat##suffix.ao_texture_channel = ao_texture_channel##suffix; \
mat##suffix.ao_light_affect = ao_light_affect##suffix; \
mat##suffix.heightmap_scale = heightmap_scale##suffix; \
mat##suffix.heightmap_flip = heightmap_flip##suffix; \
\
mat##suffix.uv = UV; \
mat##suffix.vertex = VERTEX; \
mat##suffix.normal = NORMAL; \
mat##suffix.tangent = TANGENT; \
mat##suffix.binormal = BINORMAL; \
\
out##suffix = process_material(mat##suffix, texture_albedo##suffix, texture_heightmap##suffix, texture_metallic##suffix, \
texture_roughness##suffix, texture_normal##suffix, texture_ambient_occlusion##suffix);
void fragment() {
// Output structures for each layer (R, G, B) after processing their material properties.
ProcessMaterialOut outR;
ProcessMaterialOut outG;
//ProcessMaterialOut outB;
// Initialize material properties for each layer.
{
DEFINE_MATERIAL_PROPERTIES(R)
DEFINE_MATERIAL_PROPERTIES(G)
// DEFINE_MATERIAL_PROPERTIES(B)
}
// Ensure that vertex colors are non-negative.
vec3 vcolor = max(COLOR.rgb, vec3(0.0));
// Calculate blending weights for each layer.
// These weights are based on the red, green, and the minimum of red and green components of the vertex color.
float weightR = vcolor.r;
float weightG = vcolor.g;
float weightRG = min(weightR, weightG);
float adjWeightR = pow(max(weightR - weightRG, 0.0), BlendSharpness);
float adjWeightG = pow(max(weightG - weightRG, 0.0), BlendSharpness);
float adjWeightRG = pow(weightRG, BlendSharpness);
float totalWeight = adjWeightR + adjWeightG + adjWeightRG;
// Normalize the weights so they sum up to 1.
float normWeightR = adjWeightR / totalWeight;
float normWeightG = adjWeightG / totalWeight;
float normWeightRG = adjWeightRG / totalWeight;
// Determine the step function for layer height comparison.
// This is used to create a sharp transition between layers based on their height.
float stepRG = step(outR.height, outG.height);
// Blend the albedo, roughness, specular, normal map, and other properties from each layer.
// The blending is based on the normalized weights and the height-based step function.
// Each property is blended separately.
vec3 layerColorR = outR.albedo * normWeightR;
vec3 layerColorG = outG.albedo * normWeightG;
vec3 layerColorRG = mix(outR.albedo, outG.albedo, stepRG) * normWeightRG;
float layerRoughnessR = outR.roughness * normWeightR;
float layerRoughnessG = outG.roughness * normWeightG;
float layerRoughnessRG = mix(outR.roughness, outG.roughness, stepRG) * normWeightRG;
float layerSpecularR = outR.specular * normWeightR;
float layerSpecularG = outG.specular * normWeightG;
float layerSpecularRG = mix(outR.specular, outG.specular, stepRG) * normWeightRG;
// Normal map blending is handled with care to preserve correct surface details.
vec3 layerNormalR = outR.normal_map ;
layerNormalR.z = reconstructZ(layerNormalR);
layerNormalR *= normWeightR;
vec3 layerNormalG = outG.normal_map;
layerNormalG.z = reconstructZ(layerNormalG);
layerNormalG *= normWeightG;
// This is correct, the step creates a sharp transition, no lerp happens.
vec3 layerNormalRG = mix(outR.normal_map, outG.normal_map, stepRG);
layerNormalRG.z = reconstructZ(layerNormalRG);
layerNormalRG *= normWeightRG;
// Blend the normal map depth, ambient occlusion, and AO light affect from each layer.
float layerNormalDepthR = outR.normal_map_depth * normWeightR;
float layerNormalDepthG = outG.normal_map_depth * normWeightG;
float layerNormalDepthRG = mix(outR.normal_map_depth, outG.normal_map_depth, stepRG) * normWeightRG;
float layerAoR = outR.ao * normWeightR;
float layerAoG = outG.ao * normWeightG;
float layerAoRG = mix(outR.ao, outG.ao, stepRG) * normWeightRG;
float layerAoLightR = outR.ao_light_affect * normWeightR;
float layerAoLightG = outG.ao_light_affect * normWeightG;
float layerAoLightRG = mix(outR.ao_light_affect, outG.ao_light_affect, stepRG) * normWeightRG;
// Final composition of the material properties.
// The properties from each layer are added together based on their respective weights.
ALBEDO = layerColorR + layerColorG + layerColorRG;
ROUGHNESS = layerRoughnessR + layerRoughnessG + layerRoughnessRG;
SPECULAR = layerSpecularR + layerSpecularG + layerSpecularRG;
NORMAL_MAP = normalize(layerNormalR + layerNormalG + layerNormalRG);
NORMAL_MAP_DEPTH = layerNormalDepthR + layerNormalDepthG + layerNormalDepthRG;
AO = layerAoR + layerAoG + layerAoRG;
AO_LIGHT_AFFECT = layerAoLightR + layerAoLightG + layerAoLightRG;
}
That’s a lot of code, but don’t be intimidated. It’s from the Standard Material 3D shader with the properties we want. The calculations were extracted to a function, and we use preprocessor macros to generate our layer code.
This could be extended to blending between three sub-materials, but it would go from 3 possible combinations to 7. That’s a lot of operations.
Our final output:
We can see where the yellow vertices are is a nice overlapping layer of dirt and stone which creates a pleasant layer to blend between the two.
The code we’re most interested in is after:
// Initialize material properties for each layer.
{
DEFINE_MATERIAL_PROPERTIES(R)
DEFINE_MATERIAL_PROPERTIES(G)
}
The sub-materials are applied based on weighting for R, G, and RG. Recall that RG is our R+G height-based layered sub-material.
This code may be unfamiliar:
float stepRG = step(outR.height, outG.height);
[…]
mix(outR.roughness, outG.roughness, stepRG)
It’s just an optimization to skip conditionals. stepRG is always 0.0 or 1.0, therefore we’re always getting 100% of outR’s value or outG’s value based on the value in the height maps at our fragment’s location.
We use a sharpness parameter to control the sharpness of the blends between sub-materials:
float adjWeightR = pow(max(weightR - weightRG, 0.0), BlendSharpness);
float adjWeightG = pow(max(weightG - weightRG, 0.0), BlendSharpness);
float adjWeightRG = pow(weightRG, BlendSharpness);
Sharpness value of 1.0:
Sharpness value of 3.0:
To be able to properly weight & normalize our normal vectors, we have to reconstruct the Z parameter. Normal maps pack R&G(x, y) together. After reconstruction, we nlerp between them based on their respective vertex color weights. This probably doesn’t look too good if there’s a significant angle difference, but as this is for ground materials I’ll probably be OK.
vec3 layerNormalR = outR.normal_map ;
layerNormalR.z = reconstructZ(layerNormalR);
layerNormalR *= normWeightR;
vec3 layerNormalG = outG.normal_map;
layerNormalG.z = reconstructZ(layerNormalG);
layerNormalG *= normWeightG;
vec3 layerNormalRG = mix(outR.normal_map, outG.normal_map, stepRG);
layerNormalRG.z = reconstructZ(layerNormalRG);
layerNormalRG *= normWeightRG;
[…]
NORMAL_MAP = normalize(layerNormalR + layerNormalG + layerNormalRG);