How to add a shader to a group of objects as a single entity?

Godot Version

4.7

Question

I have a fairly simple chest object made of several meshes, all under a single StaticBody3d:


If I apply the shader to each object, I get this:

I’d like to apply an outline shader to the group as a whole - that is, outline the “chest” object, not the individual components.

The goal is to get this effect:

How can I achieve this?

What’s the shader? I think a stencil outline will get your desired effect, which can be applied through a StandardMaterial3D

Use stencil.

Tried Stencil - not that impressed:

Maybe I’ll try some other outline shaders…

This is not a stencil problem, it’s a mesh displacement problem.

What shader did you use initialy?

I just used a basic highlight shader from the Asset repository.
I don’t recall which - I’ve tried a bunch.

When i tried Stencil I removed all other shaders.

Post the shader.

So far, this gives the best results.

But if I can use Stencil, I’d like to see it work properly.

shader_type spatial;
render_mode blend_mix, unshaded, cull_back;

uniform sampler2D depth_texture : hint_depth_texture;
uniform float thickness : hint_range(0.5, 20.0, 0.1) = 2.0;
uniform vec4 outline_color : source_color = vec4(0.0, 0.0, 0.0, 1.0);
uniform int samples : hint_range(4, 32) = 4;

uniform float sensitivity : hint_range(0.01, 1.0, 0.01) = 0.1;
uniform float edge_fade : hint_range(0.0, 0.1, 0.001) = 0.015;

void fragment() {
	vec2 pixel_size = 1.0 / VIEWPORT_SIZE;
	vec2 step_size = pixel_size * thickness;
	
	float angle_step = TAU / float(samples);
	
	vec3 avg_dx = vec3(0.0);
	vec3 avg_dy = vec3(0.0);
	
	// Pre-calculate screen UV adjustments
	vec2 screen_uv_normalized = SCREEN_UV * 2.0 - 1.0;
	
	// Sample in a circle around the pixel
	for (int i = 0; i < samples; i++) {
		float angle = float(i) * angle_step;
		vec2 dir = vec2(cos(angle), sin(angle));
		vec2 offset = dir * step_size;
		
		vec2 uv1 = SCREEN_UV + offset;
		vec2 uv2 = SCREEN_UV - offset;
		
		float d1 = texture(depth_texture, uv1).r;
		float d2 = texture(depth_texture, uv2).r;
		
		// Optimized inverse projection
		vec2 uv1_norm = uv1 * 2.0 - 1.0;
		vec2 uv2_norm = uv2 * 2.0 - 1.0;
		
		vec4 up1 = INV_PROJECTION_MATRIX * vec4(uv1_norm, d1, 1.0);
		vec4 up2 = INV_PROJECTION_MATRIX * vec4(uv2_norm, d2, 1.0);
		
		// Combine division and difference calculation
		vec3 diff = (up1.xyz / up1.w) - (up2.xyz / up2.w);
		
		// Accumulate weighted by direction
		avg_dx += diff * dir.x;
		avg_dy += diff * dir.y;
	}
	
	// Combine normalization with inverse samples
	float inv_samples = 1.0 / float(samples);
	avg_dx *= inv_samples;
	avg_dy *= inv_samples;
	
	// Edge detection
	vec3 normal = normalize(cross(avg_dy, avg_dx));
	float edge = 1.0 - smoothstep(sensitivity, sensitivity + 0.05, abs(dot(normal, VIEW)));
	
	// Optimized viewport edge fade
	vec2 screen_center_dist = abs(SCREEN_UV - 0.5) * 2.0;
	float max_dist = max(screen_center_dist.x, screen_center_dist.y);
	float vignette = smoothstep(0.0, edge_fade * thickness, 1.0 - max_dist);
	
	ALBEDO = outline_color.rgb;
	ALPHA = edge * vignette * outline_color.a;
}

What do you mean ‘it’s a mesh displacement problem’ ?

The built in stencil outline uses mesh displacement along the normal to render the outline shell. If mesh normals are not smoothed, there will be gaps, so this method requires a smoothed mesh. But that displacement per se has nothing to do with stenciling. Technically, those are two separate things. For your type of mesh the built in stencil method that uses displaced shell might not be ideal.

