Split mesh painter into partials and add occlusion via combined mask map

- Split MeshPainter into partial classes (Setup/Backend/Gpu/Cpu) for maintainability
- Add ambient occlusion painting, packed with metallic/smoothness into one
  MSO mask map (R=metallic, G=occlusion, A=smoothness) bound to both
  _MetallicGlossMap and _OcclusionMap, kept independently paintable via
  per-channel ColorMask
- Gate painting from a single IsChannelActive source so AO-only strokes work
- Blend albedo/emission in linear space on CPU to match the GPU framebuffer
- Pre-compensate data-channel writes for sRGB->linear so GPU matches CPU
- Harden GPU shader fallback to reflect real backend state

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-23 19:31:52 +02:00
1 parent 86c0a33687
commit c7ec12e6d9
11 files changed
+927 -776

No files matched your search

+36 -767
View File
@@ -7,8 +7,10 @@ using UnityEngine.InputSystem;
/// <summary>
/// Live mesh painting in Play mode: paints the brush onto the mesh textures with the
/// mouse. Three paint maps are driven at once (each can be toggled): albedo color
/// (_BaseMap), metallic + smoothness (_MetallicGlossMap, R = metallic, A = smoothness)
/// and emission (_EmissionMap). Two backends (switchable live via <see cref="useGpu"/>):
/// (_BaseMap), an HDRP-style mask map (R = metallic, G = occlusion, A = smoothness; one
/// texture bound to BOTH _MetallicGlossMap and _OcclusionMap) and emission (_EmissionMap).
/// Metallic/smoothness and occlusion share that mask map but stay independently paintable.
/// Two backends (switchable live via <see cref="useGpu"/>):
/// GPU shader painting into RenderTextures (fast, default) and CPU rasterization
/// (slower, identical result). Both paint seamlessly across UV seams because the brush
/// is evaluated in 3D local space around the hit point, capturing triangles on both
@@ -24,7 +26,7 @@ using UnityEngine.InputSystem;
/// overlay that wraps the mesh exactly like the paint would land.
/// </summary>
[DisallowMultipleComponent]
public class MeshPainter : MonoBehaviour
public partial class MeshPainter : MonoBehaviour
{
/// <summary>How the square stamp is oriented around the surface normal.</summary>
public enum BrushAlignment
@@ -57,9 +59,14 @@ public class MeshPainter : MonoBehaviour
[Tooltip("Paint the albedo (base color) map.")]
public bool paintAlbedo = true;
[Tooltip("Paint the metallic + smoothness map (_MetallicGlossMap).")]
[Tooltip("Paint metallic + smoothness into the mask map (R = metallic, A = smoothness). " +
"Shares one texture with occlusion but is toggled independently.")]
public bool paintMetallicSmoothness = false;
[Tooltip("Paint ambient occlusion into the mask map (G channel, also bound to _OcclusionMap). " +
"Shares one texture with metallic/smoothness but is toggled independently.")]
public bool paintOcclusion = false;
[Tooltip("Paint the emission map (_EmissionMap).")]
public bool paintEmission = false;
@@ -68,15 +75,21 @@ public class MeshPainter : MonoBehaviour
"top of the mask (alpha 1 = mask opacity as-is, lower = fainter).")]
public Color brushColor = Color.red;
[Header("Brush - metallic / smoothness")]
[Tooltip("Metallic value painted into the map (0 = dielectric, 1 = metal).")]
[Header("Brush - metallic / smoothness / occlusion")]
[Tooltip("Metallic value painted into the mask map (0 = dielectric, 1 = metal).")]
[Range(0f, 1f)]
public float metallic = 0f;
[Tooltip("Smoothness value painted into the map (0 = rough, 1 = glossy).")]
[Tooltip("Smoothness value painted into the mask map (0 = rough, 1 = glossy).")]
[Range(0f, 1f)]
public float smoothness = 0.5f;
[Tooltip("Ambient occlusion painted into the mask map (1 = fully lit / no darkening, " +
"0 = fully occluded / dark). NOTE: URP only darkens INDIRECT light (ambient + " +
"reflections), so the effect is subtle under strong direct lighting. 1 = no-op.")]
[Range(0f, 1f)]
public float occlusion = 0.5f;
[Header("Brush - emission")]
[Tooltip("Emission color painted into the map. Alpha = global opacity multiplier on top of the mask.")]
public Color emissionColor = Color.black;
@@ -140,20 +153,12 @@ public class MeshPainter : MonoBehaviour
// --- paint maps ---
private const int MAP_ALBEDO = 0;
private const int MAP_MS = 1; // metallic (R) + smoothness (A)
// Mask map: R = metallic, G = occlusion, A = smoothness. One texture bound to BOTH
// _MetallicGlossMap (reads R/A) and _OcclusionMap (reads G).
private const int MAP_MSO = 1;
private const int MAP_EM = 2; // emission (RGB)
private const int MAP_COUNT = 3;
/// <summary>GPU backend per map: a display RenderTexture + scratch for the dilation ping-pong.</summary>
private class GpuMap
{
public RenderTexture display; // painted into (work RT for the MS map)
public RenderTexture temp; // dilation ping-pong scratch
public RenderTexture final; // MS only: packed (R=metallic, A=smoothness) target; null otherwise
public bool drew; // anything drawn this stroke?
public bool sRGB; // sRGB color map (albedo/emission) vs linear data map (MS)
}
/// <summary>CPU backend per map: a Texture2D mirror + rasterization/dilation state.</summary>
private class CpuMap
{
@@ -186,25 +191,16 @@ public class MeshPainter : MonoBehaviour
private bool _setupValid;
// Seed colors per map in canonical (material) layout, built once at Start:
// albedo -> RGB color
// MS -> R = metallic, A = smoothness
// emission-> RGB emission
// albedo -> RGB color
// MSO -> R = metallic, G = occlusion (1 = unoccluded), A = smoothness (mask map)
// emission -> RGB emission
private Color32[][] _seedCanonical;
// --- backends ---
private GpuMap[] _gpu;
private CpuMap[] _cpu;
private readonly List<int> _dilateScratch = new List<int>(512);
private readonly RaycastHit[] _rayHits = new RaycastHit[32];
public Shader brushShader; // optional; else Shader.Find("Hidden/MeshPaintBrush")
public Shader dilateShader; // optional; else Shader.Find("Hidden/MeshPaintDilate")
public Shader packMsShader; // optional; else Shader.Find("Hidden/MeshPaintPackMS")
private Material _brushMat;
private Material _dilateMat;
private Material _packMat;
private bool _prevSRGB;
// --- preview overlay ---
public Shader previewShader; // optional; else Shader.Find("Hidden/MeshPaintPreview")
private Material _previewMat;
@@ -232,7 +228,10 @@ public class MeshPainter : MonoBehaviour
private static readonly int SmoothnessId = Shader.PropertyToID("_Smoothness");
private static readonly int EmissionMapId = Shader.PropertyToID("_EmissionMap");
private static readonly int EmissionColorId = Shader.PropertyToID("_EmissionColor");
private static readonly int OcclusionMapId = Shader.PropertyToID("_OcclusionMap");
private static readonly int OcclusionStrengthId = Shader.PropertyToID("_OcclusionStrength");
private static readonly int BrushColorId = Shader.PropertyToID("_BrushColor");
private static readonly int ColorWriteMaskId = Shader.PropertyToID("_ColorWriteMask");
private static readonly int BrushCenterId = Shader.PropertyToID("_BrushCenter");
private static readonly int BrushRadiusId = Shader.PropertyToID("_BrushRadius");
private static readonly int BrushUId = Shader.PropertyToID("_BrushU");
@@ -288,406 +287,6 @@ public class MeshPainter : MonoBehaviour
"Hold the left mouse button and drag over the mesh.", this);
}
// --- 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>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();
// Metallic / smoothness from the material scalars (no per-texel map here).
float metallic0 = _materialInstance.HasProperty(MetallicId) ? _materialInstance.GetFloat(MetallicId) : 0f;
float smoothness0 = _materialInstance.HasProperty(SmoothnessId) ? _materialInstance.GetFloat(SmoothnessId) : 0.5f;
_seedCanonical[MAP_MS] = FillCanonical(new Color32(
(byte)(Mathf.Clamp01(metallic0) * 255f), 0, 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();
}
}
/// <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));
}
// --- Backend selection / switching ---
private void SetupBackend(bool gpu, Color32[][] canonical)
{
if (gpu) SetupGpu(canonical);
else SetupCpu(canonical);
_gpuActive = gpu;
}
/// <summary>Switches the backend at runtime, carrying over all painted maps.</summary>
private void SwitchBackend(bool toGpu)
{
var canonical = new Color32[MAP_COUNT][];
for (int mi = 0; mi < MAP_COUNT; mi++) canonical[mi] = ReadCanonical(mi);
if (_gpuActive) TeardownGpu();
else TeardownCpu();
SetupBackend(toGpu, canonical);
_hasLastScreen = false;
Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this);
}
/// <summary>Reads a map from the active backend into canonical (material) layout.</summary>
private Color32[] ReadCanonical(int mi)
{
if (_gpuActive)
{
var gm = _gpu[mi];
// MS: read the packed final RT (R=metallic, A=smoothness). Others: the display RT.
return (mi == MAP_MS) ? ReadRtPixels(gm.final, linear: true)
: ReadRtPixels(gm.display, linear: false);
}
return (Color32[])_cpu[mi].pixels.Clone();
}
private static bool MapIsLinear(int mi) => mi == MAP_MS;
private string[] MapProps(int mi)
{
switch (mi)
{
case MAP_ALBEDO: return new[] { "_BaseMap", "_MainTex" };
case MAP_MS: return new[] { "_MetallicGlossMap" };
default: return new[] { "_EmissionMap" };
}
}
private void AssignMapTexture(int mi, Texture tex)
{
if (!_materialInstance) return;
foreach (var p in MapProps(mi))
{
int id = Shader.PropertyToID(p);
if (_materialInstance.HasProperty(id)) _materialInstance.SetTexture(id, tex);
}
}
// --- CPU backend ---
private void SetupCpu(Color32[][] canonical)
{
_cpu = new CpuMap[MAP_COUNT];
for (int mi = 0; mi < MAP_COUNT; mi++)
{
var cm = new CpuMap
{
tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, MapIsLinear(mi))
{
name = "MeshPainter_Canvas_" + mi,
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear
},
pixels = SeedCpuPixels(mi, canonical),
painted = new bool[textureSize * textureSize],
fillStamp = new int[textureSize * textureSize],
dilateStamp = 0
};
cm.tex.SetPixels32(cm.pixels);
cm.tex.Apply(false);
_cpu[mi] = cm;
AssignMapTexture(mi, cm.tex);
}
}
/// <summary>CPU pixels are stored directly in material layout; just guarantee a sane alpha for the color maps.</summary>
private Color32[] SeedCpuPixels(int mi, Color32[][] canonical)
{
var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
? canonical[mi] : _seedCanonical[mi];
var px = (Color32[])src.Clone();
if (mi != MAP_MS) // albedo / emission: opaque (alpha unused by the material)
for (int i = 0; i < px.Length; i++) px[i].a = 255;
return px;
}
private void TeardownCpu()
{
if (_cpu == null) return;
foreach (var cm in _cpu) if (cm != null && cm.tex) Destroy(cm.tex);
_cpu = null;
}
// --- GPU backend ---
private void SetupGpu(Color32[][] canonical)
{
if (!brushShader) brushShader = Shader.Find("Hidden/MeshPaintBrush");
if (!dilateShader) dilateShader = Shader.Find("Hidden/MeshPaintDilate");
if (!packMsShader) packMsShader = Shader.Find("Hidden/MeshPaintPackMS");
if (!brushShader || !dilateShader || !packMsShader)
{
Debug.LogError("[MeshPainter] GPU shaders not found (Hidden/MeshPaintBrush, " +
"Hidden/MeshPaintDilate or Hidden/MeshPaintPackMS). Falling back to CPU painting.", this);
useGpu = false;
SetupCpu(canonical);
_gpuActive = false;
return;
}
_brushMat = new Material(brushShader) { hideFlags = HideFlags.HideAndDontSave };
_dilateMat = new Material(dilateShader) { hideFlags = HideFlags.HideAndDontSave };
_packMat = new Material(packMsShader) { hideFlags = HideFlags.HideAndDontSave };
_gpu = new GpuMap[MAP_COUNT];
for (int mi = 0; mi < MAP_COUNT; mi++)
{
bool linear = MapIsLinear(mi);
var gm = new GpuMap
{
sRGB = !linear,
display = CreateRT("MeshPainter_DisplayRT_" + mi, linear),
temp = CreateRT("MeshPainter_TempRT_" + mi, linear),
final = (mi == MAP_MS) ? CreateRT("MeshPainter_FinalRT_" + mi, linear) : null
};
_gpu[mi] = gm;
SeedGpuMap(mi, canonical);
// Color maps render to the display RT; the MS map exposes its packed final RT.
AssignMapTexture(mi, gm.final != null ? gm.final : gm.display);
}
}
/// <summary>Seeds a GPU map's display RT from canonical data (alpha = 0 = unpainted), then packs MS to final.</summary>
private void SeedGpuMap(int mi, Color32[][] canonical)
{
var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
? canonical[mi] : _seedCanonical[mi];
var gm = _gpu[mi];
if (mi == MAP_MS)
{
// Canonical (R=metallic, A=smoothness) -> work layout (R=metallic, G=smoothness, B=0).
var work = new Color32[src.Length];
for (int i = 0; i < src.Length; i++) work[i] = new Color32(src[i].r, src[i].a, 0, 0);
UploadColorsToRT(work, gm.display, paintedAlpha: false, linear: true);
PackMs(gm); // build the final RT once so the material shows the seeded values
}
else
{
UploadColorsToRT((Color32[])src.Clone(), gm.display, paintedAlpha: false, linear: gm.sRGB == false);
}
}
private void TeardownGpu()
{
if (_gpu != null)
{
foreach (var gm in _gpu)
{
if (gm == null) continue;
if (gm.display) { gm.display.Release(); Destroy(gm.display); }
if (gm.temp) { gm.temp.Release(); Destroy(gm.temp); }
if (gm.final) { gm.final.Release(); Destroy(gm.final); }
}
_gpu = null;
}
if (_brushMat) { Destroy(_brushMat); _brushMat = null; }
if (_dilateMat) { Destroy(_dilateMat); _dilateMat = null; }
if (_packMat) { Destroy(_packMat); _packMat = null; }
}
private RenderTexture CreateRT(string rtName, bool linear)
{
var rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGB32,
linear ? RenderTextureReadWrite.Linear : RenderTextureReadWrite.sRGB)
{
name = rtName,
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear,
antiAliasing = 1,
useMipMap = false,
autoGenerateMips = false
};
rt.Create();
return rt;
}
/// <summary>Writes Color32 data into a RenderTexture via a temporary texture.</summary>
private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool paintedAlpha, bool linear)
{
var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
// Alpha marks painted (255) vs unpainted (0) for the dilation pass.
byte a = paintedAlpha ? (byte)255 : (byte)0;
for (int i = 0; i < colors.Length; i++) colors[i].a = a;
tmp.SetPixels32(colors);
tmp.Apply(false);
bool prev = GL.sRGBWrite;
GL.sRGBWrite = !linear;
Graphics.Blit(tmp, rt);
GL.sRGBWrite = prev;
Destroy(tmp);
}
/// <summary>Reads a RenderTexture back as Color32 (for the backend switch).</summary>
private Color32[] ReadRtPixels(RenderTexture rt, bool linear)
{
var prev = RenderTexture.active;
RenderTexture.active = rt;
var tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
tex.ReadPixels(new Rect(0, 0, textureSize, textureSize), 0, 0, false);
tex.Apply(false);
RenderTexture.active = prev;
var px = tex.GetPixels32();
Destroy(tex);
return px;
}
private void PackMs(GpuMap gm)
{
bool prev = GL.sRGBWrite;
GL.sRGBWrite = false; // linear data, no conversion
Graphics.Blit(gm.display, gm.final, _packMat, 0);
GL.sRGBWrite = prev;
}
// --- Brush mask ---
/// <summary>Builds the built-in circle mask (white inscribed circle on black), used when no mask is assigned.</summary>
@@ -992,7 +591,7 @@ public class MeshPainter : MonoBehaviour
if (!paintCamera) return;
bool anyChannel = paintAlbedo || paintMetallicSmoothness || paintEmission;
bool anyChannel = AnyChannelActive(); // includes occlusion (matches IsChannelActive / PaintStamp3D)
float moveDist = _hasLastScreen ? Vector2.Distance(_lastScreen, screenPos) : 0f;
// Move-only mode: stamp once on mouse-down, then only when the mouse actually
@@ -1096,85 +695,14 @@ public class MeshPainter : MonoBehaviour
return true;
}
// --- GPU painting ---
// --- Channels (shared by both backends) ---
private void BeginGpuStroke()
{
_prevSRGB = GL.sRGBWrite;
for (int mi = 0; mi < MAP_COUNT; mi++)
if (IsChannelActive(mi)) _gpu[mi].drew = false;
}
private void DrawBrushGpu(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
/// <summary>True if at least one paint channel is enabled (used to gate the per-frame stroke).</summary>
private bool AnyChannelActive()
{
for (int mi = 0; mi < MAP_COUNT; mi++)
{
if (!IsChannelActive(mi)) continue;
var gm = _gpu[mi];
Graphics.SetRenderTarget(gm.display);
GL.sRGBWrite = gm.sRGB; // sRGB color map vs linear data map (MS)
SetBrushColorForMap(mi);
_brushMat.SetTexture(MaskTexId, _resolvedMask);
_brushMat.SetVector(BrushCenterId, pLocal);
_brushMat.SetFloat(BrushRadiusId, radiusLocal);
_brushMat.SetVector(BrushUId, u);
_brushMat.SetVector(BrushVId, v);
_brushMat.SetVector(BrushNormalId, n);
if (!_brushMat.SetPass(0)) continue;
int sub = _bakedMesh.subMeshCount;
for (int i = 0; i < sub; i++)
Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i);
gm.drew = true;
}
}
private void EndGpuStroke()
{
Graphics.SetRenderTarget(null);
for (int mi = 0; mi < MAP_COUNT; mi++)
{
if (!IsChannelActive(mi)) continue;
var gm = _gpu[mi];
// Edge padding against UV seams: dilate the display RT into temp, then swap.
if (gm.drew && paddingTexels > 0)
{
GL.sRGBWrite = gm.sRGB;
_dilateMat.SetInt(PadId, paddingTexels);
Graphics.Blit(gm.display, gm.temp, _dilateMat, 0);
var swap = gm.display; gm.display = gm.temp; gm.temp = swap;
if (gm.final == null) AssignMapTexture(mi, gm.display);
}
// MS: pack the work layout (R=metallic, G=smoothness) into the URP final layout.
if (gm.final != null) PackMs(gm);
}
GL.sRGBWrite = _prevSRGB;
}
/// <summary>Sets _BrushColor for the given map. Albedo/emission are colors; MS packs (metallic, smoothness) as raw data.</summary>
private void SetBrushColorForMap(int mi)
{
switch (mi)
{
case MAP_ALBEDO:
_brushMat.SetColor(BrushColorId, brushColor);
break;
case MAP_MS:
// SetVector (not SetColor) so the raw metallic/smoothness pass through unconverted.
_brushMat.SetVector(BrushColorId, new Vector4(Mathf.Clamp01(metallic), Mathf.Clamp01(smoothness), 0f, 1f));
break;
default: // emission
_brushMat.SetColor(BrushColorId, emissionColor);
break;
}
if (IsChannelActive(mi)) return true;
return false;
}
private bool IsChannelActive(int mi)
@@ -1182,270 +710,11 @@ public class MeshPainter : MonoBehaviour
switch (mi)
{
case MAP_ALBEDO: return paintAlbedo;
case MAP_MS: return paintMetallicSmoothness;
case MAP_MSO: return paintMetallicSmoothness || paintOcclusion;
default: return paintEmission;
}
}
// --- CPU painting (rasterization + dilation) ---
private void ResetCpuDirty()
{
for (int mi = 0; mi < MAP_COUNT; mi++)
{
if (!IsChannelActive(mi)) continue;
var cm = _cpu[mi];
cm.dx0 = textureSize; cm.dy0 = textureSize; cm.dx1 = -1; cm.dy1 = -1;
}
}
private void EndCpuStroke()
{
for (int mi = 0; mi < MAP_COUNT; mi++)
{
if (!IsChannelActive(mi)) continue;
var cm = _cpu[mi];
if (paddingTexels > 0) DilatePadding(cm, paddingTexels);
FlushDirty(cm);
}
}
/// <summary>
/// Paints a square stamp in the surface tangent plane: rasterizes triangles in range
/// and blends every texel whose 3D position lies inside the box (|du|,|dv|,|dn| &lt;=
/// radius) over each active map, weighted by the mask opacity. Mirrors the GPU brush
/// path (mask value = per-texel opacity, gray = 50%).
/// </summary>
private void PaintStamp3D(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
{
bool aAlb = paintAlbedo, aMS = paintMetallicSmoothness, aEM = paintEmission;
if (!aAlb && !aMS && !aEM) return;
// Albedo target.
float br = brushColor.r * 255f, bg = brushColor.g * 255f, bb = brushColor.b * 255f;
float albOpMul = Mathf.Clamp01(brushColor.a);
CpuMap alb = _cpu[MAP_ALBEDO];
// Metallic-smoothness target (R=metallic, A=smoothness).
float msR = Mathf.Clamp01(metallic) * 255f, msA = Mathf.Clamp01(smoothness) * 255f;
CpuMap ms = _cpu[MAP_MS];
// Emission target.
float er = emissionColor.r * 255f, eg = emissionColor.g * 255f, eb = emissionColor.b * 255f;
float emOpMul = Mathf.Clamp01(emissionColor.a);
CpuMap em = _cpu[MAP_EM];
float invR = 1f / radiusLocal;
int triCount = _tris.Length / 3;
float ts = textureSize;
for (int t = 0; t < triCount; t++)
{
// Fast culling: stamp box (corner reach = sqrt(3)*radius) vs. the triangle's circumscribed circle.
float reach = radiusLocal * SquareReach + _triRadius[t];
if ((_triCenter[t] - pLocal).sqrMagnitude > reach * reach) continue;
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
Vector3 v0 = _verts[i0], v1 = _verts[i1], v2 = _verts[i2];
Vector2 uv0 = _uvs[i0], uv1 = _uvs[i1], uv2 = _uvs[i2];
// UV -> pixel.
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;
// Barycentric denominator.
float d = (p1y - p2y) * (p0x - p2x) + (p2x - p1x) * (p0y - p2y);
if (Mathf.Abs(d) < 1e-7f) continue;
float invD = 1f / d;
const float eps = -1e-4f; // tolerance against 1px cracks between triangles
for (int y = minY; y <= maxY; y++)
{
float py = y + 0.5f;
int rowStart = 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;
float c = 1f - a - b;
if (c < eps) continue;
// 3D position of the texel via barycentric interpolation.
float wx = a * v0.x + b * v1.x + c * v2.x;
float wy = a * v0.y + b * v1.y + c * v2.y;
float wz = a * v0.z + b * v1.z + c * v2.z;
// Project the offset onto the stamp frame (right/up/normal).
float ddx = wx - pLocal.x, ddy = wy - pLocal.y, ddz = wz - pLocal.z;
float du = ddx * u.x + ddy * u.y + ddz * u.z;
float dv = ddx * v.x + ddy * v.y + ddz * v.z;
float dn = ddx * n.x + ddy * n.y + ddz * n.z;
if (du < -radiusLocal || du > radiusLocal) continue;
if (dv < -radiusLocal || dv > radiusLocal) continue;
if (dn < -radiusLocal || dn > radiusLocal) continue;
// Mask -> opacity.
float mu = du * invR * 0.5f + 0.5f;
float mv = dv * invR * 0.5f + 0.5f;
float maskOp = SampleMaskOpacity(mu, mv);
if (maskOp <= 0f) continue;
int idx = rowStart + x;
if (aAlb)
{
float op = maskOp * albOpMul;
if (op > 0f)
{
Color32 dst = alb.pixels[idx];
float it = 1f - op;
alb.pixels[idx] = new Color32(
(byte)(dst.r * it + br * op),
(byte)(dst.g * it + bg * op),
(byte)(dst.b * it + bb * op),
255);
alb.painted[idx] = true;
ExpandDirty(alb, x, y);
}
}
if (aMS)
{
// msOpacity = mask (no extra alpha multiplier).
Color32 dst = ms.pixels[idx];
float it = 1f - maskOp;
ms.pixels[idx] = new Color32(
(byte)(dst.r * it + msR * maskOp), // metallic
0, 0,
(byte)(dst.a * it + msA * maskOp)); // smoothness
ms.painted[idx] = true;
ExpandDirty(ms, x, y);
}
if (aEM)
{
float op = maskOp * emOpMul;
if (op > 0f)
{
Color32 dst = em.pixels[idx];
float it = 1f - op;
em.pixels[idx] = new Color32(
(byte)(dst.r * it + er * op),
(byte)(dst.g * it + eg * op),
(byte)(dst.b * it + eb * op),
255);
em.painted[idx] = true;
ExpandDirty(em, x, y);
}
}
}
}
}
}
private static void ExpandDirty(CpuMap cm, int x, int y)
{
if (x < cm.dx0) cm.dx0 = x;
if (x > cm.dx1) cm.dx1 = x;
if (y < cm.dy0) cm.dy0 = y;
if (y > cm.dy1) cm.dy1 = y;
}
/// <summary>
/// CPU edge padding against UV seams: bleeds the freshly painted colors outward by
/// 'pad' texels. The dilation source is only brush-painted texels (cm.painted).
/// </summary>
private void DilatePadding(CpuMap cm, int pad)
{
if (cm.dx1 < cm.dx0 || cm.dy1 < cm.dy0) return; // nothing painted
int x0 = Mathf.Max(0, cm.dx0 - pad);
int y0 = Mathf.Max(0, cm.dy0 - pad);
int x1 = Mathf.Min(textureSize - 1, cm.dx1 + pad);
int y1 = Mathf.Min(textureSize - 1, cm.dy1 + pad);
cm.dilateStamp++;
for (int iter = 0; iter < pad; iter++)
{
_dilateScratch.Clear();
for (int y = y0; y <= y1; y++)
{
int row = y * textureSize;
for (int x = x0; x <= x1; x++)
{
int idx = row + x;
if (cm.painted[idx] || cm.fillStamp[idx] == cm.dilateStamp) continue;
if (TryFindPaddingSource(cm, x, y, out Color32 c))
{
cm.pixels[idx] = c;
_dilateScratch.Add(idx);
ExpandDirty(cm, x, y);
}
}
}
for (int k = 0; k < _dilateScratch.Count; k++)
cm.fillStamp[_dilateScratch[k]] = cm.dilateStamp;
if (_dilateScratch.Count == 0) break;
}
}
/// <summary>Searches the 8-neighborhood for a source texel (painted or already padded) and returns its color.</summary>
private bool TryFindPaddingSource(CpuMap cm, int x, int y, out Color32 col)
{
for (int oy = -1; oy <= 1; oy++)
{
int ny = y + oy;
if (ny < 0 || ny >= textureSize) continue;
int nrow = ny * textureSize;
for (int ox = -1; ox <= 1; ox++)
{
if (ox == 0 && oy == 0) continue;
int nx = x + ox;
if (nx < 0 || nx >= textureSize) continue;
int nidx = nrow + nx;
if (cm.painted[nidx] || cm.fillStamp[nidx] == cm.dilateStamp)
{
col = cm.pixels[nidx];
return true;
}
}
}
col = default;
return false;
}
/// <summary>Uploads only the region changed this frame to the GPU once (CPU backend).</summary>
private void FlushDirty(CpuMap cm)
{
if (cm.dx1 < cm.dx0 || cm.dy1 < cm.dy0) return; // nothing painted
int bw = cm.dx1 - cm.dx0 + 1;
int bh = cm.dy1 - cm.dy0 + 1;
var block = new Color32[bw * bh];
for (int y = 0; y < bh; y++)
System.Array.Copy(cm.pixels, (cm.dy0 + y) * textureSize + cm.dx0, block, y * bw, bw);
cm.tex.SetPixels32(cm.dx0, cm.dy0, bw, bh, block);
cm.tex.Apply(false);
}
// --- Helpers ---
private static float Cross2D(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;