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 <noreply@anthropic.com>
This commit is contained in:
1 parent
09556e45fa
commit
346a0c6e8c
1 file changed
+107
-7
@@ -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
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private bool TryRecoverMeshUv(Renderer rend, RaycastHit hit, out Vector2 uv)
|
||||
{
|
||||
uv = default;
|
||||
if (!rend) return false;
|
||||
MeshFilter mf = rend.GetComponent<MeshFilter>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moller-Trumbore ray/triangle intersection. On a hit, <paramref name="t"/> is the ray distance
|
||||
/// and (<paramref name="u"/>, <paramref name="v"/>) are the barycentric weights of vertices b and
|
||||
/// c (vertex a's weight is 1 - u - v). Small epsilons admit edge hits.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user