From 346a0c6e8cf9d98a86b5e86a0aea2d7d82c1373b Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sun, 28 Jun 2026 21:39:25 +0200 Subject: [PATCH] Fix eyedropper sampling base map at wrong UV on convex/primitive colliders hit.textureCoord is only filled for non-convex MeshColliders; on a convex MeshCollider (or Box/Sphere/Capsule) it returns Vector2.zero, so the pick sampled the foreign material's base map at texel (0,0) - a constant, wrong color ("base map ignored"). Now the UV is recovered from the renderer mesh itself (camera ray vs triangles, Moeller-Trumbore, barycentric UV) for convex/primitive colliders, with the screen-pixel pick as fallback when the mesh is not readable. The fast non-convex path is unchanged. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/MeshPainter.Pick.cs | 114 +++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/MeshPainter.Pick.cs b/Assets/Scripts/MeshPainter.Pick.cs index defe685..d8372b6 100644 --- a/Assets/Scripts/MeshPainter.Pick.cs +++ b/Assets/Scripts/MeshPainter.Pick.cs @@ -17,8 +17,10 @@ public partial class MeshPainter // same canonical layout, so the pick matches what is stored bit-for-bit. // - Any foreign object: read texel-exact from its material maps at the hit UV // (BaseMap/MetallicGloss/Occlusion/Emission) combined with the material scalars, per - // the URP/Lit conventions, falling back to scalar properties where a map is absent or - // the collider carries no UV (non-MeshCollider). + // the URP/Lit conventions. The UV comes from hit.textureCoord (non-convex MeshCollider) + // or, when the collider can't supply one (convex MeshCollider / primitive collider), is + // recovered from the renderer's mesh; it falls back to scalars where a map is absent and + // to the on-screen pixel where a base map exists but no UV can be resolved. [Header("Brush - pipette (eyedropper)")] [Tooltip("Pipette mode: a click samples ALL channel values at the hit point on ANY surface " + @@ -141,7 +143,7 @@ public partial class MeshPainter /// /// Picks from a foreign object's material at the hit UV: texel-exact texture samples - /// (where the surface has a MeshCollider and the map exists) combined with the material + /// (where a UV at the hit can be resolved and the map exists) combined with the material /// scalars per the URP/Lit conventions, with scalar fallbacks everywhere else. /// private bool PickFromForeignMaterial(RaycastHit hit) @@ -154,10 +156,27 @@ public partial class MeshPainter Material mat = rend.sharedMaterial; if (!mat) return false; - // textureCoord is only meaningful for a MeshCollider; other collider shapes carry no UV, - // so we fall back to the material's uniform scalar values for those. - bool hasUv = col is MeshCollider; - Vector2 uv = hasUv ? hit.textureCoord : Vector2.zero; + // A UV at the hit is required to sample any material map texel-exact. hit.textureCoord is + // only populated for a NON-convex MeshCollider; convex MeshColliders and primitive colliders + // (Box/Sphere/Capsule/Terrain) return Vector2.zero. In that case recover the UV from the + // renderer's own mesh (ray vs. triangles), which works for any solid collider whose mesh is + // readable. + Vector2 uv; + bool hasUv; + if (col is MeshCollider mc && !mc.convex && hit.triangleIndex >= 0) + { + uv = hit.textureCoord; + hasUv = true; + } + else + { + hasUv = TryRecoverMeshUv(rend, hit, out uv); + } + + // A base map exists but no UV could be resolved (e.g. a non-readable mesh behind a convex + // collider): a texel-exact read is impossible, so defer to the rendered on-screen pixel, + // which at least reflects the texture - rather than silently picking the flat _BaseColor. + if (!hasUv && GetTexProp(mat, "_BaseMap", "_MainTex")) return false; // --- Albedo: BaseMap (sRGB) * BaseColor --- Color baseCol = GetColorProp(mat, "_BaseColor", "_Color", Color.white); @@ -405,4 +424,85 @@ public partial class MeshPainter if (Application.isPlaying) Destroy(tmp); else DestroyImmediate(tmp); return c; } + + /// + /// Recovers the UV at a hit point by intersecting the camera ray with the renderer's own mesh + /// (Moller-Trumbore against each triangle, nearest hit wins). Needed when the collider that was + /// hit cannot supply hit.textureCoord - a convex MeshCollider or any primitive collider. Requires + /// a MeshFilter whose mesh is readable and UV-mapped; returns false otherwise so the caller can + /// fall back. A one-shot click cost, so the O(triangles) sweep is acceptable. + /// + private bool TryRecoverMeshUv(Renderer rend, RaycastHit hit, out Vector2 uv) + { + uv = default; + if (!rend) return false; + MeshFilter mf = rend.GetComponent(); + Mesh mesh = mf ? mf.sharedMesh : null; + if (!mesh || !mesh.isReadable) return false; + + Vector2[] uvs = mesh.uv; + if (uvs == null || uvs.Length == 0) return false; + Vector3[] verts = mesh.vertices; + int[] tris = mesh.triangles; + + // Ray from the paint camera through the surface point (its UV is what we want). Fall back to + // shooting along the inverted hit normal when no camera is bound. + Vector3 ro, rd; + if (paintCamera) + { + ro = paintCamera.transform.position; + rd = (hit.point - ro).normalized; + } + else + { + rd = -hit.normal; + ro = hit.point - rd * 0.01f; + } + + Matrix4x4 l2w = rend.transform.localToWorldMatrix; + float bestT = float.MaxValue; + Vector3 bestBary = default; + int bestTri = -1; + for (int i = 0; i + 2 < tris.Length; i += 3) + { + Vector3 a = l2w.MultiplyPoint3x4(verts[tris[i]]); + Vector3 b = l2w.MultiplyPoint3x4(verts[tris[i + 1]]); + Vector3 c = l2w.MultiplyPoint3x4(verts[tris[i + 2]]); + if (RayTriangle(ro, rd, a, b, c, out float t, out float u, out float v) && t < bestT) + { + bestT = t; + bestBary = new Vector3(1f - u - v, u, v); + bestTri = i; + } + } + if (bestTri < 0) return false; + + uv = uvs[tris[bestTri]] * bestBary.x + uvs[tris[bestTri + 1]] * bestBary.y + uvs[tris[bestTri + 2]] * bestBary.z; + return true; + } + + /// + /// Moller-Trumbore ray/triangle intersection. On a hit, is the ray distance + /// and (, ) are the barycentric weights of vertices b and + /// c (vertex a's weight is 1 - u - v). Small epsilons admit edge hits. + /// + private static bool RayTriangle(Vector3 ro, Vector3 rd, Vector3 a, Vector3 b, Vector3 c, + out float t, out float u, out float v) + { + t = u = v = 0f; + const float eps = 1e-7f; + Vector3 e1 = b - a, e2 = c - a; + Vector3 p = Vector3.Cross(rd, e2); + float det = Vector3.Dot(e1, p); + if (det > -eps && det < eps) return false; // ray parallel to the triangle + float inv = 1f / det; + Vector3 tvec = ro - a; + u = Vector3.Dot(tvec, p) * inv; + if (u < -1e-4f || u > 1f + 1e-4f) return false; + Vector3 q = Vector3.Cross(tvec, e1); + v = Vector3.Dot(rd, q) * inv; + if (v < -1e-4f || u + v > 1f + 1e-4f) return false; + t = Vector3.Dot(e2, q) * inv; + return t > eps; + } }