MeshPainter: Kugel-Brush statt Box, cornerGuard entfernt
Stamp testet den Brush wieder als 3D-Kugel (du²+dv²+dn² = r²) statt als Box (|du|,|dv|,|dn| = r) — konsistent über CPU, GPU-Brush und Preview. Die Box reichte diagonal bis r·√3 und spannte einen 2r-dicken Slab entlang der Normale, wodurch an Nähten/Kanten/dünner Geometrie auf die Gegenseite/Nachbarinseln gemalt wurde. Die Kugel reicht in jede Richtung exakt r und überbrückt echte 3D-Nähte weiterhin, greift aber nicht mehr über r hinaus. cornerGuard war nur ein Pflaster gegen genau diesen Box-Übergriff und ist damit überflüssig: Config-Felder, Suppress-Check und Helper (DabWrapsAroundCorner, CornerWrapFraction, InBrushBox, SquareReach) entfernt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
2195427e6c
commit
1f42c990fb
5 files changed
+27
-131
No files matched your search
@@ -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
|
||||
|
||||
@@ -122,10 +122,12 @@ public partial class MeshPainter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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%).
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if a brush dab at this placement would wrap excessively around a corner: more than
|
||||
/// <see cref="cornerGuardMaxFraction"/> of its footprint sits on surface whose face normal
|
||||
/// deviates from the hit face by more than <see cref="cornerGuardAngle"/>. 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates the share (0..1) of the brush footprint sitting on surface whose geometric face
|
||||
/// normal deviates from <paramref name="fnHit"/> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>True if a local-space point lies inside the brush box (same |du|,|dv|,|dn| <= r bound the stamp paints).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user