diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs
index 1a0be0d..f8a0813 100644
--- a/Assets/Scripts/MeshPainter.cs
+++ b/Assets/Scripts/MeshPainter.cs
@@ -17,6 +17,10 @@ using UnityEngine.InputSystem;
/// 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.
+///
+/// A non-destructive brush preview (size + mask) is shown at the hover point without
+/// painting (): either a flat decal quad or a real on-surface
+/// overlay that wraps the mesh exactly like the paint would land.
///
[DisallowMultipleComponent]
public class MeshPainter : MonoBehaviour
@@ -30,6 +34,17 @@ public class MeshPainter : MonoBehaviour
World
}
+ /// How the brush preview at the hover point is rendered.
+ public enum BrushPreviewMode
+ {
+ /// No preview.
+ Off,
+ /// A flat quad in the tangent plane (cheap, does not follow curvature).
+ FlatDecal,
+ /// Real on-surface overlay: wraps the mesh exactly like the paint would land.
+ OnSurface
+ }
+
/// When false, mouse painting is ignored. Toggled by the GameModeManager.
public bool PaintingEnabled { get; set; } = true;
@@ -38,7 +53,8 @@ public class MeshPainter : MonoBehaviour
public bool useGpu = true;
[Header("Brush")]
- [Tooltip("Color used for painting.")]
+ [Tooltip("Color used for painting. The alpha channel is the global opacity multiplier on " +
+ "top of the mask (alpha 1 = mask opacity as-is, lower = fainter).")]
public Color brushColor = Color.red;
[Tooltip("Brush half-extent in texels (= half the square side). Converted into a 3D size per hit.")]
@@ -60,9 +76,20 @@ public class MeshPainter : MonoBehaviour
[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;
+ [Tooltip("ON = paint once on mouse-down, then only while the mouse moves (a held but " +
+ "stationary button no longer keeps re-stamping the same spot). OFF = continuous painting while held.")]
+ public bool paintOnMoveOnly = false;
+
+ [Header("Preview")]
+ [Tooltip("Brush preview at the hover point (shows size + mask without painting). " +
+ "Flat Decal = a flat quad in the tangent plane (cheap). On Surface = a real " +
+ "overlay that wraps the mesh exactly like the paint would land. Off = no preview.")]
+ public BrushPreviewMode previewMode = BrushPreviewMode.OnSurface;
+
+ [Tooltip("Opacity of the preview overlay (the mask still modulates it per texel). " +
+ "Can exceed 1 to make the preview show up more strongly.")]
+ [Range(0f, 4f)]
+ public float previewOpacity = 0.5f;
[Header("Canvas")]
[Tooltip("Resolution of the paint texture (square).")]
@@ -117,6 +144,11 @@ public class MeshPainter : MonoBehaviour
public Shader dilateShader; // optional; else Shader.Find("Hidden/MeshPaintDilate")
private Material _brushMat;
private Material _dilateMat;
+
+ // --- preview overlay ---
+ public Shader previewShader; // optional; else Shader.Find("Hidden/MeshPaintPreview")
+ private Material _previewMat;
+ private Mesh _previewQuad; // unit quad in the tangent plane (flat decal mode)
private RenderTexture _displayRT;
private RenderTexture _tempRT; // scratch for the dilation blit (ping-pong)
private bool _drewThisStroke;
@@ -132,6 +164,7 @@ public class MeshPainter : MonoBehaviour
private const float ScreenStepPixels = 5f; // interpolation step along mouse movement
private const int MaxStepsPerFrame = 256; // raycast safeguard for fast movement
+ private const float MoveOnlyMinPixels = 0.75f; // move-only mode: min mouse travel before re-stamping
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");
@@ -145,8 +178,9 @@ public class MeshPainter : MonoBehaviour
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 static readonly int PreviewOpacityId = Shader.PropertyToID("_PreviewOpacity");
+ private static readonly int PreviewModeId = Shader.PropertyToID("_PreviewMode");
private void Start()
{
@@ -183,6 +217,7 @@ public class MeshPainter : MonoBehaviour
EnsureProceduralMask();
ResolveActiveMask();
+ SetupPreview();
_setupValid = true;
@@ -616,6 +651,94 @@ public class MeshPainter : MonoBehaviour
return t.normalized;
}
+ // --- Brush preview ---
+
+ /// Creates the preview material and the unit quad used by the flat-decal mode.
+ private void SetupPreview()
+ {
+ if (!previewShader) previewShader = Shader.Find("Hidden/MeshPaintPreview");
+ if (!previewShader)
+ {
+ Debug.LogWarning("[MeshPainter] Preview shader (Hidden/MeshPaintPreview) not found - brush preview disabled.", this);
+ return;
+ }
+ _previewMat = new Material(previewShader) { hideFlags = HideFlags.HideAndDontSave };
+ _previewQuad = BuildPreviewQuad();
+ }
+
+ /// Unit quad in its XY plane ([-1,1], normal +Z, uv 0..1) - oriented per hit in the flat-decal mode.
+ private static Mesh BuildPreviewQuad()
+ {
+ var q = new Mesh { name = "MeshPainter_PreviewQuad", hideFlags = HideFlags.HideAndDontSave };
+ q.vertices = new[]
+ {
+ new Vector3(-1f, -1f, 0f), new Vector3(1f, -1f, 0f),
+ new Vector3(1f, 1f, 0f), new Vector3(-1f, 1f, 0f)
+ };
+ q.uv = new[]
+ {
+ new Vector2(0f, 0f), new Vector2(1f, 0f),
+ new Vector2(1f, 1f), new Vector2(0f, 1f)
+ };
+ q.triangles = new[] { 0, 1, 2, 0, 2, 3 };
+ q.RecalculateBounds();
+ return q;
+ }
+
+ ///
+ /// Draws the brush preview at the hover point for this frame (no painting). On-surface
+ /// mode re-draws the baked mesh and clips it to the brush box in the shader; flat-decal
+ /// mode draws the oriented quad. Both sample the active mask (size + shape preview).
+ ///
+ private void UpdatePreview(Vector2 screenPos)
+ {
+ if (!TryComputeBrushPlacement(screenPos, out Vector3 pLocal, out float radiusLocal,
+ out Vector3 u, out Vector3 v, out Vector3 n))
+ return;
+
+ _previewMat.SetColor(BrushColorId, brushColor);
+ _previewMat.SetTexture(MaskTexId, _resolvedMask);
+ _previewMat.SetFloat(PreviewOpacityId, Mathf.Max(0f, previewOpacity) * Mathf.Clamp01(brushColor.a));
+
+ int layer = gameObject.layer;
+
+ if (previewMode == BrushPreviewMode.OnSurface)
+ {
+ _previewMat.SetFloat(PreviewModeId, 0f);
+ _previewMat.SetVector(BrushCenterId, pLocal);
+ _previewMat.SetFloat(BrushRadiusId, radiusLocal);
+ _previewMat.SetVector(BrushUId, u);
+ _previewMat.SetVector(BrushVId, v);
+ _previewMat.SetVector(BrushNormalId, n);
+
+ // Draw the collider mesh again (object transform): the shader clips it to the
+ // brush box, so only the footprint shows, wrapped onto the real surface. Using
+ // the collider mesh keeps it in the same local space as the raycast hit and
+ // valid for both skinned (baked) and static meshes.
+ Mesh previewMesh = _collider ? _collider.sharedMesh : null;
+ if (!previewMesh) return;
+ Matrix4x4 m = transform.localToWorldMatrix;
+ int sub = previewMesh.subMeshCount;
+ for (int i = 0; i < sub; i++)
+ Graphics.DrawMesh(previewMesh, m, _previewMat, layer, paintCamera, i);
+ }
+ else // FlatDecal
+ {
+ _previewMat.SetFloat(PreviewModeId, 1f);
+
+ // Quad frame in local space: x -> u*r, y -> v*r, lifted slightly along n so
+ // it floats on the surface; uv 0..1 matches the on-surface mask mapping.
+ Vector3 pos = pLocal + n * (radiusLocal * 0.02f);
+ var frame = new Matrix4x4();
+ frame.SetColumn(0, new Vector4(u.x * radiusLocal, u.y * radiusLocal, u.z * radiusLocal, 0f));
+ frame.SetColumn(1, new Vector4(v.x * radiusLocal, v.y * radiusLocal, v.z * radiusLocal, 0f));
+ frame.SetColumn(2, new Vector4(n.x, n.y, n.z, 0f));
+ frame.SetColumn(3, new Vector4(pos.x, pos.y, pos.z, 1f));
+
+ Graphics.DrawMesh(_previewQuad, transform.localToWorldMatrix * frame, _previewMat, layer, paintCamera, 0);
+ }
+ }
+
// --- Update / input ---
private void Update()
@@ -642,6 +765,11 @@ public class MeshPainter : MonoBehaviour
if (!TryGetMouse(out bool pressed, out Vector2 screenPos))
return;
+ // Brush preview (size + mask) at the hover point - independent of painting,
+ // so you see the brush before pressing the button.
+ if (previewMode != BrushPreviewMode.Off && _previewMat && paintCamera)
+ UpdatePreview(screenPos);
+
if (!pressed)
{
_hasLastScreen = false;
@@ -650,28 +778,36 @@ public class MeshPainter : MonoBehaviour
if (!paintCamera) return;
- if (_gpuActive) BeginGpuStroke();
- else { _dx0 = textureSize; _dy0 = textureSize; _dx1 = -1; _dy1 = -1; }
+ float moveDist = _hasLastScreen ? Vector2.Distance(_lastScreen, screenPos) : 0f;
- if (_hasLastScreen)
- {
- // Interpolate along the mouse movement and re-raycast at each step so the
- // stroke follows the mesh surface.
- float dist = Vector2.Distance(_lastScreen, screenPos);
- int steps = Mathf.Clamp(Mathf.CeilToInt(dist / ScreenStepPixels), 1, MaxStepsPerFrame);
- for (int s = 1; s <= steps; s++)
- PaintAtScreen(Vector2.Lerp(_lastScreen, screenPos, s / (float)steps));
- }
- else
- {
- PaintAtScreen(screenPos);
- }
+ // Move-only mode: stamp once on mouse-down, then only when the mouse actually
+ // moves - a held but stationary button no longer re-stamps the same spot.
+ bool stamp = !_hasLastScreen || !paintOnMoveOnly || moveDist > MoveOnlyMinPixels;
- if (_gpuActive) EndGpuStroke();
- else
+ if (stamp)
{
- if (paddingTexels > 0) DilatePadding(paddingTexels);
- FlushDirty();
+ if (_gpuActive) BeginGpuStroke();
+ else { _dx0 = textureSize; _dy0 = textureSize; _dx1 = -1; _dy1 = -1; }
+
+ if (_hasLastScreen)
+ {
+ // Interpolate along the mouse movement and re-raycast at each step so the
+ // stroke follows the mesh surface.
+ int steps = Mathf.Clamp(Mathf.CeilToInt(moveDist / ScreenStepPixels), 1, MaxStepsPerFrame);
+ for (int s = 1; s <= steps; s++)
+ PaintAtScreen(Vector2.Lerp(_lastScreen, screenPos, s / (float)steps));
+ }
+ else
+ {
+ PaintAtScreen(screenPos);
+ }
+
+ if (_gpuActive) EndGpuStroke();
+ else
+ {
+ if (paddingTexels > 0) DilatePadding(paddingTexels);
+ FlushDirty();
+ }
}
_lastScreen = screenPos;
@@ -681,6 +817,24 @@ public class MeshPainter : MonoBehaviour
/// Raycasts at a screen position and stamps the masked square brush onto the mesh hit point.
private void PaintAtScreen(Vector2 screenPos)
{
+ if (!TryComputeBrushPlacement(screenPos, out Vector3 pLocal, out float radiusLocal,
+ out Vector3 u, out Vector3 v, out Vector3 nLocal))
+ return;
+
+ if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, nLocal);
+ else PaintStamp3D(pLocal, radiusLocal, u, v, nLocal);
+ }
+
+ ///
+ /// Raycasts at a screen position against the paint mesh and computes the brush
+ /// placement: hit point (local space), local 3D radius (constant visual size via UV
+ /// density) and the orthonormal tangent frame (u/v/n). Shared by painting and preview.
+ ///
+ private bool TryComputeBrushPlacement(Vector2 screenPos, out Vector3 pLocal, out float radiusLocal,
+ out Vector3 u, out Vector3 v, out Vector3 nLocal)
+ {
+ pLocal = default; radiusLocal = 0f; u = default; v = default; nLocal = default;
+
Ray ray = paintCamera.ScreenPointToRay(screenPos);
// Pick the nearest hit that belongs to the paint mesh. A plain raycast would
@@ -700,10 +854,10 @@ public class MeshPainter : MonoBehaviour
found = true;
}
}
- if (!found) return;
- if (_verts == null || hit.triangleIndex < 0) return;
+ if (!found) return false;
+ if (_verts == null || hit.triangleIndex < 0) return false;
- Vector3 pLocal = transform.InverseTransformPoint(hit.point);
+ pLocal = transform.InverseTransformPoint(hit.point);
// Convert the brush radius (texels) into a local 3D radius using the hit
// triangle's UV density so the brush keeps a constant visual size.
@@ -721,16 +875,14 @@ public class MeshPainter : MonoBehaviour
}
}
- float radiusLocal = (brushSize / (float)textureSize) * scale;
- if (radiusLocal <= 0f) return;
+ radiusLocal = (brushSize / (float)textureSize) * scale;
+ if (radiusLocal <= 0f) return false;
// Tangent-plane frame for the square stamp / mask projection.
- Vector3 nLocal = transform.InverseTransformDirection(hit.normal);
- ComputeBrushFrame(nLocal, out Vector3 u, out Vector3 v);
+ nLocal = transform.InverseTransformDirection(hit.normal);
+ ComputeBrushFrame(nLocal, out u, out v);
nLocal = nLocal.normalized;
-
- if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, nLocal);
- else PaintStamp3D(pLocal, radiusLocal, u, v, nLocal);
+ return true;
}
// --- GPU painting ---
@@ -743,7 +895,6 @@ public class MeshPainter : MonoBehaviour
Graphics.SetRenderTarget(_displayRT);
_brushMat.SetColor(BrushColorId, brushColor);
_brushMat.SetTexture(MaskTexId, _resolvedMask);
- _brushMat.SetFloat(BrushOpacityId, Mathf.Clamp01(brushOpacity));
}
private void DrawBrushGpu(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
@@ -790,7 +941,7 @@ public class MeshPainter : MonoBehaviour
private void PaintStamp3D(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
{
float br = brushColor.r * 255f, bg = brushColor.g * 255f, bb = brushColor.b * 255f;
- float globalOpacity = Mathf.Clamp01(brushOpacity);
+ float globalOpacity = Mathf.Clamp01(brushColor.a);
float invR = 1f / radiusLocal;
int triCount = _tris.Length / 3;
float ts = textureSize;
@@ -995,6 +1146,8 @@ public class MeshPainter : MonoBehaviour
if (_bakedMesh) Destroy(_bakedMesh);
if (_materialInstance) Destroy(_materialInstance);
if (_proceduralMask) Destroy(_proceduralMask);
+ if (_previewMat) Destroy(_previewMat);
+ if (_previewQuad) Destroy(_previewQuad);
TeardownCpu();
TeardownGpu();
}
diff --git a/Assets/Shaders/MeshPaintBrush.shader b/Assets/Shaders/MeshPaintBrush.shader
index 06855e9..7b94d70 100644
--- a/Assets/Shaders/MeshPaintBrush.shader
+++ b/Assets/Shaders/MeshPaintBrush.shader
@@ -7,7 +7,7 @@
// 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
+// opacity = luma(mask.rgb) * mask.a * _BrushColor.a
// 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.
//
@@ -44,7 +44,6 @@ Shader "Hidden/MeshPaintBrush"
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
@@ -91,7 +90,7 @@ Shader "Hidden/MeshPaintBrush"
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;
+ float opacity = luma * m.a * _BrushColor.a;
clip(opacity - 1e-4); // skip fully transparent texels
return fixed4(_BrushColor.rgb, opacity);
diff --git a/Assets/Shaders/MeshPaintPreview.shader b/Assets/Shaders/MeshPaintPreview.shader
new file mode 100644
index 0000000..18f96e4
--- /dev/null
+++ b/Assets/Shaders/MeshPaintPreview.shader
@@ -0,0 +1,103 @@
+// Brush preview overlay: shows the brush footprint (size + mask) on the mesh
+// WITHOUT painting. Two modes, selected via _PreviewMode:
+//
+// 0 = On-surface: the baked mesh is drawn again (object transform) and each
+// fragment runs the SAME box + mask test as the paint shader, in local mesh
+// space (_BrushCenter/_BrushU/_BrushV/_BrushNormal/_BrushRadius). So the
+// preview wraps over curvature and across UV seams exactly like the paint
+// would land. Depth test (LEqual) + a slight polygon offset keep it glued to
+// the surface and correctly occluded by closer geometry.
+//
+// 1 = Flat decal quad: a single quad oriented in the tangent plane; the mask is
+// sampled straight from the quad UVs (0..1 already spans the stamp, so it
+// lines up 1:1 with the on-surface mapping). Flat - does not follow curvature.
+//
+// Unlit, transparent (SRPDefaultUnlit) so URP renders it via Graphics.DrawMesh. The
+// mask drives the alpha (luma * alpha * _PreviewOpacity), tinted with _BrushColor.
+Shader "Hidden/MeshPaintPreview"
+{
+ Properties
+ {
+ _BrushColor ("Brush Color", Color) = (1,0,0,1)
+ _MaskTex ("Brush Mask", 2D) = "white" {}
+ }
+ SubShader
+ {
+ Tags { "RenderType" = "Transparent" "Queue" = "Transparent" "IgnoreProjector" = "True" }
+ Pass
+ {
+ ZWrite Off
+ ZTest LEqual
+ Cull Off
+ Offset -1, -1 // pull the overlay toward the camera so it wins over the painted surface
+ Blend SrcAlpha OneMinusSrcAlpha
+
+ CGPROGRAM
+ #pragma vertex vert
+ #pragma fragment frag
+ #include "UnityCG.cginc"
+
+ float4 _BrushColor;
+ float3 _BrushCenter; // brush center in local mesh space (on-surface mode)
+ 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 _PreviewOpacity; // overall preview alpha multiplier (>1 saturates to fully opaque)
+ float _PreviewMode; // 0 = on-surface (mesh), 1 = flat decal quad
+ sampler2D _MaskTex;
+
+ struct appdata
+ {
+ float4 vertex : POSITION;
+ float2 uv : TEXCOORD0;
+ };
+
+ struct v2f
+ {
+ float4 pos : SV_POSITION;
+ float3 local : TEXCOORD0; // object-space position (on-surface box test)
+ float2 uv : TEXCOORD1; // quad uv (decal sampling)
+ };
+
+ v2f vert (appdata v)
+ {
+ v2f o;
+ o.pos = UnityObjectToClipPos(v.vertex);
+ o.local = v.vertex.xyz;
+ o.uv = v.uv;
+ return o;
+ }
+
+ fixed4 frag (v2f i) : SV_Target
+ {
+ float2 muv;
+ if (_PreviewMode < 0.5)
+ {
+ // On-surface: same square box test as the paint shader, in local space.
+ float3 d = i.local - _BrushCenter;
+ float du = dot(d, _BrushU);
+ float dv = dot(d, _BrushV);
+ float dn = dot(d, _BrushNormal);
+ clip(_BrushRadius - abs(du));
+ clip(_BrushRadius - abs(dv));
+ clip(_BrushRadius - abs(dn));
+ muv = float2(du, dv) / _BrushRadius * 0.5 + 0.5;
+ }
+ else
+ {
+ // Flat decal quad: uv already spans the stamp.
+ muv = i.uv;
+ }
+
+ fixed4 m = tex2D(_MaskTex, muv);
+ float luma = dot(m.rgb, fixed3(0.299, 0.587, 0.114));
+ float opacity = luma * m.a * _PreviewOpacity;
+ clip(opacity - 1e-4); // skip fully transparent texels
+ return fixed4(_BrushColor.rgb, opacity);
+ }
+ ENDCG
+ }
+ }
+ Fallback Off
+}
diff --git a/Assets/Shaders/MeshPaintPreview.shader.meta b/Assets/Shaders/MeshPaintPreview.shader.meta
new file mode 100644
index 0000000..432c4e9
--- /dev/null
+++ b/Assets/Shaders/MeshPaintPreview.shader.meta
@@ -0,0 +1,9 @@
+fileFormatVersion: 2
+guid: 8584f72f2fa12c1489898d805b793315
+ShaderImporter:
+ externalObjects: {}
+ defaultTextures: []
+ nonModifiableTextures: []
+ userData:
+ assetBundleName:
+ assetBundleVariant: