Add HUD brush mask selector and fix dilation seam artifacts
- 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>
This commit is contained in:
1 parent
d62eddc518
commit
c14525a2c8
15 files changed
+1005
-14
No files matched your search
@@ -18,7 +18,10 @@ public partial class MeshPainter
|
||||
filterMode = FilterMode.Bilinear
|
||||
},
|
||||
pixels = SeedCpuPixels(mi, canonical),
|
||||
painted = new bool[textureSize * textureSize],
|
||||
// Seed the dilation-source marker with the UV islands so seeded (loaded /
|
||||
// switched-in) content is protected from edge padding right away; the gutter
|
||||
// stays paddable. Clone: painted[] is mutated per stroke.
|
||||
painted = (bool[])GetIslandMask().Clone(),
|
||||
fillStamp = new int[textureSize * textureSize],
|
||||
dilateStamp = 0
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ public partial class MeshPainter
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Seeds a GPU map's display RT from canonical data (alpha = 0 = unpainted), then packs MS to final.</summary>
|
||||
/// <summary>Seeds a GPU map's display RT from canonical data (coverage alpha = UV islands), 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)
|
||||
@@ -78,15 +78,15 @@ public partial class MeshPainter
|
||||
if (mi == MAP_MSO)
|
||||
{
|
||||
// Canonical (R=metallic, G=occlusion, A=smoothness) -> work layout
|
||||
// (R=metallic, G=smoothness, B=occlusion); alpha = coverage stays 0 (unpainted).
|
||||
// (R=metallic, G=smoothness, B=occlusion); UploadColorsToRT fills A = island coverage.
|
||||
var work = new Color32[src.Length];
|
||||
for (int i = 0; i < src.Length; i++) work[i] = new Color32(src[i].r, src[i].a, src[i].g, 0);
|
||||
UploadColorsToRT(work, gm.display, paintedAlpha: false, linear: true);
|
||||
UploadColorsToRT(work, gm.display, linear: true);
|
||||
PackMs(gm); // build the final mask RT once so the material shows the seeded values
|
||||
}
|
||||
else
|
||||
{
|
||||
UploadColorsToRT((Color32[])src.Clone(), gm.display, paintedAlpha: false, linear: gm.sRGB == false);
|
||||
UploadColorsToRT((Color32[])src.Clone(), gm.display, linear: gm.sRGB == false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,12 +125,15 @@ public partial class MeshPainter
|
||||
}
|
||||
|
||||
/// <summary>Writes Color32 data into a RenderTexture via a temporary texture.</summary>
|
||||
private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool paintedAlpha, bool linear)
|
||||
private void UploadColorsToRT(Color32[] colors, RenderTexture rt, 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;
|
||||
// Alpha is the dilation coverage marker: island texels (real content) = 255 so the
|
||||
// dilate pass keeps them; the gutter = 0 so it stays paddable. Driving this from the UV
|
||||
// islands (not a flat "unpainted") protects seeded content from the first stroke's bleed.
|
||||
var island = GetIslandMask();
|
||||
bool useMask = island.Length == colors.Length;
|
||||
for (int i = 0; i < colors.Length; i++) colors[i].a = (useMask && island[i]) ? (byte)255 : (byte)0;
|
||||
tmp.SetPixels32(colors);
|
||||
tmp.Apply(false);
|
||||
|
||||
|
||||
@@ -72,6 +72,67 @@ public partial class MeshPainter
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -204,6 +204,7 @@ public partial class MeshPainter : MonoBehaviour
|
||||
private Vector3[] _triCenter; // centroid per triangle (CPU culling)
|
||||
private float[] _triRadius; // circumscribed radius per triangle (CPU culling)
|
||||
private float _avgUvToLocal = 1f; // average UV unit -> local unit
|
||||
private bool[] _islandMask; // texel lies inside a UV island: dilation source / overwrite guard (built once)
|
||||
|
||||
private bool _hasLastScreen;
|
||||
private Vector2 _lastScreen;
|
||||
|
||||
@@ -68,6 +68,7 @@ public partial class PaintHud
|
||||
public Color emissionColor;
|
||||
public float emissionIntensity;
|
||||
public int brushSize;
|
||||
public int activeBrushIndex;
|
||||
public int sprayDots;
|
||||
public float sprayDotSizeMin, sprayDotSizeMax, sprayDotAlphaMin, sprayDotAlphaMax;
|
||||
}
|
||||
@@ -116,6 +117,7 @@ public partial class PaintHud
|
||||
v.emissionColor = painter.emissionColor;
|
||||
v.emissionIntensity = painter.emissionIntensity;
|
||||
v.brushSize = painter.brushSize;
|
||||
v.activeBrushIndex = painter.activeBrushIndex;
|
||||
v.sprayDots = painter.sprayDots;
|
||||
v.sprayDotSizeMin = painter.sprayDotSizeMin;
|
||||
v.sprayDotSizeMax = painter.sprayDotSizeMax;
|
||||
@@ -161,6 +163,7 @@ public partial class PaintHud
|
||||
painter.emissionColor = v.emissionColor;
|
||||
painter.emissionIntensity = v.emissionIntensity;
|
||||
painter.brushSize = v.brushSize;
|
||||
painter.activeBrushIndex = v.activeBrushIndex;
|
||||
painter.sprayDots = v.sprayDots;
|
||||
painter.sprayDotSizeMin = v.sprayDotSizeMin;
|
||||
painter.sprayDotSizeMax = v.sprayDotSizeMax;
|
||||
|
||||
@@ -63,6 +63,8 @@ public partial class PaintHud : MonoBehaviour
|
||||
private const string SliderSprayDotSizeMax = "Slider_SprayDotSizeMax";
|
||||
private const string SliderSprayDotAlphaMin = "Slider_SprayDotAlphaMin";
|
||||
private const string SliderSprayDotAlphaMax = "Slider_SprayDotAlphaMax";
|
||||
private const string MaskSelector = "MaskSelector";
|
||||
private const string MaskSwatchPrefix = "MaskSwatch_";
|
||||
|
||||
// Panels made draggable at runtime - grabbable from any non-widget surface (both prefab and code-gen paths).
|
||||
private static readonly string[] DraggablePanels =
|
||||
@@ -206,6 +208,8 @@ public partial class PaintHud : MonoBehaviour
|
||||
|
||||
WireSlider(panel, SliderBrushSize, painter ? painter.brushSize : 16f, v => { if (painter) painter.brushSize = Mathf.RoundToInt(v); });
|
||||
|
||||
WireMaskSelector(panel);
|
||||
|
||||
WireSlider(panel, SliderSprayDots, painter ? painter.sprayDots : 24f, v => { if (painter) painter.sprayDots = Mathf.RoundToInt(v); });
|
||||
WireSlider(panel, SliderSprayDotSizeMin, painter ? painter.sprayDotSizeMin : .05f, v => { if (painter) painter.sprayDotSizeMin = v; });
|
||||
WireSlider(panel, SliderSprayDotSizeMax, painter ? painter.sprayDotSizeMax : .18f, v => { if (painter) painter.sprayDotSizeMax = v; });
|
||||
@@ -250,6 +254,8 @@ public partial class PaintHud : MonoBehaviour
|
||||
SetSlider(panel, SliderSprayDotSizeMax, painter.sprayDotSizeMax);
|
||||
SetSlider(panel, SliderSprayDotAlphaMin, painter.sprayDotAlphaMin);
|
||||
SetSlider(panel, SliderSprayDotAlphaMax, painter.sprayDotAlphaMax);
|
||||
|
||||
RefreshMaskHighlights();
|
||||
}
|
||||
|
||||
private static void SetToggle(Transform panel, string name, bool value)
|
||||
@@ -271,6 +277,82 @@ public partial class PaintHud : MonoBehaviour
|
||||
if (picker) picker.SetColor(color);
|
||||
}
|
||||
|
||||
// Active = accent frame, inactive = subtle frame. Selection lives on the painter (activeBrushIndex);
|
||||
// the swatch click only sets it - the painter resolves the new mask on its next Update.
|
||||
private static readonly Color MaskFrameSelected = new Color(0.30f, 0.62f, 1f, 1f);
|
||||
private static readonly Color MaskFrameUnselected = new Color(0.18f, 0.18f, 0.21f, 1f);
|
||||
|
||||
/// <summary>
|
||||
/// Binds the baked mask swatches (one per <c>MaskSwatch_i</c>) to the painter's brush masks:
|
||||
/// each swatch previews <c>painter.brushMasks[i]</c> and selects it on click. A swatch with no
|
||||
/// matching mask is hidden, so the prefab can carry more swatches than the painter has masks.
|
||||
/// </summary>
|
||||
private void WireMaskSelector(Transform panel)
|
||||
{
|
||||
var selector = FindDeep(panel, MaskSelector);
|
||||
if (!selector) return;
|
||||
var masks = painter ? painter.brushMasks : null;
|
||||
|
||||
for (int i = 0; ; i++)
|
||||
{
|
||||
var swatch = FindDeep(selector, MaskSwatchPrefix + i);
|
||||
if (!swatch) break;
|
||||
|
||||
bool has = masks != null && i < masks.Count && masks[i];
|
||||
swatch.gameObject.SetActive(has);
|
||||
if (!has) continue;
|
||||
|
||||
var preview = swatch.Find("Preview");
|
||||
var previewImg = preview ? preview.GetComponent<Image>() : null;
|
||||
if (previewImg) previewImg.sprite = MaskSprite(masks[i]);
|
||||
|
||||
var btn = swatch.GetComponent<Button>();
|
||||
if (btn)
|
||||
{
|
||||
int idx = i; // capture per swatch
|
||||
btn.onClick.RemoveAllListeners();
|
||||
btn.onClick.AddListener(() => SelectMask(idx));
|
||||
}
|
||||
}
|
||||
|
||||
RefreshMaskHighlights();
|
||||
}
|
||||
|
||||
private void SelectMask(int index)
|
||||
{
|
||||
if (painter) painter.activeBrushIndex = index;
|
||||
RefreshMaskHighlights();
|
||||
}
|
||||
|
||||
private void RefreshMaskHighlights()
|
||||
{
|
||||
if (!_panel || !painter) return;
|
||||
var selector = FindDeep(_panel.transform, MaskSelector);
|
||||
if (!selector) return;
|
||||
|
||||
for (int i = 0; ; i++)
|
||||
{
|
||||
var swatch = FindDeep(selector, MaskSwatchPrefix + i);
|
||||
if (!swatch) break;
|
||||
var frame = swatch.GetComponent<Image>();
|
||||
if (frame) frame.color = i == painter.activeBrushIndex ? MaskFrameSelected : MaskFrameUnselected;
|
||||
}
|
||||
}
|
||||
|
||||
// One sprite per mask texture, reused across rewires (display only - no CPU readback needed).
|
||||
private static readonly System.Collections.Generic.Dictionary<Texture2D, Sprite> _maskSprites
|
||||
= new System.Collections.Generic.Dictionary<Texture2D, Sprite>();
|
||||
|
||||
private static Sprite MaskSprite(Texture2D tex)
|
||||
{
|
||||
if (!tex) return null;
|
||||
if (_maskSprites.TryGetValue(tex, out var cached) && cached) return cached;
|
||||
var s = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f);
|
||||
s.hideFlags = HideFlags.HideAndDontSave;
|
||||
_maskSprites[tex] = s;
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches (idempotently) a <see cref="DraggablePanel"/> to the whole panel so it can be dragged
|
||||
/// from any of its non-interactive surface (header, padding, labels, background); the component
|
||||
|
||||
Reference in new issue
Block a user