Hide remote avatars until this client has actually applied a snapshot reaching the owner-published baseline cutoff, preventing the default grey/white material from flashing before a player's painting has loaded. - PlayerPaintSync: owner-authoritative _publishedCutoff NetworkVariable, receiver tracks _appliedCutoff; explicit blank publish for never-painted avatars so they still reveal. - NetworkPlayerSetup: cache _paintSync, hide body renderers on spawn, gate UpdateVisibility on PaintRevealReady. - MeshPainter.Pick: fur toggle now follows pickedHeight (own paint-mesh only), matching the Occlusion/Height pipette behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
566 lines
26 KiB
C#
566 lines
26 KiB
C#
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) and fur length are adopted and their " +
|
|
"toggles enabled only when our own paint mesh is picked; any other surface disables those " +
|
|
"toggles and keeps their values. 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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="FinalizePick"/>). 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.
|
|
/// </summary>
|
|
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<Terrain>() : 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void FinalizePick(bool pickedHeight)
|
|
{
|
|
paintAlbedo = true;
|
|
paintMetallicSmoothness = true;
|
|
paintOcclusion = pickedHeight;
|
|
paintEmission = true;
|
|
// Fur mirrors height/occlusion: adopted and its toggle enabled only when our OWN paint mesh was
|
|
// picked (pickedHeight, where furLength was sampled); any other surface disables the fur toggle
|
|
// and keeps furLength.
|
|
fur = pickedHeight;
|
|
|
|
pipette = false;
|
|
|
|
Picked?.Invoke();
|
|
}
|
|
|
|
/// <summary>Adopts a sampled colour as the brush albedo, preserving the brush's paint opacity (alpha).</summary>
|
|
private void AdoptAlbedo(Color c) =>
|
|
brushColor = new Color(Mathf.Clamp01(c.r), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b), brushColor.a);
|
|
|
|
/// <summary>Zeroes the emission colour, preserving its paint opacity (alpha).</summary>
|
|
private void ClearEmissionRgb() =>
|
|
emissionColor = new Color(0f, 0f, 0f, emissionColor.a);
|
|
|
|
/// <summary>
|
|
/// Picks from our own painted maps at the hit texel (bit-exact, backend-agnostic).
|
|
/// </summary>
|
|
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);
|
|
Color32[] fur = ReadCanonical(MAP_FUR);
|
|
if (idx < 0 || idx >= alb.Length || idx >= mso.Length || idx >= em.Length || idx >= fur.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);
|
|
furLength = fur[idx].r / 255f; // fur map carries only R = painted length (linear data)
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Foreign-object pick: data channels always texel-exact from the material (raw, unlit); albedo
|
|
/// optionally overwritten with the fully-lit on-screen pixel (<see cref="pipetteAlbedoFromScreen"/>).
|
|
/// Falls back to a pure screen pick when no texel-exact material read is possible.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private bool PickFromForeignMaterial(RaycastHit hit)
|
|
{
|
|
Collider col = hit.collider;
|
|
if (!col) return false;
|
|
Renderer rend = col.GetComponent<Renderer>();
|
|
if (!rend) rend = col.GetComponentInParent<Renderer>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="PickFromScreen"/> and the hybrid albedo override.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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. <paramref
|
|
/// name="srgb"/> selects the colour space so the returned value matches the readable path
|
|
/// (gamma for colour maps, linear for data maps).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <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, out int subMesh)
|
|
{
|
|
uv = default;
|
|
subMesh = 0;
|
|
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;
|
|
subMesh = SubMeshFromIndex(mesh, bestTri); // bestTri is the offset into the flattened index buffer
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves which submesh a triangle belongs to, given its offset into the mesh's flattened index
|
|
/// buffer (triangleNumber * 3, matching the <see cref="Mesh.triangles"/> 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|