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