You’re using a whole screen post-processing shader. How do you plan to exclude the objects that are not supposed to be outlined?

I hadn’t even noticed that it was whole screen!

I’ve just been running simple tests to see how each shader works (still learning!)
My tests were on simple objects - just a capsule and a cube intersecting.

What I’m looking for is an outline shader, per object (or group) as I showed in my OP.

I’m open to all suggestions.

How is your current shader set up? How did you manage to set it up and “not notice” it’s whole screen?

Well, it doesn’t behave as though it’s whole screen - I’ve got several objects, each with a different shader

They all behave individually.
I read through the shader and even though it uses some screen parameters, I don’t think it affects the entire screen and everything in view.

Well if you apply it on objects it becomes more of an “inline” shader then. That’s why you see it on all meshes of your multi-part object - it renders inside object silhouette instead of outside (the latter you probably expected).

To render an outside silhouette you need a post processing shader. Render all objects that form the silhouette you want outlined into a transparent subviewport that has the same proportion as your main viewport. Run a full screen post processing shader that samples that viewport using gaussian sample distribution. Threshold the result, subtract the original silhouette and superimpose over the main viewport.

This should give you a stable uniform outline around the joined silhouette of all objects that are rendered into the subviewport.

I don’t suppose you have an example ?

Or a link to sample shader?

Nope, and I’m not sure if there’s something like that on Godot shaders/assets websites, it could be…

How badly do you need it?

Not too badly - I’ve got some that work fairly well, but the more complex the object the worse they get

Here’s a quick proof of concept setup:

Drive the sub viewport size and its camera using a script like this that does its thing in the editor and at runtime:

@tool extends Node3D

func _process(delta):
	if Engine.is_editor_hint():
		%vport.size = EditorInterface.get_editor_viewport_3d().size
		%vport_cam.fov = EditorInterface.get_editor_viewport_3d().get_camera_3d().fov
		%vport_cam.global_transform = EditorInterface.get_editor_viewport_3d().get_camera_3d().global_transform
	else:
		%vport.size = %main_cam.get_viewport().get_size()
		%vport_cam.fov = %main_cam.fov
		%vport_cam.global_transform = %main_cam.global_transform

Set viewport camera’s cull mask to a single specific layer and add all objects that constitute the silhouette to that visual instance layer. Other objects should not be on that layer.
Enable transparent background for the viewport.

Put a full-screen plane in front of the camera and run this post processing shader on it. Plug the viewport texture into the shader:

shader_type spatial;
render_mode unshaded;

uniform float size: hint_range(0.0, 10.0) = 5.0;
uniform vec4 color: source_color = vec4(1.0, 0.0, 0.0, 1.0);
uniform sampler2D vp_texture;

const float gauss_kernel[] = {0.004, 0.054, 0.242, 0.399, 0.242, 0.054, 0.004};

void fragment() {
	ALBEDO = color.rgb;

	float sample = 0.0;
	for (int j = 0; j < 7; ++j){
		for (int i = 0; i < 7; ++i){
			vec2 sample_offset = vec2(float(i - 3), float(j - 3)) * size ;
			sample += texelFetch(vp_texture, ivec2(FRAGCOORD.xy + sample_offset), 0).a * gauss_kernel[i] * gauss_kernel[j];
		}
	}
	ALPHA = smoothstep(0.0, 0.005, sample);
	ALPHA -= step(0.99, texelFetch(vp_texture, ivec2(FRAGCOORD.xy), 0).a) * color.a;
}

WOW!

That was fast and impressive.

I’ll give this a try tomorrow and let you know.

Glad you have nothing better to do with your time! :wink:

Sorry I took so long to reply after your very fast response to me.

I’ve followed your directions (accurately I thought) but not getting the same results - so I’m certain I’m doing something wrong.

I copied your code directly from your post and everything is running without errors or warnings.
(FYI I’m using Godot v4.62 and tested 4.7 - same results)

Here’s screenshots for what I’ve set up and what I see: