- Add mask selector with previews to the brush card (data-driven from painter.brushMasks); persist activeBrushIndex with HUD layout. - Fix alpha/coverage artifacts when overpainting after load or backend switch: derive the dilation source marker from a deterministic UV island mask instead of resetting it to "unpainted" on every seed. - Import the two final brush mask textures (Brushes/). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
235 lines
9.7 KiB
C#
235 lines
9.7 KiB
C#
using UnityEngine;
|
|
|
|
public partial class MeshPainter
|
|
{
|
|
// --- Setup (shared) ---
|
|
|
|
private void SetupCollider()
|
|
{
|
|
_collider = GetComponent<MeshCollider>();
|
|
if (!_collider) _collider = gameObject.AddComponent<MeshCollider>();
|
|
_collider.convex = false; // non-convex provides triangleIndex / textureCoord
|
|
|
|
_bakedMesh = new Mesh { name = "MeshPainter_Baked" };
|
|
BakeColliderMesh();
|
|
}
|
|
|
|
private void BakeColliderMesh()
|
|
{
|
|
if (_skinned)
|
|
{
|
|
// Static skinned mesh: bake into local coordinates (scale = 1).
|
|
_skinned.BakeMesh(_bakedMesh);
|
|
_collider.sharedMesh = null;
|
|
_collider.sharedMesh = _bakedMesh;
|
|
}
|
|
else
|
|
{
|
|
var mf = GetComponent<MeshFilter>();
|
|
if (mf) _collider.sharedMesh = mf.sharedMesh;
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads vertices/triangles/UVs of the collider mesh and computes culling data.</summary>
|
|
private void BuildMeshCaches()
|
|
{
|
|
Mesh m = _collider.sharedMesh;
|
|
if (!m) return;
|
|
|
|
_verts = m.vertices;
|
|
_tris = m.triangles;
|
|
_uvs = m.uv;
|
|
|
|
// Fallback to the original mesh UVs if the baked mesh dropped them.
|
|
if ((_uvs == null || _uvs.Length != _verts.Length) && _skinned && _skinned.sharedMesh)
|
|
_uvs = _skinned.sharedMesh.uv;
|
|
|
|
if (_uvs == null || _uvs.Length != _verts.Length) return;
|
|
|
|
int triCount = _tris.Length / 3;
|
|
_triCenter = new Vector3[triCount];
|
|
_triRadius = new float[triCount];
|
|
|
|
double sumArea3D = 0.0, sumAreaUv = 0.0;
|
|
|
|
for (int t = 0; t < triCount; t++)
|
|
{
|
|
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
|
|
Vector3 a = _verts[i0], b = _verts[i1], c = _verts[i2];
|
|
|
|
Vector3 center = (a + b + c) / 3f;
|
|
_triCenter[t] = center;
|
|
float ra = (a - center).sqrMagnitude;
|
|
float rb = (b - center).sqrMagnitude;
|
|
float rc = (c - center).sqrMagnitude;
|
|
_triRadius[t] = Mathf.Sqrt(Mathf.Max(ra, Mathf.Max(rb, rc)));
|
|
|
|
sumArea3D += 0.5 * Vector3.Cross(b - a, c - a).magnitude;
|
|
Vector2 ua = _uvs[i0], ub = _uvs[i1], uc = _uvs[i2];
|
|
sumAreaUv += 0.5 * Mathf.Abs(Cross2D(ub - ua, uc - ua));
|
|
}
|
|
|
|
if (sumAreaUv > 1e-9) _avgUvToLocal = (float)System.Math.Sqrt(sumArea3D / sumAreaUv);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rasterizes the mesh UV islands into a per-texel coverage marker (true = the texel lies
|
|
/// inside a UV triangle). This is the dilation SOURCE / overwrite guard: island texels are
|
|
/// real content the edge-padding pass must never overwrite, while the gutter (false) stays
|
|
/// paddable. Deriving the marker from the STATIC UV layout - instead of from transient
|
|
/// per-stroke coverage - makes every seed (fresh start, loaded maps, backend switch) mark
|
|
/// the same real content as painted, so the first stroke after a load/switch no longer
|
|
/// bleeds over the existing paint. Built once and cached (UV layout + textureSize are fixed).
|
|
/// </summary>
|
|
private bool[] GetIslandMask()
|
|
{
|
|
int n = textureSize * textureSize;
|
|
if (_islandMask != null && _islandMask.Length == n) return _islandMask;
|
|
|
|
var mask = new bool[n];
|
|
if (_tris == null || _uvs == null) { _islandMask = mask; return mask; }
|
|
|
|
float ts = textureSize;
|
|
const float eps = -1e-4f; // matches PaintStamp3D: tolerate 1px cracks between triangles
|
|
int triCount = _tris.Length / 3;
|
|
|
|
for (int t = 0; t < triCount; t++)
|
|
{
|
|
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
|
|
Vector2 uv0 = _uvs[i0], uv1 = _uvs[i1], uv2 = _uvs[i2];
|
|
|
|
float p0x = uv0.x * ts, p0y = uv0.y * ts;
|
|
float p1x = uv1.x * ts, p1y = uv1.y * ts;
|
|
float p2x = uv2.x * ts, p2y = uv2.y * ts;
|
|
|
|
int minX = Mathf.Clamp(Mathf.FloorToInt(Mathf.Min(p0x, Mathf.Min(p1x, p2x))), 0, textureSize - 1);
|
|
int maxX = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(p0x, Mathf.Max(p1x, p2x))), 0, textureSize - 1);
|
|
int minY = Mathf.Clamp(Mathf.FloorToInt(Mathf.Min(p0y, Mathf.Min(p1y, p2y))), 0, textureSize - 1);
|
|
int maxY = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(p0y, Mathf.Max(p1y, p2y))), 0, textureSize - 1);
|
|
if (maxX < minX || maxY < minY) continue;
|
|
|
|
float d = (p1y - p2y) * (p0x - p2x) + (p2x - p1x) * (p0y - p2y);
|
|
if (Mathf.Abs(d) < 1e-7f) continue;
|
|
float invD = 1f / d;
|
|
|
|
for (int y = minY; y <= maxY; y++)
|
|
{
|
|
float py = y + 0.5f;
|
|
int row = y * textureSize;
|
|
for (int x = minX; x <= maxX; x++)
|
|
{
|
|
float px = x + 0.5f;
|
|
float a = ((p1y - p2y) * (px - p2x) + (p2x - p1x) * (py - p2y)) * invD;
|
|
if (a < eps) continue;
|
|
float b = ((p2y - p0y) * (px - p2x) + (p0x - p2x) * (py - p2y)) * invD;
|
|
if (b < eps) continue;
|
|
if (1f - a - b < eps) continue;
|
|
mask[row + x] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
_islandMask = mask;
|
|
return mask;
|
|
}
|
|
|
|
/// <summary>Builds the per-map seed colors (in canonical/material layout) from the existing material.</summary>
|
|
private void BuildSeedColors()
|
|
{
|
|
_seedCanonical = new Color32[MAP_COUNT][];
|
|
_seedCanonical[MAP_ALBEDO] = BuildBaseColors();
|
|
|
|
// Mask map seed from the material scalars: R = metallic, G = occlusion (255 =
|
|
// unoccluded, so the look is unchanged until painted), B = 0, A = smoothness.
|
|
float metallic0 = _materialInstance.HasProperty(MetallicId) ? _materialInstance.GetFloat(MetallicId) : 0f;
|
|
float smoothness0 = _materialInstance.HasProperty(SmoothnessId) ? _materialInstance.GetFloat(SmoothnessId) : 0.5f;
|
|
_seedCanonical[MAP_MSO] = FillCanonical(new Color32(
|
|
(byte)(Mathf.Clamp01(metallic0) * 255f), 255, 0, (byte)(Mathf.Clamp01(smoothness0) * 255f)));
|
|
|
|
// Emission: keep the existing look (black if the material's emission is off).
|
|
Color emission0 = Color.black;
|
|
if (_materialInstance.HasProperty(EmissionColorId) &&
|
|
(_materialInstance.globalIlluminationFlags & MaterialGlobalIlluminationFlags.EmissiveIsBlack) == 0)
|
|
emission0 = _materialInstance.GetColor(EmissionColorId);
|
|
_seedCanonical[MAP_EM] = FillCanonical(new Color32(
|
|
(byte)(Mathf.Clamp01(emission0.r) * 255f),
|
|
(byte)(Mathf.Clamp01(emission0.g) * 255f),
|
|
(byte)(Mathf.Clamp01(emission0.b) * 255f), 255));
|
|
}
|
|
|
|
private Color32[] FillCanonical(Color32 c)
|
|
{
|
|
var px = new Color32[textureSize * textureSize];
|
|
for (int i = 0; i < px.Length; i++) px[i] = c;
|
|
return px;
|
|
}
|
|
|
|
/// <summary>Builds the albedo base-color texels from the existing material texture or baseColor.</summary>
|
|
private Color32[] BuildBaseColors()
|
|
{
|
|
var px = new Color32[textureSize * textureSize];
|
|
|
|
Texture sourceTex = null;
|
|
var shared = _renderer.sharedMaterial;
|
|
if (shared)
|
|
{
|
|
if (shared.HasProperty(BaseMapId)) sourceTex = shared.GetTexture(BaseMapId);
|
|
if (!sourceTex && shared.HasProperty(MainTexId)) sourceTex = shared.GetTexture(MainTexId);
|
|
}
|
|
|
|
var src = sourceTex as Texture2D;
|
|
if (src)
|
|
{
|
|
try
|
|
{
|
|
if (src.width == textureSize && src.height == textureSize && src.isReadable)
|
|
return src.GetPixels32();
|
|
}
|
|
catch { /* not readable -> fall back to baseColor */ }
|
|
}
|
|
|
|
Color32 c = baseColor;
|
|
for (int i = 0; i < px.Length; i++) px[i] = c;
|
|
return px;
|
|
}
|
|
|
|
/// <summary>Enables the URP keywords for the painted PBR maps and seeds the material scalars.</summary>
|
|
private void ConfigureMaterialPbr()
|
|
{
|
|
if (!_materialInstance) return;
|
|
|
|
// Metallic-smoothness map: metallic from R, smoothness from the map's alpha
|
|
// (NOT the albedo alpha) scaled by _Smoothness (= 1 so the map drives it as-is).
|
|
if (_materialInstance.HasProperty(MetallicGlossMapId))
|
|
{
|
|
_materialInstance.EnableKeyword("_METALLICSPECGLOSSMAP");
|
|
_materialInstance.DisableKeyword("_SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A");
|
|
if (_materialInstance.HasProperty(SmoothnessId)) _materialInstance.SetFloat(SmoothnessId, 1f);
|
|
}
|
|
|
|
// Emission map x _EmissionColor (white * intensity). Clear the "emissive is black"
|
|
// GI flag so the emission is actually used.
|
|
if (_materialInstance.HasProperty(EmissionMapId))
|
|
{
|
|
_materialInstance.EnableKeyword("_EMISSION");
|
|
_materialInstance.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
|
|
RefreshEmissionColor();
|
|
}
|
|
|
|
// Occlusion map: URP samples the G channel (LerpWhiteTo(map.g, _OcclusionStrength)).
|
|
// Strength 1 so the painted value drives the occlusion as-is.
|
|
if (_materialInstance.HasProperty(OcclusionMapId))
|
|
{
|
|
_materialInstance.EnableKeyword("_OCCLUSIONMAP");
|
|
if (_materialInstance.HasProperty(OcclusionStrengthId)) _materialInstance.SetFloat(OcclusionStrengthId, 1f);
|
|
}
|
|
}
|
|
|
|
/// <summary>Pushes the global emission intensity to the material's _EmissionColor (live-tunable).</summary>
|
|
private void RefreshEmissionColor()
|
|
{
|
|
if (_materialInstance && _materialInstance.HasProperty(EmissionColorId))
|
|
_materialInstance.SetColor(EmissionColorId, Color.white * Mathf.Max(0f, emissionIntensity));
|
|
}
|
|
}
|