Files
chameleon/Assets/Scripts/MeshPainter.Pick.cs
T
adminandClaude Opus 4.8 04c73623d3 Pick the material of the hit submesh on multi-material renderers
PickFromForeignMaterial read rend.sharedMaterial, which is always slot 0, so any renderer with several materials (one per submesh) always sampled the first material's values. Resolve the submesh from the hit triangle (collider triangleIndex, or the recovered render-mesh triangle) and read sharedMaterials[submesh] (clamped) instead. Single-material renderers stay on slot 0, byte-identical to before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:41:57 +02:00

581 lines
27 KiB
C#

using System;
using UnityEngine;
public partial class MeshPainter
{
// --- Pipette (eyedropper) ---
//
// A sampling MODE rather than a paint channel: while active (Shift or Ctrl held, or the HUD toggle), a
// left-click raycasts onto ANY surface (not just the paint mesh) and reads every channel value at
// the hit point (albedo, metallic, smoothness, occlusion, emission) into the brush properties -
// with every opacity/alpha forced to 1 so re-painting reproduces the picked look at full strength.
//
// On a FOREIGN object the data channels (metallic/smoothness/occlusion/emission) come texel-exact
// from the material (raw, UNLIT). Albedo follows the 'pipetteAlbedoFromScreen' toggle: OFF (default)
// reads it texel-exact from the material too; ON takes it from the rendered, fully-lit on-screen
// pixel ("what you see" - honours baked lightmaps/ambient/SSAO), combining material PBR fidelity
// with the lit look. When no texel-exact material read is possible (no readable UV / headless) it
// falls back to a pure screen pick (lit albedo, data channels at defaults). Our own paint mesh and
// terrain are read texel-exact.
[Header("Brush - pipette (eyedropper)")]
[Tooltip("Pipette mode: a click samples ALL channel values at the hit point on ANY surface " +
"(albedo, metallic, smoothness, occlusion, emission) and adopts them into the brush " +
"(all alphas set to 1) instead of painting. 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/occlusion/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, samples every channel value at the hit
/// (texel-exact where possible) and copies it into the brush, forcing all opacities/alphas to
/// 1 and enabling every paint channel. Reads regardless of which channels are currently toggled.
/// 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;
if (TryRaycastAny(screenPos, out RaycastHit hit))
{
if (hit.collider == _collider)
ok = PickFromPaintTextures(hit);
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 silhouetted against the sky): the
// on-screen pixel is the only thing to sample.
ok = PickFromScreen(screenPos);
}
if (!ok) return;
FinalizePick();
}
/// <summary>
/// Common tail of every successful pick: forces all opacities/alphas to 1, enables every paint
/// channel (a whole-material pick implies repainting all of it), leaves pipette mode (one-shot)
/// and notifies listeners (the in-game HUD) to re-sync their widgets.
/// </summary>
private void FinalizePick()
{
metallicSmoothnessOpacity = 1f;
occlusionOpacity = 1f;
paintAlbedo = true;
paintMetallicSmoothness = true;
paintOcclusion = true;
paintEmission = true;
pipette = false;
Picked?.Invoke();
}
/// <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);
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).
brushColor = new Color(a.r / 255f, a.g / 255f, a.b / 255f, 1f);
metallic = m.r / 255f;
occlusion = m.g / 255f;
smoothness = m.a / 255f;
emissionColor = new Color(e.r / 255f, e.g / 255f, e.b / 255f, 1f);
return true;
}
/// <summary>
/// Foreign-object pick: the data channels (metallic/smoothness/occlusion/emission) always come
/// texel-exact from the material via <see cref="PickFromForeignMaterial"/> (raw, unlit). Albedo
/// follows <see cref="pipetteAlbedoFromScreen"/>: when set, it is overwritten with the rendered,
/// fully-lit on-screen pixel ("what you see" - honours baked lightmaps/ambient/SSAO); otherwise
/// (default) it stays the material's texel-exact value. When no texel-exact material read is
/// possible (no readable UV / headless) it falls back to a pure screen pick (lit albedo, data
/// channels at defaults).
/// </summary>
private bool PickFromForeignHybrid(RaycastHit hit, Vector2 screenPos)
{
if (PickFromForeignMaterial(hit))
{
// Keep the material's texel-exact PBR; optionally replace albedo with the lit on-screen
// colour. No camera (headless) -> keep the material's raw albedo rather than failing.
if (pipetteAlbedoFromScreen && TrySampleScreenColor(screenPos, out Color lit))
brushColor = new Color(Mathf.Clamp01(lit.r), Mathf.Clamp01(lit.g), Mathf.Clamp01(lit.b), 1f);
return true;
}
// No texel-exact material read possible (no renderer/UV, or non-readable mesh behind a convex
// collider): fall back to the lit screen pixel alone (data channels reset to defaults).
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;
// 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. Alongside the UV we resolve which SUBMESH the hit triangle belongs to, so a
// multi-material renderer reads the sub-material covering the hit instead of 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;
// 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);
Texture baseTex = GetTexProp(mat, "_BaseMap", "_MainTex");
Color albedo = baseCol;
if (hasUv && baseTex)
albedo = SampleTexel(baseTex, ApplyST(mat, "_BaseMap", "_MainTex", uv), true) * baseCol;
brushColor = new Color(Mathf.Clamp01(albedo.r), Mathf.Clamp01(albedo.g), Mathf.Clamp01(albedo.b), 1f);
// --- 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: OcclusionMap.g (linear), lerp(1, g, strength) ---
float occStrength = GetFloatProp(mat, "_OcclusionStrength", null, 1f);
float occ = 1f;
Texture occTex = GetTexProp(mat, "_OcclusionMap", null);
if (hasUv && occTex)
occ = Mathf.Lerp(1f, SampleTexel(occTex, ApplyST(mat, "_OcclusionMap", null, uv), false).g, occStrength);
occlusion = Mathf.Clamp01(occ);
// --- 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), 1f);
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; occlusion is 1 and emission black. 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
brushColor = new Color(Mathf.Clamp01(albedo.r * inv), Mathf.Clamp01(albedo.g * inv), Mathf.Clamp01(albedo.b * inv), 1f);
metallic = Mathf.Clamp01(metallicVal * inv);
smoothness = Mathf.Clamp01(smoothVal * inv);
occlusion = 1f;
emissionColor = new Color(0f, 0f, 0f, 1f);
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;
brushColor = new Color(Mathf.Clamp01(c.r), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b), 1f);
metallic = 0f;
smoothness = 0f;
occlusion = 1f;
emissionColor = new Color(0f, 0f, 0f, 1f);
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;
}
}