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:
1 parent
c67d519f68
commit
d5fc5ee804
4 files changed
+303
-39
No files matched your search
+189
-36
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user