Painting onto a model in real time. The brush toggles between UV-space and object-space; near a seam, only one of them keeps the paint where you put it.
I originally wanted to include a web demo, but Godot 4 can't export web builds for C# projects, oh well...
Say you want to paint on a model at runtime. A scorch mark where a laser lands. A splatter of mud. A decal that appears exactly under the cursor and sticks to the surface, wherever the player points. Not a pre-placed sticker, but an arbitrary mark, anywhere, at runtime.
The paint has to live somewhere, and that somewhere is a texture: a flat 2D image wrapped around the 3D model. So the whole feature collapses into one deceptively simple question:
The player hit a point in 3D space. Which pixel of the texture is that?
Get it a little wrong and your paint smears onto the wrong side of the model. This post is about getting it exactly right, seams and all. (I'll leave how the paint looks, the shader that renders it, for a follow-up. Here it's purely about placement.)
Every point lives in two places
Every spot on a model exists twice. Once in 3D, where it actually is in the world. And once as a texel: a pixel in the flat texture, the model's "skin." The UV map is the dictionary between the two: it says "this corner of this triangle maps to this spot in the image."
To lay a 3D surface out flat, the unwrap has to cut it into islands, the same way you can't flatten an orange peel without tearing it. Every cut is a seam: a place where two texels sit right next to each other on the surface but end up on opposite sides of the texture (or the reverse: two texels touching in the image that are nowhere near each other in 3D).
Hold onto that, because every bug in the rest of this post is the same bug: a disagreement between distance-in-the-texture and distance-in-the-world.
Why a texture-space brush bleeds
Here's the obvious approach. Convert the hit to a UV coordinate, then paint a little disc in the texture around that UV. Simple, and it works fine, right up until the hit lands near a seam. Then the disc spills across the edge of its island and onto whatever texels happen to be next to it in the atlas, which can belong to a completely different part of the model.
Drag the hit around the tube below. The top is the surface in 3D; the strip underneath is that surface unwrapped, with a seam on the right and a little unrelated island tucked against the strip's ends. Toggle the two brushes:
The UV-square brush asks "which texels are near my hit in the texture?" That's a fine approximation in the middle of an island, but at a seam it paints the wrong things and misses the right ones. The 3D-sphere brush asks the honest question: "which texels sit within r of my hit in 3D?" So a mark on the seam wraps cleanly around both edges of the strip and never touches the unrelated island.
Same hit, same radius. The only thing that changed is which space we measured distance in. So the fix writes itself: measure in 3D. The catch is that a texel is a 2D thing. To ask "how far is this texel from the hit in 3D?" the texel has to know where it lives in 3D. Which nothing tells it. Yet.
The bake: give every texel its 3D address
So we compute it, once. Walk every triangle of the mesh, and for each texel that triangle covers in the texture, figure out the exact 3D point on the surface that maps to it. Store that point in a texture of its own, same layout as the paint mask, but instead of "how much paint is here," each texel holds "here is where I am in 3D." It's the inverse of an unwrap: the unwrap goes surface → texture; this goes texture → surface.
The "figure out the 3D point" step is the one bit of real math, and it's the same tool that turns a raycast hit into a UV in the first place: barycentric coordinates. Any point inside a triangle is a weighted blend of the three corners, and the three weights are just the areas of the sub-triangles opposite each corner, divided by the whole area, and they always sum to 1. Drag the dot:
Whatever the corners carry gets blended by those weights. Blend the corners' UVs and you get the texel a 3D hit maps to (that's the raycaster):
// nearest triangle hit, in mesh-local space -> its UV:
Vector3 w = Geometry3D.GetTriangleBarycentricCoords(localHit, a, b, c);
Vector2 uv = uvs[i0] * w.X + uvs[i1] * w.Y + uvs[i2] * w.Z;
Blend the corners' positions instead, once per texel, and you get the bake, the surface map:
// SurfaceMapBaker.RasterTri: for each texel a UV triangle covers:
float w0 = Edge(b, c, pt) / area; // area of the sub-triangle opposite corner A
float w1 = Edge(c, a, pt) / area; // ... opposite B
float w2 = Edge(a, b, pt) / area; // ... opposite C
Vector3 position = p0 * w0 + p1 * w1 + p2 * w2; // <- stored in the surface map
Vector3 normal = n0 * w0 + n1 * w1 + n2 * w2; // (we'll need the normal shortly, too)
That Edge(p, q, r) is twice the signed area of a triangle, and it doubles as the barycentric numerator and the inside/outside test while rasterizing. Two directions of the same idea: the raycaster interpolates UVs over a 3D triangle, the baker interpolates positions over a texture-space triangle. Because the surface map only depends on the mesh's geometry, it's computed once and shared by every instance of that mesh.
Actually writing into the texture
Now the pieces snap together. The paint mask is a DrawableTexture2D (Godot 4.7's GPU-paintable texture), cleared to black and handed to the material as a shader parameter:
_mask = new DrawableTexture2D();
_mask.Setup(res, res, DrawableTexture2D.DrawableFormat.Rgba8, Colors.Black);
material.SetShaderParameter("paint_map", _mask);
A hit runs a tiny texture_blit shader over the whole mask. Every texel reads its own baked position and normal and decides, in 3D, whether it's inside the brush:
void blit() {
vec4 surf = texture(position_map, UV); // xyz = this texel's 3D position, a = coverage
vec3 nrm = texture(normal_map, UV).xyz;
float dist = distance(surf.xyz, hit_pos);
float facing = dot(normalize(nrm), hit_normal);
float amount = strength
* (1.0 - smoothstep(0.0, radius, dist)) // soft sphere: 1 at centre -> 0 at the radius
* step(0.5, surf.a) // skip empty texels no triangle covered
* step(normal_reject, facing); // the wall guard (next section)
COLOR0 = vec4(amount, 0.0, 0.0, 0.0);
}
Two details worth calling out. First, the brush blends additively, so hitting the same spot repeatedly deepens the mark and clamps at full instead of overwriting. Paint accumulates. Second, everything happens in the mesh's local space, not world space. The bake is stored in local coordinates because those never change when the model moves, rotates, or scales; bake once, correct forever. At paint time we pull the world-space hit into the object's frame before comparing:
Vector3 pLocal = mesh.GlobalTransform.AffineInverse() * worldHit;
Vector3 nLocal = mesh.GlobalTransform.Basis.Inverse() * worldNormal; // direction only
A world-space bake would be correct for exactly one frame; the instant the object moved you'd have to re-bake the entire map. Local space is free.
Don't paint through walls
There's one hole left in "paint everything within r in 3D." On a thin wall (a fin, a sheet of armour, anything with a front and a back a few millimetres apart) the two sides are within r of each other. Distance alone would paint straight through and mark the back face too.
The fix is the facing term already in the shader above: compare each texel's normal against the hit's normal, and reject any texel pointing the opposite way. Drag the hit along the front face:
The threshold is deliberately loose (dot(bakedNormal, hitNormal) > -0.25), so a concave crease (both faces roughly toward the hit) still paints across seamlessly, while the far side of a wall (facing away) is thrown out.
One small war story: the normal you get from a raycast is the triangle's geometric normal, built from the cross product of its edges, and its direction depends entirely on the winding order the mesh happens to use. The baked normals come from the mesh's authored vertex normals, which always point outward. When those two conventions disagreed, the facing test rejected everything and no paint showed up at all. The fix was to read the hit normal from the same baked map the texels use, so both sides of the comparison speak the same language.
Wrapping up
The whole thing is four moving parts:
- Hit → UV: a raycast gives a 3D point on a triangle; barycentric weights turn it into a texel.
- The bake: the inverse of an unwrap, one texture where every texel stores its own 3D position and normal.
- Paint by proximity: a
texture_blitsphere brush that measures distance in 3D, so UV seams stop existing, plus a normal gate so thin walls don't bleed through. - Local space + additive blend: bake once, and let repeated hits accumulate.
And if you remember one sentence from all of this, make it this one:
A UV map is storage, not geometry. Decide the brush in 3D, where the surface actually is, then write the result to whatever texel happens to hold that point. Never let distances in the texture stand in for distances in the world.
Next time I'll pick up where this leaves off: the mask is filled correctly, so how do you make the paint actually look like something? Eroding through layers, catching light, sinking into the surface. That's a shader story.
Want to drop it into your own project? Here's the whole thing as a self-contained Godot 4.7 sample: a MeshSurfacePainter node you add to a scene and point at a mesh, plus the baker, the raycaster, both shaders, and a README with setup steps: