diff --git a/Assets/Prefabs/Player.prefab b/Assets/Prefabs/Player.prefab
index 1231763..6f3736e 100644
--- a/Assets/Prefabs/Player.prefab
+++ b/Assets/Prefabs/Player.prefab
@@ -164,13 +164,13 @@ MonoBehaviour:
m_EditorClassIdentifier: Assembly-CSharp::PlayerPoseController
poses:
- {fileID: 11400000, guid: 3b59c882f27d0d04a82d2db251af1728, type: 2}
+ - {fileID: 11400000, guid: 404decea136467c46a0df60e01de00e4, type: 2}
- {fileID: 11400000, guid: 649ecccdabb7ef9438addcec6663b037, type: 2}
- {fileID: 11400000, guid: 90df501d86681394b834f647f6f4e939, type: 2}
- {fileID: 11400000, guid: 889c37c8c0c0b19438916869b9e34e3d, type: 2}
- {fileID: 11400000, guid: c5cf9d420554e5e4e96d3bee1903967a, type: 2}
- {fileID: 11400000, guid: 1545cfba9b5e06d41ae72c7ec766baaf, type: 2}
defaultPoseIndex: 0
- cycleKey: 17
poseBlendDuration: 0.2
cameraOffsetSpeed: 4
capsuleBlendSpeed: 8
@@ -227,7 +227,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
- GlobalObjectIdHash: 459555553
+ GlobalObjectIdHash: 1963377418
InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0
Ownership: 1
diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs
index 8885fe5..05b55c1 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs
@@ -122,10 +122,12 @@ public partial class MeshPainter
}
///
- /// Paints a square stamp in the surface tangent plane: rasterizes triangles in range
- /// and blends every texel whose 3D position lies inside the box (|du|,|dv|,|dn| <=
- /// radius) over each active map, weighted by the mask opacity. Mirrors the GPU brush
- /// path (mask value = per-texel opacity, gray = 50%).
+ /// Paints a stamp in the surface tangent plane: rasterizes triangles in range and blends
+ /// every texel whose 3D position lies inside the brush SPHERE (du²+dv²+dn² <= radius²) over
+ /// each active map, weighted by the mask opacity. The mask is still sampled along du/dv, so the
+ /// 2D mask shape is preserved - but the sphere reach (instead of a box) matches the original
+ /// isotropic brush, so a dab no longer bleeds across a UV seam / around a corner. Mirrors the
+ /// GPU brush path (mask value = per-texel opacity, gray = 50%).
///
private void PaintStamp3D(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
{
@@ -164,13 +166,14 @@ public partial class MeshPainter
CpuMap furm = _cpu[MAP_FUR];
float invR = 1f / radiusLocal;
+ float r2 = radiusLocal * radiusLocal;
int triCount = _tris.Length / 3;
float ts = textureSize;
for (int t = 0; t < triCount; t++)
{
- // Fast culling: stamp box (corner reach = sqrt(3)*radius) vs. the triangle's circumscribed circle.
- float reach = radiusLocal * SquareReach + _triRadius[t];
+ // Fast culling: brush sphere (reach = radius) vs. the triangle's circumscribed circle.
+ float reach = radiusLocal + _triRadius[t];
if ((_triCenter[t] - pLocal).sqrMagnitude > reach * reach) continue;
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
@@ -219,9 +222,9 @@ public partial class MeshPainter
float du = ddx * u.x + ddy * u.y + ddz * u.z;
float dv = ddx * v.x + ddy * v.y + ddz * v.z;
float dn = ddx * n.x + ddy * n.y + ddz * n.z;
- if (du < -radiusLocal || du > radiusLocal) continue;
- if (dv < -radiusLocal || dv > radiusLocal) continue;
- if (dn < -radiusLocal || dn > radiusLocal) continue;
+ // Brush SPHERE (not box): reach is r in every direction, so the dab can't
+ // bleed across a seam / around a corner. Also guarantees |du|,|dv| <= r -> mu,mv in [0,1].
+ if (du * du + dv * dv + dn * dn > r2) continue;
float mu = du * invR * 0.5f + 0.5f;
float mv = dv * invR * 0.5f + 0.5f;
diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs
index 36cd65f..cdfd977 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs
@@ -298,25 +298,6 @@ public partial class MeshPainter : MonoBehaviour
[Min(0f)]
public float paintInterval = 0f;
- [Header("Brush - corner guard")]
- [Tooltip("Suppresses a whole brush dab when too much of its footprint would wrap around a " +
- "corner onto an adjacent, steeply angled surface (the classic 'painted around the " +
- "corner' bleed, worst with large brushes). The dab is blocked entirely, not clipped: " +
- "you simply cannot paint at a spot that would smear around the corner - aim cleaner or " +
- "shrink the brush. OFF = paint anywhere (old behavior).")]
- public bool cornerGuard = true;
-
- [Tooltip("Surface under the brush whose normal deviates from the hit face by more than this " +
- "angle counts as 'around the corner'. Lower = stricter (gentle bends already block); " +
- "higher = only sharp corners count. 180 = effectively off.")]
- [Range(0f, 180f)]
- public float cornerGuardAngle = 50f;
-
- [Tooltip("Block the dab once this share of the brush footprint is around the corner. " +
- "0 = block at the slightest wrap, 1 = effectively off (only a fully wrapped dab blocks).")]
- [Range(0f, 1f)]
- public float cornerGuardMaxFraction = 0.25f;
-
[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 " +
@@ -450,7 +431,6 @@ public partial 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");
private static readonly int MainTexId = Shader.PropertyToID("_MainTex");
@@ -972,13 +952,7 @@ public partial class MeshPainter : MonoBehaviour
private void PaintAtScreen(Vector2 screenPos)
{
if (!TryComputeBrushPlacement(screenPos, out Vector3 pLocal, out float radiusLocal,
- out Vector3 u, out Vector3 v, out Vector3 nLocal, out int hitTri))
- return;
-
- // Corner guard: if too much of the footprint would wrap around a corner, suppress the
- // whole dab here - before drawing, emitting or marking the stroke painted, so nothing is
- // clipped and remotes never replay a dab the owner refused to paint.
- if (cornerGuard && DabWrapsAroundCorner(pLocal, radiusLocal, u, v, nLocal, hitTri))
+ out Vector3 u, out Vector3 v, out Vector3 nLocal, out int _))
return;
// Pick what this stamp paints with: a fresh random spray pattern, or the resolved
@@ -1034,85 +1008,6 @@ public partial class MeshPainter : MonoBehaviour
_paintParticles.Stop();
}
- ///
- /// True if a brush dab at this placement would wrap excessively around a corner: more than
- /// of its footprint sits on surface whose face normal
- /// deviates from the hit face by more than . Used to suppress
- /// the whole dab (not clip it). Geometry-only, so it behaves identically on the GPU and CPU
- /// backends and needs no texture readback.
- ///
- private bool DabWrapsAroundCorner(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n, int hitTri)
- {
- if (_verts == null || _tris == null || _triCenter == null || _triRadius == null) return false;
- if (hitTri < 0 || hitTri * 3 + 2 >= _tris.Length) return false;
-
- // Reference normal = the hit triangle's geometric face normal, built with the SAME
- // cross-product convention as every candidate below, so mesh winding cancels in the
- // dot comparison (we only judge relative deviation from the surface we actually hit).
- int h0 = _tris[hitTri * 3], h1 = _tris[hitTri * 3 + 1], h2 = _tris[hitTri * 3 + 2];
- Vector3 fnHit = Vector3.Cross(_verts[h1] - _verts[h0], _verts[h2] - _verts[h0]);
- if (fnHit.sqrMagnitude < 1e-12f) return false; // degenerate hit triangle -> allow
- fnHit.Normalize();
-
- float cosThresh = Mathf.Cos(Mathf.Clamp(cornerGuardAngle, 0f, 180f) * Mathf.Deg2Rad);
- float frac = CornerWrapFraction(_verts, _tris, _triCenter, _triRadius,
- pLocal, radiusLocal, u, v, n, fnHit, cosThresh);
- return frac > cornerGuardMaxFraction;
- }
-
- ///
- /// Estimates the share (0..1) of the brush footprint sitting on surface whose geometric face
- /// normal deviates from by more than the threshold (given as its
- /// cosine). Per in-range triangle (same circumscribed-circle cull as the painter), the area is
- /// weighted by how many of {v0,v1,v2,centroid} fall inside the brush box - a cheap, backend-
- /// agnostic footprint estimate (no GPU readback). Winding cancels because both normals use the
- /// same cross convention. Returns 0 when nothing is in range (caller then allows the dab).
- /// Static + array-parameterized so the geometry math is unit-testable without a live component.
- ///
- private static float CornerWrapFraction(
- Vector3[] verts, int[] tris, Vector3[] triCenter, float[] triRadius,
- Vector3 center, float r, Vector3 u, Vector3 v, Vector3 n, Vector3 fnHit, float cosThresh)
- {
- double totalW = 0.0, cornerW = 0.0;
- int triCount = tris.Length / 3;
- for (int t = 0; t < triCount; t++)
- {
- float reach = r * SquareReach + triRadius[t];
- if ((triCenter[t] - center).sqrMagnitude > reach * reach) continue;
-
- int i0 = tris[t * 3], i1 = tris[t * 3 + 1], i2 = tris[t * 3 + 2];
- Vector3 a = verts[i0], b = verts[i1], c = verts[i2];
- Vector3 ctr = (a + b + c) * (1f / 3f);
-
- int inside = (InBrushBox(a, center, u, v, n, r) ? 1 : 0)
- + (InBrushBox(b, center, u, v, n, r) ? 1 : 0)
- + (InBrushBox(c, center, u, v, n, r) ? 1 : 0)
- + (InBrushBox(ctr, center, u, v, n, r) ? 1 : 0);
- if (inside == 0) continue;
-
- Vector3 cross = Vector3.Cross(b - a, c - a);
- float area2 = cross.magnitude; // 2 x triangle area (relative weight is enough)
- if (area2 < 1e-12f) continue;
-
- double w = inside * 0.25 * area2;
- totalW += w;
-
- float aligned = Vector3.Dot(cross / area2, fnHit); // -1..1, winding-cancelled
- if (aligned < cosThresh) cornerW += w; // deviates past the threshold angle
- }
-
- return totalW > 1e-9 ? (float)(cornerW / totalW) : 0f;
- }
-
- /// True if a local-space point lies inside the brush box (same |du|,|dv|,|dn| <= r bound the stamp paints).
- private static bool InBrushBox(Vector3 p, Vector3 center, Vector3 u, Vector3 v, Vector3 n, float r)
- {
- Vector3 d = p - center;
- return Mathf.Abs(Vector3.Dot(d, u)) <= r
- && Mathf.Abs(Vector3.Dot(d, v)) <= r
- && Mathf.Abs(Vector3.Dot(d, n)) <= r;
- }
-
///
/// Raycasts at a screen position and returns the nearest hit that belongs to THIS paint
/// mesh. A plain raycast would return only the closest collider, so anything in front of
@@ -1178,7 +1073,8 @@ public partial class MeshPainter : MonoBehaviour
// Brush radius (texels) -> constant local 3D radius via the MESH-WIDE average UV density -
// deliberately not the hit triangle's own (that varied up to ~7.6x at seams/stretched islands,
- // painting visibly different sizes). The stamp stays a 3D box, so seams are bridged as before.
+ // painting visibly different sizes). The stamp is a 3D sphere, so seams are bridged (both sides
+ // of a shared edge) without bleeding past r onto adjacent surfaces.
radiusLocal = (brushSize / (float)textureSize) * _avgUvToLocal;
if (radiusLocal <= 0f) return false;
diff --git a/Assets/Shaders/MeshPaintBrush.shader b/Assets/Shaders/MeshPaintBrush.shader
index 348f379..2e18365 100644
--- a/Assets/Shaders/MeshPaintBrush.shader
+++ b/Assets/Shaders/MeshPaintBrush.shader
@@ -5,8 +5,9 @@
// passed through to the fragment shader. There, per texel, the offset to the brush
// center (_BrushCenter, in local mesh space) is projected onto the stamp's tangent
// 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:
+// if it lies inside the brush SPHERE (du²+dv²+dn² <= _BrushRadius²) - isotropic reach
+// like the original circle brush, so the dab never bleeds across a seam / around a
+// corner. The mask (_MaskTex, sampled along du/dv) then provides the per-texel OPACITY:
// 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.
@@ -86,13 +87,11 @@ Shader "Hidden/MeshPaintBrush"
float dv = dot(d, _BrushV);
float dn = dot(d, _BrushNormal);
- // Square stamp in the tangent plane + depth bound (keeps the seam-
- // bridging 3D behavior while preventing bleed to far surfaces).
- clip(_BrushRadius - abs(du));
- clip(_BrushRadius - abs(dv));
- clip(_BrushRadius - abs(dn));
+ // Brush sphere: isotropic reach r in every direction, so the dab bridges seams
+ // (both sides of a 3D edge) yet never bleeds onto far/adjacent surfaces past r.
+ clip(_BrushRadius * _BrushRadius - (du * du + dv * dv + dn * dn));
- // Mask -> opacity. du/dv in [-r,r] -> uv in [0,1].
+ // Mask -> opacity. The sphere guarantees |du|,|dv| <= r -> uv in [0,1].
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));
diff --git a/Assets/Shaders/MeshPaintPreview.shader b/Assets/Shaders/MeshPaintPreview.shader
index 18f96e4..d1d64d1 100644
--- a/Assets/Shaders/MeshPaintPreview.shader
+++ b/Assets/Shaders/MeshPaintPreview.shader
@@ -2,7 +2,7 @@
// 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
+// fragment runs the SAME sphere + 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
@@ -74,14 +74,12 @@ Shader "Hidden/MeshPaintPreview"
float2 muv;
if (_PreviewMode < 0.5)
{
- // On-surface: same square box test as the paint shader, in local space.
+ // On-surface: same brush-sphere 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));
+ clip(_BrushRadius * _BrushRadius - (du * du + dv * dv + dn * dn));
muv = float2(du, dv) / _BrushRadius * 0.5 + 0.5;
}
else