Add square mask brush with opacity blending to mesh painter
Replace the 3D sphere brush with a square stamp in the surface tangent plane whose shape and opacity come from a grayscale/alpha mask. Masks are assignable via an inspector list with a live-switchable active index; an empty list falls back to a procedurally generated circle mask that reproduces the previous round brush. - Mask drives opacity (luma * alpha * brushOpacity) via source-over blend instead of a hard threshold cut; gray 0.5 = 50%. - GPU and CPU paths stay identical; non-readable textures are supported on the CPU via GPU blit-readback. - Dilate pass treats any alpha > 0 as painted so soft edges survive seam padding. - Brush alignment enum (Camera/World) plus manual rotation; tangent frame handedness fixed so stamps read upright and unmirrored in both modes.
This commit is contained in:
1 parent
680d9eafea
commit
c67d519f68
3 files changed
+327
-35
No files matched your search
+280
-18
@@ -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 <see cref="useGpu"/>): 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.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class MeshPainter : MonoBehaviour
|
||||
{
|
||||
/// <summary>How the square stamp is oriented around the surface normal.</summary>
|
||||
public enum BrushAlignment
|
||||
{
|
||||
/// <summary>Stamp "up"/"right" follow the camera (projected onto the surface) - natural decal feel.</summary>
|
||||
Camera,
|
||||
/// <summary>Stamp "up" = world up (projected onto the surface) - stable, does not rotate with the camera.</summary>
|
||||
World
|
||||
}
|
||||
|
||||
/// <summary>When false, mouse painting is ignored. Toggled by the GameModeManager.</summary>
|
||||
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<Texture2D> brushMasks = new List<Texture2D>();
|
||||
|
||||
[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 ---
|
||||
|
||||
/// <summary>Builds the built-in circle mask (white inscribed circle on black), used when no mask is assigned.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Picks the active mask (list entry or procedural circle) and refreshes the CPU luminance cache on change.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Bilinear sample of the resolved mask opacity at uv in [0,1]. Returns 1 (full) if no mask.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Raycasts at a screen position and paints a 3D sphere around the mesh hit point.</summary>
|
||||
/// <summary>Raycasts at a screen position and stamps the masked square brush onto the mesh hit point.</summary>
|
||||
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) ---
|
||||
|
||||
/// <summary>
|
||||
/// 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%).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user