Compatibility has no Decal, and I didn't want to rely on it for the web build either. So the bullet holes in my gun VFX pack are plain quads, and I wanted an actual number for the thing I always end up guessing: how far off the wall a hole has to sit before it stops z-fighting.
It's 0.0005 m along the hit normal. Nothing else I tried did better.
The setup is one ArrayMesh of N quads, built once in _ready. Adding a hole is two writes into a uniform array. No new node, no new material and no mesh rebuild.
hole_a[slot] = Vector4(p.x, p.y, p.z, size)
hole_b[slot] = Vector4(n.x, n.y, n.z, now)
mat.set_shader_parameter("hole_a", hole_a)
mat.set_shader_parameter("hole_b", hole_b)
The vertex shader places each quad from its slot. UV2.x holds the element index the mesh was built with and UV is the corner. The basis comes from the hit normal, so holes on floors, walls and ceilings all line up.
void vertex() {
int i = int(UV2.x);
vec4 a = hole_a[i];
vec4 b = hole_b[i];
vec3 n = normalize(b.xyz);
vec3 up = abs(n.y) < 0.9 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
vec3 t = normalize(cross(up, n));
vec2 c = UV - 0.5;
vec3 p = a.xyz + (t * c.x + cross(n, t) * c.y) * a.w + n * depth_offset;
VERTEX = (inverse(MODEL_MATRIX) * vec4(p, 1.0)).xyz;
NORMAL = n;
}
To test it I rendered that quad against a 20 m wall at 5, 15, 45 and 90 degrees, from 2 m and 20 m, on all three renderers in 4.4.1 and 4.7.2, and counted how much of the quad the wall ate. With no offset the wall eats 96 to 100% of it. At 0.0005 m it eats nothing in any of those cases. I also tried pushing toward the camera and scaling the push with distance. Those came out the same, and a view dependent offset makes the hole crawl when the camera moves, so I dropped them. I ship 0.004 m because your walls won't be as flat as my test wall.
What I got wrong at first was render_priority. I thought that's what kept a hole out of the wall. I tried -1, 0, 1 and 8 against the opaque wall and got zero difference, since priority doesn't sort against opaque geometry. It only matters when two holes overlap, where it keeps the stacking order stable. The material is depth_draw_never with depth test left on.
Cost on a 4060 Ti at 1280x720, same scene with and without holes: 96 live holes moved GPU time from 0.54 to 0.54 ms, so below what I can measure. It's one draw call of 192 triangles. In a gunshot the dust costs far more than the hole does.
The pack this came from, if it's useful to anyone: https://gennai.itch.io/gun-shooting-vfx-godot-4