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; }