Add brush preview and drive paint opacity from brush color alpha

- Add hover preview (MeshPaintPreview.shader) with a Preview Mode enum
  (Off / Flat Decal / On Surface) sharing the paint placement path so
  size, mask, alignment and rotation match the actual stroke.
- Preview Opacity now ranges up to 4.0 so faint/grey masks can be shown
  more strongly (saturates to full opacity before the hardware blend).
- Add Paint On Move Only: stamp once on mouse-down, then only while the
  cursor moves (GPU and CPU paths alike).
- Remove brushOpacity; brushColor.a is now the global opacity multiplier
  over the mask in both backends (the alpha channel was previously unused
  by the brush shader).
This commit is contained in:
admin committed 2026-06-23 13:58:39 +02:00
1 parent c67d519f68
commit d5fc5ee804
4 files changed
+303 -39

No files matched your search

+189 -36
View File
@@ -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 (<see cref="previewMode"/>): either a flat decal quad or a real on-surface
/// overlay that wraps the mesh exactly like the paint would land.
/// </summary>
[DisallowMultipleComponent]
public class MeshPainter : MonoBehaviour
@@ -30,6 +34,17 @@ public class MeshPainter : MonoBehaviour
World
}
/// <summary>How the brush preview at the hover point is rendered.</summary>
public enum BrushPreviewMode
{
/// <summary>No preview.</summary>
Off,
/// <summary>A flat quad in the tangent plane (cheap, does not follow curvature).</summary>
FlatDecal,
/// <summary>Real on-surface overlay: wraps the mesh exactly like the paint would land.</summary>
OnSurface
}
/// <summary>When false, mouse painting is ignored. Toggled by the GameModeManager.</summary>
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 ---
/// <summary>Creates the preview material and the unit quad used by the flat-decal mode.</summary>
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();
}
/// <summary>Unit quad in its XY plane ([-1,1], normal +Z, uv 0..1) - oriented per hit in the flat-decal mode.</summary>
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;
}
/// <summary>
/// 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).
/// </summary>
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
/// <summary>Raycasts at a screen position and stamps the masked square brush onto the mesh hit point.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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();
}
+2 -3
View File
@@ -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);
+103
View File
@@ -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
}
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 8584f72f2fa12c1489898d805b793315
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant: