diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs
index 3146785..1a0be0d 100644
--- a/Assets/Scripts/MeshPainter.cs
+++ b/Assets/Scripts/MeshPainter.cs
@@ -8,13 +8,28 @@ using UnityEngine.InputSystem;
/// Live mesh painting in Play mode: paints the brush color onto the mesh texture
/// with the mouse. Two backends (switchable live via ): GPU
/// shader painting into a RenderTexture (fast, default) and CPU rasterization
-/// (slower, identical result). Both paint seamlessly across UV seams because a 3D
-/// sphere around the hit point captures triangles on both sides of a seam at once.
-/// Attach to the GameObject you want to paint.
+/// (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 sides of a seam at once.
+///
+/// The brush is a square stamp in the surface tangent plane whose shape is given by
+/// a grayscale mask texture (white = paint, black = skip, hard threshold). A list of
+/// masks is assignable in the Inspector; if it is empty a procedural circle mask is
+/// used, which reproduces the classic round brush. Attach to the GameObject you want
+/// to paint.
///
[DisallowMultipleComponent]
public class MeshPainter : MonoBehaviour
{
+ /// How the square stamp is oriented around the surface normal.
+ public enum BrushAlignment
+ {
+ /// Stamp "up"/"right" follow the camera (projected onto the surface) - natural decal feel.
+ Camera,
+ /// Stamp "up" = world up (projected onto the surface) - stable, does not rotate with the camera.
+ World
+ }
+
/// When false, mouse painting is ignored. Toggled by the GameModeManager.
public bool PaintingEnabled { get; set; } = true;
@@ -26,10 +41,29 @@ public class MeshPainter : MonoBehaviour
[Tooltip("Color used for painting.")]
public Color brushColor = Color.red;
- [Tooltip("Brush radius in texels. Converted into a 3D radius per hit.")]
+ [Tooltip("Brush half-extent in texels (= half the square side). Converted into a 3D size per hit.")]
[Range(1, 256)]
public int brushSize = 16;
+ [Tooltip("Brush masks: the mask value (luminance x alpha) is the per-texel opacity " +
+ "(gray = 50%). Empty = built-in circle (= classic round brush). Import grayscale " +
+ "masks as Non-Color so midtones map 1:1; alpha-based masks (e.g. soft particles) work too.")]
+ public List brushMasks = new List();
+
+ [Tooltip("Index of the active mask in the list above. Out of range = built-in circle.")]
+ public int activeBrushIndex = 0;
+
+ [Tooltip("How the square stamp is oriented around the surface normal (only matters for non-symmetric masks).")]
+ public BrushAlignment brushAlignment = BrushAlignment.Camera;
+
+ [Tooltip("Extra rotation of the stamp around the surface normal, in degrees.")]
+ [Range(-180f, 180f)]
+ public float brushRotation = 0f;
+
+ [Tooltip("Global opacity multiplier on top of the mask (1 = mask opacity as-is, lower = fainter).")]
+ [Range(0f, 1f)]
+ public float brushOpacity = 1f;
+
[Header("Canvas")]
[Tooltip("Resolution of the paint texture (square).")]
public int textureSize = 1024;
@@ -88,8 +122,17 @@ public class MeshPainter : MonoBehaviour
private bool _drewThisStroke;
private bool _prevSRGB;
+ // --- brush mask (shared) ---
+ private Texture2D _proceduralMask; // built-in circle, used when the list is empty/invalid
+ private float[] _proceduralOpacity; // cached opacity (luma*alpha) of the procedural mask (CPU)
+ private int _procW, _procH;
+ private Texture2D _resolvedMask; // currently active mask (user or procedural)
+ private float[] _maskOpacity; // opacity (luma*alpha) of the resolved mask (CPU sampling)
+ private int _maskW, _maskH;
+
private const float ScreenStepPixels = 5f; // interpolation step along mouse movement
private const int MaxStepsPerFrame = 256; // raycast safeguard for fast movement
+ private const float SquareReach = 1.7321f; // sqrt(3): max 3D distance from center to a painted box texel (CPU culling)
private static readonly int BaseMapId = Shader.PropertyToID("_BaseMap");
private static readonly int MainTexId = Shader.PropertyToID("_MainTex");
@@ -98,6 +141,11 @@ public class MeshPainter : MonoBehaviour
private static readonly int BrushColorId = Shader.PropertyToID("_BrushColor");
private static readonly int BrushCenterId = Shader.PropertyToID("_BrushCenter");
private static readonly int BrushRadiusId = Shader.PropertyToID("_BrushRadius");
+ private static readonly int BrushUId = Shader.PropertyToID("_BrushU");
+ private static readonly int BrushVId = Shader.PropertyToID("_BrushV");
+ private static readonly int BrushNormalId = Shader.PropertyToID("_BrushNormal");
+ private static readonly int MaskTexId = Shader.PropertyToID("_MaskTex");
+ private static readonly int BrushOpacityId = Shader.PropertyToID("_BrushOpacity");
private static readonly int PadId = Shader.PropertyToID("_Pad");
private void Start()
@@ -132,6 +180,10 @@ public class MeshPainter : MonoBehaviour
else if (_materialInstance.HasProperty(ColorId)) _materialInstance.SetColor(ColorId, Color.white);
SetupBackend(useGpu, fromColor32: BuildBaseColors());
+
+ EnsureProceduralMask();
+ ResolveActiveMask();
+
_setupValid = true;
Debug.Log($"[MeshPainter] Ready ({(_gpuActive ? "GPU" : "CPU")} painting, seamless). " +
@@ -389,6 +441,181 @@ public class MeshPainter : MonoBehaviour
if (_materialInstance.HasProperty(MainTexId)) _materialInstance.SetTexture(MainTexId, tex);
}
+ // --- Brush mask ---
+
+ /// Builds the built-in circle mask (white inscribed circle on black), used when no mask is assigned.
+ private void EnsureProceduralMask()
+ {
+ if (_proceduralMask) return;
+
+ const int s = 256;
+ _proceduralMask = new Texture2D(s, s, TextureFormat.RGBA32, false, true) // linear: raw mask values
+ {
+ name = "MeshPainter_CircleMask",
+ wrapMode = TextureWrapMode.Clamp,
+ filterMode = FilterMode.Bilinear,
+ hideFlags = HideFlags.HideAndDontSave
+ };
+
+ var px = new Color32[s * s];
+ _proceduralOpacity = new float[s * s];
+ float half = (s - 1) * 0.5f;
+ for (int y = 0; y < s; y++)
+ {
+ for (int x = 0; x < s; x++)
+ {
+ float nx = (x - half) / half;
+ float ny = (y - half) / half;
+ bool inside = nx * nx + ny * ny <= 1f;
+ byte v = inside ? (byte)255 : (byte)0;
+ int idx = y * s + x;
+ px[idx] = new Color32(v, v, v, 255);
+ _proceduralOpacity[idx] = inside ? 1f : 0f;
+ }
+ }
+ _proceduralMask.SetPixels32(px);
+ _proceduralMask.Apply(false);
+ _procW = s;
+ _procH = s;
+ }
+
+ /// Picks the active mask (list entry or procedural circle) and refreshes the CPU luminance cache on change.
+ private void ResolveActiveMask()
+ {
+ Texture2D desired = null;
+ if (brushMasks != null && activeBrushIndex >= 0 && activeBrushIndex < brushMasks.Count)
+ desired = brushMasks[activeBrushIndex];
+ if (!desired) desired = _proceduralMask;
+
+ if (desired == _resolvedMask) return;
+ _resolvedMask = desired;
+
+ if (_resolvedMask == _proceduralMask)
+ {
+ _maskOpacity = _proceduralOpacity;
+ _maskW = _procW;
+ _maskH = _procH;
+ return;
+ }
+
+ // User mask: cache opacity (luma*alpha) for the CPU backend so it matches the GPU.
+ // Non-readable textures are read back via the GPU (so even built-ins like
+ // Default-Particle work on the CPU path, not just the GPU one).
+ var px = ReadMaskPixels(_resolvedMask, out int w, out int h);
+ _maskW = w;
+ _maskH = h;
+ _maskOpacity = new float[px.Length];
+ for (int i = 0; i < px.Length; i++)
+ {
+ Color32 p = px[i];
+ float luma = (0.299f * p.r + 0.587f * p.g + 0.114f * p.b) / 255f;
+ _maskOpacity[i] = luma * (p.a / 255f);
+ }
+ }
+
+ ///
+ /// Reads a mask texture's pixels. Readable textures are read directly (correct
+ /// orientation, matches GPU tex2D); non-readable ones are blitted to a temp RT and
+ /// read back, so any texture (incl. built-ins without Read/Write) works on the CPU.
+ ///
+ private static Color32[] ReadMaskPixels(Texture2D tex, out int w, out int h)
+ {
+ w = tex.width;
+ h = tex.height;
+ if (tex.isReadable) return tex.GetPixels32();
+
+ var tmp = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
+ var prev = RenderTexture.active;
+ Graphics.Blit(tex, tmp);
+ RenderTexture.active = tmp;
+ var readTex = new Texture2D(w, h, TextureFormat.RGBA32, false, true);
+ readTex.ReadPixels(new Rect(0, 0, w, h), 0, 0, false);
+ readTex.Apply(false);
+ RenderTexture.active = prev;
+ RenderTexture.ReleaseTemporary(tmp);
+ var px = readTex.GetPixels32();
+ Destroy(readTex);
+ return px;
+ }
+
+ /// Bilinear sample of the resolved mask opacity at uv in [0,1]. Returns 1 (full) if no mask.
+ private float SampleMaskOpacity(float u, float v)
+ {
+ if (_maskOpacity == null || _maskW <= 0 || _maskH <= 0) return 1f;
+
+ float fx = Mathf.Clamp01(u) * (_maskW - 1);
+ float fy = Mathf.Clamp01(v) * (_maskH - 1);
+ int x0 = Mathf.FloorToInt(fx);
+ int y0 = Mathf.FloorToInt(fy);
+ int x1 = Mathf.Min(x0 + 1, _maskW - 1);
+ int y1 = Mathf.Min(y0 + 1, _maskH - 1);
+ float tx = fx - x0;
+ float ty = fy - y0;
+
+ float l00 = _maskOpacity[y0 * _maskW + x0];
+ float l10 = _maskOpacity[y0 * _maskW + x1];
+ float l01 = _maskOpacity[y1 * _maskW + x0];
+ float l11 = _maskOpacity[y1 * _maskW + x1];
+
+ float a = Mathf.Lerp(l00, l10, tx);
+ float b = Mathf.Lerp(l01, l11, tx);
+ return Mathf.Lerp(a, b, ty);
+ }
+
+ ///
+ /// Builds an orthonormal tangent frame (u = right, v = up) for the stamp at a hit
+ /// with the given local-space surface normal, honoring the alignment mode and the
+ /// manual rotation. u/v span the tangent plane; the mask is sampled along them.
+ ///
+ private void ComputeBrushFrame(Vector3 nLocal, out Vector3 u, out Vector3 v)
+ {
+ nLocal = nLocal.normalized;
+
+ if (brushAlignment == BrushAlignment.Camera && paintCamera)
+ {
+ // Stamp horizontal follows the camera: u = camera right projected onto the surface.
+ Vector3 camRight = transform.InverseTransformDirection(paintCamera.transform.right);
+ u = ProjectOntoPlane(camRight, nLocal);
+ if (u.sqrMagnitude < 1e-8f)
+ {
+ Vector3 camUp = transform.InverseTransformDirection(paintCamera.transform.up);
+ u = ProjectOntoPlane(camUp, nLocal);
+ if (u.sqrMagnitude < 1e-8f) u = AnyTangent(nLocal);
+ }
+ u.Normalize();
+ // up = right x n, so the frame reads correctly when viewed from the front
+ // (normal toward the camera): u x v = -n. Using n x u would flip v -> upside down.
+ v = Vector3.Cross(u, nLocal).normalized;
+ }
+ else
+ {
+ // Stamp vertical = world up projected onto the surface.
+ Vector3 worldUp = transform.InverseTransformDirection(Vector3.up);
+ v = ProjectOntoPlane(worldUp, nLocal);
+ if (v.sqrMagnitude < 1e-8f) v = AnyTangent(nLocal);
+ v.Normalize();
+ // right = n x up (front-readable: u x v = -n). Using up x n would mirror u.
+ u = Vector3.Cross(nLocal, v).normalized;
+ }
+
+ if (Mathf.Abs(brushRotation) > 1e-4f)
+ {
+ Quaternion q = Quaternion.AngleAxis(brushRotation, nLocal);
+ u = q * u;
+ v = q * v;
+ }
+ }
+
+ private static Vector3 ProjectOntoPlane(Vector3 dir, Vector3 normal)
+ => dir - normal * Vector3.Dot(dir, normal);
+
+ private static Vector3 AnyTangent(Vector3 n)
+ {
+ Vector3 t = Vector3.Cross(n, Vector3.right);
+ if (t.sqrMagnitude < 1e-6f) t = Vector3.Cross(n, Vector3.up);
+ return t.normalized;
+ }
+
// --- Update / input ---
private void Update()
@@ -410,6 +637,8 @@ public class MeshPainter : MonoBehaviour
BuildMeshCaches();
}
+ ResolveActiveMask();
+
if (!TryGetMouse(out bool pressed, out Vector2 screenPos))
return;
@@ -449,7 +678,7 @@ public class MeshPainter : MonoBehaviour
_hasLastScreen = true;
}
- /// Raycasts at a screen position and paints a 3D sphere around the mesh hit point.
+ /// Raycasts at a screen position and stamps the masked square brush onto the mesh hit point.
private void PaintAtScreen(Vector2 screenPos)
{
Ray ray = paintCamera.ScreenPointToRay(screenPos);
@@ -495,8 +724,13 @@ public class MeshPainter : MonoBehaviour
float radiusLocal = (brushSize / (float)textureSize) * scale;
if (radiusLocal <= 0f) return;
- if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal);
- else PaintSphere3D(pLocal, radiusLocal);
+ // Tangent-plane frame for the square stamp / mask projection.
+ Vector3 nLocal = transform.InverseTransformDirection(hit.normal);
+ ComputeBrushFrame(nLocal, out Vector3 u, out Vector3 v);
+ nLocal = nLocal.normalized;
+
+ if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, nLocal);
+ else PaintStamp3D(pLocal, radiusLocal, u, v, nLocal);
}
// --- GPU painting ---
@@ -508,12 +742,17 @@ public class MeshPainter : MonoBehaviour
GL.sRGBWrite = true; // correct sRGB encoding when writing into the sRGB RT
Graphics.SetRenderTarget(_displayRT);
_brushMat.SetColor(BrushColorId, brushColor);
+ _brushMat.SetTexture(MaskTexId, _resolvedMask);
+ _brushMat.SetFloat(BrushOpacityId, Mathf.Clamp01(brushOpacity));
}
- private void DrawBrushGpu(Vector3 pLocal, float radiusLocal)
+ private void DrawBrushGpu(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
{
_brushMat.SetVector(BrushCenterId, pLocal);
_brushMat.SetFloat(BrushRadiusId, radiusLocal);
+ _brushMat.SetVector(BrushUId, u);
+ _brushMat.SetVector(BrushVId, v);
+ _brushMat.SetVector(BrushNormalId, n);
if (!_brushMat.SetPass(0)) return;
int sub = _bakedMesh.subMeshCount;
@@ -543,20 +782,23 @@ public class MeshPainter : MonoBehaviour
// --- CPU painting (rasterization + dilation) ---
///
- /// Paints a sphere in local 3D space: rasterizes triangles in range and colors
- /// every texel whose 3D position lies inside the sphere.
+ /// 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| <= radius) over the canvas, weighted by the mask opacity. Mirrors the
+ /// GPU brush shader (mask value = per-texel opacity, gray = 50%).
///
- private void PaintSphere3D(Vector3 pLocal, float radiusLocal)
+ private void PaintStamp3D(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
{
- Color32 col = brushColor;
- float r2 = radiusLocal * radiusLocal;
+ float br = brushColor.r * 255f, bg = brushColor.g * 255f, bb = brushColor.b * 255f;
+ float globalOpacity = Mathf.Clamp01(brushOpacity);
+ float invR = 1f / radiusLocal;
int triCount = _tris.Length / 3;
float ts = textureSize;
for (int t = 0; t < triCount; t++)
{
- // Fast culling: sphere vs. the triangle's circumscribed circle.
- float reach = radiusLocal + _triRadius[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];
@@ -601,11 +843,30 @@ public class MeshPainter : MonoBehaviour
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;
- if (ddx * ddx + ddy * ddy + ddz * ddz > r2) continue;
+ 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;
- _pixels[rowStart + x] = col;
- _painted[rowStart + x] = true;
+ // Mask -> opacity, then source-over blend (matches the GPU path).
+ float mu = du * invR * 0.5f + 0.5f;
+ float mv = dv * invR * 0.5f + 0.5f;
+ float opacity = SampleMaskOpacity(mu, mv) * globalOpacity;
+ if (opacity <= 0f) continue;
+
+ int idx = rowStart + x;
+ Color32 dst = _pixels[idx];
+ float it = 1f - opacity;
+ _pixels[idx] = new Color32(
+ (byte)(dst.r * it + br * opacity),
+ (byte)(dst.g * it + bg * opacity),
+ (byte)(dst.b * it + bb * opacity),
+ 255);
+ _painted[idx] = true;
if (x < _dx0) _dx0 = x;
if (x > _dx1) _dx1 = x;
@@ -733,6 +994,7 @@ public class MeshPainter : MonoBehaviour
{
if (_bakedMesh) Destroy(_bakedMesh);
if (_materialInstance) Destroy(_materialInstance);
+ if (_proceduralMask) Destroy(_proceduralMask);
TeardownCpu();
TeardownGpu();
}
diff --git a/Assets/Shaders/MeshPaintBrush.shader b/Assets/Shaders/MeshPaintBrush.shader
index 9234f4b..06855e9 100644
--- a/Assets/Shaders/MeshPaintBrush.shader
+++ b/Assets/Shaders/MeshPaintBrush.shader
@@ -1,18 +1,25 @@
-// GPU brush: paints a 3D sphere seamlessly into the paint texture.
+// GPU brush: paints a square mask stamp seamlessly into the paint texture.
//
// In the vertex shader the mesh is "unwrapped" into the texture layout using its
// UVs (uv -> clip space), while the actual local 3D position of the vertex is
-// passed through to the fragment shader. There, per texel, the distance to the
-// brush center (_BrushCenter, in local mesh space) is tested against _BrushRadius.
+// passed through to the fragment shader. There, per texel, the offset to the brush
+// center (_BrushCenter, in local mesh space) is projected onto the stamp's tangent
+// frame: _BrushU (right), _BrushV (up) and _BrushNormal. The texel is painted only
+// if it lies inside the box (|du|,|dv|,|dn| <= _BrushRadius); the brush mask
+// (_MaskTex, sampled along du/dv) then provides the per-texel OPACITY:
+// opacity = luma(mask.rgb) * mask.a * _BrushOpacity
+// and the color is alpha-blended over the existing paint (source-over). So a mask
+// gray value of 0.5 paints at 50%, white = full, black/transparent = nothing.
//
-// This bridges UV seams automatically: triangles on both sides of a seam share
-// the same 3D edge and are captured by the same brush stroke, no matter where
-// they lie in the UV layout (identical behavior to the CPU path).
+// This bridges UV seams automatically: triangles on both sides of a seam share the
+// same 3D edge and are captured by the same brush stamp, no matter where they lie in
+// the UV layout (identical behavior to the CPU path).
Shader "Hidden/MeshPaintBrush"
{
Properties
{
_BrushColor ("Brush Color", Color) = (1,0,0,1)
+ _MaskTex ("Brush Mask", 2D) = "white" {}
}
SubShader
{
@@ -22,7 +29,9 @@ Shader "Hidden/MeshPaintBrush"
ZTest Always
ZWrite Off
Cull Off
- Blend Off
+ // Source-over for color; alpha accumulates coverage toward 1 (painted marker
+ // for the dilation pass). Soft edges keep partial alpha.
+ Blend SrcAlpha OneMinusSrcAlpha, One OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
@@ -31,7 +40,12 @@ Shader "Hidden/MeshPaintBrush"
float4 _BrushColor;
float3 _BrushCenter; // brush center in local mesh space
- float _BrushRadius; // brush radius in local mesh space
+ float3 _BrushU; // stamp right axis (unit, local space)
+ float3 _BrushV; // stamp up axis (unit, local space)
+ float3 _BrushNormal; // surface normal at the hit (unit, local space)
+ float _BrushRadius; // stamp half-extent in local mesh space
+ float _BrushOpacity; // global opacity multiplier (0..1)
+ sampler2D _MaskTex;
struct appdata
{
@@ -61,11 +75,26 @@ Shader "Hidden/MeshPaintBrush"
fixed4 frag (v2f i) : SV_Target
{
- // Only color texels inside the 3D brush sphere.
- float d = distance(i.local, _BrushCenter);
- clip(_BrushRadius - d);
- // Alpha = 1 marks "painted by the brush" (source for the edge padding).
- return fixed4(_BrushColor.rgb, 1.0);
+ // Offset from the brush center, projected onto the stamp frame.
+ float3 d = i.local - _BrushCenter;
+ float du = dot(d, _BrushU);
+ float dv = dot(d, _BrushV);
+ float dn = dot(d, _BrushNormal);
+
+ // Square stamp in the tangent plane + depth bound (keeps the seam-
+ // bridging 3D behavior while preventing bleed to far surfaces).
+ clip(_BrushRadius - abs(du));
+ clip(_BrushRadius - abs(dv));
+ clip(_BrushRadius - abs(dn));
+
+ // Mask -> opacity. du/dv in [-r,r] -> uv in [0,1].
+ float2 muv = float2(du, dv) / _BrushRadius * 0.5 + 0.5;
+ fixed4 m = tex2D(_MaskTex, muv);
+ float luma = dot(m.rgb, fixed3(0.299, 0.587, 0.114));
+ float opacity = luma * m.a * _BrushOpacity;
+ clip(opacity - 1e-4); // skip fully transparent texels
+
+ return fixed4(_BrushColor.rgb, opacity);
}
ENDCG
}
diff --git a/Assets/Shaders/MeshPaintDilate.shader b/Assets/Shaders/MeshPaintDilate.shader
index ab7e81c..3613311 100644
--- a/Assets/Shaders/MeshPaintDilate.shader
+++ b/Assets/Shaders/MeshPaintDilate.shader
@@ -5,9 +5,10 @@
// base color -> bright seam lines. This pass "bleeds" the painted color up to
// _Pad texels beyond the island edges.
//
-// The dilation source is exclusively painted texels (alpha >= 0.5). The padding
+// The dilation source is exclusively painted texels (alpha > 0). The padding
// texels produced get alpha = 0 -> next frame they are NO LONGER a source, so the
-// padding does not grow unbounded (it stays at _Pad texels).
+// padding does not grow unbounded (it stays at _Pad texels). The threshold is > 0
+// (not >= 0.5) so soft, partially transparent brush edges count as painted too.
Shader "Hidden/MeshPaintDilate"
{
Properties
@@ -38,7 +39,7 @@ Shader "Hidden/MeshPaintDilate"
fixed4 frag (v2f_img i) : SV_Target
{
fixed4 c = tex2D(_MainTex, i.uv);
- if (c.a >= 0.5) return c; // already painted -> leave unchanged
+ if (c.a > 0.0) return c; // already painted (even partially) -> leave unchanged
float2 ts = _MainTex_TexelSize.xy;
float best = 1e20;
@@ -53,7 +54,7 @@ Shader "Hidden/MeshPaintDilate"
{
if (abs(x) > _Pad) continue;
fixed4 s = tex2D(_MainTex, i.uv + float2(x, y) * ts);
- if (s.a >= 0.5)
+ if (s.a > 0.0)
{
float dd = (float)(x * x + y * y);
if (dd < best) { best = dd; bestCol = s.rgb; found = true; }