From d5fc5ee8042b5a6381f1d514e5526b845852f5ed Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Tue, 23 Jun 2026 13:58:39 +0200 Subject: [PATCH] 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). --- Assets/Scripts/MeshPainter.cs | 225 ++++++++++++++++---- Assets/Shaders/MeshPaintBrush.shader | 5 +- Assets/Shaders/MeshPaintPreview.shader | 103 +++++++++ Assets/Shaders/MeshPaintPreview.shader.meta | 9 + 4 files changed, 303 insertions(+), 39 deletions(-) create mode 100644 Assets/Shaders/MeshPaintPreview.shader create mode 100644 Assets/Shaders/MeshPaintPreview.shader.meta 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: