Add spray brush, paint interval, and per-channel paint opacity

Spray brush mode: bakes a procedural scattered-dot mask per stamp (random
position, size, alpha within the brush disc) and paints it through the
existing single-stamp pipeline; dot centers clamped to (0.5 - dotR) so the
spray footprint matches the solid brush radius.

Paint interval: float on MeshPainter; 0 keeps the continuous interpolated
stroke, >0 emits a single timed dab every x.xx seconds.

Color picker alpha: horizontal alpha bar on both HSV pickers, runtime-built
in Rebind (no prefab re-bake), passed through to brushColor.a / emissionColor.a.

Metallic/smoothness + occlusion paint opacity: new opacity fields applied in
both CPU and GPU backends (GPU MSO draw split into MS and occlusion sub-stamps
for independent opacity), with cloned alpha sliders wired into the HUD.

Also includes the prefab layout tweaks and scene adjustments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-24 19:07:28 +02:00
1 parent af3aa34d66
commit b07d9a22d2
9 files changed
+1227 -86

No files matched your search

+66 -16
View File
@@ -85,12 +85,24 @@ public partial class MeshPainter : MonoBehaviour
[Range(0f, 1f)]
public float smoothness = 0.5f;
[Tooltip("Paint opacity for metallic + smoothness on top of the mask (1 = mask opacity " +
"as-is, lower = blends the metallic/smoothness target in more faintly, 0 = no-op). " +
"The albedo equivalent of brushColor.a, but for the metallic/smoothness channels.")]
[Range(0f, 1f)]
public float metallicSmoothnessOpacity = 1f;
[Tooltip("Ambient occlusion painted into the mask map (1 = fully lit / no darkening, " +
"0 = fully occluded / dark). NOTE: URP only darkens INDIRECT light (ambient + " +
"reflections), so the effect is subtle under strong direct lighting. 1 = no-op.")]
[Range(0f, 1f)]
public float occlusion = 0.5f;
[Tooltip("Paint opacity for occlusion on top of the mask (1 = mask opacity as-is, lower = " +
"blends the occlusion target in more faintly, 0 = no-op). The albedo equivalent of " +
"brushColor.a, but for the occlusion channel.")]
[Range(0f, 1f)]
public float occlusionOpacity = 1f;
[Header("Brush - emission")]
[Tooltip("Emission color painted into the map. Alpha = global opacity multiplier on top of the mask.")]
public Color emissionColor = Color.black;
@@ -123,6 +135,13 @@ public partial class MeshPainter : MonoBehaviour
"stationary button no longer keeps re-stamping the same spot). OFF = continuous painting while held.")]
public bool paintOnMoveOnly = false;
[Tooltip("Seconds between paint stamps while the button is held. 0 = paint every frame as " +
"an interpolated, continuous stroke (as before). Above 0 = deposit one stamp at the " +
"cursor every this many seconds (timed dabs, no movement interpolation - pairs well " +
"with the spray can).")]
[Min(0f)]
public float paintInterval = 0f;
[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 " +
@@ -188,6 +207,7 @@ public partial class MeshPainter : MonoBehaviour
private bool _hasLastScreen;
private Vector2 _lastScreen;
private float _intervalTimer; // accumulates while held; gates timed-dab stamping (paintInterval > 0)
private bool _gpuActive;
private bool _setupValid;
@@ -385,24 +405,24 @@ public partial class MeshPainter : MonoBehaviour
return px;
}
/// <summary>Bilinear sample of the resolved mask opacity at uv in [0,1]. Returns 1 (full) if no mask.</summary>
/// <summary>Bilinear sample of the active stamp mask opacity at uv in [0,1]. Returns 1 (full) if no mask.</summary>
private float SampleMaskOpacity(float u, float v)
{
if (_maskOpacity == null || _maskW <= 0 || _maskH <= 0) return 1f;
if (_stampOpacity == null || _stampW <= 0 || _stampH <= 0) return 1f;
float fx = Mathf.Clamp01(u) * (_maskW - 1);
float fy = Mathf.Clamp01(v) * (_maskH - 1);
float fx = Mathf.Clamp01(u) * (_stampW - 1);
float fy = Mathf.Clamp01(v) * (_stampH - 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);
int x1 = Mathf.Min(x0 + 1, _stampW - 1);
int y1 = Mathf.Min(y0 + 1, _stampH - 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 l00 = _stampOpacity[y0 * _stampW + x0];
float l10 = _stampOpacity[y0 * _stampW + x1];
float l01 = _stampOpacity[y1 * _stampW + x0];
float l11 = _stampOpacity[y1 * _stampW + x1];
float a = Mathf.Lerp(l00, l10, tx);
float b = Mathf.Lerp(l01, l11, tx);
@@ -509,7 +529,9 @@ public partial class MeshPainter : MonoBehaviour
return;
_previewMat.SetColor(BrushColorId, brushColor);
_previewMat.SetTexture(MaskTexId, _resolvedMask);
// Spray paints scattered dots, but the preview shows the round footprint (the area
// the dots land in), so the hover ring stays a stable, readable size indicator.
_previewMat.SetTexture(MaskTexId, spray ? _proceduralMask : _resolvedMask);
_previewMat.SetFloat(PreviewOpacityId, Mathf.Max(0f, previewOpacity) * Mathf.Clamp01(brushColor.a));
int layer = gameObject.layer;
@@ -598,18 +620,39 @@ public partial class MeshPainter : MonoBehaviour
if (!paintCamera) return;
bool anyChannel = AnyChannelActive(); // includes occlusion (matches IsChannelActive / PaintStamp3D)
float moveDist = _hasLastScreen ? Vector2.Distance(_lastScreen, screenPos) : 0f;
bool firstPress = !_hasLastScreen;
float moveDist = firstPress ? 0f : Vector2.Distance(_lastScreen, 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 = anyChannel && (!_hasLastScreen || !paintOnMoveOnly || moveDist > MoveOnlyMinPixels);
bool stamp;
if (paintInterval > 0f)
{
// Timed-dab mode: one stamp on press, then one at the cursor every paintInterval
// seconds while held (discrete dabs, no movement interpolation).
if (firstPress)
{
_intervalTimer = 0f;
stamp = anyChannel;
}
else
{
_intervalTimer += Time.deltaTime;
stamp = anyChannel && _intervalTimer >= paintInterval;
if (stamp) _intervalTimer = 0f;
}
}
else
{
// Continuous mode: stamp every frame as an interpolated stroke. Move-only optionally
// gates it - a held but stationary button no longer re-stamps the same spot.
stamp = anyChannel && (firstPress || !paintOnMoveOnly || moveDist > MoveOnlyMinPixels);
}
if (stamp)
{
if (_gpuActive) BeginGpuStroke();
else ResetCpuDirty();
if (_hasLastScreen)
if (!firstPress && paintInterval <= 0f)
{
// Interpolate along the mouse movement and re-raycast at each step so the
// stroke follows the mesh surface.
@@ -619,6 +662,7 @@ public partial class MeshPainter : MonoBehaviour
}
else
{
// First stamp of a stroke, or a timed dab: a single discrete stamp at the cursor.
PaintAtScreen(screenPos);
}
@@ -637,6 +681,11 @@ public partial class MeshPainter : MonoBehaviour
out Vector3 u, out Vector3 v, out Vector3 nLocal))
return;
// Pick what this stamp paints with: a fresh random spray pattern, or the resolved
// standard mask. Both backends read it through the _stamp* fields.
if (spray) BuildSprayStampMask();
else UseResolvedStampMask();
if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, nLocal);
else PaintStamp3D(pLocal, radiusLocal, u, v, nLocal);
}
@@ -761,6 +810,7 @@ public partial class MeshPainter : MonoBehaviour
if (_proceduralMask) Destroy(_proceduralMask);
if (_previewMat) Destroy(_previewMat);
if (_previewQuad) Destroy(_previewQuad);
TeardownSpray();
TeardownCpu();
TeardownGpu();
}