using System; using UnityEngine; public partial class MeshPainter { // --- Pipette (eyedropper) --- (semantics: see the pipette tooltip; per-surface routing: PickAtScreen) [Header("Brush - pipette (eyedropper)")] [Tooltip("Pipette mode: a click samples the channel values at the hit point on ANY surface " + "(albedo, metallic, smoothness, emission) and adopts them into the brush (opacities/alphas " + "left untouched) instead of painting. Height (occlusion) is adopted and its toggle enabled " + "only when our own paint mesh is picked; any other surface disables the height toggle. On a " + "foreign object the data channels are read texel-exact from the material; albedo follows " + "'Pipette Albedo From Screen'. Held button picks once.")] public bool pipette = false; [Tooltip("On a foreign-object pick, where albedo comes from. OFF (default): albedo is read " + "texel-exact from the material like the other channels (raw, unlit). ON: albedo is taken " + "from the lit on-screen pixel ('what you see' - honours baked lightmaps/ambient/SSAO). " + "The data channels (metallic/smoothness/emission) always come from the material.")] [SerializeField] private bool pipetteAlbedoFromScreen = false; /// /// Raised after a successful pick, once the brush has adopted the sampled values and all four /// paint-channel flags have been enabled. The in-game HUD listens to re-sync its widgets. /// public event Action Picked; private bool _pickHeld; // edge tracking so a held button picks once, not every frame // A terrain hit higher than this above the heightfield is a tree (trees share the terrain // collider but stand proud of the ground); on the surface it is bare ground. private const float TerrainFoliageHeightEpsilon = 0.3f; /// /// Raycasts at a screen position against ANY collider (regardless of which channels are toggled), /// samples the channel values at the hit and adopts them into the brush (toggle rules: see /// ). Pick paths, by what was hit: /// - our paint mesh -> texel-exact from the painted maps (full PBR); /// - terrain ground -> blend of the terrain layers by their splat weights (full PBR); /// - terrain tree / detail -> the rendered on-screen colour (no per-instance renderer/UV); /// - any foreign object -> data channels texel-exact from the material; albedo from the /// material or the lit on-screen pixel per pipetteAlbedoFromScreen; /// - anything else / sky -> the rendered on-screen colour. /// private void PickAtScreen(Vector2 screenPos) { bool ok; bool pickedHeight = false; // height is only meaningful off our OWN painted maps if (TryRaycastAny(screenPos, out RaycastHit hit)) { if (hit.collider == _collider) { ok = PickFromPaintTextures(hit); pickedHeight = ok; } else { Terrain terrain = hit.collider is TerrainCollider ? hit.collider.GetComponent() : null; if (terrain) ok = PickFromTerrain(terrain, hit, screenPos); else ok = PickFromForeignHybrid(hit, screenPos); } } else { // Ray left the scene entirely (e.g. foliage against the sky) - only the screen pixel remains. ok = PickFromScreen(screenPos); } if (!ok) return; FinalizePick(pickedHeight); } /// /// Common tail of every successful pick: enables the sampled paint channels (height only when our /// own paint mesh was picked - any other surface disables its toggle and keeps the value), leaves /// pipette mode (one-shot) and notifies listeners (the HUD) to re-sync their widgets. /// private void FinalizePick(bool pickedHeight) { paintAlbedo = true; paintMetallicSmoothness = true; paintOcclusion = pickedHeight; paintEmission = true; pipette = false; Picked?.Invoke(); } /// Adopts a sampled colour as the brush albedo, preserving the brush's paint opacity (alpha). private void AdoptAlbedo(Color c) => brushColor = new Color(Mathf.Clamp01(c.r), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b), brushColor.a); /// Zeroes the emission colour, preserving its paint opacity (alpha). private void ClearEmissionRgb() => emissionColor = new Color(0f, 0f, 0f, emissionColor.a); /// /// Picks from our own painted maps at the hit texel (bit-exact, backend-agnostic). /// private bool PickFromPaintTextures(RaycastHit hit) { if (_verts == null || _uvs == null || _tris == null || hit.triangleIndex < 0) return false; int t = hit.triangleIndex; if (t * 3 + 2 >= _tris.Length) return false; int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2]; // UV at the hit via barycentric interpolation of the cached UVs - same UV space the // brush paints into, so the sampled texel is exactly the one that would be painted. Vector3 bc = hit.barycentricCoordinate; Vector2 uv = _uvs[i0] * bc.x + _uvs[i1] * bc.y + _uvs[i2] * bc.z; int px = Mathf.Clamp(Mathf.FloorToInt(uv.x * textureSize), 0, textureSize - 1); int py = Mathf.Clamp(Mathf.FloorToInt(uv.y * textureSize), 0, textureSize - 1); int idx = py * textureSize + px; // ReadCanonical gives material layout for either backend: // albedo -> sRGB RGB // MSO -> R = metallic, G = occlusion, A = smoothness (linear data) // emission -> sRGB RGB Color32[] alb = ReadCanonical(MAP_ALBEDO); Color32[] mso = ReadCanonical(MAP_MSO); Color32[] em = ReadCanonical(MAP_EM); if (idx < 0 || idx >= alb.Length || idx >= mso.Length || idx >= em.Length) return false; Color32 a = alb[idx]; Color32 m = mso[idx]; Color32 e = em[idx]; // sRGB bytes map straight back into the gamma-space brush colours (round-trips exactly). The // alpha (paint opacity) is deliberately preserved, not overwritten - a pick changes only the colour. brushColor = new Color(a.r / 255f, a.g / 255f, a.b / 255f, brushColor.a); metallic = m.r / 255f; smoothness = m.a / 255f; occlusion = m.g / 255f; // the painted HEIGHT - adopted only off our own mesh (FinalizePick) emissionColor = new Color(e.r / 255f, e.g / 255f, e.b / 255f, emissionColor.a); return true; } /// /// Foreign-object pick: data channels always texel-exact from the material (raw, unlit); albedo /// optionally overwritten with the fully-lit on-screen pixel (). /// Falls back to a pure screen pick when no texel-exact material read is possible. /// private bool PickFromForeignHybrid(RaycastHit hit, Vector2 screenPos) { if (PickFromForeignMaterial(hit)) { // No camera (headless) -> keep the material's raw albedo rather than failing. if (pipetteAlbedoFromScreen && TrySampleScreenColor(screenPos, out Color lit)) AdoptAlbedo(lit); return true; } return PickFromScreen(screenPos); } /// /// Picks from a foreign object's material at the hit UV: texel-exact texture samples /// (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) { Collider col = hit.collider; if (!col) return false; Renderer rend = col.GetComponent(); if (!rend) rend = col.GetComponentInParent(); if (!rend) return false; // hit.textureCoord is only populated for a NON-convex MeshCollider (convex/primitive colliders // return zero) - recover the UV from the renderer's own mesh then. The hit SUBMESH is resolved // alongside so a multi-material renderer reads the covering sub-material, not always slot 0. Vector2 uv; bool hasUv; int subMesh; if (col is MeshCollider mc && !mc.convex && hit.triangleIndex >= 0) { uv = hit.textureCoord; hasUv = true; subMesh = SubMeshFromIndex(mc.sharedMesh, hit.triangleIndex * 3); } else { hasUv = TryRecoverMeshUv(rend, hit, out uv, out subMesh); } // One material per submesh: pick the slot the hit submesh maps to (clamped, so a renderer with // fewer materials than submeshes still reads). Reads the shared material - never instances it. Material[] mats = rend.sharedMaterials; Material mat = (mats != null && mats.Length > 0) ? mats[Mathf.Clamp(subMesh, 0, mats.Length - 1)] : null; if (!mat) return false; // Base map but no UV resolvable: defer to the 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); Texture baseTex = GetTexProp(mat, "_BaseMap", "_MainTex"); Color albedo = baseCol; if (hasUv && baseTex) albedo = SampleTexel(baseTex, ApplyST(mat, "_BaseMap", "_MainTex", uv), true) * baseCol; AdoptAlbedo(albedo); // --- Metallic / Smoothness: MetallicGloss map (linear) --- // URP/Lit: metallic = map.r ; smoothness = map.a * _Smoothness (or _Metallic/_Glossiness scalars). float metallicVal = GetFloatProp(mat, "_Metallic", null, 0f); float smoothVal = GetFloatProp(mat, "_Smoothness", "_Glossiness", 0.5f); Texture mgTex = GetTexProp(mat, "_MetallicGlossMap", null); if (hasUv && mgTex) { Color mg = SampleTexel(mgTex, ApplyST(mat, "_MetallicGlossMap", null, uv), false); metallicVal = mg.r; smoothVal = mg.a * smoothVal; } metallic = Mathf.Clamp01(metallicVal); smoothness = Mathf.Clamp01(smoothVal); // Occlusion is deliberately NOT sampled (that channel drives painted height now, see FinalizePick). // --- Emission: EmissionMap (sRGB) * EmissionColor --- Color emColor = GetColorProp(mat, "_EmissionColor", null, Color.black); Texture emTex = GetTexProp(mat, "_EmissionMap", null); Color emCol = emColor; if (hasUv && emTex) emCol = SampleTexel(emTex, ApplyST(mat, "_EmissionMap", null, uv), true) * emColor; emissionColor = new Color(Mathf.Clamp01(emCol.r), Mathf.Clamp01(emCol.g), Mathf.Clamp01(emCol.b), emissionColor.a); return true; } /// /// Picks at a terrain hit. The TerrainCollider also carries the painted TREES (which sit above /// the heightfield and expose no per-instance renderer/UV here), and grass/mushroom DETAILS have /// no collider at all: /// - hit above the surface -> a tree: adopt the rendered on-screen colour; /// - hit on a cell that has grass/mushroom detail -> adopt the on-screen colour; /// - otherwise (bare ground) -> blend the terrain layers by their splat weights (full PBR). /// private bool PickFromTerrain(Terrain terrain, RaycastHit hit, Vector2 screenPos) { TerrainData td = terrain.terrainData; if (!td) return false; Vector3 tpos = terrain.transform.position; Vector3 size = td.size; if (size.x <= 1e-4f || size.z <= 1e-4f) return false; // Trees share the terrain collider but stand proud of the heightfield. float surfaceY = terrain.SampleHeight(hit.point) + tpos.y; if (hit.point.y > surfaceY + TerrainFoliageHeightEpsilon) return PickFromScreen(screenPos); Vector3 local = hit.point - tpos; float nx = Mathf.Clamp01(local.x / size.x); float nz = Mathf.Clamp01(local.z / size.z); // Grass/mushroom detail rendered on this ground cell -> what's visible there is the // foliage (which has no collider of its own), so trust the screen pixel. if (TerrainHasDetail(td, nx, nz)) return PickFromScreen(screenPos); return PickFromTerrainLayers(td, local, nx, nz); } /// /// Blends the terrain's layers at a ground point by their splat (alphamap) weights: the weighted /// diffuse colour becomes albedo and the weighted per-layer metallic/smoothness scalars feed /// those channels; emission is black (occlusion is left as the brush's height value). Terrain has no Renderer/material, so this /// is the only way to read its surface. /// private bool PickFromTerrainLayers(TerrainData td, Vector3 local, float nx, float nz) { int aw = td.alphamapWidth, ah = td.alphamapHeight; int mapX = Mathf.Clamp(Mathf.FloorToInt(nx * aw), 0, aw - 1); int mapZ = Mathf.Clamp(Mathf.FloorToInt(nz * ah), 0, ah - 1); float[,,] weights = td.GetAlphamaps(mapX, mapZ, 1, 1); TerrainLayer[] layers = td.terrainLayers; int count = Mathf.Min(layers.Length, weights.GetLength(2)); Color albedo = Color.black; float metallicVal = 0f, smoothVal = 0f, weightSum = 0f; for (int i = 0; i < count; i++) { TerrainLayer L = layers[i]; if (L == null) continue; float w = weights[0, 0, i]; if (w <= 0f) continue; Color col = Color.gray; if (L.diffuseTexture) { // Terrain tiles each layer across the world by tileSize (metres); sample that texel. Vector2 uv = new Vector2( (local.x - L.tileOffset.x) / Mathf.Max(1e-4f, L.tileSize.x), (local.z - L.tileOffset.y) / Mathf.Max(1e-4f, L.tileSize.y)); col = SampleTexel(L.diffuseTexture, uv, true); } albedo += col * w; metallicVal += L.metallic * w; smoothVal += L.smoothness * w; weightSum += w; } if (weightSum <= 1e-4f) return false; float inv = 1f / weightSum; // alphamap weights normally sum to 1, but guard anyway AdoptAlbedo(albedo * inv); metallic = Mathf.Clamp01(metallicVal * inv); smoothness = Mathf.Clamp01(smoothVal * inv); ClearEmissionRgb(); // occlusion left untouched (now painted height) return true; } /// /// True when any grass/mushroom detail layer has coverage at the given normalized terrain /// position - i.e. foliage (which carries no collider) is rendered on that ground cell. /// private static bool TerrainHasDetail(TerrainData td, float nx, float nz) { int dw = td.detailWidth, dh = td.detailHeight; if (dw <= 0 || dh <= 0) return false; int dx = Mathf.Clamp(Mathf.FloorToInt(nx * dw), 0, dw - 1); int dy = Mathf.Clamp(Mathf.FloorToInt(nz * dh), 0, dh - 1); int layerCount = td.detailPrototypes.Length; for (int i = 0; i < layerCount; i++) { int[,] d = td.GetDetailLayer(dx, dy, 1, 1, i); if (d[0, 0] > 0) return true; } return false; } /// /// Fallback colour pick: reads the rendered pixel under the cursor (the paint camera re-rendered /// to a temporary target, so it works on either render pipeline). Used for surfaces that can't be /// sampled texel-exact - terrain trees and grass/mushroom details (no per-instance renderer/UV) /// and anything hit without a readable material, plus rays that miss all geometry. Only albedo is /// meaningful from a screen pixel; the data channels default to matte/unoccluded/no-emission. /// private bool PickFromScreen(Vector2 screenPos) { if (!TrySampleScreenColor(screenPos, out Color c)) return false; AdoptAlbedo(c); metallic = 0f; smoothness = 0f; ClearEmissionRgb(); // occlusion left untouched (now painted height) return true; } /// /// Reads the rendered pixel under the cursor (the paint camera re-rendered to a temporary target, /// so it works on either render pipeline) without touching any brush channel. Returns the fully-lit /// on-screen colour - what is visible, including baked lightmaps/ambient/SSAO. False when no camera /// is bound (headless). Shared by and the hybrid albedo override. /// private bool TrySampleScreenColor(Vector2 screenPos, out Color color) { color = default; Camera cam = paintCamera; if (!cam) return false; int w = Mathf.Max(1, cam.pixelWidth), h = Mathf.Max(1, cam.pixelHeight); RenderTexture rt = RenderTexture.GetTemporary(w, h, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); RenderTexture prevTarget = cam.targetTexture; RenderTexture prevActive = RenderTexture.active; cam.targetTexture = rt; cam.Render(); RenderTexture.active = rt; var tmp = new Texture2D(1, 1, TextureFormat.RGBA32, false, false); int px = Mathf.Clamp(Mathf.RoundToInt(screenPos.x), 0, w - 1); int py = Mathf.Clamp(Mathf.RoundToInt(screenPos.y), 0, h - 1); tmp.ReadPixels(new Rect(px, py, 1, 1), 0, 0, false); tmp.Apply(false); color = tmp.GetPixel(0, 0); cam.targetTexture = prevTarget; RenderTexture.active = prevActive; RenderTexture.ReleaseTemporary(rt); if (Application.isPlaying) Destroy(tmp); else DestroyImmediate(tmp); return true; } // --- Material read helpers (name-tolerant: URP and legacy Standard) --- private static Color GetColorProp(Material m, string nameA, string nameB, Color fallback) { if (m.HasProperty(nameA)) return m.GetColor(nameA); if (nameB != null && m.HasProperty(nameB)) return m.GetColor(nameB); return fallback; } private static float GetFloatProp(Material m, string nameA, string nameB, float fallback) { if (m.HasProperty(nameA)) return m.GetFloat(nameA); if (nameB != null && m.HasProperty(nameB)) return m.GetFloat(nameB); return fallback; } private static Texture GetTexProp(Material m, string nameA, string nameB) { if (m.HasProperty(nameA)) { Texture t = m.GetTexture(nameA); if (t) return t; } if (nameB != null && m.HasProperty(nameB)) { Texture t = m.GetTexture(nameB); if (t) return t; } return null; } private static Vector2 ApplyST(Material m, string nameA, string nameB, Vector2 uv) { string prop = m.HasProperty(nameA) ? nameA : (nameB != null && m.HasProperty(nameB) ? nameB : null); if (prop == null) return uv; Vector2 s = m.GetTextureScale(prop); Vector2 o = m.GetTextureOffset(prop); return new Vector2(uv.x * s.x + o.x, uv.y * s.y + o.y); } /// /// Samples a single texel from a texture at a UV (texel-exact). Uses GetPixelBilinear when the /// texture is CPU-readable; otherwise blits it to a temporary RenderTexture and reads back the /// one texel, so it works regardless of the texture's "Read/Write" import flag. selects the colour space so the returned value matches the readable path /// (gamma for colour maps, linear for data maps). /// private static Color SampleTexel(Texture tex, Vector2 uv, bool srgb) { Texture2D t2d = tex as Texture2D; if (t2d != null && t2d.isReadable) return t2d.GetPixelBilinear(uv.x, uv.y); int w = Mathf.Max(1, tex.width), h = Mathf.Max(1, tex.height); var rw = srgb ? RenderTextureReadWrite.sRGB : RenderTextureReadWrite.Linear; RenderTexture rt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32, rw); RenderTexture prev = RenderTexture.active; Graphics.Blit(tex, rt); RenderTexture.active = rt; var tmp = new Texture2D(1, 1, TextureFormat.RGBA32, false, !srgb); int px = Mathf.Clamp(Mathf.FloorToInt(Mathf.Repeat(uv.x, 1f) * w), 0, w - 1); int py = Mathf.Clamp(Mathf.FloorToInt(Mathf.Repeat(uv.y, 1f) * h), 0, h - 1); tmp.ReadPixels(new Rect(px, py, 1, 1), 0, 0, false); tmp.Apply(false); Color c = tmp.GetPixel(0, 0); RenderTexture.active = prev; RenderTexture.ReleaseTemporary(rt); 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, out int subMesh) { uv = default; subMesh = 0; 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; subMesh = SubMeshFromIndex(mesh, bestTri); // bestTri is the offset into the flattened index buffer return true; } /// /// Resolves which submesh a triangle belongs to, given its offset into the mesh's flattened index /// buffer (triangleNumber * 3, matching the layout). Submesh index then /// maps 1:1 onto the renderer's material slots. Returns 0 for a single-material mesh (the common /// case, no descriptor lookup) or when the mesh is missing. /// private static int SubMeshFromIndex(Mesh mesh, int indexOffset) { if (!mesh) return 0; int subCount = mesh.subMeshCount; if (subCount <= 1) return 0; for (int s = 0; s < subCount; s++) { UnityEngine.Rendering.SubMeshDescriptor d = mesh.GetSubMesh(s); if (indexOffset >= d.indexStart && indexOffset < d.indexStart + d.indexCount) return s; } return 0; } /// /// 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; } }