diff --git a/Assets/Prefabs/Player.prefab b/Assets/Prefabs/Player.prefab index 6f3736e..11b5f68 100644 --- a/Assets/Prefabs/Player.prefab +++ b/Assets/Prefabs/Player.prefab @@ -510,6 +510,7 @@ MonoBehaviour: furLength: 0.5 brushSize: 25 brushSizeScrollFactor: 0.15 + brushMaxSurfaceAngle: 135 brushMasks: - {fileID: 2800000, guid: d07c01c40cc7be54da0ff3ae52484cd2, type: 3} - {fileID: 2800000, guid: ccc205272b051834eb39593f647c7bf1, type: 3} @@ -520,9 +521,6 @@ MonoBehaviour: brushRotation: 0 paintOnMoveOnly: 1 paintInterval: 0 - cornerGuard: 1 - cornerGuardAngle: 70 - cornerGuardMaxFraction: 0.7 previewMode: 2 previewOpacity: 1 paintParticlesPrefab: {fileID: 723043959019929999, guid: 129e63016225faa4cb3c3ec86a7f66e5, type: 3} diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs index 05b55c1..25a6b8d 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs @@ -167,6 +167,11 @@ public partial class MeshPainter float invR = 1f / radiusLocal; float r2 = radiusLocal * radiusLocal; + // Back-face rejection: skip texels whose surface normal faces away from the clicked + // surface (the opposite side of thin geometry faces ~180deg away). cos(maxAngle); at + // 180deg it is -1 -> test disabled. n is unit, so dot(normalize(texelN), n) >= cutoff. + float nCutoff = BrushNormalCutoff; + bool doNormalTest = _normals != null && nCutoff > -0.9999f; int triCount = _tris.Length / 3; float ts = textureSize; @@ -226,6 +231,18 @@ public partial class MeshPainter // 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; + // Reject the opposite side of thin geometry (interpolated texel normal vs. brush normal). + if (doNormalTest) + { + Vector3 tn0 = _normals[i0], tn1 = _normals[i1], tn2 = _normals[i2]; + float nx = a * tn0.x + b * tn1.x + c * tn2.x; + float ny = a * tn0.y + b * tn1.y + c * tn2.y; + float nz = a * tn0.z + b * tn1.z + c * tn2.z; + float nl = nx * n.x + ny * n.y + nz * n.z; + float nmag = Mathf.Sqrt(nx * nx + ny * ny + nz * nz); + if (nmag > 1e-6f && nl < nCutoff * nmag) continue; + } + float mu = du * invR * 0.5f + 0.5f; float mv = dv * invR * 0.5f + 0.5f; float maskOp = SampleMaskOpacity(mu, mv); diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs index beb7912..b4fa036 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs @@ -342,6 +342,7 @@ public partial class MeshPainter _brushMat.SetVector(BrushUId, u); _brushMat.SetVector(BrushVId, v); _brushMat.SetVector(BrushNormalId, n); + _brushMat.SetFloat(BrushNormalCutoffId, BrushNormalCutoff); if (mi == MAP_MSO) { diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs index 63b8821..f08d30f 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs @@ -42,6 +42,7 @@ public partial class MeshPainter // BuildMeshCaches scratch: refilled per bake without allocating (with updateColliderEveryFrame this // runs per frame while painting; mesh.vertices/.triangles/.uv return fresh arrays per call). private readonly System.Collections.Generic.List _vertScratch = new System.Collections.Generic.List(); + private readonly System.Collections.Generic.List _normalScratch = new System.Collections.Generic.List(); private int _meshCacheId; // mesh instance the topology/UV caches were built for (0 = none) /// Reads vertices/triangles/UVs of the collider mesh and computes culling data. @@ -56,6 +57,16 @@ public partial class MeshPainter if (_verts == null || _verts.Length != vCount) _verts = new Vector3[vCount]; _vertScratch.CopyTo(_verts); + // Per-vertex normals for the brush's back-face rejection (opposite side of thin geometry). + // Re-baked each frame for skinned meshes; falls back to null (test disabled) if absent. + m.GetNormals(_normalScratch); + if (_normalScratch.Count == vCount) + { + if (_normals == null || _normals.Length != vCount) _normals = new Vector3[vCount]; + _normalScratch.CopyTo(_normals); + } + else _normals = null; + if (_meshCacheId != m.GetInstanceID()) { _meshCacheId = m.GetInstanceID(); diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs index cdfd977..687c172 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs @@ -266,6 +266,21 @@ public partial class MeshPainter : MonoBehaviour [Range(0.01f, 1f)] public float brushSizeScrollFactor = 0.15f; + [Tooltip("Max angle (deg) between a texel's surface normal and the clicked surface for the brush to " + + "still paint it. Prevents a large brush from bleeding onto the opposite side of thin geometry " + + "(that side faces ~180deg away). Lower = stricter separation; 180 = disabled (paint everything " + + "inside the sphere, old behavior). ~120 keeps corners/rounded edges while cutting the back face.")] + [Range(90f, 180f)] + public float brushMaxSurfaceAngle = 120f; + + /// + /// cos(brushMaxSurfaceAngle): a texel is painted only if dot(texelNormal, brushNormal) >= this. + /// At 180deg the cutoff is -1 (always true) -> the normal test is effectively disabled. Returns -1 + /// when the mesh has no normals (test not possible) so the brush degrades to the plain sphere. + /// + private float BrushNormalCutoff => + _normals != null ? Mathf.Cos(brushMaxSurfaceAngle * Mathf.Deg2Rad) : -1f; + /// /// Raised after Shift+wheel changes so the in-game HUD can re-sync its /// brush-size slider (the slider then re-clamps the value to its own configured range). @@ -372,6 +387,7 @@ public partial class MeshPainter : MonoBehaviour // Mesh caches (in local/collider space). private Vector3[] _verts; + private Vector3[] _normals; // per-vertex normals (brush back-face rejection); null if unavailable private int[] _tris; private Vector2[] _uvs; private Vector3[] _triCenter; // centroid per triangle (CPU culling) @@ -464,6 +480,7 @@ public partial class MeshPainter : MonoBehaviour private static readonly int BrushUId = Shader.PropertyToID("_BrushU"); private static readonly int BrushVId = Shader.PropertyToID("_BrushV"); private static readonly int BrushNormalId = Shader.PropertyToID("_BrushNormal"); + private static readonly int BrushNormalCutoffId = Shader.PropertyToID("_BrushNormalCutoff"); private static readonly int MaskTexId = Shader.PropertyToID("_MaskTex"); private static readonly int PadId = Shader.PropertyToID("_Pad"); private static readonly int PreviewOpacityId = Shader.PropertyToID("_PreviewOpacity"); @@ -773,6 +790,7 @@ public partial class MeshPainter : MonoBehaviour _previewMat.SetVector(BrushUId, u); _previewMat.SetVector(BrushVId, v); _previewMat.SetVector(BrushNormalId, n); + _previewMat.SetFloat(BrushNormalCutoffId, BrushNormalCutoff); // Re-draw the collider mesh; the shader clips it to the brush box. The collider mesh is in // the same local space as the raycast hit and valid for skinned (baked) and static meshes. diff --git a/Assets/Shaders/MeshPaintBrush.shader b/Assets/Shaders/MeshPaintBrush.shader index 2e18365..f4a5190 100644 --- a/Assets/Shaders/MeshPaintBrush.shader +++ b/Assets/Shaders/MeshPaintBrush.shader @@ -51,18 +51,21 @@ 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 _BrushNormalCutoff; // cos(maxAngle): paint only if dot(texelN, brushN) >= this (-1 = off) sampler2D _MaskTex; struct appdata { float4 vertex : POSITION; // local 3D position + float3 normal : NORMAL; // local surface normal float2 uv : TEXCOORD0; // UV (target in the texture layout) }; struct v2f { - float4 pos : SV_POSITION; - float3 local : TEXCOORD0; // interpolated local 3D position + float4 pos : SV_POSITION; + float3 local : TEXCOORD0; // interpolated local 3D position + float3 normal : TEXCOORD1; // interpolated local surface normal }; v2f vert (appdata v) @@ -76,6 +79,7 @@ Shader "Hidden/MeshPaintBrush" #endif o.pos = float4(p, 0.0, 1.0); o.local = v.vertex.xyz; + o.normal = v.normal; return o; } @@ -91,6 +95,11 @@ Shader "Hidden/MeshPaintBrush" // (both sides of a 3D edge) yet never bleeds onto far/adjacent surfaces past r. clip(_BrushRadius * _BrushRadius - (du * du + dv * dv + dn * dn)); + // Back-face rejection: the opposite side of thin geometry faces ~180deg away, + // so drop texels whose normal turns away from the clicked surface past the cutoff. + float nl = dot(normalize(i.normal), _BrushNormal); + clip(nl - _BrushNormalCutoff); + // 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); diff --git a/Assets/Shaders/MeshPaintPreview.shader b/Assets/Shaders/MeshPaintPreview.shader index d1d64d1..5591936 100644 --- a/Assets/Shaders/MeshPaintPreview.shader +++ b/Assets/Shaders/MeshPaintPreview.shader @@ -43,6 +43,7 @@ Shader "Hidden/MeshPaintPreview" 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 _BrushNormalCutoff; // cos(maxAngle): show only if dot(texelN, brushN) >= this (-1 = off) float _PreviewOpacity; // overall preview alpha multiplier (>1 saturates to fully opaque) float _PreviewMode; // 0 = on-surface (mesh), 1 = flat decal quad sampler2D _MaskTex; @@ -50,14 +51,16 @@ Shader "Hidden/MeshPaintPreview" struct appdata { float4 vertex : POSITION; + float3 normal : NORMAL; 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) + float4 pos : SV_POSITION; + float3 local : TEXCOORD0; // object-space position (on-surface box test) + float2 uv : TEXCOORD1; // quad uv (decal sampling) + float3 normal : TEXCOORD2; // object-space normal (on-surface back-face test) }; v2f vert (appdata v) @@ -66,6 +69,7 @@ Shader "Hidden/MeshPaintPreview" o.pos = UnityObjectToClipPos(v.vertex); o.local = v.vertex.xyz; o.uv = v.uv; + o.normal = v.normal; return o; } @@ -80,6 +84,8 @@ Shader "Hidden/MeshPaintPreview" float dv = dot(d, _BrushV); float dn = dot(d, _BrushNormal); clip(_BrushRadius * _BrushRadius - (du * du + dv * dv + dn * dn)); + // Back-face rejection: hide the preview on the opposite side of thin geometry. + clip(dot(normalize(i.normal), _BrushNormal) - _BrushNormalCutoff); muv = float2(du, dv) / _BrushRadius * 0.5 + 0.5; } else