Selective post-processing via material ID masking on a second visibility layer

I put this demo together in Godot 4.3 after researching how to accomplish selective mesh-based post-processing and not finding a detailed explanation.

This technique is based on the material ID masking from (https://github.com/danilw/godot-utils-and-other) to selectively apply custom post-process effects to certain 3D objects and leave others untouched.  This shader removes the need to instantiate a duplicate node in the SubViewport for each mesh we want to assign a material ID to and sync their positions each frame, which can be cumbersome if we have many mobile meshes.  We will use the red channel of the SubViewport to encode a unique value for each material ID.  We can set the material ID for a node per camera by using a shader parameter in the material overlay and later retrieve it in the post-processing shader from the mask texture.  Credit to Danil for the original material ID logic.

The post_processor.gdshader file can be extended for a variety of different post-processing effects. The demo only applies custom effects to material IDs 4 and 5.

HOW TO USE:

Step 1: Create the “post_processor.tscn” scene we will use for applying effects (You can skip this step if you download the scene from the demo):

  • Create a new empty scene with a MeshInstance3D as the root node. We’ll name it “PostProcessor”.
  • Set its mesh to a QuadMesh, set the width and height to 2 and set *Flip Faces* to true.
  • Set the QuadMesh *Resource->Local to Scene* to true and set *Geometry->Extra Cull Margin* to the maximum.
  • IMPORTANT! Set the *Node->Process->Priority* of the QuadMesh to a positive number. It must be higher than whatever the process priority is set to on your target camera or the masking will lag a frame behind and look very bad in motion.
  • Set the mesh material to a new ShaderMaterial and set the material *Resource->Local to Scene* to true also.
  • Save this material as “post_processor.tres”.
  • Set the shader to post_processor.gdshader but leave it shared by keeping *Local to Scene* as false.
  • Add a child node of type SubViewport. We’ll name it “MaskViewport” and set its *Handle Input Locally* to false.
  • Add a child node to MaskViewport of type Camera3D. We’ll name it “MaskCamera”.
  • Set the *Cull Mask* property of MaskCamera to only be lit up for layer 2 (you can choose a different layer if you change the *mask_layer_val* const in mask_mat.gdshader but we will use layer 2 for demonstration).
  • Add a new Environment to MaskCamera and set the *Background->Mode* to *Custom Color* with the default black color.
  • Return to *PostProcessor->Material* and open *Shader Parameters*.
  • Set the mask texture shader parameter to a new ViewportTexture that will target MaskViewport.
  • IMPORTANT! Click the mask texture again and select *Make Unique*. Set the mask texture resource to *Local to Scene* as well.
  • Attach the post_processor.gd script to the root QuadMesh.
  • Save this scene as “post_processor.tscn” and close the scene. We will use it later.

Step 2: Set up the material overlays and render layers for your geometry nodes:

  • On any mesh that you want to apply post-processing effects to, or occlude effects applied to other meshes, open the *GeometryInstance3D->Material Overlay* properties.
  • Attach a new material overlay of type ShaderMaterial. If you have other material overlays, you can attach it in the *Next Pass* property instead.
  • Set the shader to “mask_mat.gdshader” and set *Shader Parameters->Material ID* to a valid ID number.
    • Material ID of 0 is the default and also the ID that appears on the 3D background where no objects are visible.
    • Material ID of 1-5 is valid in the demo, but you can extend the script with additional IDs if needed.
  • You can save this material for convenience in attaching the same material ID to other meshes. In the demo these are saved in the “mat_masks” folder.
  • Scroll down in the inspector and set the *VisualInstance3D->Layers* property for the node to include both Layers 1 and 2 (the primary layer and the mask layer).
  • Repeat this step for any other meshes that will have a material ID or occlude meshes with a material ID.

Step 3: Attaching post processor effects to your target camera:

  • Open or create and setup the scene that will contain your primary camera.
  • Instantiate a child scene under your primary camera using the “post_processor.tscn” node.
  • Select the PostProcessor node and open *Mesh->Material->Shader Parameters*.
  • Set *Debug View* to true to display the material ID numbers for each fragment in-editor and in-game to allow for easier debugging.

Note: While the PostProcessor node is visible, you will be unable to select other nodes from the 3D editor view. There are 3 ways to address this:

  • You can hide the node when you want to select objects in the 3D view and unhide to display effects again.
  • You can enable *Show list of selectable nodes at position clicked* button above the 3D view.
  • If using 4.4+, you should be able to lock the PostProcessor node to select other nodes and still view effects after this commit: (https://github.com/godotengine/godot/issues/84764)

Note: The PostProcessor node script will default to rendering to its parent node if it is a Camera3D, but you can reassign the *target_camera* exported property if it gets moved or was instantiated under a different node.

Warning: Materials that are improperly configured will lead to the awkward visual effects demonstrated in the demo:

  • The blue sphere in the center is only visible on the primary layer and has no material overlay shader. It receives the post-processing effects meant for the geometry behind it.
  • The white sphere in the corner is visible on both layers but with no material overlay shader. It receives a range of mask IDs at different angles including invalid IDs.
  • The pink cone is correctly set to material 0 and will obstruct post-processing effects meant for geometry behind it.

 

Shader code
/** ---- ---- */
/** ---- post_processor.gd ---- */
/** ---- ---- */

@tool
extends MeshInstance3D

@export var target_camera: Camera3D = null

var mask_viewport: SubViewport = null
var mask_camera: Camera3D = null

var shader_material: ShaderMaterial = null

func _ready() -> void:
	mask_viewport = $MaskViewport
	mask_camera = $MaskViewport/MaskCamera
	if target_camera == null:
		var parent = get_parent_node_3d()
		if parent is Camera3D:
			target_camera = parent

func _process(delta: float) -> void:
	if mask_viewport == null && $MaskViewport.is_node_ready():
		mask_viewport = $MaskViewport
	
	if mask_camera == null && $MaskViewport/MaskCamera.is_node_ready():
		mask_camera = $MaskViewport/MaskCamera
	
	if shader_material == null:
		shader_material = get_surface_override_material(0)
	
	if Engine.is_editor_hint() and is_visible():
		var editor_viewport = EditorInterface.get_editor_viewport_3d(0)
		var editor_camera = editor_viewport.get_camera_3d();
		
		mask_camera.global_transform = editor_camera.global_transform
		mask_camera.fov = editor_camera.fov
		mask_viewport.size = editor_viewport.size
		
	elif target_camera != null:
		mask_camera.global_transform = target_camera.global_transform
		mask_camera.fov = target_camera.fov
		mask_viewport.size = get_viewport().size

/** ---- ---- */
/** ---- mask_mat.gdshader ---- */
/** ---- ---- */

shader_type spatial;
render_mode unshaded;
#include "mat_id_lookup.gdshaderinc"

// Add to GeometryInstance3D properties a Material Overlay of type ShaderMaterial using this shader.
// Put in the Material Overlay->Next Pass property if the geometry has other material overlays.
const uint skip_layer_val = 1u; // Bitmask must include the primary target render layer.
const uint mask_layer_val = 2u; // Bitmask must match the cull mask used for MaskCamera.
// Layer values are not layer numbers! Hover over the cull mask layer in the inspector to see the correct value, i.e. for layer 10 the value is 512u.

uniform int material_id = 0; // Shader parameter set in the inspector.
uniform sampler2D depth_texture: source_color, hint_depth_texture, filter_linear_mipmap;

void fragment() {
	uint visible_layers = CAMERA_VISIBLE_LAYERS;
	ALBEDO = vec3(0.0);
	if ((visible_layers & skip_layer_val) == skip_layer_val) {
		discard; // Skip fragment on ignored render layers
	}
	if ((visible_layers & mask_layer_val) == mask_layer_val) {
		texture(depth_texture, SCREEN_UV); // Without this line material ID breaks for some reason?
		ALBEDO.r = encode_mat_id(material_id); // Storing material ID in the red channel
		//ALBEDO.gb; // You could store additional material IDs in the other channels
	}
}

/** ---- ---- */
/** ---- post_processor.gdshader ---- */
/** ---- ---- */

shader_type spatial;
render_mode unshaded;
#include "mat_id_lookup.gdshaderinc"
#include "number_printing.gdshaderinc"

uniform bool debug_view = false; // Shader parameter to enable displaying material ID numbers over materials
uniform sampler2D mask_texture: filter_linear, repeat_disable;

uniform sampler2D depth_texture: source_color, hint_depth_texture, filter_nearest, repeat_disable;
uniform sampler2D screen_texture: hint_screen_texture, repeat_disable, filter_nearest;

varying mat4 camera;

vec3 get_material_id_1_albedo() { return vec3(0.5); } // Example function for material ID-specific processing

void vertex() {
	POSITION = vec4(VERTEX.xy, 1.0, 1.0);
	camera = INV_VIEW_MATRIX;
}

void fragment() {
	vec4 mask = texelFetch(mask_texture, ivec2(FRAGCOORD.xy), 0); // Get the material ID showing for this fragment.
	int mat_id = decode_mat_id(mask.r);
	
	vec4 screen = textureLod(screen_texture, SCREEN_UV, 0.0); // Get the original color for this fragment.
	ALPHA = 0.0;
	
	switch (mat_id % 6) { // Handle material ID-specific conditional post processing.
		case 1:
			//ALBEDO = get_material_id_1_albedo(); ALPHA = 1.0;
			break;
		case 4:
			ALBEDO = vec3(screen.rgb * (sin(TIME) + 1.0)); ALPHA = screen.a;
			break;
		case 5:
			ALBEDO = vec3(CAMERA_DIRECTION_WORLD); ALPHA = 1.0;
			break;
	}
	
	if (debug_view) {
		vec3 debug_albedo = mat_id_debug_albedo(mat_id);
		vec2 grid_uv = fract(UV * 20.0);
		float has_number = print_number(grid_uv, float(mat_id)); // True or false
		
		if (has_number == 1.0) { // Whether a debug number pixel should be printed on this fragment.
			ALBEDO = debug_albedo;
			ALPHA = 1.0;
		}
	}
}

/** ---- ---- */
/** ---- mat_id_lookup.gdshaderinc ---- */
/** ---- ---- */

const float mat_id_1 = 0.1; // You can add more material IDs with arbitrary encoded values if needed.
const float mat_id_2 = 0.2;
const float mat_id_3 = 0.3;
const float mat_id_4 = 0.4;
const float mat_id_5 = 0.5;

int decode_mat_id(float encoded) {
	encoded -= 0.26; // Encoded value seems to get offset by about 0.26 somewhere between the shaders?
	int mat_id = -1;
	if (encoded < 0.05) { mat_id = 0; }
	else if (abs(mat_id_1 - encoded) < 0.025) { mat_id = 1; }
	else if (abs(mat_id_2 - encoded) < 0.025) { mat_id = 2; }
	else if (abs(mat_id_3 - encoded) < 0.025) { mat_id = 3; }
	else if (abs(mat_id_4 - encoded) < 0.025) { mat_id = 4; }
	else if (abs(mat_id_5 - encoded) < 0.025) { mat_id = 5; }
	return mat_id;
}

float encode_mat_id(int mat_id) {
	float encoded = 0.0;
	if (mat_id == 1) { return mat_id_1; }
	else if (mat_id == 2) { return mat_id_2; }
	else if (mat_id == 3) { return mat_id_3; }
	else if (mat_id == 4) { return mat_id_4; }
	else if (mat_id == 5) { return mat_id_5; }
	return encoded;
}

vec3 mat_id_debug_albedo(int mat_id) {
	vec3 color = vec3(0.05);
	if (mat_id < 0) { return color; }
	mat_id = mat_id % 6;
	if (mat_id == 0 || mat_id == 3 || mat_id == 5) { color.r = 0.8; }
	if (mat_id == 1 || mat_id == 3 || mat_id == 4) { color.g = 0.8; }
	if (mat_id == 2 || mat_id == 4 || mat_id == 5) { color.b = 0.8; }
	return color;
}

/** ---- ---- */
/** ---- number_printing.gdshaderinc ---- */
/** ---- ---- */

// Debug view copied from this project by Danil: (https://github.com/danilw/godot-utils-and-other) MIT license
// "print in shader" ShaderToy by @P_Malin: (https://www.shadertoy.com/view/4sBSWW) CC0 license
float DigitBin(in int x)
{
    if (x==0)return 480599.0;else if(x==1) return 139810.0;else if(x==2) return 476951.0;
	else if(x==3) return 476999.0;else if(x==4) return 350020.0;else if(x==5) return 464711.0;
	else if(x==6) return 464727.0;else if(x==7) return 476228.0;else if(x==8) return 481111.0;
	else if(x==9) return 481095.0;return 0.0;
}

float PrintValue(vec2 fragCoord, vec2 pixelCoord, vec2 fontSize, float value,
		float digits, float decimals) {
	vec2 charCoord = (fragCoord - pixelCoord) / fontSize;
	if(charCoord.y < 0.0 || charCoord.y >= 1.0) return 0.0;
	float bits = 0.0;
	float digitIndex1 = digits - floor(charCoord.x)+ 1.0;
	if(- digitIndex1 <= decimals) {
		float pow1 = pow(10.0, digitIndex1);
		float absValue = abs(value);
		float pivot = max(absValue, 1.5) * 10.0;
		if(pivot < pow1) {
			if(value < 0.0 && pivot >= pow1 * 0.1) bits = 1792.0;
		} else if(digitIndex1 == 0.0) {
			if(decimals > 0.0) bits = 2.0;
		} else {
			value = digitIndex1 < 0.0 ? fract(absValue) : absValue * 10.0;
			bits = DigitBin(int (mod(value / pow1, 10.0)));
		}
	}
	return floor(mod(bits / pow(2.0, floor(fract(charCoord.x) * 4.0) + floor(charCoord.y * 5.0) * 4.0), 2.0));
}

float print_number(in vec2 uv ,float number){
	uv.x += 0.5;
   	vec2 vPixelCoord2 = vec2(0.0);
	float fDigits = 2.0;
	float fDecimalPlaces = 0.0;
    vec2 fontSize = vec2(8.)/vec2(16.,9.);
	float fIsDigit2 = PrintValue(uv, vPixelCoord2, fontSize, number, fDigits, fDecimalPlaces);
    return fIsDigit2;
}
Tags
post process
The shader code and all code snippets in this post are under MIT license and can be used freely. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

Related shaders

3D Pixelation via material

Clean pixel perfect outline via material

The simplest outline shader (via material)

Subscribe
Notify of
guest

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments