Very Specific Spatial Shader for Wireframe Edge Gradient

Godot Version

4.6 (Windows)

Question

I’ve been struggling to make this cool wireframe effect with a spatial shader that gives a gradient from the edges of each sharp edge-separated n-gon to the center (see demo picture).

This could be possible using poke faces and vertex colors in Blender, but the effect will probably be used across my entire game and I would rather avoid making that many destructive mesh edits. Instead, I’m wondering if it’s possible to do this on plain meshes without using any extra data, or if Godot could create that extra data during runtime, or ideally during import.

Ideally I also want the gradient to scale with vertex world coordinates and be consistent across differently sized meshes, so the generated normal for the gradient would only inform the direction, where the gradient itself would have a world-space length. In other words the gradient normal for each fragment should be from the closest edge, perpendicular and inward along the face - and that’s the problem since shaders can’t access advanced model data, so I’m stumped.

Please let me know if there’s anything I’m missing and thanks!! I will be forever grateful if this works out the way I’m describing.

It took a while I no longer need help!!! I came to a perfect solution using a onetime GDScript preprocess and I have no further questions - I will mark as resolved.

Special thanks to Kio, Badcrow and others in the discord.

You should give the solution to the problem if you’re marking your post as solution. People are just going to be led astray thinking your post contains the solution.

Congrats and cheers !

I’ve come to that problem once and abandoned the project, so knowing the solution would be quite poggers, because I could get into that project again. Please post solution at your earliest convenience.

It is very still buggy at the moment - once it works perfectly I’ll make a separate post detailing what I landed on. If it helps someone, the full code can be public for all I care, lol

Please link it here after you do it, it’ll help a lot for when someone from the future is trying to google a spacial shader to highlight edges and vertices.

Basically the solution was a preprocessor script using MeshDataTool to search the mesh for “sharp” edges on the condition that they are part of only one triangle, since godot’s default import behavior is to split the mesh along those. The sharp edges get encoded into a packedvector3array which is then handed to the shader as a shader parameter, where every two entries are treated as two points of an edge. At runtime I am able to automatically do this for all meshes that need it.

The shader knows to reconstruct the edges from there using a (stolen) point to edge distance function. That distance determines the color in the gradient!!

The part I’m still working on is offsetting the array points for transformations and such, but I’m getting there - the mesh stuff was the hard part, being new territory for me. Once that’s done I might put the full code in a separate post.

That’s cool, but could you provide a snippet of what you did?

A standalone post with more details would go hard, especially if it’s linked here.

Yes I’m definitely doing that with the full code - just taking a minute to clean it up and optimize. It stays intact with in-game transformations using MODEL_MATRIX currently, but my use case also involves compatibility with other vertex shaders, so I’m putting in another matrix for that. There is also some pretty terrible lag at the moment and I think the post will be a chance to fix that.

Will be a standalone showcase post for sure. Tonight or tomorrow depending on how tired I am. Thanks for your patience and I’m still be very glad to help, I haven’t forgotten.

Ok, so I cannot for the life of me solve the vertex shader distortion issue so honestly just take the full code. It’s thoroughly spaghetti and I have a headache. It may take another post just to get it the way I want, let alone showcase it. I’d still be glad if it helps someone though.

Sharp edge finder (shader param preprocess). Just a couple functions to use as you see fit (I put them in autoload).

func validate_wireframe(target: Node):
	var mat = target.get_active_material(0) as ShaderMaterial
	if mat:
		mat.set_shader_parameter("wireframe_edge_points", null)
		mat.set_shader_parameter("wireframe_edge_count", null)
		var edgearray: PackedVector3Array = find_boundary_edges(target.mesh)
		print("validated wireframe for ",target.get_class()," ",target.name)
		print("> edge array size: ",edgearray.size())
		print("> found boundary edges: ",edgearray.size() / 2)
		mat.set_shader_parameter("wireframe_edge_points", edgearray)
		mat.set_shader_parameter("wireframe_edge_count", edgearray.size())
	else:
		print("validate wireframe error: node has no material")

