Files
chameleon/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs
T
adminandClaude Fable 5 6c03e5d3f6 Refactor Assets/Scripts: trim restating comments, minor structure cleanups
Sweep over 48 files (Editor/ excluded): remove comments that restate the
code, condense class summaries, fix three stale doc statements
(HsvColorPicker alpha bar, PaintHud layoutPrefab, MasterVolume
FallbackDefault). Inline two single-use wrappers (IsPointerOverUi,
BuildModes), drop an unused using, replace a magic literal with the
existing ParallaxBase constant, and convert ~10 UnityEngine.Object null
checks to the implicit-bool convention. No serialized fields, signatures,
RPC/NetworkVariable names, defaults, or #if blocks touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 08:29:18 +02:00

315 lines
14 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
// An always-on SkinnedColliderBaker (skinned rigs only) owns the bake even outside paint
// mode; we delegate to it and only borrow its mesh. Without one we keep our own legacy bake.
_baker = _skinned ? GetComponent<SkinnedColliderBaker>() : null;
if (!_baker) _bakedMesh = new Mesh { name = "MeshPainter_Baked" };
BakeColliderMesh();
}
private void BakeColliderMesh()
{
if (_baker)
{
// Baker already baked (negative execution order) and keeps _collider.sharedMesh fresh;
// we only track its mesh for the GPU raster path. No bake here - that's the whole point.
_bakedMesh = _baker.BakedMesh;
}
else 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;
}
}
// BuildMeshCaches scratch: refilled per bake without allocating (with updateColliderEveryFrame this
// runs per frame while painting; mesh.vertices/.triangles/.uv return fresh arrays per call).
private readonly System.Collections.Generic.List<Vector3> _vertScratch = new System.Collections.Generic.List<Vector3>();
private int _meshCacheId; // mesh instance the topology/UV caches were built for (0 = none)
/// <summary>Reads vertices/triangles/UVs of the collider mesh and computes culling data.
/// Only the vertices change across re-bakes of the same mesh; topology/UVs are fetched once.</summary>
private void BuildMeshCaches()
{
Mesh m = _collider.sharedMesh;
if (!m) return;
m.GetVertices(_vertScratch);
int vCount = _vertScratch.Count;
if (_verts == null || _verts.Length != vCount) _verts = new Vector3[vCount];
_vertScratch.CopyTo(_verts);
if (_meshCacheId != m.GetInstanceID())
{
_meshCacheId = m.GetInstanceID();
_tris = m.triangles;
_uvs = m.uv;
// Fallback to the original mesh UVs if the baked mesh dropped them.
if ((_uvs == null || _uvs.Length != vCount) && _skinned && _skinned.sharedMesh)
_uvs = _skinned.sharedMesh.uv;
}
if (_uvs == null || _uvs.Length != _verts.Length) return;
int triCount = _tris.Length / 3;
if (_triCenter == null || _triCenter.Length != triCount)
{
_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 - the dilation SOURCE / overwrite
/// guard (island texels are real content edge padding must never overwrite; the gutter stays
/// paddable). Derived from the STATIC UV layout instead of transient per-stroke coverage, so every
/// seed (fresh start, load, backend switch) marks the same content and the first stroke after a
/// load/switch no longer bleeds over existing paint. Built once and cached.
/// </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/height, B = 0,
// A = smoothness. G = 128 (= 0.5) is the NEUTRAL height: no parallax/relief and AO =
// saturate(2*0.5) = 1 (no darkening), so the unpainted look is unchanged. Painting G
// below 0.5 carves a hole (and darkens), above 0.5 raises a hill (see PlayerCamoLit).
float metallic0 = GetMatFloat(MetallicId, 0f);
float smoothness0 = GetMatFloat(SmoothnessId, 0.5f);
_seedCanonical[MAP_MSO] = FillCanonical(new Color32(
(byte)(Mathf.Clamp01(metallic0) * 255f), 128, 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));
// Fur: R = 0 so a fresh avatar has NO fur (R is painted-in length, not coverage, so it is never
// island-stamped - see SeedGpuMap). G/B/A unused. The strand colour comes from the albedo map.
_seedCanonical[MAP_FUR] = FillCanonical(new Color32(0, 0, 0, 0));
}
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()
{
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 */ }
}
return FillCanonical(baseColor);
}
/// <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");
SetMatFloat(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;
_lastEmissionIntensity = float.NaN; // fresh material instance -> force the first write
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");
SetMatFloat(OcclusionStrengthId, 1f);
}
// Height: the same mask map is also bound to _ParallaxMap, so the painted occlusion (G) doubles
// as a 0.5-centred height. _PARALLAXMAP enables URP's parallax AND our relief-normal/AO remap in
// PlayerCamoLit; _Parallax is lifted off its floor so the displacement is visible. _NORMALMAP is
// REQUIRED for the relief normal to show - URP only transforms normalTS into world space when it
// (or _DETAIL) is set, otherwise the relief is invisible. _BumpMap stays unbound (flat default
// normal), so the keyword only enables the tangent->world path.
if (_materialInstance.HasProperty(ParallaxMapId))
{
_materialInstance.EnableKeyword("_PARALLAXMAP");
_materialInstance.EnableKeyword("_NORMALMAP");
if (GetMatFloat(ParallaxId, ParallaxBase) < ParallaxBase)
SetMatFloat(ParallaxId, ParallaxBase);
}
}
private float _lastEmissionIntensity = float.NaN;
/// <summary>Pushes the global emission intensity to the material's _EmissionColor (live-tunable).
/// Runs per frame, so it only writes the material when the intensity actually changed.</summary>
private void RefreshEmissionColor()
{
if (emissionIntensity == _lastEmissionIntensity) return;
if (_materialInstance && _materialInstance.HasProperty(EmissionColorId))
{
_materialInstance.SetColor(EmissionColorId, Color.white * Mathf.Max(0f, emissionIntensity));
_lastEmissionIntensity = emissionIntensity;
}
}
/// <summary>Eases the see-through X-ray glow's alpha toward its on/off target (<see cref="XRayRevealed"/>)
/// at a constant rate, so the reveal fades in/out over <c>xrayFadeDuration</c> seconds instead of popping.
/// Rgb is left untouched (the authored glow colour). Only writes the material on an actual change, and
/// snaps instantly when the duration is 0.</summary>
private void TickXRayFade()
{
if (!_materialInstance || !_materialInstance.HasProperty(XRayColorId)) return;
Color c = _materialInstance.GetColor(XRayColorId);
float step = xrayFadeDuration > 0f ? Time.deltaTime / xrayFadeDuration : 1f;
float a = Mathf.MoveTowards(c.a, _xrayTarget, step);
if (a == c.a) return; // already at target -> no material write
c.a = a;
_materialInstance.SetColor(XRayColorId, c);
}
/// <summary>Applies the current hit-flash state to the X-ray material: flash mode boosts the authored glow
/// rgb by <c>xrayFlashBoost</c>, turns the breathing pulse off (steady), and snaps the reveal alpha on for an
/// immediate pop; leaving flash restores the authored rgb + pulse (alpha keeps easing via TickXRayFade). The
/// pulse plateau otherwise leaves a short hit window looking dim, so a steady boosted glow reads far stronger.</summary>
private void ApplyXRayFlashState()
{
if (!_materialInstance || !_materialInstance.HasProperty(XRayColorId)) return;
float boost = _xrayFlash ? Mathf.Max(1f, xrayFlashBoost) : 1f;
Color c = _materialInstance.GetColor(XRayColorId);
c.r = _xrayBaseColor.r * boost;
c.g = _xrayBaseColor.g * boost;
c.b = _xrayBaseColor.b * boost;
if (_xrayFlash) c.a = _xrayTarget; // snap the glow on so the hit reads instantly (no ramp-in)
_materialInstance.SetColor(XRayColorId, c);
if (_materialInstance.HasProperty(XRayPulseStrengthId))
_materialInstance.SetFloat(XRayPulseStrengthId, _xrayFlash ? 0f : _xrayBasePulseStrength);
}
}