func find_boundary_edges(selfmesh: Mesh) -> PackedVector3Array:
		
	var mdt = MeshDataTool.new()
	mdt.create_from_surface(selfmesh, 0)
	
	var edgearray: PackedVector3Array = []
	var unique_edges = PackedVector3Array()
	
	for index in range(mdt.get_edge_count()):
	
		if mdt.get_edge_faces(index).size() == 1:
		
			var vert_a: int = mdt.get_edge_vertex(index, 0)
			var vert_b: int = mdt.get_edge_vertex(index, 1)
			var edge_exists: bool = false
			
			var pos_a: Vector3 = mdt.get_vertex(vert_a)
			var pos_b: Vector3 = mdt.get_vertex(vert_b)
			
			for edgecheck in range(0, unique_edges.size(), 2):
				var checked_a: Vector3 = unique_edges[edgecheck]
				var checked_b: Vector3 = unique_edges[edgecheck + 1]
				
				var dist_a_a = pos_a.distance_squared_to(checked_a)
				var dist_b_b = pos_b.distance_squared_to(checked_b)
				var dist_a_b = pos_a.distance_squared_to(checked_b)
				var dist_b_a = pos_b.distance_squared_to(checked_a)
				
				if (dist_a_a < 0.001 and dist_b_b < 0.001) or \
				(dist_a_b < 0.001 and dist_b_a < 0.001):
					edge_exists = true
					break
				
			if edge_exists == false:
				unique_edges.append(pos_a)
				unique_edges.append(pos_b)
				
				edgearray = unique_edges
	return edgearray

Wireframe module as a ShaderInclude.

group_uniforms Wireframe_Module;
uniform vec3 wireframe_edge_points[1024];
uniform int wireframe_edge_count;

//optional, for compatibility with other vertex shaders
varying vec3 wireframe_displace_modelspace;
varying vec3 wireframe_displace_worldspace;

//distance calculation
float point_to_segment_distance(vec3 p, vec3 a, vec3 b) {
	vec3 pa = p - a;
	vec3 ba = b - a;
	float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
	return length(pa - ba * h);
}

vec3 do_wireframe(vec3 fragment_vertex, mat4 fragment_viewmatrix, mat4 fragment_modelmatrix,
float wireframe_size, float wireframe_power, float wireframe_strength, vec3 wireframe_color) {
	//conversions etc
	vec3 net_displace = wireframe_displace_worldspace + (fragment_modelmatrix * vec4(wireframe_displace_modelspace, 0.0)).xyz;
	vec3 fragment_worldspace = (inverse(fragment_viewmatrix) * vec4(fragment_vertex, 1.0)).xyz;
	float min_dist = 999.0;
	
	//unpack edges and do distance
	for (int i = 0; i < wireframe_edge_count; i++) {
		vec3 edge_a = wireframe_edge_points[i * 2];
		vec3 edge_a_worldspace = (fragment_modelmatrix * vec4(edge_a, 1.0)).xyz + net_displace;
		vec3 edge_b = wireframe_edge_points[i * 2 + 1];
		vec3 edge_b_worldspace = (fragment_modelmatrix * vec4(edge_b, 1.0)).xyz + net_displace;
		float dist = point_to_segment_distance(fragment_worldspace, edge_a_worldspace, edge_b_worldspace);
		min_dist = min(min_dist, dist);
	}
	
	//wireframe color output for my use case, but feel free to adjust for a float output with wirefactor]
	float wirefactor = 1.0 - clamp(min_dist / wireframe_size, 0.0, 1.0);
	return vec3(pow(wirefactor, wireframe_power)) * wireframe_color * wireframe_strength;
}

Example main shader with both model- and world-space transforms.

shader_type spatial;
render_mode unshaded, blend_add, cull_disabled;

uniform float wire_size = 0.1;
uniform float wire_power = 3.0;
uniform float wire_strength = 1.0;
uniform vec3 wire_color: source_color = vec3(1.0);

#include "res://wireframe3.gdshaderinc"

void vertex() {
	vec3 original = VERTEX;
	VERTEX *= sin(VERTEX.y * 6.0 + TIME) / 2.0 + 1.0;
	wireframe_displace_modelspace = VERTEX - original;
	
	vec3 worldspace = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
	vec3 worldspacedeformed = worldspace + vec3(sin(worldspace.y + TIME),0.0,0.0) * 1.0;
	vec3 modelspace = (inverse(MODEL_MATRIX) * vec4(worldspacedeformed, 1.0)).xyz;
	VERTEX = modelspace;
	wireframe_displace_worldspace = worldspacedeformed - worldspace;
}

void fragment() {
	vec3 new_wire = do_wireframe(VERTEX,VIEW_MATRIX,MODEL_MATRIX,wire_size,wire_power,wire_strength,wire_color);
	ALBEDO = new_wire * 0.5;
	EMISSION = new_wire * 0.5;
}

So then the remaining issue has to do with non-uniform transformations where there’s unexpected variation in the size of the wireframe surface effect whenever a non-uniform transformation is applied. Not ideal because I’m wanting to do crazy stuff and have all the shaders be compatible, but instead it seems like all the world-space fragment math happens BEFORE vertex transforms (what???), or they actively screw the world space up, or something like that. It’s just so weird that the edges match and the faces don’t.

Please please get in touch if you’d like to collaborate on this further. Otherwise, consider this version of this snippet public domain.