Reorganize scripts into subfolders by responsibility

Move scripts under Assets/Scripts/ into Components, GameMode, Helper,
Network, Enum, Struct and ScriptableObject folders. Pure file moves
(plus folder .meta files); no code changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-02 15:02:15 +02:00
1 parent 382bfb36df
commit e23fae0c50
126 files changed
+112

No files matched your search

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 741ea71ad8a1001469a879e03bca0296
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,773 @@
using System;
using System.Collections.Generic;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.SceneManagement;
#endif
/// <summary>
/// Editor tool that decomposes an environment mesh (typically an open-shell / non-watertight
/// furniture mesh) into a small set of CONVEX MeshColliders via surface clustering, so
/// <see cref="ConcealmentMeter"/> can treat the furniture as "embeddable" through its
/// convex-collider ClosestPoint path WITHOUT a single convex hull filling the open compartments.
///
/// Approach (no voxels, no native plugin — "approximate convex decomposition" on the surface):
/// 1. Weld coincident vertices so connectivity is detected across split-normal/UV seams.
/// 2. Grow connected triangle regions into SLABS: a region keeps absorbing edge-adjacent
/// triangles while it stays thinner than <see cref="maxSlabThickness"/> along its flattest
/// axis (PCA minor axis). This spans both faces of one board (they are within a board's
/// thickness) but refuses to jump the gap to the next board (that would thicken the region).
/// 3. Convex-hull each slab into its own convex collider. Boards become thin convex slabs;
/// the empty space between boards is covered by no collider, so it stays hollow.
///
/// The colliders are real child GameObjects referencing SAVED hull-mesh assets, so they survive
/// domain reload / play-mode / scene save (unlike an in-memory-only bake).
///
/// Runtime-inert: the component exists only so <see cref="ConcealmentMeter"/> can detect it in a
/// collider's parent and skip the furniture's own concave MeshCollider (exactly like a baked
/// <c>ConcealmentVolume</c> does) — the generated convex slabs are the authoritative embedding shape.
/// </summary>
[DisallowMultipleComponent]
public class ConcealmentColliderBaker : MonoBehaviour
{
[Tooltip("Optional. If empty, the shared mesh of a MeshFilter on this object (or a child) is used.")]
[SerializeField] private Mesh sourceMesh;
[Header("Clustering")]
[Tooltip("Vertices closer than this (local units) are welded, so shared edges are found across split-normal/UV seams. ~ a hair below the smallest real feature.")]
[Min(1e-4f)] [SerializeField] private float weldEpsilon = 1e-3f;
[Tooltip("A connected region keeps growing while it stays thinner than this (local units) along its flattest axis. ~ board thickness. Larger => fewer, chunkier colliders (risk of bridging compartments); smaller => more, thinner slabs.")]
[Min(1e-3f)] [SerializeField] private float maxSlabThickness = 0.12f;
[Tooltip("Flat/thin clusters are inflated to at least this thickness (local units) so the convex collider has usable volume for ClosestPoint tests.")]
[Min(1e-3f)] [SerializeField] private float minThickness = 0.04f;
[Tooltip("A slab is recursively split until its convex-hull footprint is at least this fraction covered by real surface (0..1). Guards against one hull bridging open gaps between coplanar members (e.g. a table side = 2 legs + top rail merge into a flat panel whose hull fills the empty gap). Lower => tolerate more concavity/bridging; higher => split into more, tighter surface-hugging pieces. Solid boards (~fully covered) are never split.")]
[Range(0f, 1f)] [SerializeField] private float hullFillThreshold = 0.6f;
[Tooltip("Clusters with fewer triangles than this are discarded as noise.")]
[Min(1)] [SerializeField] private int minClusterTriangles = 1;
[Tooltip("Hard cap on generated colliders. The meter's broadphase is capped at 64 colliders TOTAL across all environment in range, so keep this modest. Largest clusters win.")]
[Min(1)] [SerializeField] private int maxColliders = 24;
[Header("Output")]
[Tooltip("If true, generated colliders inherit the source object's layer (so they land on the meter's environment mask). Otherwise 'Collider Layer' is used.")]
[SerializeField] private bool useSourceLayer = true;
[SerializeField] private int colliderLayer;
[SerializeField, HideInInspector] private List<string> generatedAssetPaths = new List<string>();
private const string ContainerName = "ConcealmentColliders (generated)";
private const string AssetFolder = "Assets/Generated/ConcealmentColliders";
private const int MaxRefineDepth = 8;
#if UNITY_EDITOR
[ContextMenu("Bake Convex Colliders")]
public void Bake()
{
Mesh mesh = sourceMesh;
Transform meshSpace = transform;
if (!mesh)
{
MeshFilter mf = GetComponentInChildren<MeshFilter>();
if (mf && mf.sharedMesh) { mesh = mf.sharedMesh; meshSpace = mf.transform; }
}
if (!mesh)
{
Debug.LogError($"[ConcealmentColliderBaker] '{name}': no source mesh (assign one or add a MeshFilter).", this);
return;
}
Clear(); // remove previous container + assets first
string baseName = ResolveAssetBaseName();
Vector3[] rawVerts = mesh.vertices;
int[] rawTris = mesh.triangles;
// 1. Weld coincident vertices.
WeldVertices(rawVerts, weldEpsilon, out List<Vector3> wv, out int[] map);
// Welded triangles (drop degenerate).
var triA = new List<int>(); var triB = new List<int>(); var triC = new List<int>();
for (int i = 0; i + 2 < rawTris.Length; i += 3)
{
int a = map[rawTris[i]], b = map[rawTris[i + 1]], c = map[rawTris[i + 2]];
if (a == b || b == c || a == c) continue;
triA.Add(a); triB.Add(b); triC.Add(c);
}
int triCount = triA.Count;
if (triCount == 0)
{
Debug.LogWarning($"[ConcealmentColliderBaker] '{name}': mesh has no valid triangles after welding.", this);
return;
}
// 2. Edge adjacency (welded edges shared by triangles).
var edgeToTri = new Dictionary<long, int>(triCount * 3);
var neighbors = new List<int>[triCount];
for (int t = 0; t < triCount; t++) neighbors[t] = new List<int>();
for (int t = 0; t < triCount; t++)
{
LinkEdge(edgeToTri, neighbors, triA[t], triB[t], t);
LinkEdge(edgeToTri, neighbors, triB[t], triC[t], t);
LinkEdge(edgeToTri, neighbors, triC[t], triA[t], t);
}
// 3. Grow connected triangles into thickness-bounded slabs.
// Disconnected boards never link (separate components); connected assemblies split
// wherever a region would exceed maxSlabThickness.
var clusters = new List<List<int>>();
bool[] assigned = new bool[triCount];
var queue = new Queue<int>();
var vset = new HashSet<int>();
var vpos = new List<Vector3>();
var added = new List<int>();
for (int s = 0; s < triCount; s++)
{
if (assigned[s]) continue;
var cluster = new List<int> { s };
assigned[s] = true;
vset.Clear(); vpos.Clear();
AddTriVerts(vset, vpos, wv, triA[s], triB[s], triC[s], null);
queue.Clear();
for (int k = 0; k < neighbors[s].Count; k++) queue.Enqueue(neighbors[s][k]);
while (queue.Count > 0)
{
int t = queue.Dequeue();
if (assigned[t]) continue;
int before = vpos.Count;
AddTriVerts(vset, vpos, wv, triA[t], triB[t], triC[t], added);
float th = SlabThickness(vpos, out _);
if (th <= maxSlabThickness)
{
assigned[t] = true;
cluster.Add(t);
for (int k = 0; k < neighbors[t].Count; k++)
if (!assigned[neighbors[t][k]]) queue.Enqueue(neighbors[t][k]);
}
else
{
// Revert the tentatively added verts.
for (int k = 0; k < added.Count; k++) vset.Remove(added[k]);
if (vpos.Count > before) vpos.RemoveRange(before, vpos.Count - before);
}
}
clusters.Add(cluster);
}
// 3b. Refine: split slabs whose convex hull would bridge open concavities. A table side is
// 2 legs + a top rail — all coplanar and thin, so they merge into ONE slab whose hull
// fills the empty gap between the legs. Each slab is recursively halved along its larger
// in-plane axis until its hull footprint is mostly covered by real surface. Solid boards
// (the shelf) are already ~fully covered => returned untouched.
var refined = new List<List<int>>(clusters.Count);
for (int c = 0; c < clusters.Count; c++)
RefineCluster(clusters[c], wv, triA, triB, triC, 0, refined);
clusters = refined;
// 4. Filter noise + cap count (keep largest slabs).
clusters.RemoveAll(c => c.Count < minClusterTriangles);
clusters.Sort((x, y) => y.Count.CompareTo(x.Count));
int dropped = 0;
if (clusters.Count > maxColliders)
{
dropped = clusters.Count - maxColliders;
clusters.RemoveRange(maxColliders, dropped);
}
// 5. Build a convex collider per slab.
EnsureFolder();
Transform container = new GameObject(ContainerName).transform;
container.SetParent(meshSpace, false);
container.localPosition = Vector3.zero;
container.localRotation = Quaternion.identity;
container.localScale = Vector3.one;
int outLayer = useSourceLayer ? gameObject.layer : colliderLayer;
int made = 0;
for (int ci = 0; ci < clusters.Count; ci++)
{
List<Vector3> pts = CollectClusterPoints(clusters[ci], wv, triA, triB, triC);
float thick = SlabThickness(pts, out Vector3 normal);
List<Vector3> hullPts;
if (thick < minThickness)
{
// Inflate a (near-)flat slab into a thin prism so the hull has volume.
float half = minThickness * 0.5f;
hullPts = new List<Vector3>(pts.Count * 2);
for (int k = 0; k < pts.Count; k++)
{
hullPts.Add(pts[k] + normal * half);
hullPts.Add(pts[k] - normal * half);
}
}
else hullPts = pts;
if (!ComputeConvexHull(hullPts.ToArray(), out List<Vector3> hv, out List<int> ht) || hv.Count < 4)
continue;
Mesh cm = new Mesh { name = $"{baseName}_{made}" };
cm.SetVertices(hv);
cm.SetTriangles(ht, 0);
cm.RecalculateBounds();
string path = $"{AssetFolder}/{baseName}_{made}.asset";
AssetDatabase.CreateAsset(cm, path);
generatedAssetPaths.Add(path);
GameObject go = new GameObject($"hull_{made}");
go.transform.SetParent(container, false);
go.layer = outLayer;
MeshCollider col = go.AddComponent<MeshCollider>();
col.sharedMesh = cm;
col.convex = true;
col.isTrigger = false;
made++;
}
AssetDatabase.SaveAssets();
EditorUtility.SetDirty(this);
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene);
Debug.Log($"[ConcealmentColliderBaker] '{name}': baked {made} convex collider(s)" +
(dropped > 0 ? $" (+{dropped} smaller slab(s) dropped by Max Colliders={maxColliders})" : "") +
". Save the scene to persist.", this);
}
[ContextMenu("Clear Baked Colliders")]
public void Clear()
{
for (int i = 0; i < generatedAssetPaths.Count; i++)
if (!string.IsNullOrEmpty(generatedAssetPaths[i]))
AssetDatabase.DeleteAsset(generatedAssetPaths[i]);
generatedAssetPaths.Clear();
var all = GetComponentsInChildren<Transform>(true);
for (int i = 0; i < all.Length; i++)
if (all[i] && all[i] != transform && all[i].name == ContainerName)
DestroyImmediate(all[i].gameObject);
EditorUtility.SetDirty(this);
if (gameObject.scene.IsValid())
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene);
}
private static void EnsureFolder()
{
if (!AssetDatabase.IsValidFolder("Assets/Generated"))
AssetDatabase.CreateFolder("Assets", "Generated");
if (!AssetDatabase.IsValidFolder(AssetFolder))
AssetDatabase.CreateFolder("Assets/Generated", "ConcealmentColliders");
}
/// <summary>
/// Readable, deterministic base name for the generated collider-mesh assets:
/// - Prefab stage: "{assetPath without 'Assets/' and '.prefab'}/{path below the prefab root}"
/// - Scene: "{sceneName}/{full hierarchy path from the scene root}"
/// Sanitized to a filesystem-safe token; on collision with a DIFFERENT baker's assets, "_2", "_3", … is appended.
/// </summary>
private string ResolveAssetBaseName()
{
string raw;
PrefabStage stage = PrefabStageUtility.GetPrefabStage(gameObject);
if (stage != null)
{
string ap = stage.assetPath;
if (ap.StartsWith("Assets/")) ap = ap.Substring("Assets/".Length);
if (ap.EndsWith(".prefab")) ap = ap.Substring(0, ap.Length - ".prefab".Length);
// Path below the prefab root — the root's name already lives in the asset path.
string hier = GetHierarchyPath(transform, stage.prefabContentsRoot.transform);
raw = hier.Length == 0 ? ap : ap + "/" + hier;
}
else
{
string scene = string.IsNullOrEmpty(gameObject.scene.name) ? "Untitled" : gameObject.scene.name;
raw = scene + "/" + GetHierarchyPath(transform, null);
}
string baseName = SanitizeFileName(raw);
if (baseName.Length == 0) baseName = "Bake";
// Clear() already removed THIS baker's assets, so a hit here means another baker resolved to the
// same name -> disambiguate. The per-collider "_0" file is the presence marker of a base name.
string candidate = baseName;
for (int dup = 2; System.IO.File.Exists($"{AssetFolder}/{candidate}_0.asset"); dup++)
candidate = $"{baseName}_{dup}";
return candidate;
}
/// <summary>Slash-joined transform names from <paramref name="t"/> up to (but excluding) <paramref name="stopAt"/> (null = up to the root).</summary>
private static string GetHierarchyPath(Transform t, Transform stopAt)
{
var names = new List<string>();
for (Transform cur = t; cur && cur != stopAt; cur = cur.parent)
names.Add(cur.name);
names.Reverse();
return string.Join("/", names);
}
/// <summary>Collapses every run of non-[A-Za-z0-9-] characters to a single '_' and trims leading/trailing '_'.</summary>
private static string SanitizeFileName(string s)
{
var sb = new System.Text.StringBuilder(s.Length);
bool pendingSep = false;
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
bool keep = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-';
if (keep)
{
if (pendingSep && sb.Length > 0) sb.Append('_');
pendingSep = false;
sb.Append(c);
}
else pendingSep = true;
}
return sb.ToString();
}
#endif
// ---- Welding & clustering helpers (runtime-safe: no editor API) ----
private static void WeldVertices(Vector3[] src, float eps, out List<Vector3> welded, out int[] map)
{
welded = new List<Vector3>(src.Length);
map = new int[src.Length];
float inv = 1f / Mathf.Max(eps, 1e-6f);
var lut = new Dictionary<long, int>(src.Length);
for (int i = 0; i < src.Length; i++)
{
Vector3 p = src[i];
int qx = Mathf.RoundToInt(p.x * inv);
int qy = Mathf.RoundToInt(p.y * inv);
int qz = Mathf.RoundToInt(p.z * inv);
long key = ((long)(qx & 0x1FFFFF) << 42) | ((long)(qy & 0x1FFFFF) << 21) | (long)(qz & 0x1FFFFF);
if (lut.TryGetValue(key, out int idx)) { map[i] = idx; }
else { idx = welded.Count; lut[key] = idx; welded.Add(p); map[i] = idx; }
}
}
private static void LinkEdge(Dictionary<long, int> edgeToTri, List<int>[] neighbors, int u, int v, int tri)
{
long key = EdgeKey(u, v);
if (edgeToTri.TryGetValue(key, out int other))
{
neighbors[tri].Add(other);
neighbors[other].Add(tri);
edgeToTri[key] = tri; // chain non-manifold edges (>2 tris) so all get linked
}
else edgeToTri[key] = tri;
}
private static void AddTriVerts(HashSet<int> set, List<Vector3> pos, List<Vector3> wv, int a, int b, int c, List<int> addedOut)
{
addedOut?.Clear();
if (set.Add(a)) { pos.Add(wv[a]); addedOut?.Add(a); }
if (set.Add(b)) { pos.Add(wv[b]); addedOut?.Add(b); }
if (set.Add(c)) { pos.Add(wv[c]); addedOut?.Add(c); }
}
/// <summary>Unique welded vertex positions of a triangle cluster.</summary>
private static List<Vector3> CollectClusterPoints(List<int> cluster, List<Vector3> wv, List<int> triA, List<int> triB, List<int> triC)
{
var pts = new List<Vector3>(cluster.Count * 3);
var seen = new HashSet<int>();
for (int j = 0; j < cluster.Count; j++)
{
int t = cluster[j];
if (seen.Add(triA[t])) pts.Add(wv[triA[t]]);
if (seen.Add(triB[t])) pts.Add(wv[triB[t]]);
if (seen.Add(triC[t])) pts.Add(wv[triC[t]]);
}
return pts;
}
/// <summary>Mean-centered 3x3 covariance of the point cloud, eigendecomposed via <see cref="Jacobi3"/>.</summary>
private static void CovarianceEigen(List<Vector3> pts, out double[,] vec, out double[] d)
{
int n = pts.Count;
Vector3 mean = Vector3.zero;
for (int i = 0; i < n; i++) mean += pts[i];
mean /= n;
double c00 = 0, c01 = 0, c02 = 0, c11 = 0, c12 = 0, c22 = 0;
for (int i = 0; i < n; i++)
{
double x = pts[i].x - mean.x, y = pts[i].y - mean.y, z = pts[i].z - mean.z;
c00 += x * x; c01 += x * y; c02 += x * z;
c11 += y * y; c12 += y * z; c22 += z * z;
}
var a = new double[3, 3] { { c00, c01, c02 }, { c01, c11, c12 }, { c02, c12, c22 } };
Jacobi3(a, out vec, out d);
}
/// <summary>Extent of the point cloud along its PCA minor axis (= slab thickness), plus that axis.</summary>
private static float SlabThickness(List<Vector3> pts, out Vector3 normal)
{
normal = Vector3.up;
int n = pts.Count;
if (n < 2) return 0f;
CovarianceEigen(pts, out double[,] v, out double[] d);
int mi = 0;
if (d[1] < d[mi]) mi = 1;
if (d[2] < d[mi]) mi = 2;
normal = new Vector3((float)v[0, mi], (float)v[1, mi], (float)v[2, mi]);
if (normal.sqrMagnitude < 1e-12f) { normal = Vector3.up; return 0f; }
normal.Normalize();
float mn = float.MaxValue, mx = float.MinValue;
for (int i = 0; i < n; i++)
{
float dp = Vector3.Dot(pts[i], normal);
if (dp < mn) mn = dp;
if (dp > mx) mx = dp;
}
return mx - mn;
}
// ---- Concavity refinement (split slabs whose hull would bridge open gaps) ----
/// <summary>
/// Recursively halves a slab cluster along its larger in-plane axis until its hull footprint is
/// at least <see cref="hullFillThreshold"/> covered by real surface (or it gets too small / too deep).
/// Prevents one convex hull from bridging the empty space between disjoint coplanar members (table legs).
/// </summary>
private void RefineCluster(List<int> cluster, List<Vector3> wv, List<int> triA, List<int> triB, List<int> triC, int depth, List<List<int>> outClusters)
{
if (cluster.Count <= 2 || depth >= MaxRefineDepth)
{
outClusters.Add(cluster);
return;
}
float fill = ClusterFill(cluster, wv, triA, triB, triC, out Vector3 axis);
if (fill >= hullFillThreshold)
{
outClusters.Add(cluster);
return;
}
// Halve at the centroid median along the larger in-plane axis. Recursion + the fill test
// converge on surface-hugging pieces; a solid board is >= threshold at once and never splits.
int m = cluster.Count;
var proj = new float[m];
for (int j = 0; j < m; j++)
proj[j] = Vector3.Dot((wv[triA[cluster[j]]] + wv[triB[cluster[j]]] + wv[triC[cluster[j]]]) * (1f / 3f), axis);
var sorted = (float[])proj.Clone();
Array.Sort(sorted);
float median = sorted[m / 2];
var left = new List<int>();
var right = new List<int>();
for (int j = 0; j < m; j++)
{
if (proj[j] < median) left.Add(cluster[j]); else right.Add(cluster[j]);
}
if (left.Count == 0 || right.Count == 0)
{
outClusters.Add(cluster); // all centroids coincide on the axis — can't halve, keep as-is
return;
}
RefineCluster(left, wv, triA, triB, triC, depth + 1, outClusters);
RefineCluster(right, wv, triA, triB, triC, depth + 1, outClusters);
}
/// <summary>
/// Fraction (0..1) of the cluster's in-plane bounding footprint actually covered by surface, via a
/// coverage grid on the PCA plane (union of the projected triangles — single-/double-sided agnostic).
/// Low fill => the convex hull would enclose empty space. Outputs the larger in-plane axis (split dir).
/// </summary>
private static float ClusterFill(List<int> cluster, List<Vector3> wv, List<int> triA, List<int> triB, List<int> triC, out Vector3 splitAxis)
{
List<Vector3> pts = CollectClusterPoints(cluster, wv, triA, triB, triC);
PcaFrame(pts, out Vector3 u, out Vector3 v, out _);
float umin = float.MaxValue, umax = float.MinValue, vmin = float.MaxValue, vmax = float.MinValue;
for (int i = 0; i < pts.Count; i++)
{
float pu = Vector3.Dot(pts[i], u), pv = Vector3.Dot(pts[i], v);
if (pu < umin) umin = pu; if (pu > umax) umax = pu;
if (pv < vmin) vmin = pv; if (pv > vmax) vmax = pv;
}
float eu = Mathf.Max(umax - umin, 1e-5f), ev = Mathf.Max(vmax - vmin, 1e-5f);
splitAxis = eu >= ev ? u : v;
float cell = Mathf.Max(Mathf.Max(eu, ev) / 32f, 1e-4f);
int NU = Mathf.Clamp(Mathf.CeilToInt(eu / cell), 1, 40);
int NV = Mathf.Clamp(Mathf.CeilToInt(ev / cell), 1, 40);
var cov = new bool[NU, NV];
for (int j = 0; j < cluster.Count; j++)
{
int t = cluster[j];
RasterTri(cov, NU, NV,
ToGrid(wv[triA[t]], u, v, umin, vmin, eu, ev, NU, NV),
ToGrid(wv[triB[t]], u, v, umin, vmin, eu, ev, NU, NV),
ToGrid(wv[triC[t]], u, v, umin, vmin, eu, ev, NU, NV));
}
int covered = 0;
for (int a = 0; a < NU; a++) for (int b = 0; b < NV; b++) if (cov[a, b]) covered++;
return (float)covered / (NU * NV);
}
private static Vector2 ToGrid(Vector3 p, Vector3 u, Vector3 v, float umin, float vmin, float eu, float ev, int NU, int NV)
=> new Vector2((Vector3.Dot(p, u) - umin) / eu * NU, (Vector3.Dot(p, v) - vmin) / ev * NV);
/// <summary>Marks every grid cell whose center falls inside the 2D triangle (edge-on triangles paint their thin bbox strip).</summary>
private static void RasterTri(bool[,] cov, int NU, int NV, Vector2 a, Vector2 b, Vector2 c)
{
float minx = Mathf.Min(a.x, Mathf.Min(b.x, c.x)), maxx = Mathf.Max(a.x, Mathf.Max(b.x, c.x));
float miny = Mathf.Min(a.y, Mathf.Min(b.y, c.y)), maxy = Mathf.Max(a.y, Mathf.Max(b.y, c.y));
int x0 = Mathf.Clamp(Mathf.FloorToInt(minx), 0, NU - 1), x1 = Mathf.Clamp(Mathf.CeilToInt(maxx), 0, NU - 1);
int y0 = Mathf.Clamp(Mathf.FloorToInt(miny), 0, NV - 1), y1 = Mathf.Clamp(Mathf.CeilToInt(maxy), 0, NV - 1);
float d = (b.y - c.y) * (a.x - c.x) + (c.x - b.x) * (a.y - c.y);
if (Mathf.Abs(d) < 1e-9f)
{
for (int x = x0; x <= x1; x++) for (int y = y0; y <= y1; y++) cov[x, y] = true;
return;
}
for (int x = x0; x <= x1; x++)
for (int y = y0; y <= y1; y++)
{
float px = x + 0.5f, py = y + 0.5f;
float w0 = ((b.y - c.y) * (px - c.x) + (c.x - b.x) * (py - c.y)) / d;
float w1 = ((c.y - a.y) * (px - c.x) + (a.x - c.x) * (py - c.y)) / d;
float w2 = 1f - w0 - w1;
if (w0 >= -1e-3f && w1 >= -1e-3f && w2 >= -1e-3f) cov[x, y] = true;
}
}
/// <summary>PCA orthonormal frame of a point cloud: u = major axis, v = mid, n = minor (slab normal). Eigenvectors sorted by variance desc.</summary>
private static void PcaFrame(List<Vector3> pts, out Vector3 u, out Vector3 v, out Vector3 n)
{
u = Vector3.right; v = Vector3.up; n = Vector3.forward;
if (pts.Count < 2) return;
CovarianceEigen(pts, out double[,] vec, out double[] d);
int hi = 0, mid = 1, lo = 2;
if (d[hi] < d[mid]) { int t = hi; hi = mid; mid = t; }
if (d[mid] < d[lo]) { int t = mid; mid = lo; lo = t; }
if (d[hi] < d[mid]) { int t = hi; hi = mid; mid = t; }
u = AxisOr(vec, hi, Vector3.right);
v = AxisOr(vec, mid, Vector3.up);
n = AxisOr(vec, lo, Vector3.forward);
}
private static Vector3 AxisOr(double[,] vec, int col, Vector3 fallback)
{
Vector3 a = new Vector3((float)vec[0, col], (float)vec[1, col], (float)vec[2, col]);
return a.sqrMagnitude < 1e-12f ? fallback : a.normalized;
}
/// <summary>Cyclic Jacobi eigendecomposition of a symmetric 3x3 matrix (eigenvectors in columns of v).</summary>
private static void Jacobi3(double[,] a, out double[,] v, out double[] d)
{
v = new double[3, 3] { { 1, 0, 0 }, { 0, 1, 0 }, { 0, 0, 1 } };
for (int sweep = 0; sweep < 24; sweep++)
{
double off = Math.Abs(a[0, 1]) + Math.Abs(a[0, 2]) + Math.Abs(a[1, 2]);
if (off < 1e-18) break;
JacobiRotate(a, v, 0, 1);
JacobiRotate(a, v, 0, 2);
JacobiRotate(a, v, 1, 2);
}
d = new double[] { a[0, 0], a[1, 1], a[2, 2] };
}
private static void JacobiRotate(double[,] a, double[,] v, int p, int q)
{
double apq = a[p, q];
if (Math.Abs(apq) < 1e-20) return;
double app = a[p, p], aqq = a[q, q];
double phi = 0.5 * Math.Atan2(2.0 * apq, aqq - app);
double c = Math.Cos(phi), s = Math.Sin(phi);
a[p, p] = c * c * app - 2.0 * s * c * apq + s * s * aqq;
a[q, q] = s * s * app + 2.0 * s * c * apq + c * c * aqq;
a[p, q] = 0; a[q, p] = 0;
int r = 3 - p - q;
double arp = a[r, p], arq = a[r, q];
double nrp = c * arp - s * arq;
double nrq = s * arp + c * arq;
a[r, p] = nrp; a[p, r] = nrp;
a[r, q] = nrq; a[q, r] = nrq;
for (int k = 0; k < 3; k++)
{
double vkp = v[k, p], vkq = v[k, q];
v[k, p] = c * vkp - s * vkq;
v[k, q] = s * vkp + c * vkq;
}
}
// ---- Convex hull (incremental; outputs a triangulated hull mesh) ----
private struct HFace { public int a, b, c; public Vector3 n; }
private static bool ComputeConvexHull(Vector3[] pts, out List<Vector3> hullVertsOut, out List<int> hullTrisOut)
{
hullVertsOut = new List<Vector3>();
hullTrisOut = new List<int>();
int n = pts.Length;
if (n < 4) return false;
Vector3 bmn = pts[0], bmx = pts[0];
for (int i = 1; i < n; i++) { bmn = Vector3.Min(bmn, pts[i]); bmx = Vector3.Max(bmx, pts[i]); }
float diag = (bmx - bmn).magnitude;
if (diag < 1e-6f) return false;
float eps = diag * 1e-6f;
int minX = 0, maxX = 0;
for (int i = 1; i < n; i++) { if (pts[i].x < pts[minX].x) minX = i; if (pts[i].x > pts[maxX].x) maxX = i; }
int i0 = minX, i1 = maxX;
if (i0 == i1) return false;
Vector3 lineDir = pts[i1] - pts[i0];
float lineLen = lineDir.magnitude;
if (lineLen < eps) return false;
lineDir /= lineLen;
int i2 = -1; float best = eps;
for (int i = 0; i < n; i++)
{
float dd = Vector3.Cross(pts[i] - pts[i0], lineDir).magnitude;
if (dd > best) { best = dd; i2 = i; }
}
if (i2 < 0) return false;
Vector3 nrm0 = Vector3.Cross(pts[i1] - pts[i0], pts[i2] - pts[i0]);
float nl = nrm0.magnitude;
if (nl < eps) return false;
nrm0 /= nl;
int i3 = -1; best = eps;
for (int i = 0; i < n; i++)
{
float dd = Mathf.Abs(Vector3.Dot(pts[i] - pts[i0], nrm0));
if (dd > best) { best = dd; i3 = i; }
}
if (i3 < 0) return false;
Vector3 interior = (pts[i0] + pts[i1] + pts[i2] + pts[i3]) * 0.25f;
var faces = new List<HFace>(4 + n);
AddHullFace(faces, pts, interior, i0, i1, i2);
AddHullFace(faces, pts, interior, i0, i1, i3);
AddHullFace(faces, pts, interior, i0, i2, i3);
AddHullFace(faces, pts, interior, i1, i2, i3);
var visible = new List<int>();
var edgeCount = new Dictionary<long, int>();
var horizonA = new List<int>();
var horizonB = new List<int>();
for (int pi = 0; pi < n; pi++)
{
if (pi == i0 || pi == i1 || pi == i2 || pi == i3) continue;
Vector3 p = pts[pi];
visible.Clear();
for (int f = 0; f < faces.Count; f++)
{
HFace hf = faces[f];
if (Vector3.Dot(hf.n, p - pts[hf.a]) > eps) visible.Add(f);
}
if (visible.Count == 0) continue;
edgeCount.Clear();
for (int vi = 0; vi < visible.Count; vi++)
{
HFace hf = faces[visible[vi]];
AccEdge(edgeCount, hf.a, hf.b);
AccEdge(edgeCount, hf.b, hf.c);
AccEdge(edgeCount, hf.c, hf.a);
}
horizonA.Clear(); horizonB.Clear();
for (int vi = 0; vi < visible.Count; vi++)
{
HFace hf = faces[visible[vi]];
if (EdgeIsHorizon(edgeCount, hf.a, hf.b)) { horizonA.Add(hf.a); horizonB.Add(hf.b); }
if (EdgeIsHorizon(edgeCount, hf.b, hf.c)) { horizonA.Add(hf.b); horizonB.Add(hf.c); }
if (EdgeIsHorizon(edgeCount, hf.c, hf.a)) { horizonA.Add(hf.c); horizonB.Add(hf.a); }
}
visible.Sort();
for (int vi = visible.Count - 1; vi >= 0; vi--) faces.RemoveAt(visible[vi]);
for (int h = 0; h < horizonA.Count; h++)
AddHullFace(faces, pts, interior, horizonA[h], horizonB[h], pi);
}
if (faces.Count < 4) return false;
var remap = new Dictionary<int, int>();
for (int f = 0; f < faces.Count; f++)
{
HFace hf = faces[f];
hullTrisOut.Add(RemapVert(remap, hullVertsOut, pts, hf.a));
hullTrisOut.Add(RemapVert(remap, hullVertsOut, pts, hf.b));
hullTrisOut.Add(RemapVert(remap, hullVertsOut, pts, hf.c));
}
return true;
}
private static void AddHullFace(List<HFace> faces, Vector3[] pts, Vector3 interior, int a, int b, int c)
{
Vector3 nn = Vector3.Cross(pts[b] - pts[a], pts[c] - pts[a]);
float l = nn.magnitude;
if (l < 1e-20f) return;
nn /= l;
if (Vector3.Dot(nn, pts[a] - interior) < 0f) nn = -nn;
faces.Add(new HFace { a = a, b = b, c = c, n = nn });
}
private static long EdgeKey(int u, int v)
{
int lo = u < v ? u : v, hi = u < v ? v : u;
return ((long)lo << 32) | (uint)hi;
}
private static void AccEdge(Dictionary<long, int> ec, int u, int v)
{
long k = EdgeKey(u, v);
ec.TryGetValue(k, out int c);
ec[k] = c + 1;
}
private static bool EdgeIsHorizon(Dictionary<long, int> ec, int u, int v)
{
ec.TryGetValue(EdgeKey(u, v), out int c);
return c == 1;
}
private static int RemapVert(Dictionary<int, int> remap, List<Vector3> outV, Vector3[] pts, int idx)
{
if (remap.TryGetValue(idx, out int r)) return r;
r = outV.Count; remap[idx] = r; outV.Add(pts[idx]);
return r;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 80f683e75cf2f2a41bb32d8e9de0a2f0
@@ -0,0 +1,393 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Standalone volumetric concealment meter for the painted character.
///
/// Each evaluation bakes the character's current (deformed) skinned mesh, voxelizes its
/// SOLID volume, and reports what fraction of that volume sits INSIDE environment colliders
/// (= embedded / hidden) versus exposed. <see cref="EmbeddedPercent"/> is the headline value,
/// <see cref="visiblePercent"/> its complement.
///
/// Pipeline:
/// 1. <see cref="SkinnedMeshRenderer.BakeMesh(Mesh)"/> -> mesh in the renderer's LOCAL space, then the
/// player's world scale is divided back out (BakeMesh bakes that scale IN; see PlayerScaler) so the
/// convention world = transform.localToWorldMatrix holds at ANY size — the same fixup SkinnedColliderBaker
/// applies, and the unit-scale mesh MeshPainter borrows from it.
/// 2. Column ray-stabbing in local space: each (x,z) grid column collects the Y where the mesh
/// surface crosses it; parity (odd # of crossings below a voxel center) marks interior voxels.
/// This is the same XZ triangle rasterization MeshPainter.GetIslandMask uses for UV islands.
/// 3. Each interior voxel center -> world; tested against broadphase-filtered environment
/// colliders: convex primitives / convex MeshCollider via ClosestPoint, Terrain via
/// SampleHeight, optional concave MeshCollider via raycast parity.
///
/// Surface-watertightness caveat: parity assumes a closed mesh. After the Blender merge-by-distance
/// the paintman rig is effectively watertight; small leaks only perturb the percentage slightly.
/// </summary>
[DisallowMultipleComponent]
public class ConcealmentMeter : MonoBehaviour
{
[Header("Targets")]
[Tooltip("Skinned character to measure. Auto-resolved from this GameObject / children if empty.")]
[SerializeField] private SkinnedMeshRenderer targetRenderer;
[Tooltip("Root of the character's OWN collider hierarchy, excluded from the environment test. " +
"Auto = the renderer transform's root.")]
[SerializeField] private Transform characterRoot;
[Header("Voxelization")]
[Min(2)]
[Tooltip("Voxels along the longest local bounds axis. Higher = more precise & more cost. ~24 is a good start.")]
[SerializeField] private int voxelResolution = 24;
[Header("Environment")]
[Tooltip("Layers that count as environment the body can be embedded in.")]
[SerializeField] private LayerMask environmentMask = ~0;
[Tooltip("Treat concave (non-convex) MeshColliders as solid via raycast parity. Off = ignored " +
"(ClosestPoint is unsupported for them, and flat floors are meaningless as 'inside').")]
[SerializeField] private bool includeConcaveMeshColliders;
[Tooltip("Count being below a Terrain surface as embedded.")]
[SerializeField] private bool includeTerrain = true;
[Header("Scheduling")]
[Min(0f)]
[Tooltip("Seconds between evaluations while playing. 0 = every frame.")]
[SerializeField] private float evaluateInterval = 0.1f;
[SerializeField] private bool evaluateInPlayMode = true;
[Header("Debug")]
[SerializeField] private bool drawGizmos;
[SerializeField] private Color exposedColor = new Color(0.2f, 1f, 0.3f, 0.25f);
[SerializeField] private Color embeddedColor = new Color(1f, 0.25f, 0.2f, 0.5f);
/// <summary>Percentage [0..100] of the character's solid volume embedded inside environment colliders.</summary>
public float EmbeddedPercent { get; private set; }
public float visiblePercent; // Inspector debug readout (complement of EmbeddedPercent)
public bool HasResult { get; private set; }
private Mesh _baked;
private readonly List<Vector3> _verts = new List<Vector3>(); // refilled per eval without allocating
private int[] _tris; // topology is invariant across bakes of the same renderer -> fetched once
private int _trisVertexCount = -1; // vertex count _tris was fetched for (detects a renderer/mesh swap)
private bool _overlapSaturated; // log-once guard for a saturated broadphase buffer
private float _timer;
private readonly List<Vector3> _vbuf = new List<Vector3>(); // reused scale-fixup scratch; nothing allocated per eval
// Per-column Y crossings (index = iz * nx + ix). Reused across evals; sizes grow as needed.
private List<float>[] _columns;
// Broadphase scratch + classified environment candidates (rebuilt each eval).
private readonly Collider[] _overlap = new Collider[64];
private readonly List<Collider> _convex = new List<Collider>();
private readonly List<Terrain> _terrains = new List<Terrain>();
private readonly List<MeshCollider> _concave = new List<MeshCollider>();
private readonly List<ConcealmentVolume> _volumes = new List<ConcealmentVolume>();
private readonly List<Vector3> _gizPos = new List<Vector3>();
private readonly List<bool> _gizEmbedded = new List<bool>();
private float _gizVoxelWorld;
// ClosestPoint returns the input point unchanged when it is inside a (convex) collider.
private const float InsideEpsSqr = 1e-8f;
private void Awake()
{
if (!targetRenderer) targetRenderer = GetComponentInChildren<SkinnedMeshRenderer>();
if (!characterRoot && targetRenderer) characterRoot = targetRenderer.transform.root;
_baked = new Mesh { name = "ConcealmentMeter_Baked" };
}
private void OnDestroy()
{
if (_baked) Destroy(_baked);
}
private void Update()
{
if (!evaluateInPlayMode) return;
if (evaluateInterval <= 0f) { Evaluate(); return; }
_timer += Time.deltaTime;
if (_timer < evaluateInterval) return;
_timer = 0f;
Evaluate();
}
/// <summary>Bakes the current pose, voxelizes it, and recomputes the embedded percentage.</summary>
[ContextMenu("Evaluate Now")]
public void Evaluate()
{
if (!targetRenderer) return;
targetRenderer.BakeMesh(_baked);
// BakeMesh freezes the bone deformation — which already carries the player's world scale (PlayerScaler)
// — into the mesh. Everything below treats the bake as unit-scale LOCAL geometry and raises it to world
// via targetRenderer.localToWorldMatrix, which re-applies that same scale. Divide it back out first,
// else a scaled player is measured at scale² (voxel world positions, broadphase box AND gizmos), which
// also corrupts EmbeddedPercent. No-op at scale 1; the uniform divide is rotation-independent, which the
// player's uniform scale guarantees. Mirrors SkinnedColliderBaker's identical fixup.
GeometryUtil.RescaleToUnitScale(_baked, targetRenderer.transform.lossyScale, _vbuf);
// mesh.vertices/.triangles return fresh arrays per call - at 10 evals/s that is steady GC
// pressure. Vertices go into a reused list; the (bake-invariant) index buffer is fetched only
// when the source mesh actually changed.
_baked.GetVertices(_verts);
if (_tris == null || _trisVertexCount != _baked.vertexCount)
{
_tris = _baked.triangles;
_trisVertexCount = _baked.vertexCount;
}
if (_verts.Count == 0 || _tris.Length < 3) return;
Bounds lb = _baked.bounds; // local-space AABB of the baked pose
Vector3 size = lb.size;
float maxExtent = Mathf.Max(size.x, Mathf.Max(size.y, size.z));
if (maxExtent <= 1e-6f) return;
float voxel = maxExtent / voxelResolution;
int nx = Mathf.Max(1, Mathf.CeilToInt(size.x / voxel));
int ny = Mathf.Max(1, Mathf.CeilToInt(size.y / voxel));
int nz = Mathf.Max(1, Mathf.CeilToInt(size.z / voxel));
Vector3 origin = lb.min;
StabColumns(origin, voxel, nx, nz);
Matrix4x4 l2w = targetRenderer.transform.localToWorldMatrix;
// Derive the broadphase box from the ACTUAL transformed geometry, not SkinnedMeshRenderer.bounds
// (the latter is a root-bone approximation that can be badly offset for a deformed/posed rig).
Bounds worldBounds = GeometryUtil.TransformBounds(l2w, lb);
BuildEnvironmentCandidates(worldBounds);
bool anyEnv = _convex.Count > 0 ||
_volumes.Count > 0 ||
(includeTerrain && _terrains.Count > 0) ||
(includeConcaveMeshColliders && _concave.Count > 0);
_gizPos.Clear();
_gizEmbedded.Clear();
_gizVoxelWorld = voxel * AverageScale(targetRenderer.transform);
int interior = 0, embedded = 0;
for (int iz = 0; iz < nz; iz++)
{
for (int ix = 0; ix < nx; ix++)
{
List<float> col = _columns[iz * nx + ix];
if (col == null || col.Count < 2) continue;
col.Sort();
// col is sorted and cy grows monotonically with iy, so a single running index
// replaces a from-scratch scan per voxel: O(ny + crossings) per column.
int below = 0;
for (int iy = 0; iy < ny; iy++)
{
float cy = origin.y + (iy + 0.5f) * voxel;
while (below < col.Count && col[below] <= cy) below++;
if ((below & 1) == 0) continue; // even crossings below -> outside the solid
interior++;
Vector3 localC = new Vector3(origin.x + (ix + 0.5f) * voxel, cy, origin.z + (iz + 0.5f) * voxel);
Vector3 worldC = l2w.MultiplyPoint3x4(localC);
bool inside = anyEnv && IsInsideAnyEnvironment(worldC);
if (inside) embedded++;
if (drawGizmos)
{
_gizPos.Add(worldC);
_gizEmbedded.Add(inside);
}
}
}
}
EmbeddedPercent = interior > 0 ? 100f * embedded / interior : 0f;
visiblePercent = 100 - EmbeddedPercent;
HasResult = true;
}
/// <summary>
/// Rasterizes every triangle into the XZ column grid; each column accumulates the Y values
/// where the surface crosses it (one per triangle whose XZ projection covers the column center).
/// </summary>
private void StabColumns(Vector3 origin, float voxel, int nx, int nz)
{
int cells = nx * nz;
if (_columns == null || _columns.Length < cells) _columns = new List<float>[cells];
for (int i = 0; i < cells; i++) _columns[i]?.Clear();
int triCount = _tris.Length / 3;
for (int t = 0; t < triCount; t++)
{
Vector3 a = _verts[_tris[t * 3]];
Vector3 b = _verts[_tris[t * 3 + 1]];
Vector3 c = _verts[_tris[t * 3 + 2]];
float ax = a.x, az = a.z, bx = b.x, bz = b.z, cx = c.x, cz = c.z;
float d = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz);
if (Mathf.Abs(d) < 1e-12f) continue; // degenerate in XZ (e.g. a vertical wall triangle)
float invD = 1f / d;
float minXf = Mathf.Min(ax, Mathf.Min(bx, cx));
float maxXf = Mathf.Max(ax, Mathf.Max(bx, cx));
float minZf = Mathf.Min(az, Mathf.Min(bz, cz));
float maxZf = Mathf.Max(az, Mathf.Max(bz, cz));
int ix0 = Mathf.Clamp(Mathf.FloorToInt((minXf - origin.x) / voxel), 0, nx - 1);
int ix1 = Mathf.Clamp(Mathf.CeilToInt((maxXf - origin.x) / voxel), 0, nx - 1);
int iz0 = Mathf.Clamp(Mathf.FloorToInt((minZf - origin.z) / voxel), 0, nz - 1);
int iz1 = Mathf.Clamp(Mathf.CeilToInt((maxZf - origin.z) / voxel), 0, nz - 1);
for (int iz = iz0; iz <= iz1; iz++)
{
float pz = origin.z + (iz + 0.5f) * voxel;
for (int ix = ix0; ix <= ix1; ix++)
{
float px = origin.x + (ix + 0.5f) * voxel;
float wa = ((bz - cz) * (px - cx) + (cx - bx) * (pz - cz)) * invD;
if (wa < 0f) continue;
float wb = ((cz - az) * (px - cx) + (ax - cx) * (pz - cz)) * invD;
if (wb < 0f) continue;
float wc = 1f - wa - wb;
if (wc < 0f) continue;
float y = wa * a.y + wb * b.y + wc * c.y;
int ci = iz * nx + ix;
(_columns[ci] ?? (_columns[ci] = new List<float>(8))).Add(y);
}
}
}
}
/// <summary>Broadphase-overlaps the character bounds and classifies the hit colliders by test type, excluding the character itself.</summary>
private void BuildEnvironmentCandidates(Bounds wb)
{
_convex.Clear();
_terrains.Clear();
_concave.Clear();
_volumes.Clear();
// Baked concealment volumes are authoritative for the (open-shell) furniture they cover;
// gathered from the registry independent of colliders so they need no specific collider type.
IReadOnlyList<ConcealmentVolume> vols = ConcealmentVolume.All;
for (int i = 0; i < vols.Count; i++)
{
ConcealmentVolume v = vols[i];
if (!v || !v.HasBake) continue;
if (characterRoot && (v.transform == characterRoot || v.transform.IsChildOf(characterRoot))) continue;
if (v.WorldBounds.Intersects(wb)) _volumes.Add(v);
}
int n = Physics.OverlapBoxNonAlloc(wb.center, wb.extents, _overlap, Quaternion.identity,
environmentMask, QueryTriggerInteraction.Ignore);
if (n == _overlap.Length && !_overlapSaturated)
{
_overlapSaturated = true; // silently dropped colliders would skew EmbeddedPercent low
Debug.LogWarning($"ConcealmentMeter: broadphase buffer saturated ({n}); environment colliders may be missed.", this);
}
for (int i = 0; i < n; i++)
{
Collider col = _overlap[i];
if (!col) continue;
if (characterRoot && (col.transform == characterRoot || col.transform.IsChildOf(characterRoot))) continue;
if (col is TerrainCollider)
{
Terrain terr = col.GetComponent<Terrain>();
if (terr) _terrains.Add(terr);
continue;
}
if (col is MeshCollider mc && !mc.convex)
{
// A baked ConcealmentVolume or ConcealmentColliderBaker supersedes raycast parity for this object.
if (includeConcaveMeshColliders && !mc.GetComponentInParent<ConcealmentVolume>()
&& !mc.GetComponentInParent<ConcealmentColliderBaker>())
_concave.Add(mc);
continue; // concave can't use ClosestPoint
}
_convex.Add(col); // Box / Sphere / Capsule / convex MeshCollider
}
}
private bool IsInsideAnyEnvironment(Vector3 p)
{
for (int i = 0; i < _volumes.Count; i++)
{
if (_volumes[i].ContainsWorld(p)) return true;
}
for (int i = 0; i < _convex.Count; i++)
{
if ((_convex[i].ClosestPoint(p) - p).sqrMagnitude <= InsideEpsSqr) return true;
}
if (includeTerrain)
{
for (int i = 0; i < _terrains.Count; i++)
{
Terrain terr = _terrains[i];
Vector3 tp = terr.transform.position;
TerrainData td = terr.terrainData;
float lx = p.x - tp.x, lz = p.z - tp.z;
if (lx < 0f || lz < 0f || lx > td.size.x || lz > td.size.z) continue;
if (p.y <= tp.y + terr.SampleHeight(p)) return true; // below the surface
}
}
if (includeConcaveMeshColliders)
{
for (int i = 0; i < _concave.Count; i++)
{
if (InsideConcave(_concave[i], p)) return true;
}
}
return false;
}
/// <summary>Parity test for a closed concave MeshCollider: odd # of surface crossings straight up = inside.</summary>
private static bool InsideConcave(MeshCollider mc, Vector3 p)
{
const float maxDist = 1000f;
const float skin = 1e-4f;
Vector3 dir = Vector3.up;
Ray ray = new Ray(p, dir);
int hits = 0;
float travelled = 0f;
while (travelled < maxDist && mc.Raycast(ray, out RaycastHit h, maxDist - travelled))
{
hits++;
travelled += h.distance + skin;
ray = new Ray(h.point + dir * skin, dir);
}
return (hits & 1) == 1;
}
private static float AverageScale(Transform t)
{
Vector3 s = t.lossyScale;
return (Mathf.Abs(s.x) + Mathf.Abs(s.y) + Mathf.Abs(s.z)) / 3f;
}
private void OnDrawGizmos()
{
if (!drawGizmos || _gizPos.Count == 0) return;
Vector3 cube = Vector3.one * (_gizVoxelWorld * 0.9f);
for (int i = 0; i < _gizPos.Count; i++)
{
Gizmos.color = _gizEmbedded[i] ? embeddedColor : exposedColor;
Gizmos.DrawCube(_gizPos[i], cube);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8baaa10e1e77f0944b3aa0b4312fc5a1
@@ -0,0 +1,262 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Editor-baked solid/enclosed voxel volume for a single (typically non-convex, non-watertight)
/// environment mesh. Runtime lookup is an O(1) grid test, so <see cref="ConcealmentMeter"/> can
/// treat furniture as "embeddable" without per-voxel raycast parity (which is unreliable on the
/// open-shell furniture meshes of asset kits).
///
/// Bake (editor only): voxelizes the source mesh in LOCAL space and marks a voxel as INSIDE when
/// either classifier fires:
/// * Generalized Winding Number &gt; 0.5 -> robustly captures SOLID material even for open shells.
/// * Exterior flood-fill cannot reach it -> captures fully ENCLOSED air cavities.
/// The union is conservative in the safe direction: an unreachable solid OR an enclosed pocket both
/// count as concealment; open regions (seat surface, under a thin table) correctly read as exposed.
///
/// Query is done via <see cref="ContainsWorld"/>; bake and query share the SAME mesh-local space
/// (verts at bake time, transform.InverseTransformPoint at query time), so position/rotation/scale
/// are handled uniformly.
/// </summary>
[DisallowMultipleComponent]
public class ConcealmentVolume : MonoBehaviour
{
public enum BakeMethod { WindingNumberOrFloodFill, WindingNumberOnly, FloodFillOnly }
[Header("Bake settings")]
[Min(2)]
[Tooltip("Voxels along the longest local-bounds axis. Higher = sharper & bigger bake. ~32 is a good start.")]
[SerializeField] private int resolution = 32;
[Tooltip("Source mesh. Auto = MeshCollider.sharedMesh, else MeshFilter.sharedMesh on this object.")]
[SerializeField] private Mesh sourceMesh;
[SerializeField] private BakeMethod method = BakeMethod.WindingNumberOrFloodFill;
[Header("Debug")]
[SerializeField] private bool drawBakedGizmos;
[SerializeField] private Color gizmoColor = new Color(0.2f, 0.6f, 1f, 0.35f);
// --- Baked data (local space) ---
[SerializeField, HideInInspector] private Vector3 origin; // local min corner of the grid
[SerializeField, HideInInspector] private float voxelSize;
[SerializeField, HideInInspector] private int nx, ny, nz;
[SerializeField, HideInInspector] private uint[] bits; // packed inside-flags, idx = ix + nx*(iy + ny*iz)
[SerializeField, HideInInspector] private int insideCount;
public bool HasBake => bits != null && bits.Length > 0 && nx > 0;
private static readonly List<ConcealmentVolume> Active = new List<ConcealmentVolume>();
public static IReadOnlyList<ConcealmentVolume> All => Active;
private void OnEnable() { if (!Active.Contains(this)) Active.Add(this); }
private void OnDisable() { Active.Remove(this); }
/// <summary>World-space AABB of the baked grid (for broadphase rejection).</summary>
public Bounds WorldBounds
{
get
{
Vector3 mn = origin;
Vector3 mx = origin + new Vector3(nx * voxelSize, ny * voxelSize, nz * voxelSize);
var local = new Bounds();
local.SetMinMax(mn, mx);
return GeometryUtil.TransformBounds(transform.localToWorldMatrix, local);
}
}
/// <summary>O(1) test: is the world point inside a baked-as-solid/enclosed voxel?</summary>
public bool ContainsWorld(Vector3 world)
{
if (!HasBake) return false;
Vector3 lp = transform.InverseTransformPoint(world);
int ix = Mathf.FloorToInt((lp.x - origin.x) / voxelSize);
if (ix < 0 || ix >= nx) return false;
int iy = Mathf.FloorToInt((lp.y - origin.y) / voxelSize);
if (iy < 0 || iy >= ny) return false;
int iz = Mathf.FloorToInt((lp.z - origin.z) / voxelSize);
if (iz < 0 || iz >= nz) return false;
int idx = ix + nx * (iy + ny * iz);
return (bits[idx >> 5] & (1u << (idx & 31))) != 0;
}
#if UNITY_EDITOR
[ContextMenu("Bake Concealment Volume")]
public void Bake()
{
Mesh mesh = sourceMesh;
if (!mesh)
{
var mc = GetComponent<MeshCollider>();
if (mc) mesh = mc.sharedMesh;
}
if (!mesh)
{
var mf = GetComponent<MeshFilter>();
if (mf) mesh = mf.sharedMesh;
}
if (!mesh) { Debug.LogError($"[ConcealmentVolume] {name}: no source mesh.", this); return; }
Vector3[] verts = mesh.vertices;
int[] tris = mesh.triangles;
if (verts.Length == 0 || tris.Length < 3) { Debug.LogError($"[ConcealmentVolume] {name}: empty mesh.", this); return; }
Bounds lb = mesh.bounds;
float maxExtent = Mathf.Max(lb.size.x, Mathf.Max(lb.size.y, lb.size.z));
if (maxExtent <= 1e-6f) { Debug.LogError($"[ConcealmentVolume] {name}: degenerate bounds.", this); return; }
float vs = maxExtent / resolution;
const int pad = 1; // empty shell so exterior flood always has a seed
nx = Mathf.CeilToInt(lb.size.x / vs) + 2 * pad;
ny = Mathf.CeilToInt(lb.size.y / vs) + 2 * pad;
nz = Mathf.CeilToInt(lb.size.z / vs) + 2 * pad;
voxelSize = vs;
origin = lb.min - new Vector3(vs, vs, vs) * pad;
int count = nx * ny * nz;
bool[] wall = new bool[count];
// --- 1) Rasterize triangles into wall voxels (barycentric supersampling, step = vs/2) ---
for (int t = 0; t < tris.Length; t += 3)
{
Vector3 a = verts[tris[t]], b = verts[tris[t + 1]], c = verts[tris[t + 2]];
float eMax = Mathf.Max((b - a).magnitude, (c - a).magnitude);
int steps = Mathf.Max(1, Mathf.CeilToInt(eMax / (vs * 0.5f)));
float inv = 1f / steps;
for (int u = 0; u <= steps; u++)
{
for (int v = 0; v <= steps - u; v++)
{
Vector3 p = a + (b - a) * (u * inv) + (c - a) * (v * inv);
int ix = Mathf.Clamp(Mathf.FloorToInt((p.x - origin.x) / vs), 0, nx - 1);
int iy = Mathf.Clamp(Mathf.FloorToInt((p.y - origin.y) / vs), 0, ny - 1);
int iz = Mathf.Clamp(Mathf.FloorToInt((p.z - origin.z) / vs), 0, nz - 1);
wall[ix + nx * (iy + ny * iz)] = true;
}
}
}
// --- 2) Exterior flood-fill (6-connected) through non-wall voxels ---
bool[] outside = new bool[count];
var stack = new Stack<int>(count / 4 + 16);
for (int iz = 0; iz < nz; iz++)
for (int iy = 0; iy < ny; iy++)
for (int ix = 0; ix < nx; ix++)
{
if (ix != 0 && ix != nx - 1 && iy != 0 && iy != ny - 1 && iz != 0 && iz != nz - 1) continue;
int idx = ix + nx * (iy + ny * iz);
if (!wall[idx] && !outside[idx]) { outside[idx] = true; stack.Push(idx); }
}
while (stack.Count > 0)
{
int idx = stack.Pop();
int ix = idx % nx;
int iy = (idx / nx) % ny;
int iz = idx / (nx * ny);
PushNeighbor(ix - 1, iy, iz, wall, outside, stack);
PushNeighbor(ix + 1, iy, iz, wall, outside, stack);
PushNeighbor(ix, iy - 1, iz, wall, outside, stack);
PushNeighbor(ix, iy + 1, iz, wall, outside, stack);
PushNeighbor(ix, iy, iz - 1, wall, outside, stack);
PushNeighbor(ix, iy, iz + 1, wall, outside, stack);
}
// --- 3) Classify each voxel; pack into bits ---
bits = new uint[(count + 31) >> 5];
insideCount = 0;
int floodOnly = 0, windingOnly = 0;
bool useWinding = method != BakeMethod.FloodFillOnly;
bool useFlood = method != BakeMethod.WindingNumberOnly;
for (int iz = 0; iz < nz; iz++)
for (int iy = 0; iy < ny; iy++)
for (int ix = 0; ix < nx; ix++)
{
int idx = ix + nx * (iy + ny * iz);
bool enclosed = useFlood && !outside[idx];
bool solid = false;
if (useWinding)
solid = WindingNumber(VoxelCenter(origin, ix, iy, iz, vs), verts, tris) > 0.5;
bool inside = enclosed || solid;
if (!inside) continue;
bits[idx >> 5] |= 1u << (idx & 31);
insideCount++;
if (enclosed && !solid) floodOnly++;
if (solid && !enclosed) windingOnly++;
}
UnityEditor.Undo.RecordObject(this, "Bake Concealment Volume");
UnityEditor.EditorUtility.SetDirty(this);
if (!Application.isPlaying)
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene);
Debug.Log($"[ConcealmentVolume] {name}: baked {nx}x{ny}x{nz} (vs={vs:F3}) -> inside={insideCount}/{count} " +
$"(winding-only={windingOnly}, flood-only={floodOnly}), bits={bits.Length * 4}B", this);
}
[ContextMenu("Clear Bake")]
public void ClearBake()
{
bits = null; nx = ny = nz = 0; insideCount = 0;
UnityEditor.EditorUtility.SetDirty(this);
}
private void PushNeighbor(int ix, int iy, int iz, bool[] wall, bool[] outside, Stack<int> stack)
{
if (ix < 0 || ix >= nx || iy < 0 || iy >= ny || iz < 0 || iz >= nz) return;
int idx = ix + nx * (iy + ny * iz);
if (wall[idx] || outside[idx]) return;
outside[idx] = true;
stack.Push(idx);
}
/// <summary>Generalized winding number at p (mesh-local). ~1 inside a solid, ~0 outside; robust for open shells.</summary>
private static double WindingNumber(Vector3 p, Vector3[] verts, int[] tris)
{
double sum = 0.0;
double px = p.x, py = p.y, pz = p.z;
for (int t = 0; t < tris.Length; t += 3)
{
Vector3 A = verts[tris[t]], B = verts[tris[t + 1]], C = verts[tris[t + 2]];
double ax = A.x - px, ay = A.y - py, az = A.z - pz;
double bx = B.x - px, by = B.y - py, bz = B.z - pz;
double cx = C.x - px, cy = C.y - py, cz = C.z - pz;
double la = System.Math.Sqrt(ax * ax + ay * ay + az * az);
double lb = System.Math.Sqrt(bx * bx + by * by + bz * bz);
double lc = System.Math.Sqrt(cx * cx + cy * cy + cz * cz);
if (la < 1e-12 || lb < 1e-12 || lc < 1e-12) continue;
// triple product a . (b x c)
double crx = by * cz - bz * cy;
double cry = bz * cx - bx * cz;
double crz = bx * cy - by * cx;
double numer = ax * crx + ay * cry + az * crz;
double denom = la * lb * lc
+ (ax * bx + ay * by + az * bz) * lc
+ (bx * cx + by * cy + bz * cz) * la
+ (cx * ax + cy * ay + cz * az) * lb;
sum += System.Math.Atan2(numer, denom) * 2.0;
}
return sum / (4.0 * System.Math.PI);
}
/// <summary>Local-space center of grid voxel (ix, iy, iz).</summary>
private static Vector3 VoxelCenter(Vector3 origin, int ix, int iy, int iz, float size)
=> new Vector3(origin.x + (ix + 0.5f) * size, origin.y + (iy + 0.5f) * size, origin.z + (iz + 0.5f) * size);
private void OnDrawGizmosSelected()
{
if (!drawBakedGizmos || !HasBake) return;
Gizmos.color = gizmoColor;
Gizmos.matrix = transform.localToWorldMatrix;
Vector3 cube = Vector3.one * (voxelSize * 0.9f);
for (int iz = 0; iz < nz; iz++)
for (int iy = 0; iy < ny; iy++)
for (int ix = 0; ix < nx; ix++)
{
int idx = ix + nx * (iy + ny * iz);
if ((bits[idx >> 5] & (1u << (idx & 31))) == 0) continue;
Gizmos.DrawCube(VoxelCenter(origin, ix, iy, iz, voxelSize), cube);
}
}
#endif
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0a59a347a3128354ebd4f84afc97a0c5
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c34d766647ee8994d83c19705febc269
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,184 @@
using UnityEngine;
using Unity.Cinemachine;
/// <summary>
/// First-person camera. Drives its OWN dedicated <see cref="CinemachineCamera"/>: each frame it snaps
/// to the player's dedicated <c>FirstPersonTarget</c> (the eye anchor) for position and
/// applies free mouse-look (yaw/pitch) for rotation. While active the player's mesh is hidden so the
/// body isn't seen from the inside. Movement stays with the player controller, which reads the
/// brain-driven Main Camera forward, so WASD is automatically relative to this view.
///
/// Activation is driven by <see cref="FirstPersonMode"/> via <see cref="SetActive"/>: it raises the
/// vcam priority so the CinemachineBrain blends to it, snaps look to the current view (no jump), hides
/// the mesh and locks the cursor. While inactive the vcam rests at a low priority and this component
/// does nothing.
/// </summary>
[RequireComponent(typeof(CinemachineCamera))]
[DisallowMultipleComponent]
public class FirstPersonController : MonoBehaviour
{
[Header("Target")]
[Tooltip("Eye position the camera sits at (the player's FirstPersonTarget). Empty = auto-resolved from the player's \"FirstPersonTarget\" child.")]
public Transform cameraTarget;
[Tooltip("Renderers hidden while first-person is active (so you don't see your own body). Empty = the player's SkinnedMeshRenderers, auto-resolved.")]
public Renderer[] hiddenRenderers;
[Header("Look")]
[Tooltip("Horizontal look sensitivity (degrees of yaw per pixel).")]
public float horizontalSensitivity = 0.2f;
[Tooltip("Vertical look sensitivity (degrees of pitch per pixel).")]
public float verticalSensitivity = 0.15f;
[Tooltip("Invert vertical look (mouse up tilts the camera down).")]
public bool invertY = false;
[Tooltip("Pitch is clamped to +/- this angle (degrees) so the camera never flips over.")]
[Range(1f, 89.9f)]
public float maxPitch = 89f;
[Header("Smoothing")]
[Tooltip("Look smoothing, in seconds. 0 = raw mouse (instant); small values take the edge off jitter without feeling laggy.")]
[Range(0f, 0.3f)]
public float lookSmoothTime = 0.04f;
[Header("Camera")]
[Tooltip("Priority the dedicated CinemachineCamera takes while active (must beat the gameplay camera).")]
public int activePriority = 100;
[Tooltip("Priority the dedicated CinemachineCamera rests at while inactive (must lose to the gameplay camera).")]
public int inactivePriority = -100;
CinemachineCamera _vcam;
PlayerCharacterController _player; // body whose yaw we pin to the camera while active
PlayerPoseController _poseController; // supplies the active pose's per-pose first-person eye offset
SmoothedLook _look; // yaw/pitch accumulator + smoothing shared with the freecam
bool _resolved;
void Awake()
{
_vcam = GetComponent<CinemachineCamera>();
if (!_vcam)
Debug.LogError("[FirstPersonController] No CinemachineCamera on this GameObject; first-person cannot go live.", this);
else
_vcam.Priority = inactivePriority;
ResolveReferences();
// Dormant until FirstPersonMode enters; nothing reads input or moves the rig meanwhile.
enabled = false;
}
/// <summary>Enter/leave first-person. Raises/lowers the vcam priority, hides the player mesh, snaps
/// look to the live view and manages the cursor. Called by <see cref="FirstPersonMode"/>.</summary>
public void SetActive(bool active)
{
if (_vcam)
_vcam.Priority = active ? activePriority : inactivePriority;
SetMeshHidden(active);
if (active)
{
SyncToCurrentView();
InputUtil.SetCursorLocked(true);
ApplyTransform(); // place at the eye on the same frame so the blend starts from there
if (_player) _player.FaceYaw = _look.DisplayYaw; // body faces the camera from frame one
}
else
{
InputUtil.SetCursorLocked(false);
if (_player) _player.FaceYaw = null; // restore movement-facing
}
enabled = active;
}
void LateUpdate()
{
// LateUpdate so the player has already moved the CameraTarget this frame.
LookTick();
ApplyTransform();
if (_player) _player.FaceYaw = _look.DisplayYaw; // keep the body's yaw locked to the camera
}
void LookTick()
{
Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
_look.Tick(delta, horizontalSensitivity, verticalSensitivity, invertY, maxPitch, lookSmoothTime);
}
void ApplyTransform()
{
Quaternion rot = _look.Rotation;
if (cameraTarget)
{
// Sit at the first-person eye anchor, then raise/lower the eye by the active pose's first-person
// offset. TransformVector keeps it in the target's local up and scales with the player (so it
// tracks PlayerScaler).
Vector3 pos = cameraTarget.position;
float offset = _poseController ? _poseController.FirstPersonCameraYOffset : 0f;
if (offset != 0f)
pos += cameraTarget.TransformVector(Vector3.up * offset);
transform.SetPositionAndRotation(pos, rot);
}
else
transform.rotation = rot;
}
/// <summary>Seed look from the currently rendered view so going live produces no visible jump.</summary>
void SyncToCurrentView()
{
var cam = Camera.main;
if (cam)
_look.SyncTo(cam.transform.rotation, maxPitch);
else
_look.Snap(); // no camera: keep the current angles but still snap the smoothed state
}
void SetMeshHidden(bool hidden)
{
if (hiddenRenderers == null) return;
foreach (var r in hiddenRenderers)
if (r) r.enabled = !hidden;
}
/// <summary>
/// Explicitly binds this rig to the locally-owned networked player (overriding the scene-wide
/// auto-resolve, which could otherwise latch onto a remote avatar). Called by the GameModeManager
/// when the local player spawns.
/// </summary>
public void SetPlayer(PlayerCharacterController p)
{
if (!p) return;
_player = p;
_poseController = p.GetComponent<PlayerPoseController>();
Transform t = p.transform.Find("FirstPersonTarget");
if (t) cameraTarget = t;
hiddenRenderers = p.GetComponentsInChildren<SkinnedMeshRenderer>(true);
_resolved = true;
}
void ResolveReferences()
{
if (_resolved) return;
_resolved = true;
var player = FindFirstObjectByType<PlayerCharacterController>(FindObjectsInactive.Include);
if (!player) return;
_player = player;
if (!_poseController)
_poseController = player.GetComponent<PlayerPoseController>();
if (!cameraTarget)
{
Transform t = player.transform.Find("FirstPersonTarget");
if (t) cameraTarget = t;
}
if (hiddenRenderers == null || hiddenRenderers.Length == 0)
hiddenRenderers = player.GetComponentsInChildren<SkinnedMeshRenderer>(true);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 43b8f1dc43642294f99fab0aaabf6197
@@ -0,0 +1,193 @@
using UnityEngine;
using Unity.Cinemachine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// Free-flying spectator camera. Drives its OWN dedicated <see cref="CinemachineCamera"/> by moving
/// this transform with WASD (relative to where it looks), Q/E (or Space/Left&#160;Ctrl) for down/up
/// and the mouse for look. Collision is a <see cref="CharacterController"/> capsule sized like the
/// player's, so the freecam slides along world geometry instead of clipping through walls/floors.
///
/// Activation is driven by <see cref="FreeCamMode"/> via <see cref="SetActive"/>: it raises the vcam
/// priority so the CinemachineBrain blends to it, snaps the rig to the current view (no jump) and
/// locks the cursor. While inactive the vcam rests at a low priority and this component does nothing.
/// </summary>
[RequireComponent(typeof(CharacterController))]
[DisallowMultipleComponent]
public class FreeCamController : MonoBehaviour
{
[Header("Flight")]
[Tooltip("Base flight speed (units/second).")]
public float moveSpeed = 6f;
[Tooltip("Speed multiplier while the sprint key (Left Shift) is held.")]
public float sprintMultiplier = 3f;
[Header("Look")]
[Tooltip("Horizontal look sensitivity (degrees of yaw per pixel).")]
public float horizontalSensitivity = 0.2f;
[Tooltip("Vertical look sensitivity (degrees of pitch per pixel).")]
public float verticalSensitivity = 0.15f;
[Tooltip("Invert vertical look (mouse up tilts the camera down).")]
public bool invertY = false;
[Tooltip("Pitch is clamped to +/- this angle (degrees) so the camera never flips over.")]
[Range(1f, 89.9f)]
public float maxPitch = 89f;
[Header("Smoothing")]
[Tooltip("Acceleration/deceleration smoothing for flight, in seconds. 0 = instant (snappy starts/stops); higher = floatier ramp-up and coast-down.")]
[Range(0f, 1f)]
public float moveSmoothTime = 0.12f;
[Tooltip("Look smoothing, in seconds. 0 = raw mouse (instant); small values take the edge off jitter without feeling laggy.")]
[Range(0f, 0.3f)]
public float lookSmoothTime = 0.04f;
[Header("Camera")]
[Tooltip("Priority the dedicated CinemachineCamera takes while active (must beat the gameplay camera).")]
public int activePriority = 100;
[Tooltip("Priority the dedicated CinemachineCamera rests at while inactive (must lose to the gameplay camera).")]
public int inactivePriority = -100;
CharacterController _controller;
CinemachineCamera _vcam;
SmoothedLook _look; // yaw/pitch accumulator + smoothing shared with first person
Vector3 _moveVelocity; // smoothed world-space flight velocity
Vector3 _moveVelocityRef;
bool _ignoredPlayer;
void Awake()
{
_controller = GetComponent<CharacterController>();
_vcam = GetComponent<CinemachineCamera>();
if (!_vcam)
Debug.LogError("[FreeCamController] No CinemachineCamera on this GameObject; freecam cannot go live.", this);
else
_vcam.Priority = inactivePriority;
// Dormant until FreeCamMode enters; nothing reads input or moves the rig meanwhile.
enabled = false;
}
/// <summary>Enter/leave freecam. Raises/lowers the vcam priority, snaps to the live view and
/// manages the cursor. Called by <see cref="FreeCamMode"/>.</summary>
public void SetActive(bool active)
{
if (_vcam)
_vcam.Priority = active ? activePriority : inactivePriority;
if (active)
{
IgnorePlayerCollision();
SyncToCurrentView();
InputUtil.SetCursorLocked(true);
}
else
{
InputUtil.SetCursorLocked(false);
}
enabled = active;
}
void Update()
{
LookTick();
MoveTick();
}
void LookTick()
{
Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
_look.Tick(delta, horizontalSensitivity, verticalSensitivity, invertY, maxPitch, lookSmoothTime);
// Rotation is unconstrained by the CharacterController; only position is.
transform.rotation = _look.Rotation;
}
void MoveTick()
{
Vector3 dir = ReadMoveDirection();
if (dir.sqrMagnitude > 1f)
dir.Normalize();
float speed = moveSpeed * (SprintHeld() ? sprintMultiplier : 1f);
Vector3 targetVelocity = dir * speed;
// SmoothDamp eases accel/decel and is already dt-correct; 0 collapses to instant.
_moveVelocity = moveSmoothTime > 0f
? Vector3.SmoothDamp(_moveVelocity, targetVelocity, ref _moveVelocityRef, moveSmoothTime)
: targetVelocity;
_controller.Move(_moveVelocity * Time.deltaTime);
}
/// <summary>Place the rig at the currently rendered view so going live produces no visible jump.</summary>
void SyncToCurrentView()
{
var cam = Camera.main;
if (!cam) return;
CharacterTeleport.Move(_controller, transform, cam.transform.position, cam.transform.rotation);
_look.SyncTo(transform.rotation, maxPitch);
_moveVelocity = Vector3.zero;
_moveVelocityRef = Vector3.zero;
}
/// <summary>Stop the freecam capsule from being blocked by the (frozen) player's capsule.</summary>
void IgnorePlayerCollision()
{
if (_ignoredPlayer) return;
_ignoredPlayer = true;
var player = FindFirstObjectByType<PlayerCharacterController>();
if (player)
{
var playerCC = player.GetComponent<CharacterController>();
// Only an enabled capsule can take part in a collision-ignore (a spectator's body has its CC
// disabled, and there is nothing to collide with there anyway).
if (playerCC && playerCC.enabled)
Physics.IgnoreCollision(_controller, playerCC, true);
}
}
Vector3 ReadMoveDirection()
{
Vector3 dir = Vector3.zero;
#if ENABLE_INPUT_SYSTEM
var kb = Keyboard.current;
if (kb != null)
{
if (kb.wKey.isPressed) dir += transform.forward;
if (kb.sKey.isPressed) dir -= transform.forward;
if (kb.dKey.isPressed) dir += transform.right;
if (kb.aKey.isPressed) dir -= transform.right;
if (kb.eKey.isPressed || kb.spaceKey.isPressed) dir += Vector3.up;
if (kb.qKey.isPressed || kb.leftCtrlKey.isPressed) dir -= Vector3.up;
}
#else
dir += transform.forward * Input.GetAxisRaw("Vertical");
dir += transform.right * Input.GetAxisRaw("Horizontal");
if (Input.GetKey(KeyCode.E) || Input.GetKey(KeyCode.Space)) dir += Vector3.up;
if (Input.GetKey(KeyCode.Q) || Input.GetKey(KeyCode.LeftControl)) dir -= Vector3.up;
#endif
return dir;
}
bool SprintHeld()
{
#if ENABLE_INPUT_SYSTEM
return Keyboard.current != null && Keyboard.current.leftShiftKey.isPressed;
#else
return Input.GetKey(KeyCode.LeftShift);
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5ca7cff7542b53b498f0a9834786b9e0
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 463b6be548abf464287096e1e92fcd01
@@ -0,0 +1,525 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
using UnityEngine.Serialization;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// Holds a list of <see cref="PlayerPose"/>s plus the active index. The pose system owns ALL character
/// animation (the Animator is left empty): standing / airborne / wall / ceiling holds the active pose's
/// idle clip, walking/sprinting on the ground plays its optional walk/run clip (unset = idle held).
///
/// One key cycles poses (wrapping); sprinting in any state snaps back to the default pose, held
/// continuously so the reset shows up immediately. Clips play through a tiny <see cref="PlayableGraph"/>
/// (two-input mixer on an <see cref="AnimationPlayableOutput"/>) that crossfades every clip switch over
/// <see cref="poseBlendDuration"/>. Each pose also carries its own CharacterController capsule (eased,
/// so a low pose can crawl under furniture) and per-pose camera y-offsets (third-person framing and
/// first-person eye).
/// </summary>
[RequireComponent(typeof(PlayerCharacterController))]
[DisallowMultipleComponent]
// Run after PlayerCharacterController (default order 0) so the sprint / locomotion state polled below is
// this frame's, and the per-pose capsule / camera offset resolve without a one-frame seam.
[DefaultExecutionOrder(100)]
public class PlayerPoseController : MonoBehaviour
{
[Header("Poses")]
[Tooltip("Selectable poses. Index 0 is the default pose that sprinting resets to.")]
[SerializeField] private List<PlayerPose> poses = new();
[Tooltip("Index the selection resets to while sprinting in any state (the default/standard pose).")]
[Min(0)]
[SerializeField] private int defaultPoseIndex = 0;
[Header("Input")]
#if ENABLE_INPUT_SYSTEM
[Tooltip("Key that cycles to the next pose (wraps at the end of the list).")]
[SerializeField] private Key cycleKey = Key.C;
#else
[Tooltip("Key that cycles to the next pose (wraps at the end of the list).")]
[SerializeField] private KeyCode cycleKey = KeyCode.C;
#endif
[Header("Transitions")]
[Tooltip("Seconds to crossfade from the previous pose to a newly selected one. 0 = instant snap.")]
[Min(0f)]
[SerializeField] private float poseBlendDuration = 0.2f;
[Tooltip("Approach speed (local units/sec) for easing the per-pose camera-target y-offset toward its " +
"target while cycling poses. 0 = instant.")]
[Min(0f)]
[FormerlySerializedAs("ceilingOffsetSpeed")]
[SerializeField] private float cameraOffsetSpeed = 4f;
[Tooltip("Blend speed (0..1 per second) for resizing the CharacterController capsule toward the active " +
"pose's dimensions when the pose changes. 0 = instant snap; higher = snappier resize.")]
[Min(0f)]
[SerializeField] private float capsuleBlendSpeed = 8f;
[Tooltip("Apply Mecanim foot IK to the played humanoid clips. Off (default) plays each pose exactly as " +
"authored. On bends the knees to plant the feet on the IK goal, which makes a straight-knee idle " +
"render visibly bent even when the clip's Lower Leg Stretch muscle is fully straight.")]
[SerializeField] private bool applyFootIK = false;
private PlayerCharacterController _player;
private Animator _animator;
private string _graphName; // cached so building the graph allocates no string from the Update path
private int _currentIndex;
/// <summary>Locomotion category that selects which clip the active pose plays.</summary>
public enum Locomotion { Idle, Walk, Run, Fall }
// Network drive: a remote avatar's controllers are disabled (NetworkTransform owns the transform),
// so its movement flags are dead - pose index + locomotion come from the owner via SetNetworkState.
// _currentLocomotion is the category resolved this frame, published by the owner for replication.
private bool _networkControlled;
private Locomotion _networkLoco;
private Locomotion _currentLocomotion;
// Playables: a 2-input AnimationMixerPlayable that overrides the Animator Controller. Input 1 holds
// the current ("to") pose; input 0 holds the outgoing ("from") pose during a crossfade and is empty
// once the blend completes. Blending both inputs over a per-frame rest baseline (see below) makes
// poses interpolate instead of snapping; an empty/partial "to" clip still resolves to rest.
private PlayableGraph _graph;
private AnimationPlayableOutput _output;
private AnimationMixerPlayable _mixer;
private AnimationClipPlayable _fromPlayable;
private AnimationClipPlayable _toPlayable;
private AnimationClip _toClip; // the clip currently targeted (held on input 1)
private bool _posePlaying;
private float _blend; // 0..1 weight of the "to" input; 1 = fully on the target pose
private bool _blending;
private float _cameraPoseOffset; // eased per-pose camera-target offset routed to the controller's camera target
private float _firstPersonCameraOffset; // eased per-pose first-person eye offset, read by the FirstPersonController (never written to the shared camera target)
// Capsule blend: the CharacterController dimensions are eased toward the active pose's via a single
// normalized blend (so the capsule stays a valid shape throughout) and written through the controller.
// Snapped on the first frame so the player spawns at its pose size with no resize blend.
private bool _capsuleStarted;
private float _capFromCenterY, _capFromRadius, _capFromHeight;
private float _capToCenterY, _capToRadius, _capToHeight;
private float _capCenterY, _capRadius, _capHeight;
private float _capBlend;
// Rest-pose snapshot of the Animator subtree, taken before the Animator first evaluates. A clip only
// writes the bones its curves cover, so un-keyed bones would stay frozen on the previous animation;
// restoring rest first makes every pose define the full body. The controller's meshOffsetTarget is
// dropped from the set (see CaptureRestPose) so the wall-lean / ground raise is never wiped.
private Transform[] _restBones;
private Vector3[] _restPos;
private Quaternion[] _restRot;
private Vector3[] _restScale;
/// <summary>Index of the currently selected pose.</summary>
public int CurrentIndex => _currentIndex;
/// <summary>Locomotion category resolved this frame (which clip the active pose is playing). The owner publishes it for network sync.</summary>
public Locomotion CurrentLocomotion => _currentLocomotion;
/// <summary>
/// Switches this avatar to network-driven animation: local input, the sprint-reset, the per-pose
/// collision capsule and camera framing are all skipped; pose index and locomotion are supplied by
/// <see cref="SetNetworkState"/>. Called on a remote avatar by <see cref="PlayerAnimationSync"/>.
/// </summary>
public void EnableNetworkDrive() => _networkControlled = true;
/// <summary>Pushes the replicated pose index and locomotion category onto a network-driven avatar.</summary>
public void SetNetworkState(int poseIndex, Locomotion loco)
{
_currentIndex = WrapIndex(poseIndex);
_networkLoco = loco;
}
/// <summary>Eased per-pose y-offset for the first-person eye, applied on top of the camera target by the FirstPersonController.</summary>
public float FirstPersonCameraYOffset => _firstPersonCameraOffset;
/// <summary>Holds the avatar on its current pose frame: the Playable graph stops advancing (its bones stay
/// exactly where they are) and all per-frame locomotion / rest-reset / capsule / camera work is skipped.
/// Set per-client for every still-in-play hider during the resolution reveal so nobody can wiggle in place.
/// Clearing it resumes normal playback. Independent of network drive — works on the owner and on remotes.</summary>
public bool Frozen { get; set; }
void Awake()
{
_player = GetComponent<PlayerCharacterController>();
_animator = _player ? _player.GetComponentInChildren<Animator>() : null;
_graphName = $"{name}-Pose";
if (_animator)
CaptureRestPose();
else
Debug.LogWarning("[PlayerPoseController] No Animator on the PlayerCharacterController; poses cannot play.", this);
}
void Update()
{
// Resolution freeze: keep the graph playing (it re-asserts the frozen frame every render, even
// under Animator culling) but zero the MIXER's speed so clip time stops advancing; skip everything
// below so no locomotion/rest-reset moves a bone. The per-input clip speeds are untouched; the
// mixer multiplier is restored on the first un-frozen frame just below.
if (Frozen)
{
if (_graph.IsValid())
{
if (!_graph.IsPlaying()) _graph.Play();
if (_mixer.IsValid()) PlayableExtensions.SetSpeed(_mixer, 0d);
}
return;
}
if (_mixer.IsValid() && PlayableExtensions.GetSpeed(_mixer) == 0d)
PlayableExtensions.SetSpeed(_mixer, 1d);
// Local control only - a remote's pose index is pushed in via SetNetworkState.
if (!_networkControlled)
{
if (InputUtil.GameplayKeyPressed(cycleKey))
NextPose();
// Sprinting in any state (ground, air, wall, ceiling) snaps the selection back to the default
// pose. Held continuously, so the reset shows up immediately, not only once back on the floor.
if (_player.IsSprinting)
_currentIndex = WrapIndex(defaultPoseIndex);
}
PlayerPose pose = ActivePose();
// Resolve the locomotion category and its clip: locally from the controller's movement state, on
// a remote the replicated value. ClipFor falls back to idle for unset optional clips, so a remote
// (same pose asset) resolves to exactly the clip the owner shows.
_currentLocomotion = _networkControlled ? _networkLoco : ResolveLocalLocomotion();
AnimationClip clip = ClipFor(pose, _currentLocomotion, out float speed);
PlayPose(clip, speed);
// Collision capsule and camera framing are local-owner concerns: a remote avatar's controller and
// CharacterController are disabled and no camera tracks it, so a network-driven avatar stops here.
if (_networkControlled)
return;
// Per-pose collision capsule so a low pose can crawl where the standing capsule is too tall;
// unset dimensions (<= 0) keep the default capsule.
bool hasCapsule = pose && pose.ControllerHeight > 0f && pose.ControllerRadius > 0f;
float capCenterY = hasCapsule ? pose.ControllerCenterY : _player.BaseCapsuleCenterY;
float capRadius = hasCapsule ? pose.ControllerRadius : _player.BaseCapsuleRadius;
float capHeight = hasCapsule ? pose.ControllerHeight : _player.BaseCapsuleHeight;
ApplyCapsule(capCenterY, capRadius, capHeight);
// Camera target: a per-pose framing offset (applied in any state), eased so cycling poses glides
// the camera, routed to the ThirdPersonCamera's follow target only (never the body).
float cameraPoseTarget = pose ? pose.CameraTargetYOffset : 0f;
_cameraPoseOffset = EaseCameraOffset(_cameraPoseOffset, cameraPoseTarget);
_player.SetCameraTargetLocalYOffset(_cameraPoseOffset);
// Separate per-pose first-person eye offset - NOT routed to the shared camera target (that would
// also move the ThirdPersonCamera); the FirstPersonController reads it directly.
float firstPersonTarget = pose ? pose.FirstPersonCameraTargetYOffset : 0f;
_firstPersonCameraOffset = EaseCameraOffset(_firstPersonCameraOffset, firstPersonTarget);
}
/// <summary>Eases a camera y-offset toward its target at <see cref="cameraOffsetSpeed"/>; 0 snaps.</summary>
private float EaseCameraOffset(float current, float target)
{
return cameraOffsetSpeed > 0f
? Mathf.MoveTowards(current, target, cameraOffsetSpeed * Time.deltaTime)
: target;
}
void OnDisable()
{
// Hand the rig back to the Animator Controller and never leak a graph; rebuilt lazily on the
// next idle frame. Also clear the camera offset and restore the default capsule we were holding.
ReleasePose();
_cameraPoseOffset = 0f;
_firstPersonCameraOffset = 0f;
if (_player)
{
_player.SetCameraTargetLocalYOffset(0f);
_player.SetCapsule(_player.BaseCapsuleCenterY, _player.BaseCapsuleRadius, _player.BaseCapsuleHeight);
}
_capsuleStarted = false; // re-snap to the active pose's capsule if this is re-enabled
}
/// <summary>Advances to the next pose, wrapping at the end of the list.</summary>
public void NextPose() => SetPoseIndex(_currentIndex + 1);
/// <summary>Selects a pose by index, wrapping into range so cycling never goes out of bounds.</summary>
public void SetPoseIndex(int index) => _currentIndex = WrapIndex(index);
private int WrapIndex(int index)
{
int count = poses.Count;
if (count == 0) return 0;
return ((index % count) + count) % count;
}
private PlayerPose ActivePose()
{
if (poses.Count == 0) return null;
return poses[Mathf.Clamp(_currentIndex, 0, poses.Count - 1)];
}
/// <summary>Maps the controller's current movement state to a locomotion category for clip selection.</summary>
private Locomotion ResolveLocalLocomotion()
{
if (_player.IsMovingOnGround)
return _player.IsSprinting ? Locomotion.Run : Locomotion.Walk;
if (_player.IsFalling)
return Locomotion.Fall;
return Locomotion.Idle;
}
/// <summary>
/// Returns the clip (and its <paramref name="speed"/>) for <paramref name="pose"/> in the given
/// locomotion category. The walk/run/fall clips are optional: an unset one falls back to the pose's
/// idle clip, so an unauthored category keeps the held-pose behaviour identically on the owner and on
/// a network-driven remote avatar. A null pose hands the rig back (no clip).
/// </summary>
private AnimationClip ClipFor(PlayerPose pose, Locomotion loco, out float speed)
{
if (pose)
{
switch (loco)
{
case Locomotion.Walk when pose.WalkClip:
speed = pose.WalkClipSpeed;
return pose.WalkClip;
case Locomotion.Run when pose.RunClip:
speed = pose.RunClipSpeed;
return pose.RunClip;
case Locomotion.Fall when pose.FallClip:
speed = pose.FallClipSpeed;
return pose.FallClip;
}
speed = pose.ClipSpeed;
return pose.Clip;
}
speed = 1f;
return null;
}
/// <summary>
/// Eases the CharacterController capsule toward the given target dimensions and writes them through
/// the controller. A single normalized blend drives all three values together, so the capsule stays a
/// valid shape (and its floor contact moves linearly) throughout the resize. The first call snaps, so
/// the player spawns at its pose size without a resize blend; <see cref="capsuleBlendSpeed"/> 0 also snaps.
/// </summary>
private void ApplyCapsule(float centerY, float radius, float height)
{
if (!_capsuleStarted)
{
_capsuleStarted = true;
_capFromCenterY = _capToCenterY = _capCenterY = centerY;
_capFromRadius = _capToRadius = _capRadius = radius;
_capFromHeight = _capToHeight = _capHeight = height;
_capBlend = 1f;
}
else if (centerY != _capToCenterY || radius != _capToRadius || height != _capToHeight)
{
// New target (pose changed): ease from wherever the capsule currently is.
_capFromCenterY = _capCenterY;
_capFromRadius = _capRadius;
_capFromHeight = _capHeight;
_capToCenterY = centerY;
_capToRadius = radius;
_capToHeight = height;
_capBlend = 0f;
}
_capBlend = capsuleBlendSpeed > 0f
? Mathf.MoveTowards(_capBlend, 1f, capsuleBlendSpeed * Time.deltaTime)
: 1f;
_capCenterY = Mathf.Lerp(_capFromCenterY, _capToCenterY, _capBlend);
_capRadius = Mathf.Lerp(_capFromRadius, _capToRadius, _capBlend);
_capHeight = Mathf.Lerp(_capFromHeight, _capToHeight, _capBlend);
_player.SetCapsule(_capCenterY, _capRadius, _capHeight);
}
/// <summary>
/// Plays and holds the given clip, overriding the Animator Controller. Switching to a different
/// clip crossfades from the current pose over <see cref="poseBlendDuration"/> (the first pose, or a
/// zero duration, appears instantly). Called every frame: it starts a transition on a clip change,
/// advances any in-flight blend, applies <paramref name="speed"/> to the target playback, and
/// re-snaps un-keyed bones to rest before the graph evaluates. A null clip (or no animator) releases
/// back to the controller.
/// </summary>
private void PlayPose(AnimationClip clip, float speed)
{
if (!clip || !_animator)
{
ReleasePose();
return;
}
EnsureGraph();
if (!(_posePlaying && _toClip == clip))
BeginTransition(clip);
AdvanceBlend(Time.deltaTime);
// Set every frame so live speed-slider tuning takes effect immediately; the outgoing pose on
// input 0 keeps the speed it carried, fading out at its own pace.
if (_toPlayable.IsValid())
PlayableExtensions.SetSpeed(_toPlayable, speed);
// Snap un-keyed bones to rest every frame so each mixer input blends relative to the same
// baseline; the graph then overrides the keyed bones when it evaluates later this frame.
ResetToRestPose();
if (!_graph.IsPlaying())
_graph.Play();
_posePlaying = true;
}
/// <summary>Builds the graph (a 2-input mixer feeding the Animator output) lazily if it isn't alive yet.</summary>
private void EnsureGraph()
{
if (_graph.IsValid())
return;
_graph = PlayableGraph.Create(_graphName);
_graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
_mixer = AnimationMixerPlayable.Create(_graph, 2);
_output = AnimationPlayableOutput.Create(_graph, "Pose", _animator);
_output.SetSourcePlayable(_mixer);
}
/// <summary>
/// Starts a crossfade to <paramref name="clip"/>: the in-flight target becomes the outgoing pose on
/// input 0 (carrying its playback time), the new clip goes on input 1, and the blend ramps up from
/// 0. The first pose (or a zero duration) snaps in at full weight with no fade-from-nothing.
/// </summary>
private void BeginTransition(AnimationClip clip)
{
bool hadPose = _posePlaying && _toPlayable.IsValid();
// Drop the previous outgoing pose (already faded out, or none) before reusing input 0.
if (_fromPlayable.IsValid())
{
_mixer.DisconnectInput(0);
_graph.DestroyPlayable(_fromPlayable);
_fromPlayable = default;
}
if (hadPose)
{
// Move the current target from input 1 to input 0 so it becomes the outgoing pose without a
// recreate (its animation time carries over, no jump).
_mixer.DisconnectInput(1);
_fromPlayable = _toPlayable;
_mixer.ConnectInput(0, _fromPlayable, 0);
}
_toPlayable = AnimationClipPlayable.Create(_graph, clip);
// Off by default: foot IK plants the foot on its goal and bends the knee to reach it, so a straight-knee
// pose renders bent (SampleAnimation, which the authoring tools check against, never applies it). The
// outgoing pose on input 0 is a former _toPlayable, so it already carries this setting.
_toPlayable.SetApplyFootIK(applyFootIK);
_mixer.ConnectInput(1, _toPlayable, 0);
_toClip = clip;
_blend = (hadPose && poseBlendDuration > 0f) ? 0f : 1f;
_blending = _blend < 1f;
ApplyBlendWeights();
}
/// <summary>Advances an in-flight crossfade and drops the outgoing pose once it has fully faded.</summary>
private void AdvanceBlend(float dt)
{
if (!_blending)
return;
_blend = poseBlendDuration > 0f ? Mathf.MoveTowards(_blend, 1f, dt / poseBlendDuration) : 1f;
if (_blend >= 1f)
{
_blend = 1f;
_blending = false;
if (_fromPlayable.IsValid())
{
_mixer.DisconnectInput(0);
_graph.DestroyPlayable(_fromPlayable);
_fromPlayable = default;
}
}
ApplyBlendWeights();
}
/// <summary>Pushes the current blend onto the mixer inputs (smoothstepped while a "from" pose exists).</summary>
private void ApplyBlendWeights()
{
if (!_mixer.IsValid())
return;
bool hasFrom = _fromPlayable.IsValid();
float w = hasFrom ? Mathf.SmoothStep(0f, 1f, _blend) : 1f;
_mixer.SetInputWeight(0, hasFrom ? 1f - w : 0f);
_mixer.SetInputWeight(1, _toPlayable.IsValid() ? w : 0f);
}
/// <summary>
/// Snapshots every transform a pose clip can address (the Animator's whole subtree) in its
/// authored rest pose. Taken in Awake before the Animator first evaluates, so it captures the rig
/// at rest (the paintman rig's rest pose is its T-pose).
/// </summary>
private void CaptureRestPose()
{
_restBones = _animator.transform.GetComponentsInChildren<Transform>(true);
int n = _restBones.Length;
_restPos = new Vector3[n];
_restRot = new Quaternion[n];
_restScale = new Vector3[n];
for (int i = 0; i < n; i++)
{
Transform t = _restBones[i];
_restPos[i] = t.localPosition;
_restRot[i] = t.localRotation;
_restScale[i] = t.localScale;
}
// Drop the model-offset transform from the reset set: the wall-lean / ground raise (SetMeshLean)
// is its sole writer, and with the humanoid rig the Animator shares this node - resetting it each
// frame was exactly what wiped the offsets. Nulling the slot makes ResetToRestPose skip it.
Transform offsetTarget = _player ? _player.MeshOffsetTarget : null;
if (offsetTarget)
{
int idx = System.Array.IndexOf(_restBones, offsetTarget);
if (idx >= 0)
_restBones[idx] = null;
}
}
/// <summary>Restores the captured rest pose so an empty or partial pose clip fully defines the body.</summary>
private void ResetToRestPose()
{
if (_restBones == null)
return;
for (int i = 0; i < _restBones.Length; i++)
{
Transform t = _restBones[i];
if (!t)
continue;
t.localPosition = _restPos[i];
t.localRotation = _restRot[i];
t.localScale = _restScale[i];
}
}
/// <summary>Destroys the graph so the Animator Controller (run/idle) drives the rig again.</summary>
private void ReleasePose()
{
if (!_posePlaying)
return;
if (_graph.IsValid())
_graph.Destroy();
_graph = default;
_output = default;
_mixer = default;
_fromPlayable = default;
_toPlayable = default;
_toClip = null;
_blend = 0f;
_blending = false;
_posePlaying = false;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 324ce9a23e6a5144096a195460dd567f
@@ -0,0 +1,346 @@
using UnityEngine;
using Unity.Cinemachine;
/// <summary>
/// Feeds mouse input into a <see cref="CinemachineOrbitalFollow"/> so the camera
/// orbits the follow target. Mouse movement drives yaw/pitch, the wheel zooms.
/// The orbit behaviour depends on <see cref="LookMode"/> (usually set by the
/// GameModeManager). Attach to the camera rig (the CinemachineOrbitalFollow).
/// </summary>
[RequireComponent(typeof(CinemachineOrbitalFollow))]
[DisallowMultipleComponent]
public class ThirdPersonOrbitInput : MonoBehaviour
{
public enum OrbitLookMode
{
/// <summary>Rotate with every mouse move; cursor locked/hidden.</summary>
AlwaysActive,
/// <summary>Rotate only while the right mouse button is held; cursor free otherwise.</summary>
HoldRightMouseButton,
/// <summary>No rotation; cursor free.</summary>
Disabled
}
[Header("Look Mode")]
[Tooltip("How the camera orbit is driven (usually set by GameModeManager).")]
[SerializeField] private OrbitLookMode lookMode = OrbitLookMode.AlwaysActive;
[Header("Rotation")]
[Tooltip("Horizontal look sensitivity (degrees of yaw per pixel).")]
public float horizontalSensitivity = 0.2f;
[Tooltip("Vertical look sensitivity (degrees of pitch per pixel).")]
public float verticalSensitivity = 0.15f;
[Tooltip("Invert vertical look (mouse up tilts the camera down).")]
public bool invertY = false;
[Tooltip("Eases orbit rotation so quick mouse moves ramp in/out (higher = snappier, 0 = raw/instant).")]
public float rotationSmoothing = 20f;
[Header("Zoom")]
[Tooltip("Mouse wheel zooms the camera in and out (changes orbit radius).")]
public bool enableZoom = true;
[Tooltip("Scale the zoom range, step and default radius by the follow target's size (PlayerScaler), " +
"so a small (Hider) or large (Hunter) player is framed at the same body-relative distance. " +
"The radius fields below are then size-1 design values, multiplied by the target's scale at " +
"runtime. Off = absolute world units regardless of player size.")]
public bool scaleWithTarget = true;
[Tooltip("World units the orbit radius changes per mouse wheel notch (size-1 design value; scaled by " +
"the target when Scale With Target is on).")]
public float zoomSpeed = 0.5f;
[Tooltip("Eases the orbit radius toward the zoom target (higher = snappier, 0 = instant).")]
public float zoomSmoothing = 10f;
[Tooltip("Closest orbit radius / zoom in (size-1 design value; scaled by the target).")]
public float minRadius = 1.5f;
[Tooltip("Farthest orbit radius / zoom out (size-1 design value; scaled by the target).")]
public float maxRadius = 8f;
[Tooltip("Orbit radius applied when the camera (re)binds to a player - the starting zoom level " +
"(size-1 design value; scaled by the target). Set so the bound player is comfortably framed.")]
public float defaultRadius = 4.5f;
[Header("Pan (middle mouse)")]
[Tooltip("Hold the middle mouse button and drag to pan the camera framing - a temporary offset to bring an " +
"awkward spot of the model in front of the lens for painting. The pan stays put on release and only " +
"glides back to center when paint mode is left. Enabled only while PaintMode is active.")]
public bool enablePan = true;
[Tooltip("World units the pan offset moves per pixel of mouse drag, measured at the default zoom. It then " +
"scales with the live zoom distance so the model slides the same amount on screen per pixel at any " +
"zoom (closer in = finer). Body scale is folded in automatically via the orbit radius.")]
public float panSensitivity = 0.004f;
[Tooltip("Farthest the framing can pan from center, in world units (body scale folded in). This is NOT scaled " +
"by zoom, so the maximum pan distance stays the same at every zoom level - only the sensitivity changes.")]
public float panMaxDistance = 1f;
[Tooltip("How snappily the pan glides back to center when paint mode is left (1/second; 0 = snaps home " +
"instantly). The pan stays put on middle-button release - it only returns on leaving paint mode.")]
public float panReturnSpeed = 14f;
[Header("Cursor")]
[Tooltip("Lock and hide the cursor while the camera is actively rotating.")]
public bool manageCursor = true;
CinemachineOrbitalFollow _orbit;
Vector2 _smoothedLook; // low-pass-filtered look delta (deg/frame) for eased rotation
float _targetRadius; // zoom radius the actual radius eases toward
bool _zoomInit; // _targetRadius seeded from the live radius yet?
Vector2 _pan; // accumulated pan in camera-space units (x=right, y=up); stays put on release, eases to 0 on leaving paint mode
CinemachineCameraOffset _camOffset; // post-aim extension that slides the whole framing (true pan, not an orbit arc)
/// <summary>Current orbit mode. Setting it also refreshes the cursor state.</summary>
public OrbitLookMode LookMode
{
get => lookMode;
set { lookMode = value; ApplyCursorState(); }
}
/// <summary>
/// While true, mouse-look is paused (orbit frozen) without changing the cursor
/// state. The player controller sets this while dragging the wall-climb offset so
/// the same mouse motion is not also spent orbiting the camera.
/// </summary>
public bool LookSuppressed { get; set; }
/// <summary>While true, holding the middle mouse button and dragging pans the camera framing - a temporary
/// look-point offset that stays put on release. Set by PaintMode so the pan is only available while painting;
/// clearing it (on leaving paint mode) is what glides the framing back home.</summary>
public bool PanEnabled { get; set; }
void Awake()
{
_orbit = GetComponent<CinemachineOrbitalFollow>();
if (!_orbit)
Debug.LogError("[ThirdPersonOrbitInput] No CinemachineOrbitalFollow found on this GameObject.", this);
// The pan rides a CinemachineCameraOffset extension applied AFTER aim with PreserveComposition off: it
// shifts the camera position AND the look-at point by the same world vector, so the framing translates
// (the model slides across the screen) instead of re-aiming back to center like a body TargetOffset
// would. Offset is camera-local (x=right, y=up). Added at runtime so no scene/prefab authoring is needed.
if (enablePan)
{
_camOffset = GetComponent<CinemachineCameraOffset>();
if (!_camOffset) _camOffset = gameObject.AddComponent<CinemachineCameraOffset>();
_camOffset.ApplyAfter = CinemachineCore.Stage.Aim;
_camOffset.PreserveComposition = false;
_camOffset.Offset = Vector3.zero;
}
}
void OnEnable() => ApplyCursorState();
void OnDisable()
{
InputUtil.SetCursorLocked(false);
// Deliberately DON'T zero the pan: only FreeCamMode disables this component, and the freecam
// snaps to the live (still panned) view - resetting now would jump the blend-FROM camera to
// center while the freecam sits at the panned pose (the visible snap on the paint->freecam
// switch). Frozen offset = blend-from == blend-to; the pan glides home once the orbit runs again.
}
/// <summary>Uniform scale of the current follow target (1 when none, or when Scale With Target is off).
/// The zoom range, step and default radius are multiplied by this so the framing is body-relative across
/// player sizes. PlayerScaler keeps the player uniformly scaled, so a single lossyScale component is exact.</summary>
float CurrentScale()
{
if (!scaleWithTarget || !_orbit) return 1f;
Transform t = _orbit.FollowTarget;
return t ? Mathf.Max(0.0001f, t.lossyScale.x) : 1f;
}
/// <summary>Pan-SENSITIVITY scale with the live zoom: radius / defaultRadius = 1 at the default zoom,
/// finer zoomed in, coarser zoomed out - the model slides the same amount ON SCREEN per pixel at any
/// zoom. Body scale cancels out (the radius already embeds it). The clamp deliberately does NOT use
/// this: panMaxDistance stays body-relative only, constant across zoom levels.</summary>
float PanZoomScale() => _orbit ? _orbit.Radius / Mathf.Max(0.0001f, defaultRadius) : 1f;
/// <summary>Snaps the zoom to the scaled default radius. Called when a new avatar binds (see
/// GameModeManager.BindLocalPlayer) so each player starts at its body-relative default zoom instead of
/// inheriting the previous avatar's radius.</summary>
public void ResetZoom()
{
if (!_orbit) return;
_targetRadius = defaultRadius * CurrentScale();
_orbit.Radius = _targetRadius;
_zoomInit = true;
}
/// <summary>Snaps the orbit axes to Camera.main's current view direction so the next orbit mode keeps
/// that facing instead of the orbit's last-used angle. Called when switching out of a non-orbit rig:
/// Camera.main still shows that rig's view this frame (the brain hasn't blended yet), so the read is
/// exact. With WorldSpace binding + Sphere orbit + a centered composer the look rotation is exactly
/// Euler(VerticalAxis, HorizontalAxis, 0).</summary>
public void SyncToView()
{
if (!_orbit) return;
var cam = Camera.main;
if (!cam) return;
Vector3 e = cam.transform.rotation.eulerAngles;
SetAxisValue(ref _orbit.HorizontalAxis, e.y > 180f ? e.y - 360f : e.y);
SetAxisValue(ref _orbit.VerticalAxis, e.x > 180f ? e.x - 360f : e.x);
// Drop any eased look carry-over so the snapped angle isn't immediately dragged back.
_smoothedLook = Vector2.zero;
}
void Update()
{
if (!_orbit)
return;
bool modeWantsLook = ShouldRotate();
if (manageCursor)
InputUtil.SetCursorLocked(modeWantsLook);
if (modeWantsLook && !LookSuppressed)
{
Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
Vector2 raw = new Vector2(
delta.x * horizontalSensitivity,
delta.y * verticalSensitivity * (invertY ? 1f : -1f));
// Ease the per-frame look toward the raw delta so fast flicks ramp in/out
// instead of snapping; steady motion still converges to full sensitivity.
Vector2 applied = rotationSmoothing > 0f
? (_smoothedLook = Vector2.Lerp(_smoothedLook, raw, 1f - Mathf.Exp(-rotationSmoothing * Time.deltaTime)))
: raw;
if (applied.sqrMagnitude > 0f)
{
ApplyAxisDelta(ref _orbit.HorizontalAxis, applied.x);
ApplyAxisDelta(ref _orbit.VerticalAxis, applied.y);
}
}
else
{
_smoothedLook = Vector2.zero;
}
if (enableZoom)
{
if (!_zoomInit)
{
// Seed the starting zoom at the scaled default (not the live radius) so a fresh bind frames
// the player body-relative; ResetZoom() does the same on an explicit (re)bind.
_targetRadius = defaultRadius * CurrentScale();
_orbit.Radius = _targetRadius;
_zoomInit = true;
}
float scale = CurrentScale();
// Shift/Ctrl+wheel is reserved for the paint brush resize (MeshPainter), so the orbit camera
// ignores the wheel while either is held - otherwise it would zoom and resize at once.
float scroll = (InputUtil.ShiftHeld() || InputUtil.CtrlHeld()) ? 0f : InputUtil.ReadScroll();
if (!Mathf.Approximately(scroll, 0f))
{
// One wheel notch = one zoom step (sign only, platform-independent). The step scales with
// the player so the notch count to cross the range is the same at every size.
_targetRadius -= Mathf.Sign(scroll) * zoomSpeed * scale;
}
// Clamp every frame against the scaled range so a runtime size change (PlayerScaler grow/shrink)
// keeps the target in-range instead of sticking at a stale absolute bound.
_targetRadius = Mathf.Clamp(_targetRadius, minRadius * scale, maxRadius * scale);
// Ease the actual radius toward the target (frame-rate independent).
_orbit.Radius = zoomSmoothing > 0f
? Mathf.Lerp(_orbit.Radius, _targetRadius, 1f - Mathf.Exp(-zoomSmoothing * Time.deltaTime))
: _targetRadius;
}
UpdatePan(Time.deltaTime);
}
bool ShouldRotate()
{
switch (lookMode)
{
case OrbitLookMode.AlwaysActive: return true;
case OrbitLookMode.HoldRightMouseButton: return InputUtil.RightMouseHeld();
default: return false;
}
}
void ApplyCursorState()
{
if (manageCursor)
InputUtil.SetCursorLocked(ShouldRotate());
}
/// <summary>Adds to an input axis; wrapping axes wrap, others clamp to their range.</summary>
static void ApplyAxisDelta(ref InputAxis axis, float amount)
=> SetAxisValue(ref axis, axis.Value + amount);
/// <summary>Sets an input axis to an absolute value; wrapping axes wrap, others clamp to their range.</summary>
static void SetAxisValue(ref InputAxis axis, float value)
{
float min = axis.Range.x;
float max = axis.Range.y;
if (axis.Wrap && max > min)
{
float span = max - min;
value = ((value - min) % span + span) % span + min;
}
else
{
value = Mathf.Clamp(value, min, max);
}
axis.Value = value;
}
/// <summary>Middle-mouse pan (while PaintMode enables it): slides the whole framing along the screen
/// right/up axes via the camera-offset extension - a temporary offset to line up an awkward spot of
/// the model for painting. Eases home outside paint mode / while the orbit is Disabled.</summary>
void UpdatePan(float dt)
{
if (!enablePan || !_camOffset)
return;
bool active = PanEnabled && lookMode != OrbitLookMode.Disabled;
float zoom = PanZoomScale();
if (active && InputUtil.MiddleMouseHeld())
{
Vector2 d = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
// Subtracting gives a hand-tool feel (the model follows the cursor). The step scales with the
// zoom (same on-screen feel, see PanZoomScale); the clamp stays body-relative only.
_pan -= d * (panSensitivity * zoom);
_pan = Vector2.ClampMagnitude(_pan, panMaxDistance * CurrentScale());
}
else if (active)
{
return; // still painting, middle button released - hold the pan where it was left
}
else if (_pan != Vector2.zero)
{
// Left paint mode (or look disabled) - glide the framing back to center.
if (panReturnSpeed > 0f)
_pan = Vector2.Lerp(_pan, Vector2.zero, 1f - Mathf.Exp(-panReturnSpeed * dt));
else
_pan = Vector2.zero;
// Snap the last sliver so the offset settles exactly on center and this branch stops running.
if (_pan.sqrMagnitude < 1e-8f)
_pan = Vector2.zero;
}
else
{
return; // at rest and not panning - nothing to write
}
// Offset is camera-local (x=right, y=up); the extension translates eye + look-at together -> true pan.
_camOffset.Offset = new Vector3(_pan.x, _pan.y, 0f);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: df129be8fadab79498bd429a59bb98e4
@@ -0,0 +1,66 @@
using UnityEngine;
/// <summary>
/// Caps the frame rate via <see cref="Application.targetFrameRate"/>. VSync is
/// disabled automatically while limiting, because it would otherwise override
/// targetFrameRate. Changes apply live (also in Play mode) via OnValidate.
/// </summary>
[DisallowMultipleComponent]
public class FrameRateLimiter : MonoBehaviour
{
[Header("Limit")]
[Tooltip("Cap the frame rate to 'Target FPS' (off = unlimited).")]
public bool limitFrameRate = true;
[Tooltip("Target frame rate when the limit is active.")]
[Range(1, 360)]
public int targetFps = 60;
[Header("VSync")]
[Tooltip("Disable VSync while limiting (VSync would otherwise override the FPS limit). Normally ON.")]
public bool disableVSyncWhenLimiting = true;
[Tooltip("VSync count when no limit is active (0 = off, 1 = every refresh, 2 = every second refresh).")]
[Range(0, 2)]
public int vSyncCountWhenUnlimited = 0;
void OnEnable() => Apply();
void OnValidate()
{
if (Application.isPlaying)
Apply();
}
/// <summary>Applies the current FPS + VSync settings.</summary>
public void Apply()
{
if (limitFrameRate)
{
if (disableVSyncWhenLimiting)
QualitySettings.vSyncCount = 0;
Application.targetFrameRate = Mathf.Max(1, targetFps);
}
else
{
QualitySettings.vSyncCount = vSyncCountWhenUnlimited;
Application.targetFrameRate = -1;
}
}
/// <summary>Enable/disable the limit (e.g. for UI buttons).</summary>
public void SetLimitEnabled(bool enabled)
{
limitFrameRate = enabled;
Apply();
}
/// <summary>Set the target FPS and enable the limit (e.g. for UI sliders).</summary>
public void SetTargetFps(int fps)
{
targetFps = Mathf.Max(1, fps);
limitFrameRate = true;
Apply();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dd08e354312b3b14d8f2573ae830c513
+241
View File
@@ -0,0 +1,241 @@
using UnityEngine;
using Unity.Cinemachine;
/// <summary>
/// Drives the world-space "hero cutout": occluder geometry inside a cone from the third-person camera
/// (apex) to the player is clipped away, punching a see-through funnel toward the character. Each late
/// frame this feeds two global shader properties (<c>_HeroCutoutTarget</c>, <c>_HeroCutoutParams</c>)
/// that <c>Custom/HeroCutoutLit</c> reads.
///
/// Arming is deliberately simple: ONE ray from the camera toward the player - blocked = armed, clear
/// view = occluders stay solid. The cut volume lives entirely in the shader: everything between camera
/// and player (within the cone) is cut, geometry behind the player stays solid, and the far end fades
/// across a soft depth band (<see cref="depthFeather"/>) instead of a hard plane. The cone radius grows
/// with camera distance, so the hole keeps a constant on-screen size at every occluder depth.
///
/// Active only while the third-person orbit vcam is the live Cinemachine camera; disarming clears the
/// global flag (also the zero-initialised default), so scenes without this driver render cutout
/// materials as plain URP/Lit. Replaces the Cinemachine Deoccluder pull-in: the camera holds its orbit
/// distance and the obstacle goes see-through. Attach to a persistent scene object.
/// </summary>
[DisallowMultipleComponent]
public class HeroCutout : MonoBehaviour
{
[Tooltip("Orbit camera input whose CinemachineCamera must be live for the cutout to arm. Empty = found automatically.")]
public ThirdPersonOrbitInput orbitInput;
[Tooltip("Game mode manager, used as a fallback source for the live player. Empty = found automatically.")]
public GameModeManager modes;
[Tooltip("Switch off the orbit camera's Cinemachine Deoccluder obstacle avoidance at runtime so the " +
"camera holds its orbit distance and this cutout handles occlusion - instead of the camera " +
"pulling in toward the player. The scene's Deoccluder asset is left untouched; only its live " +
"pull-in is disabled (and restored if this driver is disabled).")]
public bool replaceDeoccluderPullIn = true;
[Header("Cutout cone")]
[Tooltip("Radius of the see-through cone AT THE PLAYER, in metres, AT PLAYER SCALE 1. The live radius is " +
"multiplied by the hero target's uniform world scale (PlayerScaler) so the hole keeps the same " +
"on-screen size for a small hider or a large hunter. The cone also tapers to nothing at the " +
"camera, so the hole keeps a constant on-screen size at every occluder depth.")]
[Range(0.05f, 3f)] public float radius = 0.5f;
[Tooltip("Soft-edge width of the cone wall at the player, in metres, AT PLAYER SCALE 1 (also tapers " +
"toward the camera). Like the radius, the live value is multiplied by the hero target's uniform " +
"world scale (PlayerScaler) so the soft edge keeps the same on-screen proportion at any size.")]
[Range(0f, 1f)] public float feather = 0.15f;
[Tooltip("Soft depth band (m) straddling the player: instead of stopping at a hard plane at the " +
"player's depth, the cut fades out across +/- this distance around the player, so an occluder " +
"running past the player (a shelf board) fades just behind them too instead of showing a hard " +
"straight cut line. Geometry beyond this band stays fully solid.")]
[Range(0.01f, 2f)] public float depthFeather = 0.5f;
[Tooltip("Vertical offset (m) from the follow target the cone aims at (chest/head height).")]
public float heightOffset = 0f;
[Header("Occlusion ray (camera -> player)")]
[Tooltip("Layers that count as occluders. Leave empty (Nothing) to fall back to all default raycast layers.")]
public LayerMask occluderMask = ~0;
[Tooltip("Collider tag ignored by the ray (the player's own colliders). Must be an existing tag; " +
"empty = ignore nothing.")]
public string ignoreTag = "Player";
[Tooltip("Metres the ray stops short of the player. Keep at 0 so a wall the player stands right " +
"behind still arms the cut (everything between camera and player counts). The ray already " +
"ends AT the player, so geometry behind them is never hit regardless. Raise this only if a " +
"low camera grazes the floor at the player's feet (better fixed via Height Offset).")]
[Range(0f, 1.5f)] public float minDistanceFromTarget = 0f;
[Tooltip("Seconds the cutout stays armed after the view clears again, to stop flicker at occluder edges.")]
public float occlusionHold = 0.1f;
static readonly int TargetId = Shader.PropertyToID("_HeroCutoutTarget");
static readonly int ParamsId = Shader.PropertyToID("_HeroCutoutParams");
private CinemachineCamera _orbitCam;
private CinemachineBrain _brain;
private CinemachineDeoccluder _deoccluder;
private bool _deoccluderResolved;
private bool _deoccluderOriginalAvoid;
private bool _pullInSuppressed;
private float _lastOccludedTime = -999f;
private void Awake()
{
if (!orbitInput) orbitInput = FindFirstObjectByType<ThirdPersonOrbitInput>(FindObjectsInactive.Include);
if (!modes) modes = FindFirstObjectByType<GameModeManager>();
EnsureRefs();
// CompareTag throws on an undefined tag - validate once so the per-frame ray never does.
if (!string.IsNullOrEmpty(ignoreTag))
{
try { _ = gameObject.CompareTag(ignoreTag); }
catch
{
Debug.LogWarning($"[HeroCutout] Ignore Tag '{ignoreTag}' is not defined; the ray will ignore nothing.", this);
ignoreTag = string.Empty;
}
}
}
private void OnDisable()
{
Disarm();
RestoreDeoccluderPullIn();
}
private void LateUpdate()
{
EnsureRefs();
ApplyPullInSuppression();
Camera cam = Camera.main;
if (!cam) { Disarm(); return; }
if (!_brain || _brain.gameObject != cam.gameObject)
_brain = cam.GetComponent<CinemachineBrain>();
if (!IsThirdPersonLive()) { Disarm(); return; }
Transform target = ResolveHeroTarget();
if (!target) { Disarm(); return; }
Vector3 heroPos = target.position + Vector3.up * heightOffset;
Vector3 camPos = cam.transform.position;
// Body-relative cone: the authored radius is the scale-1 size, scaled by the hero's uniform world
// size so the hole stays the same on screen for a small hider or a large hunter. lossyScale captures
// the full hierarchy (PlayerScaler drives localScale); reading it live tracks a mid-match conversion.
float bodyScale = Mathf.Max(1e-4f, target.lossyScale.x);
// Only punch the tube when something actually stands between the player and the camera; with a
// clear view the occluders stay solid. A short hold after the last blocked frame avoids flicker
// when the single ray grazes an occluder edge.
if (IsViewBlocked(camPos, heroPos)) _lastOccludedTime = Time.time;
if (Time.time - _lastOccludedTime > Mathf.Max(0f, occlusionHold)) { Disarm(); return; }
// Same hero point feeds the ray and the cone axis, so the gate and the cut volume agree.
_armed = true;
Shader.SetGlobalVector(TargetId, new Vector4(heroPos.x, heroPos.y, heroPos.z, 1f));
Shader.SetGlobalVector(ParamsId, new Vector4(Mathf.Max(0f, radius * bodyScale), Mathf.Max(1e-4f, feather * bodyScale), Mathf.Max(1e-4f, depthFeather), 0f));
}
/// <summary>
/// Single ray, camera -> player. Returns true when a non-ignored collider blocks the view before
/// the ray reaches the player (stopped short by <see cref="minDistanceFromTarget"/> so the wall the
/// player stands against / the floor at their feet never counts). Anything "between" the camera and
/// the player is necessarily the first thing the ray hits, so checking the first hit is enough.
/// </summary>
private bool IsViewBlocked(Vector3 camPos, Vector3 heroPos)
{
Vector3 delta = heroPos - camPos;
float dist = delta.magnitude;
if (dist <= 1e-3f) return false;
Vector3 dir = delta / dist;
float maxDist = dist - Mathf.Clamp(minDistanceFromTarget, 0f, dist);
if (maxDist <= 1e-3f) return false;
int mask = occluderMask.value != 0 ? occluderMask.value : Physics.DefaultRaycastLayers;
if (!Physics.Raycast(camPos, dir, out RaycastHit hit, maxDist, mask, QueryTriggerInteraction.Ignore))
return false;
// First (closest) hit decides: if it's the ignored player, nothing real is in between.
return string.IsNullOrEmpty(ignoreTag) || !hit.collider.CompareTag(ignoreTag);
}
/// <summary>True only when the orbit vcam is the live Cinemachine camera (third-person modes).</summary>
private bool IsThirdPersonLive()
{
if (!_orbitCam || !_brain) return false;
var live = _brain.ActiveVirtualCamera as CinemachineCamera;
return live && live == _orbitCam;
}
private Transform ResolveHeroTarget()
{
if (_orbitCam && _orbitCam.Follow) return _orbitCam.Follow;
if (modes && modes.Player) return modes.Player.transform;
return null;
}
private bool _armed; // change-guard: Disarm runs every non-armed frame, so it only writes the transition
/// <summary>Clears the "enabled" flag so cutout materials render as plain URP/Lit.</summary>
private void Disarm()
{
if (!_armed) return;
_armed = false;
Shader.SetGlobalVector(TargetId, Vector4.zero);
}
/// <summary>
/// Lazily resolves the orbit vcam and its Cinemachine Deoccluder (they may live in a scene that
/// loads after this driver). Captures the deoccluder's authored obstacle-avoidance state once so it
/// can be restored later.
/// </summary>
private void EnsureRefs()
{
if (!_orbitCam && orbitInput) _orbitCam = orbitInput.GetComponent<CinemachineCamera>();
if (!_deoccluderResolved && _orbitCam)
{
_deoccluder = _orbitCam.GetComponent<CinemachineDeoccluder>();
if (_deoccluder) _deoccluderOriginalAvoid = _deoccluder.AvoidObstacles.Enabled;
_deoccluderResolved = true;
}
}
/// <summary>
/// Switches the deoccluder's obstacle avoidance (the camera pull-in) off while this driver replaces
/// it with the cutout, or restores the authored state when the replacement is turned off. Only
/// toggles on transitions, so it never fights a value changed elsewhere mid-frame.
/// </summary>
private void ApplyPullInSuppression()
{
if (!_deoccluder) return;
if (replaceDeoccluderPullIn && !_pullInSuppressed)
{
_deoccluder.AvoidObstacles.Enabled = false;
_pullInSuppressed = true;
}
else if (!replaceDeoccluderPullIn && _pullInSuppressed)
{
_deoccluder.AvoidObstacles.Enabled = _deoccluderOriginalAvoid;
_pullInSuppressed = false;
}
}
/// <summary>Restores the deoccluder's authored pull-in if this driver had suppressed it.</summary>
private void RestoreDeoccluderPullIn()
{
if (_pullInSuppressed && _deoccluder)
{
_deoccluder.AvoidObstacles.Enabled = _deoccluderOriginalAvoid;
_pullInSuppressed = false;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c7d67030b8dcdec489471b88c35dd78b
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3fe9179f5a6af7349909c74b7b867309
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,109 @@
using UnityEngine;
public partial class MeshPainter
{
// --- Backend selection / switching ---
private void SetupBackend(bool gpu, Color32[][] canonical)
{
// SetupGpu returns false if the shaders are missing and it fell back to CPU; honor
// the real outcome so _gpuActive never claims GPU while _gpu is null (would NRE on
// the next SwitchBackend / paint).
if (gpu)
{
_gpuActive = SetupGpu(canonical);
}
else
{
SetupCpu(canonical);
_gpuActive = false;
}
}
/// <summary>Switches the backend at runtime, carrying over all painted maps.</summary>
private void SwitchBackend(bool toGpu)
{
var canonical = new Color32[MAP_COUNT][];
for (int mi = 0; mi < MAP_COUNT; mi++) canonical[mi] = ReadCanonical(mi);
RebuildBackend(toGpu, canonical);
Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this);
}
/// <summary>
/// Tears down the active backend and rebuilds it from externally supplied canonical maps (load /
/// late-join snapshot / undo). Runs the island/dilation seeding path so edge padding is rebuilt for
/// the new content. The input arrays are never mutated (SetupBackend clones its seed).
/// </summary>
private void ReseedCanonical(Color32[][] canonical) => RebuildBackend(useGpu, canonical);
/// <summary>Tears down the active backend and sets up the requested one on the given maps.</summary>
private void RebuildBackend(bool gpu, Color32[][] canonical)
{
if (_gpuActive) TeardownGpu();
else TeardownCpu();
SetupBackend(gpu, canonical);
_hasLastScreen = false;
}
/// <summary>Seed source for a map: the supplied canonical data when valid, else the material seed.</summary>
private Color32[] ResolveSeed(int mi, Color32[][] canonical)
{
return (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
? canonical[mi] : _seedCanonical[mi];
}
// Backend-agnostic stroke dispatch (GPU vs CPU), shared by local painting and replayed net stamps.
private void BeginBackendStroke()
{
if (_gpuActive) BeginGpuStroke();
else ResetCpuDirty();
}
private void DrawBackendStamp(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
{
if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, n);
else PaintStamp3D(pLocal, radiusLocal, u, v, n);
}
private void EndBackendStroke()
{
if (_gpuActive) EndGpuStroke();
else EndCpuStroke();
}
/// <summary>Reads a map from the active backend into canonical (material) layout.</summary>
private Color32[] ReadCanonical(int mi)
{
if (_gpuActive)
{
var gm = _gpu[mi];
// MSO: read the packed final mask RT (R=metallic, G=occlusion, A=smoothness).
// Others: the display RT. The mask map is linear data; color maps read sRGB.
if (mi == MAP_MSO) return ReadRtPixels(gm.final, linear: true);
return ReadRtPixels(gm.display, linear: MapIsLinear(mi));
}
return (Color32[])_cpu[mi].pixels.Clone();
}
private static bool MapIsLinear(int mi) => mi == MAP_MSO;
// Material slots per map, as pre-hashed IDs (this runs per stroke end - no per-call allocations).
// MSO: one mask map drives three URP slots: _MetallicGlossMap (R/A) + _OcclusionMap (G) +
// _ParallaxMap (G = occlusion reused as a 0.5-centred height for parallax/relief).
private static readonly int[][] MapPropIds =
{
new[] { BaseMapId, MainTexId }, // MAP_ALBEDO
new[] { MetallicGlossMapId, OcclusionMapId, ParallaxMapId }, // MAP_MSO
new[] { EmissionMapId }, // MAP_EM
};
private void AssignMapTexture(int mi, Texture tex)
{
if (!_materialInstance) return;
foreach (int id in MapPropIds[mi])
if (_materialInstance.HasProperty(id)) _materialInstance.SetTexture(id, tex);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 43a45b2bb2bb2b8499debf83ef5a7371
@@ -0,0 +1,341 @@
using UnityEngine;
public partial class MeshPainter
{
// --- CPU backend ---
private void SetupCpu(Color32[][] canonical)
{
_cpu = new CpuMap[MAP_COUNT];
for (int mi = 0; mi < MAP_COUNT; mi++)
{
var cm = new CpuMap
{
tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, MapIsLinear(mi))
{
name = "MeshPainter_Canvas_" + mi,
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear
},
pixels = SeedCpuPixels(mi, canonical),
// UV-island seed protects loaded/switched-in content from edge padding right away
// (the gutter stays paddable). Clone: painted[] is mutated per stroke.
painted = (bool[])GetIslandMask().Clone(),
fillStamp = new int[textureSize * textureSize],
dilateStamp = 0
};
cm.tex.SetPixels32(cm.pixels);
cm.tex.Apply(false);
_cpu[mi] = cm;
AssignMapTexture(mi, cm.tex);
}
}
/// <summary>CPU pixels are stored directly in material layout; just guarantee a sane alpha for the color maps.</summary>
private Color32[] SeedCpuPixels(int mi, Color32[][] canonical)
{
var src = ResolveSeed(mi, canonical);
var px = (Color32[])src.Clone();
if (mi != MAP_MSO) // albedo / emission: opaque. The mask map keeps alpha = smoothness.
for (int i = 0; i < px.Length; i++) px[i].a = 255;
return px;
}
private void TeardownCpu()
{
if (_cpu == null) return;
foreach (var cm in _cpu) if (cm != null && cm.tex) Destroy(cm.tex);
_cpu = null;
}
// --- CPU painting (rasterization + dilation) ---
private void ResetCpuDirty()
{
for (int mi = 0; mi < MAP_COUNT; mi++)
{
if (!IsChannelActive(mi)) continue;
var cm = _cpu[mi];
cm.dx0 = textureSize; cm.dy0 = textureSize; cm.dx1 = -1; cm.dy1 = -1;
}
}
private void EndCpuStroke()
{
for (int mi = 0; mi < MAP_COUNT; mi++)
{
if (!IsChannelActive(mi)) continue;
var cm = _cpu[mi];
if (paddingTexels > 0) DilatePadding(cm, paddingTexels);
FlushDirty(cm);
}
}
// sRGB<->linear LUT so the CPU blends the sRGB color maps (albedo/emission) in LINEAR
// space, matching the GPU's sRGB-framebuffer blend. Without it, partial-opacity texels
// (soft mask edges, brushColor.a<1) would differ between the GPU and CPU backends.
private static readonly float[] _srgbToLinear = BuildSrgbToLinearLut();
private static float[] BuildSrgbToLinearLut()
{
var lut = new float[256];
for (int i = 0; i < 256; i++) lut[i] = Mathf.GammaToLinearSpace(i / 255f);
return lut;
}
/// <summary>Blends a linear source over an sRGB-encoded destination byte in linear space, returns the sRGB byte.</summary>
private static byte BlendSrgb(byte dstByte, float srcLinear, float invOp, float op)
{
float outLinear = _srgbToLinear[dstByte] * invOp + srcLinear * op;
return (byte)(Mathf.Clamp01(Mathf.LinearToGammaSpace(outLinear)) * 255f + 0.5f);
}
/// <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| &lt;=
/// radius) over each active map, weighted by the mask opacity. 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)
{
bool aAlb = paintAlbedo, aMS = paintMetallicSmoothness, aEM = paintEmission, aAO = paintOcclusion;
if (!aAlb && !aMS && !aEM && !aAO) return;
// Albedo target (brush color in linear space; blended linear to match the GPU).
float brLin = Mathf.GammaToLinearSpace(brushColor.r);
float bgLin = Mathf.GammaToLinearSpace(brushColor.g);
float bbLin = Mathf.GammaToLinearSpace(brushColor.b);
float albOpMul = Mathf.Clamp01(brushColor.a);
CpuMap alb = _cpu[MAP_ALBEDO];
// Mask map target: R = metallic, G = occlusion, B = 0, A = smoothness (one texture
// bound to both _MetallicGlossMap and _OcclusionMap).
float msoR = Mathf.Clamp01(metallic) * 255f;
float msoG = Mathf.Clamp01(occlusion) * 255f;
float msoA = Mathf.Clamp01(smoothness) * 255f;
float msOpMul = Mathf.Clamp01(metallicSmoothnessOpacity);
float aoOpMul = Mathf.Clamp01(occlusionOpacity);
CpuMap mso = _cpu[MAP_MSO];
bool aMSO = aMS || aAO;
// Emission target (linear, like albedo).
float erLin = Mathf.GammaToLinearSpace(emissionColor.r);
float egLin = Mathf.GammaToLinearSpace(emissionColor.g);
float ebLin = Mathf.GammaToLinearSpace(emissionColor.b);
float emOpMul = Mathf.Clamp01(emissionColor.a);
CpuMap em = _cpu[MAP_EM];
float invR = 1f / 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];
if ((_triCenter[t] - pLocal).sqrMagnitude > reach * reach) continue;
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
Vector3 v0 = _verts[i0], v1 = _verts[i1], v2 = _verts[i2];
Vector2 uv0 = _uvs[i0], uv1 = _uvs[i1], uv2 = _uvs[i2];
float p0x = uv0.x * ts, p0y = uv0.y * ts;
float p1x = uv1.x * ts, p1y = uv1.y * ts;
float p2x = uv2.x * ts, p2y = uv2.y * ts;
int minX = Mathf.Clamp(Mathf.FloorToInt(Mathf.Min(p0x, Mathf.Min(p1x, p2x))), 0, textureSize - 1);
int maxX = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(p0x, Mathf.Max(p1x, p2x))), 0, textureSize - 1);
int minY = Mathf.Clamp(Mathf.FloorToInt(Mathf.Min(p0y, Mathf.Min(p1y, p2y))), 0, textureSize - 1);
int maxY = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(p0y, Mathf.Max(p1y, p2y))), 0, textureSize - 1);
if (maxX < minX || maxY < minY) continue;
// Barycentric denominator.
float d = (p1y - p2y) * (p0x - p2x) + (p2x - p1x) * (p0y - p2y);
if (Mathf.Abs(d) < 1e-7f) continue;
float invD = 1f / d;
const float eps = -1e-4f; // tolerance against 1px cracks between triangles
for (int y = minY; y <= maxY; y++)
{
float py = y + 0.5f;
int rowStart = y * textureSize;
for (int x = minX; x <= maxX; x++)
{
float px = x + 0.5f;
float a = ((p1y - p2y) * (px - p2x) + (p2x - p1x) * (py - p2y)) * invD;
if (a < eps) continue;
float b = ((p2y - p0y) * (px - p2x) + (p0x - p2x) * (py - p2y)) * invD;
if (b < eps) continue;
float c = 1f - a - b;
if (c < eps) continue;
// 3D position of the texel via barycentric interpolation.
float wx = a * v0.x + b * v1.x + c * v2.x;
float wy = a * v0.y + b * v1.y + c * v2.y;
float wz = a * v0.z + b * v1.z + c * v2.z;
// Project the offset onto the stamp frame (right/up/normal).
float ddx = wx - pLocal.x, ddy = wy - pLocal.y, ddz = wz - pLocal.z;
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;
float mu = du * invR * 0.5f + 0.5f;
float mv = dv * invR * 0.5f + 0.5f;
float maskOp = SampleMaskOpacity(mu, mv);
if (maskOp <= 0f) continue;
int idx = rowStart + x;
if (aAlb)
{
float op = maskOp * albOpMul;
if (op > 0f)
{
Color32 dst = alb.pixels[idx];
float it = 1f - op;
alb.pixels[idx] = new Color32(
BlendSrgb(dst.r, brLin, it, op),
BlendSrgb(dst.g, bgLin, it, op),
BlendSrgb(dst.b, bbLin, it, op),
255);
alb.painted[idx] = true;
ExpandDirty(alb, x, y);
}
}
if (aMSO)
{
// Mask map blend. Metallic(R) + smoothness(A) blend with the MS opacity,
// occlusion(G) with its own opacity - each = mask * the channel's opacity
// multiplier (the albedo brushColor.a equivalent). Untouched channels keep
// their previous value; B stays 0.
Color32 dst = mso.pixels[idx];
float msOp = maskOp * msOpMul;
float aoOp = maskOp * aoOpMul;
byte mr = aMS ? (byte)(dst.r * (1f - msOp) + msoR * msOp) : dst.r;
byte mg = aAO ? (byte)(dst.g * (1f - aoOp) + msoG * aoOp) : dst.g;
byte ma = aMS ? (byte)(dst.a * (1f - msOp) + msoA * msOp) : dst.a;
mso.pixels[idx] = new Color32(mr, mg, 0, ma);
mso.painted[idx] = true;
ExpandDirty(mso, x, y);
}
if (aEM)
{
float op = maskOp * emOpMul;
if (op > 0f)
{
Color32 dst = em.pixels[idx];
float it = 1f - op;
em.pixels[idx] = new Color32(
BlendSrgb(dst.r, erLin, it, op),
BlendSrgb(dst.g, egLin, it, op),
BlendSrgb(dst.b, ebLin, it, op),
255);
em.painted[idx] = true;
ExpandDirty(em, x, y);
}
}
}
}
}
}
private static void ExpandDirty(CpuMap cm, int x, int y)
{
if (x < cm.dx0) cm.dx0 = x;
if (x > cm.dx1) cm.dx1 = x;
if (y < cm.dy0) cm.dy0 = y;
if (y > cm.dy1) cm.dy1 = y;
}
/// <summary>
/// CPU edge padding against UV seams: bleeds the freshly painted colors outward by
/// 'pad' texels. The dilation source is only brush-painted texels (cm.painted).
/// </summary>
private void DilatePadding(CpuMap cm, int pad)
{
if (cm.dx1 < cm.dx0 || cm.dy1 < cm.dy0) return; // nothing painted
int x0 = Mathf.Max(0, cm.dx0 - pad);
int y0 = Mathf.Max(0, cm.dy0 - pad);
int x1 = Mathf.Min(textureSize - 1, cm.dx1 + pad);
int y1 = Mathf.Min(textureSize - 1, cm.dy1 + pad);
cm.dilateStamp++;
for (int iter = 0; iter < pad; iter++)
{
_dilateScratch.Clear();
for (int y = y0; y <= y1; y++)
{
int row = y * textureSize;
for (int x = x0; x <= x1; x++)
{
int idx = row + x;
if (cm.painted[idx] || cm.fillStamp[idx] == cm.dilateStamp) continue;
if (TryFindPaddingSource(cm, x, y, out Color32 c))
{
cm.pixels[idx] = c;
_dilateScratch.Add(idx);
ExpandDirty(cm, x, y);
}
}
}
for (int k = 0; k < _dilateScratch.Count; k++)
cm.fillStamp[_dilateScratch[k]] = cm.dilateStamp;
if (_dilateScratch.Count == 0) break;
}
}
/// <summary>Searches the 8-neighborhood for a source texel (painted or already padded) and returns its color.</summary>
private bool TryFindPaddingSource(CpuMap cm, int x, int y, out Color32 col)
{
for (int oy = -1; oy <= 1; oy++)
{
int ny = y + oy;
if (ny < 0 || ny >= textureSize) continue;
int nrow = ny * textureSize;
for (int ox = -1; ox <= 1; ox++)
{
if (ox == 0 && oy == 0) continue;
int nx = x + ox;
if (nx < 0 || nx >= textureSize) continue;
int nidx = nrow + nx;
if (cm.painted[nidx] || cm.fillStamp[nidx] == cm.dilateStamp)
{
col = cm.pixels[nidx];
return true;
}
}
}
col = default;
return false;
}
/// <summary>Uploads only the region changed this frame to the GPU once (CPU backend).</summary>
private void FlushDirty(CpuMap cm)
{
if (cm.dx1 < cm.dx0 || cm.dy1 < cm.dy0) return; // nothing painted
int bw = cm.dx1 - cm.dx0 + 1;
int bh = cm.dy1 - cm.dy0 + 1;
var block = new Color32[bw * bh];
for (int y = 0; y < bh; y++)
System.Array.Copy(cm.pixels, (cm.dy0 + y) * textureSize + cm.dx0, block, y * bw, bw);
cm.tex.SetPixels32(cm.dx0, cm.dy0, bw, bh, block);
cm.tex.Apply(false);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c651e96b15e679740a526502ded8cdc2
@@ -0,0 +1,308 @@
using UnityEngine;
public partial class MeshPainter
{
// --- GPU backend ---
/// <summary>GPU backend per map: a display RenderTexture + scratch for the dilation ping-pong.</summary>
private class GpuMap
{
public RenderTexture display; // painted into (work RT for the mask map)
public RenderTexture temp; // dilation ping-pong scratch
public RenderTexture final; // MSO only: packed mask map (R=metallic, G=occlusion, A=smoothness); null otherwise
public bool drew; // anything drawn this stroke?
public bool sRGB; // sRGB color map (albedo/emission) vs linear data map (MSO)
}
public Shader brushShader; // optional; else Shader.Find("Hidden/MeshPaintBrush")
public Shader dilateShader; // optional; else Shader.Find("Hidden/MeshPaintDilate")
public Shader packMsShader; // optional; else Shader.Find("Hidden/MeshPaintPackMS")
private GpuMap[] _gpu;
private Material _brushMat;
private Material _dilateMat;
private Material _packMat;
private bool _prevSRGB;
/// <summary>
/// Sets up the GPU backend (RenderTexture set + materials per map). Returns true on
/// success; on missing shaders it falls back to the CPU backend and returns false so
/// the caller leaves _gpuActive=false (and useGpu in sync) instead of crashing later.
/// </summary>
private bool SetupGpu(Color32[][] canonical)
{
if (!brushShader) brushShader = Shader.Find("Hidden/MeshPaintBrush");
if (!dilateShader) dilateShader = Shader.Find("Hidden/MeshPaintDilate");
if (!packMsShader) packMsShader = Shader.Find("Hidden/MeshPaintPackMS");
if (!brushShader || !dilateShader || !packMsShader)
{
Debug.LogError("[MeshPainter] GPU shaders not found (Hidden/MeshPaintBrush, " +
"Hidden/MeshPaintDilate or Hidden/MeshPaintPackMS). Falling back to CPU painting.", this);
useGpu = false;
SetupCpu(canonical);
return false;
}
_brushMat = new Material(brushShader) { hideFlags = HideFlags.HideAndDontSave };
_dilateMat = new Material(dilateShader) { hideFlags = HideFlags.HideAndDontSave };
_packMat = new Material(packMsShader) { hideFlags = HideFlags.HideAndDontSave };
_gpu = new GpuMap[MAP_COUNT];
for (int mi = 0; mi < MAP_COUNT; mi++)
{
bool linear = MapIsLinear(mi);
var gm = new GpuMap
{
sRGB = !linear,
display = CreateRT("MeshPainter_DisplayRT_" + mi, linear),
temp = CreateRT("MeshPainter_TempRT_" + mi, linear),
final = (mi == MAP_MSO) ? CreateRT("MeshPainter_FinalRT_" + mi, linear) : null
};
_gpu[mi] = gm;
SeedGpuMap(mi, canonical);
// Color maps render to the display RT; the MS map exposes its packed final RT.
AssignMapTexture(mi, gm.final != null ? gm.final : gm.display);
}
return true;
}
/// <summary>Seeds a GPU map's display RT from canonical data (coverage alpha = UV islands), then packs MS to final.</summary>
private void SeedGpuMap(int mi, Color32[][] canonical)
{
var src = ResolveSeed(mi, canonical);
var gm = _gpu[mi];
if (mi == MAP_MSO)
{
// Canonical (R=metallic, G=occlusion, A=smoothness) -> work layout
// (R=metallic, G=smoothness, B=occlusion). Alpha = coverage, stamped below.
var work = new Color32[src.Length];
for (int i = 0; i < src.Length; i++) work[i] = new Color32(src[i].r, src[i].a, src[i].g, 0);
UploadColorsToRT(work, gm.display, linear: true);
StampCoverage(gm.display);
PackMs(gm); // build the final mask RT once so the material shows the seeded values
}
else
{
UploadColorsToRT((Color32[])src.Clone(), gm.display, linear: gm.sRGB == false);
StampCoverage(gm.display);
}
}
/// <summary>
/// Stamps the dilation coverage guard into a map's alpha: rasterizes the mesh with the brush
/// shader's coverage pass (pass 1), writing alpha = 1 on exactly the texels the GPU brush can
/// paint. This replaces the CPU island-mask approximation, whose generous edge tolerance marked
/// sub-pixel island borders as protected even though the brush never reaches them - so they
/// could never be overpainted (stayed frozen at their seeded value) while the dilation guarded
/// them. With a GPU-accurate guard those border texels fall in the gutter (alpha 0) and dilation
/// overpaints them normally. Texels stay alpha 0 until reached, identical to the brush.
/// </summary>
private void StampCoverage(RenderTexture rt)
{
if (!_brushMat || !_bakedMesh) return;
var prev = RenderTexture.active;
Graphics.SetRenderTarget(rt);
if (_brushMat.SetPass(1))
{
DrawBakedMeshNow();
}
else
{
Debug.LogError("[MeshPainter] Brush coverage pass (pass 1) missing - dilation guard not seeded.", this);
}
RenderTexture.active = prev;
}
private void TeardownGpu()
{
if (_gpu != null)
{
foreach (var gm in _gpu)
{
if (gm == null) continue;
if (gm.display) { gm.display.Release(); Destroy(gm.display); }
if (gm.temp) { gm.temp.Release(); Destroy(gm.temp); }
if (gm.final) { gm.final.Release(); Destroy(gm.final); }
}
_gpu = null;
}
if (_brushMat) { Destroy(_brushMat); _brushMat = null; }
if (_dilateMat) { Destroy(_dilateMat); _dilateMat = null; }
if (_packMat) { Destroy(_packMat); _packMat = null; }
}
private RenderTexture CreateRT(string rtName, bool linear)
{
var rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGB32,
linear ? RenderTextureReadWrite.Linear : RenderTextureReadWrite.sRGB)
{
name = rtName,
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear,
antiAliasing = 1,
useMipMap = false,
autoGenerateMips = false
};
rt.Create();
return rt;
}
/// <summary>Writes Color32 RGB into a RenderTexture via a temporary texture. Alpha (the
/// dilation coverage marker) is cleared to 0 here and stamped afterwards by StampCoverage,
/// so the guard matches the GPU rasterizer exactly (see SeedGpuMap).</summary>
private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool linear)
{
var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
for (int i = 0; i < colors.Length; i++) colors[i].a = 0;
tmp.SetPixels32(colors);
tmp.Apply(false);
bool prev = GL.sRGBWrite;
GL.sRGBWrite = !linear;
Graphics.Blit(tmp, rt);
GL.sRGBWrite = prev;
Destroy(tmp);
}
/// <summary>Reads a RenderTexture back as Color32 (for the backend switch).</summary>
private Color32[] ReadRtPixels(RenderTexture rt, bool linear)
{
var prev = RenderTexture.active;
RenderTexture.active = rt;
var tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
tex.ReadPixels(new Rect(0, 0, textureSize, textureSize), 0, 0, false);
tex.Apply(false);
RenderTexture.active = prev;
var px = tex.GetPixels32();
Destroy(tex);
return px;
}
private void PackMs(GpuMap gm)
{
bool prev = GL.sRGBWrite;
GL.sRGBWrite = false; // linear data, no conversion
Graphics.Blit(gm.display, gm.final, _packMat, 0);
GL.sRGBWrite = prev;
}
// --- GPU painting ---
private void BeginGpuStroke()
{
_prevSRGB = GL.sRGBWrite;
for (int mi = 0; mi < MAP_COUNT; mi++)
if (IsChannelActive(mi)) _gpu[mi].drew = false;
}
private void DrawBrushGpu(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
{
for (int mi = 0; mi < MAP_COUNT; mi++)
{
if (!IsChannelActive(mi)) continue;
var gm = _gpu[mi];
Graphics.SetRenderTarget(gm.display);
GL.sRGBWrite = gm.sRGB; // sRGB color map vs linear data map (MS)
// Stamp placement is shared by every draw on this map.
_brushMat.SetTexture(MaskTexId, _stampMask);
_brushMat.SetVector(BrushCenterId, pLocal);
_brushMat.SetFloat(BrushRadiusId, radiusLocal);
_brushMat.SetVector(BrushUId, u);
_brushMat.SetVector(BrushVId, v);
_brushMat.SetVector(BrushNormalId, n);
if (mi == MAP_MSO)
{
// Metallic/smoothness and occlusion share one texture but paint with their OWN
// opacity, so each is a separate stamp (own _BrushColor.a + per-channel write
// mask). Untouched channels keep their value while coverage (A) accumulates.
if (paintMetallicSmoothness)
StampMso(Mathf.Clamp01(metallicSmoothnessOpacity), 13f); // R|G|A (skip B = occlusion)
if (paintOcclusion)
StampMso(Mathf.Clamp01(occlusionOpacity), 3f); // B|A (occlusion only)
}
else
{
// Albedo / emission: full RGBA, opacity carried by the colour's own alpha.
_brushMat.SetColor(BrushColorId, mi == MAP_ALBEDO ? brushColor : emissionColor);
_brushMat.SetFloat(ColorWriteMaskId, 15f);
DrawStamp();
}
gm.drew = true;
}
}
/// <summary>
/// Issues one mask-map stamp: raw (metallic, smoothness, occlusion) data with the given paint
/// opacity in alpha and a per-channel colour write mask, so metallic/smoothness and occlusion
/// stay independently paintable with their own opacity. EncodeData pre-compensates the Color
/// property's sRGB->linear upload so the values round-trip exactly (CPU parity); alpha is not converted.
/// </summary>
private void StampMso(float opacity, float colorMask)
{
_brushMat.SetVector(BrushColorId, new Vector4(
EncodeData(metallic), EncodeData(smoothness), EncodeData(occlusion), opacity));
_brushMat.SetFloat(ColorWriteMaskId, colorMask);
DrawStamp();
}
/// <summary>Draws the baked mesh once per sub-mesh with the current brush pass (UV-space rasterization).</summary>
private void DrawStamp()
{
if (!_brushMat.SetPass(0)) return;
DrawBakedMeshNow();
}
/// <summary>Draws the baked mesh once per sub-mesh with the pass the caller just set.</summary>
private void DrawBakedMeshNow()
{
int sub = _bakedMesh.subMeshCount;
for (int i = 0; i < sub; i++)
Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i);
}
private void EndGpuStroke()
{
Graphics.SetRenderTarget(null);
for (int mi = 0; mi < MAP_COUNT; mi++)
{
if (!IsChannelActive(mi)) continue;
var gm = _gpu[mi];
// Edge padding against UV seams: dilate the display RT into temp, then swap.
if (gm.drew && paddingTexels > 0)
{
GL.sRGBWrite = gm.sRGB;
_dilateMat.SetInt(PadId, paddingTexels);
Graphics.Blit(gm.display, gm.temp, _dilateMat, 0);
var swap = gm.display; gm.display = gm.temp; gm.temp = swap;
if (gm.final == null) AssignMapTexture(mi, gm.display);
}
// MS: pack the work layout (R=metallic, G=smoothness) into the URP final layout.
if (gm.final != null) PackMs(gm);
}
GL.sRGBWrite = _prevSRGB;
}
/// <summary>
/// Pre-compensates a raw data value (0..1) for the brush's Color property, which Unity
/// converts sRGB->linear on upload in a linear project. Applying the inverse here makes
/// the value round-trip exactly (so metallic/smoothness/occlusion are stored faithfully
/// and match the CPU byte writes). In gamma color space no conversion happens, so pass through.
/// </summary>
private static float EncodeData(float v)
{
v = Mathf.Clamp01(v);
return QualitySettings.activeColorSpace == ColorSpace.Linear ? Mathf.LinearToGammaSpace(v) : v;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0e2c632532b3a794f8a2dd6ac26a5fd7
@@ -0,0 +1,271 @@
using System.IO;
using System.IO.Compression;
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// One committed brush stamp in replicable form. Painting is synced as commands, not pixels: the owner
/// already resolves every stamp into local object space (hit point, radius, tangent frame), so a remote
/// peer replays the SAME stamp through its own backend onto the identical mesh/UVs.
/// Self-contained on purpose: each stamp carries its full brush state, so the stream is stateless.
/// Channel data quantizes to bytes with no real loss (the paint maps are 8-bit anyway).
/// </summary>
public struct PaintStampNet : INetworkSerializable
{
// Channel/mode bit flags (Channels field).
public const byte FlagAlbedo = 1 << 0;
public const byte FlagMetallicSmoothness = 1 << 1;
public const byte FlagOcclusion = 1 << 2;
public const byte FlagEmission = 1 << 3;
public const byte FlagSpray = 1 << 4;
// Placement (object-local space).
public Vector3 P; // hit point
public float R; // 3D brush radius
public Vector3 U, V, N; // tangent frame (right / up / normal)
public byte Channels; // active channels + spray flag
public int BrushIndex; // active brush mask index (resolved-mask painting)
public int SpraySeed; // RNG seed so a spray stamp scatters identically on every peer
public Color32 BrushColor; // albedo color (alpha = opacity)
public Color32 EmissionColor; // emission color (alpha = opacity)
public byte Metallic, Smoothness, Occlusion; // 0..255 = 0..1
public byte MSOpacity, OccOpacity; // 0..255 = 0..1
public void NetworkSerialize<T>(BufferSerializer<T> s) where T : IReaderWriter
{
s.SerializeValue(ref P);
s.SerializeValue(ref R);
s.SerializeValue(ref U);
s.SerializeValue(ref V);
s.SerializeValue(ref N);
s.SerializeValue(ref Channels);
s.SerializeValue(ref BrushIndex);
s.SerializeValue(ref SpraySeed);
s.SerializeValue(ref BrushColor);
s.SerializeValue(ref EmissionColor);
s.SerializeValue(ref Metallic);
s.SerializeValue(ref Smoothness);
s.SerializeValue(ref Occlusion);
s.SerializeValue(ref MSOpacity);
s.SerializeValue(ref OccOpacity);
}
}
public partial class MeshPainter
{
/// <summary>
/// Fires once per committed brush stamp on the owner (after the stamp has been drawn locally),
/// carrying everything a remote peer needs to replay it. Only the owner's PlayerPaintSync
/// subscribes; with no subscriber this is a no-op, so single-player keeps zero overhead.
/// </summary>
public event System.Action<PaintStampNet> OnStampCommitted;
// Last spray seed used by the owner this stamp (captured into the replicated stamp).
private int _lastSpraySeed;
private static byte ToByte01(float v) => (byte)(Mathf.Clamp01(v) * 255f + 0.5f);
/// <summary>True if anyone is listening for replicated stamps (the owner is networked + painting).</summary>
private bool WantsStampCapture => OnStampCommitted != null;
/// <summary>Builds the replicated stamp from the current brush state + resolved placement and raises the event.</summary>
private void EmitStamp(Vector3 p, float r, Vector3 u, Vector3 v, Vector3 n)
{
var handler = OnStampCommitted;
if (handler == null) return;
byte ch = 0;
if (paintAlbedo) ch |= PaintStampNet.FlagAlbedo;
if (paintMetallicSmoothness) ch |= PaintStampNet.FlagMetallicSmoothness;
if (paintOcclusion) ch |= PaintStampNet.FlagOcclusion;
if (paintEmission) ch |= PaintStampNet.FlagEmission;
if (spray) ch |= PaintStampNet.FlagSpray;
handler(new PaintStampNet
{
P = p, R = r, U = u, V = v, N = n,
Channels = ch,
BrushIndex = activeBrushIndex,
SpraySeed = _lastSpraySeed,
BrushColor = brushColor,
EmissionColor = emissionColor,
Metallic = ToByte01(metallic),
Smoothness = ToByte01(smoothness),
Occlusion = ToByte01(occlusion),
MSOpacity = ToByte01(metallicSmoothnessOpacity),
OccOpacity = ToByte01(occlusionOpacity),
});
}
/// <summary>
/// Bakes the spray mask from a fixed RNG seed so every peer scatters the identical pattern. The
/// global Unity RNG state is saved and restored so nothing else in the frame is perturbed.
/// </summary>
private void BuildSprayStampMaskSeeded(int seed)
{
var prev = Random.state;
Random.InitState(seed);
BuildSprayStampMask();
Random.state = prev;
}
/// <summary>
/// Replays a replicated brush stamp on a remote avatar: adopts the stamp's brush state, rebuilds
/// the matching mask (resolved or seeded spray) and runs it through the active backend exactly like
/// a local stamp. Mutating the public brush fields is safe here because the remote painter never
/// paints from local input (PaintingEnabled is false on remotes).
/// </summary>
public void ApplyNetworkStamp(in PaintStampNet s)
{
if (!_setupValid) return;
paintAlbedo = (s.Channels & PaintStampNet.FlagAlbedo) != 0;
paintMetallicSmoothness = (s.Channels & PaintStampNet.FlagMetallicSmoothness) != 0;
paintOcclusion = (s.Channels & PaintStampNet.FlagOcclusion) != 0;
paintEmission = (s.Channels & PaintStampNet.FlagEmission) != 0;
spray = (s.Channels & PaintStampNet.FlagSpray) != 0;
brushColor = s.BrushColor;
emissionColor = s.EmissionColor;
metallic = s.Metallic / 255f;
smoothness = s.Smoothness / 255f;
occlusion = s.Occlusion / 255f;
metallicSmoothnessOpacity = s.MSOpacity / 255f;
occlusionOpacity = s.OccOpacity / 255f;
activeBrushIndex = s.BrushIndex;
if (spray) BuildSprayStampMaskSeeded(s.SpraySeed);
else { ResolveActiveMask(); UseResolvedStampMask(); }
BeginBackendStroke();
DrawBackendStamp(s.P, s.R, s.U, s.V, s.N);
EndBackendStroke();
// Accumulate this peer's stroke dirty mask so a network checkpoint marker can commit the same
// history entry as the owner (same stamps -> same touched maps -> COW-aligned with the owner).
MarkStrokePainted();
}
// --- Late-join snapshot: the whole painted state as a compressed blob ---
//
// Live stamps only reach peers connected at paint time; a late joiner needs the accumulated
// result as a baseline. Instead of streaming 12 MB of raw maps, we dump the canonical maps once
// and Deflate them (paint is largely flat -> compresses well), then PlayerPaintSync chunks the
// blob over the network to the single joining client and re-seeds their painter with it.
private const uint SnapshotMagic = 0x50414e53; // 'SNAP' little-endian
private const int SnapshotVersion = 1;
/// <summary>True once Start() has built the backend, so capture/apply are safe.</summary>
public bool IsPaintReady => _setupValid;
/// <summary>
/// Serializes every paint map (canonical layout) and Deflate-compresses the result. Returns false
/// if the painter is not set up yet. Run on the authority that holds the up-to-date maps.
/// </summary>
public bool TryCaptureSnapshot(out byte[] compressed)
{
compressed = null;
if (!_setupValid) return false;
int n = textureSize * textureSize;
using (var raw = new MemoryStream(16 + n * 4 * MAP_COUNT))
{
using (var w = new BinaryWriter(raw, System.Text.Encoding.UTF8, leaveOpen: true))
{
w.Write(SnapshotMagic);
w.Write(SnapshotVersion);
w.Write(textureSize);
w.Write(MAP_COUNT);
WriteCanonicalMaps(w);
}
compressed = Deflate(raw.ToArray());
}
return true;
}
/// <summary>
/// Decompresses a snapshot, re-seeds the active backend with it, and makes it this peer's sole history
/// baseline under the authority's <paramref name="baselineId"/> (so subsequent checkpoint/restore
/// commands line up). Caller must ensure IsPaintReady.
/// </summary>
public void ApplyPaintSnapshot(byte[] compressed, int baselineId)
{
if (!TryDecodeSnapshot(compressed, "Paint", out Color32[][] canonical)) return;
ReseedCanonical(canonical);
SetHistoryBaseline(canonical, baselineId);
}
/// <summary>
/// Owner-side: re-seeds the painter from an in-memory snapshot blob and resets history to it as a
/// fresh local baseline (like <see cref="LoadPaintByName"/>, just from bytes) - used to carry a
/// painting across a scene-switch respawn, then rebroadcast via BroadcastLoadedPaint. Returns false
/// if the painter is not ready or the blob is incompatible (the current painting stays untouched).
/// </summary>
public bool LoadPaintFromSnapshot(byte[] compressed)
{
if (!TryDecodeSnapshot(compressed, "Carry", out Color32[][] canonical)) return false;
ReseedCanonical(canonical);
ResetHistoryToCurrent(canonical);
return true;
}
/// <summary>Guard + decode for an incoming snapshot blob; false = not ready / empty / undecodable.</summary>
private bool TryDecodeSnapshot(byte[] compressed, string context, out Color32[][] canonical)
{
canonical = null;
if (!_setupValid || compressed == null || compressed.Length == 0) return false;
try { canonical = DecodeSnapshot(compressed); }
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] {context} snapshot decode failed: {e.Message}", this);
return false;
}
return canonical != null;
}
private Color32[][] DecodeSnapshot(byte[] compressed)
{
byte[] raw = Inflate(compressed);
using (var ms = new MemoryStream(raw))
using (var r = new BinaryReader(ms))
{
if (r.ReadUInt32() != SnapshotMagic) return null;
int ver = r.ReadInt32();
int size = r.ReadInt32();
int maps = r.ReadInt32();
// Resolution / version mismatch: ignore rather than smear; the painter keeps its state.
if (ver != SnapshotVersion || size != textureSize) return null;
return ReadCanonicalMaps(r, maps);
}
}
private static byte[] Deflate(byte[] data)
{
using (var outp = new MemoryStream())
{
using (var ds = new DeflateStream(outp, System.IO.Compression.CompressionLevel.Fastest, leaveOpen: true))
ds.Write(data, 0, data.Length);
return outp.ToArray();
}
}
private static byte[] Inflate(byte[] data)
{
using (var inp = new MemoryStream(data))
using (var ds = new DeflateStream(inp, CompressionMode.Decompress))
using (var outp = new MemoryStream())
{
ds.CopyTo(outp);
return outp.ToArray();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ff9c722ff799831408a3475fe7fd57a6
@@ -0,0 +1,309 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
public partial class MeshPainter
{
// --- Persistence: named paint slots, saved/loaded explicitly from the HUD ---
//
// The painter no longer auto-loads on start or auto-saves on quit; painting is volatile until the
// player explicitly saves it to a named slot, and a fresh session always starts from the material
// seed. Saved slots live in a single global library folder (role/object independent), so any avatar
// can load any saved painting as a camouflage preset.
// Raw, bit-exact map dump: 'CHMP' magic + version + name + width + height + map count, then each
// map's canonical Color32 bytes. Raw (not PNG) avoids any sRGB/alpha-premultiply ambiguity and
// round-trips the exact canonical bytes ReadCanonical produces.
private const uint PaintFileMagic = 0x504D4843; // 'CHMP' little-endian
private const int PaintFileVersion = 4; // v3: emission + camo scalars; v4: + painted-height knobs
private const string PaintFileExt = ".paint";
/// <summary>
/// Per-player material scalars stored alongside the maps from v3 on. These are NOT baked into the
/// painted pixels (a global emission multiplier + the PlayerCamoLit knobs), so they ride the slot
/// header and are re-applied onto the painter when the slot loads.
/// </summary>
private struct PaintScalars
{
public bool has; // false = legacy v2 slot with no scalars -> leave the painter's values untouched
public bool hasHeight; // v4+ painted-height knobs present (occlusion threshold + normal/parallax intensity)
public float emissionIntensity;
public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen;
public float occlusionThreshold, heightNormalIntensity, parallaxIntensity;
}
private static string PaintLibraryDir =>
Path.Combine(Application.persistentDataPath, "MeshPaint", "Saved");
/// <summary>Filesystem-safe key: invalid filename characters and spaces become underscores.</summary>
internal static string SanitizeFileKey(string s)
{
foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
return s.Replace(' ', '_');
}
private static string SanitizeKey(string s) =>
string.IsNullOrEmpty(s) ? "unnamed" : SanitizeFileKey(s);
/// <summary>Disk path for a slot. The display name lives inside the file; the file name is a sanitized key.</summary>
private static string PathForName(string name) =>
Path.Combine(PaintLibraryDir, SanitizeKey(name) + PaintFileExt);
/// <summary>True if a slot with this name already exists (used to block overwriting on save).</summary>
public static bool PaintExists(string name) =>
!string.IsNullOrWhiteSpace(name) && File.Exists(PathForName(name));
/// <summary>Display names of every saved slot, sorted alphabetically. Each name is read from the file header.</summary>
public static List<string> ListSavedPaintings()
{
var names = new List<string>();
try
{
if (!Directory.Exists(PaintLibraryDir)) return names;
foreach (string path in Directory.GetFiles(PaintLibraryDir, "*" + PaintFileExt))
{
string n = ReadHeaderName(path);
if (!string.IsNullOrEmpty(n)) names.Add(n);
}
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Listing saved paintings failed: {e.Message}");
}
names.Sort(System.StringComparer.CurrentCultureIgnoreCase);
return names;
}
/// <summary>
/// Writes the current painted maps to a named slot. Returns false without writing if the painter is
/// not ready. An existing slot of the same name is overwritten only when <paramref name="overwrite"/>
/// is true; otherwise the call fails rather than clobbering it.
/// </summary>
public bool SavePaintAs(string name, bool overwrite = false)
{
if (!_setupValid || string.IsNullOrWhiteSpace(name)) return false;
name = name.Trim();
if (!overwrite && PaintExists(name)) return false;
try
{
Directory.CreateDirectory(PaintLibraryDir);
using (var fs = new FileStream(PathForName(name), FileMode.Create, FileAccess.Write))
using (var w = new BinaryWriter(fs))
{
w.Write(PaintFileMagic);
w.Write(PaintFileVersion);
w.Write(name); // length-prefixed UTF8 (BinaryWriter.Write(string))
w.Write(textureSize);
w.Write(textureSize);
w.Write(MAP_COUNT);
// v3: per-player material scalars that are NOT baked into the maps (a global emission
// multiplier + the PlayerCamoLit knobs). The painter is ready here (_setupValid), so the
// material-backed camo getters return the live values rather than the not-ready 0.
w.Write(emissionIntensity);
w.Write(CamoHalfLambert);
w.Write(CamoHalfLambertBrightness);
w.Write(CamoSpecularDampen);
// v4: the painted-height knobs (same material-instance source of truth as the camo knobs).
w.Write(OcclusionThreshold);
w.Write(HeightNormalIntensity);
w.Write(ParallaxIntensity);
WriteCanonicalMaps(w);
}
return true;
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Saving paint '{name}' failed: {e.Message}", this);
return false;
}
}
/// <summary>
/// Loads a named slot and re-seeds the active backend with it, replacing the current painting.
/// Returns false if the slot is missing or incompatible (the current painting is left untouched).
/// </summary>
public bool LoadPaintByName(string name)
{
if (!_setupValid) return false;
Color32[][] canonical = ReadMaps(PathForName(name), out PaintScalars scalars);
if (canonical == null) return false;
ReseedCanonical(canonical);
// A load replaces the whole painting and is NOT undoable: the loaded maps become the new history
// baseline, clearing any prior undo/redo. Peers reset to the same baseline via the load broadcast.
ResetHistoryToCurrent(canonical);
// v3 slots also carry the per-player material scalars; apply them so the loaded look is complete.
// Legacy v2 slots have none -> leave the current emission/camo untouched. The painter is ready here
// (_setupValid), so the material-backed camo setters take effect; PlayerPaintSync replicates the
// changed values to peers via their owner-authoritative NetworkVariables on its next LateUpdate.
if (scalars.has)
{
emissionIntensity = scalars.emissionIntensity;
RefreshEmissionColor();
CamoHalfLambert = scalars.camoHalfLambert;
CamoHalfLambertBrightness = scalars.camoHalfLambertBrightness;
CamoSpecularDampen = scalars.camoSpecularDampen;
}
// v4 slots also carry the painted-height knobs; older slots (v2/v3) leave them at the current value.
if (scalars.hasHeight)
{
OcclusionThreshold = scalars.occlusionThreshold;
HeightNormalIntensity = scalars.heightNormalIntensity;
ParallaxIntensity = scalars.parallaxIntensity;
}
return true;
}
/// <summary>Deletes a named slot. Returns true if a file was removed.</summary>
public static bool DeletePaint(string name)
{
try
{
string path = PathForName(name);
if (!File.Exists(path)) return false;
File.Delete(path);
return true;
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Deleting paint '{name}' failed: {e.Message}");
return false;
}
}
/// <summary>Reads just the display-name header of a slot file (or the file name as a fallback).</summary>
private static string ReadHeaderName(string path)
{
try
{
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
using (var r = new BinaryReader(fs, Encoding.UTF8))
{
if (!TryReadPaintVersion(r, out _)) return null;
return r.ReadString();
}
}
catch
{
return Path.GetFileNameWithoutExtension(path);
}
}
/// <summary>
/// Reads all maps from a slot file in canonical layout, or null if missing / incompatible. Per-map
/// null entries are tolerated by both backends (they fall back to the material seed).
/// </summary>
private Color32[][] ReadMaps(string path, out PaintScalars scalars)
{
scalars = default;
if (!File.Exists(path)) return null;
try
{
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
using (var r = new BinaryReader(fs, Encoding.UTF8))
{
if (!TryReadPaintVersion(r, out int ver)) return null;
r.ReadString(); // display name (header only; not needed for the pixel load)
int w = r.ReadInt32();
int h = r.ReadInt32();
int maps = r.ReadInt32();
// v3+: the per-player material scalars sit between the header and the pixel maps.
if (ver >= 3)
{
scalars.has = true;
scalars.emissionIntensity = r.ReadSingle();
scalars.camoHalfLambert = r.ReadSingle();
scalars.camoHalfLambertBrightness = r.ReadSingle();
scalars.camoSpecularDampen = r.ReadSingle();
}
// v4+: the painted-height knobs follow the camo scalars.
if (ver >= 4)
{
scalars.hasHeight = true;
scalars.occlusionThreshold = r.ReadSingle();
scalars.heightNormalIntensity = r.ReadSingle();
scalars.parallaxIntensity = r.ReadSingle();
}
// Resolution change -> ignore rather than smear mismatched data; the brush re-seeds
// from the material instead.
if (w != textureSize || h != textureSize)
{
Debug.LogWarning($"[MeshPainter] Saved paint ignored (got {w}x{h}, expected " +
$"{textureSize}x{textureSize}).", this);
return null;
}
return ReadCanonicalMaps(r, maps);
}
}
catch (System.Exception e)
{
Debug.LogWarning($"[MeshPainter] Loading paint failed: {e.Message}", this);
return null;
}
}
/// <summary>Validates the paint-file magic and version range (legacy v2 .. current).</summary>
private static bool TryReadPaintVersion(BinaryReader r, out int ver)
{
if (r.ReadUInt32() != PaintFileMagic) { ver = 0; return false; }
ver = r.ReadInt32();
return ver >= 2 && ver <= PaintFileVersion;
}
/// <summary>Writes every map's canonical Color32 bytes to the stream (shared by slot save + net snapshot).</summary>
private void WriteCanonicalMaps(BinaryWriter w)
{
int n = textureSize * textureSize;
var bytes = new byte[n * 4];
for (int mi = 0; mi < MAP_COUNT; mi++)
{
Color32[] px = ReadCanonical(mi);
int count = Mathf.Min(n, px.Length);
for (int i = 0; i < count; i++)
{
int o = i * 4;
bytes[o] = px[i].r;
bytes[o + 1] = px[i].g;
bytes[o + 2] = px[i].b;
bytes[o + 3] = px[i].a;
}
w.Write(bytes, 0, count * 4);
}
}
/// <summary>Reads up to MAP_COUNT canonical map blocks; a truncated stream leaves the remaining maps null (seeded).</summary>
private Color32[][] ReadCanonicalMaps(BinaryReader r, int mapsInFile)
{
int n = textureSize * textureSize;
var result = new Color32[MAP_COUNT][];
int load = Mathf.Min(mapsInFile, MAP_COUNT);
for (int mi = 0; mi < load; mi++)
{
byte[] raw = r.ReadBytes(n * 4);
if (raw.Length != n * 4) break; // truncated -> remaining maps stay at the material seed
var px = new Color32[n];
for (int i = 0; i < n; i++)
{
int o = i * 4;
px[i] = new Color32(raw[o], raw[o + 1], raw[o + 2], raw[o + 3]);
}
result[mi] = px;
}
return result;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cbe2b3b1d4eb9eb489c40b1ee7f31ac3
@@ -0,0 +1,558 @@
using System;
using UnityEngine;
public partial class MeshPainter
{
// --- Pipette (eyedropper) --- (semantics: see the pipette tooltip; per-surface routing: PickAtScreen)
[Header("Brush - pipette (eyedropper)")]
[Tooltip("Pipette mode: a click samples the channel values at the hit point on ANY surface " +
"(albedo, metallic, smoothness, emission) and adopts them into the brush (opacities/alphas " +
"left untouched) instead of painting. Height (occlusion) is adopted and its toggle enabled " +
"only when our own paint mesh is picked; any other surface disables the height toggle. On a " +
"foreign object the data channels are read texel-exact from the material; albedo follows " +
"'Pipette Albedo From Screen'. Held button picks once.")]
public bool pipette = false;
[Tooltip("On a foreign-object pick, where albedo comes from. OFF (default): albedo is read " +
"texel-exact from the material like the other channels (raw, unlit). ON: albedo is taken " +
"from the lit on-screen pixel ('what you see' - honours baked lightmaps/ambient/SSAO). " +
"The data channels (metallic/smoothness/emission) always come from the material.")]
[SerializeField] private bool pipetteAlbedoFromScreen = false;
/// <summary>
/// Raised after a successful pick, once the brush has adopted the sampled values and all four
/// paint-channel flags have been enabled. The in-game HUD listens to re-sync its widgets.
/// </summary>
public event Action Picked;
private bool _pickHeld; // edge tracking so a held button picks once, not every frame
// A terrain hit higher than this above the heightfield is a tree (trees share the terrain
// collider but stand proud of the ground); on the surface it is bare ground.
private const float TerrainFoliageHeightEpsilon = 0.3f;
/// <summary>
/// Raycasts at a screen position against ANY collider (regardless of which channels are toggled),
/// samples the channel values at the hit and adopts them into the brush (toggle rules: see
/// <see cref="FinalizePick"/>). Pick paths, by what was hit:
/// - our paint mesh -> texel-exact from the painted maps (full PBR);
/// - terrain ground -> blend of the terrain layers by their splat weights (full PBR);
/// - terrain tree / detail -> the rendered on-screen colour (no per-instance renderer/UV);
/// - any foreign object -> data channels texel-exact from the material; albedo from the
/// material or the lit on-screen pixel per pipetteAlbedoFromScreen;
/// - anything else / sky -> the rendered on-screen colour.
/// </summary>
private void PickAtScreen(Vector2 screenPos)
{
bool ok;
bool pickedHeight = false; // height is only meaningful off our OWN painted maps
if (TryRaycastAny(screenPos, out RaycastHit hit))
{
if (hit.collider == _collider)
{
ok = PickFromPaintTextures(hit);
pickedHeight = ok;
}
else
{
Terrain terrain = hit.collider is TerrainCollider ? hit.collider.GetComponent<Terrain>() : null;
if (terrain)
ok = PickFromTerrain(terrain, hit, screenPos);
else
ok = PickFromForeignHybrid(hit, screenPos);
}
}
else
{
// Ray left the scene entirely (e.g. foliage against the sky) - only the screen pixel remains.
ok = PickFromScreen(screenPos);
}
if (!ok) return;
FinalizePick(pickedHeight);
}
/// <summary>
/// Common tail of every successful pick: enables the sampled paint channels (height only when our
/// own paint mesh was picked - any other surface disables its toggle and keeps the value), leaves
/// pipette mode (one-shot) and notifies listeners (the HUD) to re-sync their widgets.
/// </summary>
private void FinalizePick(bool pickedHeight)
{
paintAlbedo = true;
paintMetallicSmoothness = true;
paintOcclusion = pickedHeight;
paintEmission = true;
pipette = false;
Picked?.Invoke();
}
/// <summary>Adopts a sampled colour as the brush albedo, preserving the brush's paint opacity (alpha).</summary>
private void AdoptAlbedo(Color c) =>
brushColor = new Color(Mathf.Clamp01(c.r), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b), brushColor.a);
/// <summary>Zeroes the emission colour, preserving its paint opacity (alpha).</summary>
private void ClearEmissionRgb() =>
emissionColor = new Color(0f, 0f, 0f, emissionColor.a);
/// <summary>
/// Picks from our own painted maps at the hit texel (bit-exact, backend-agnostic).
/// </summary>
private bool PickFromPaintTextures(RaycastHit hit)
{
if (_verts == null || _uvs == null || _tris == null || hit.triangleIndex < 0) return false;
int t = hit.triangleIndex;
if (t * 3 + 2 >= _tris.Length) return false;
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
// UV at the hit via barycentric interpolation of the cached UVs - same UV space the
// brush paints into, so the sampled texel is exactly the one that would be painted.
Vector3 bc = hit.barycentricCoordinate;
Vector2 uv = _uvs[i0] * bc.x + _uvs[i1] * bc.y + _uvs[i2] * bc.z;
int px = Mathf.Clamp(Mathf.FloorToInt(uv.x * textureSize), 0, textureSize - 1);
int py = Mathf.Clamp(Mathf.FloorToInt(uv.y * textureSize), 0, textureSize - 1);
int idx = py * textureSize + px;
// ReadCanonical gives material layout for either backend:
// albedo -> sRGB RGB
// MSO -> R = metallic, G = occlusion, A = smoothness (linear data)
// emission -> sRGB RGB
Color32[] alb = ReadCanonical(MAP_ALBEDO);
Color32[] mso = ReadCanonical(MAP_MSO);
Color32[] em = ReadCanonical(MAP_EM);
if (idx < 0 || idx >= alb.Length || idx >= mso.Length || idx >= em.Length) return false;
Color32 a = alb[idx];
Color32 m = mso[idx];
Color32 e = em[idx];
// sRGB bytes map straight back into the gamma-space brush colours (round-trips exactly). The
// alpha (paint opacity) is deliberately preserved, not overwritten - a pick changes only the colour.
brushColor = new Color(a.r / 255f, a.g / 255f, a.b / 255f, brushColor.a);
metallic = m.r / 255f;
smoothness = m.a / 255f;
occlusion = m.g / 255f; // the painted HEIGHT - adopted only off our own mesh (FinalizePick)
emissionColor = new Color(e.r / 255f, e.g / 255f, e.b / 255f, emissionColor.a);
return true;
}
/// <summary>
/// Foreign-object pick: data channels always texel-exact from the material (raw, unlit); albedo
/// optionally overwritten with the fully-lit on-screen pixel (<see cref="pipetteAlbedoFromScreen"/>).
/// Falls back to a pure screen pick when no texel-exact material read is possible.
/// </summary>
private bool PickFromForeignHybrid(RaycastHit hit, Vector2 screenPos)
{
if (PickFromForeignMaterial(hit))
{
// No camera (headless) -> keep the material's raw albedo rather than failing.
if (pipetteAlbedoFromScreen && TrySampleScreenColor(screenPos, out Color lit))
AdoptAlbedo(lit);
return true;
}
return PickFromScreen(screenPos);
}
/// <summary>
/// Picks from a foreign object's material at the hit UV: texel-exact texture samples
/// (where a UV at the hit can be resolved and the map exists) combined with the material
/// scalars per the URP/Lit conventions, with scalar fallbacks everywhere else.
/// </summary>
private bool PickFromForeignMaterial(RaycastHit hit)
{
Collider col = hit.collider;
if (!col) return false;
Renderer rend = col.GetComponent<Renderer>();
if (!rend) rend = col.GetComponentInParent<Renderer>();
if (!rend) return false;
// hit.textureCoord is only populated for a NON-convex MeshCollider (convex/primitive colliders
// return zero) - recover the UV from the renderer's own mesh then. The hit SUBMESH is resolved
// alongside so a multi-material renderer reads the covering sub-material, not always slot 0.
Vector2 uv;
bool hasUv;
int subMesh;
if (col is MeshCollider mc && !mc.convex && hit.triangleIndex >= 0)
{
uv = hit.textureCoord;
hasUv = true;
subMesh = SubMeshFromIndex(mc.sharedMesh, hit.triangleIndex * 3);
}
else
{
hasUv = TryRecoverMeshUv(rend, hit, out uv, out subMesh);
}
// One material per submesh: pick the slot the hit submesh maps to (clamped, so a renderer with
// fewer materials than submeshes still reads). Reads the shared material - never instances it.
Material[] mats = rend.sharedMaterials;
Material mat = (mats != null && mats.Length > 0)
? mats[Mathf.Clamp(subMesh, 0, mats.Length - 1)]
: null;
if (!mat) return false;
// Base map but no UV resolvable: defer to the on-screen pixel (which at least reflects the
// texture) rather than silently picking the flat _BaseColor.
if (!hasUv && GetTexProp(mat, "_BaseMap", "_MainTex")) return false;
// --- Albedo: BaseMap (sRGB) * BaseColor ---
Color baseCol = GetColorProp(mat, "_BaseColor", "_Color", Color.white);
Texture baseTex = GetTexProp(mat, "_BaseMap", "_MainTex");
Color albedo = baseCol;
if (hasUv && baseTex)
albedo = SampleTexel(baseTex, ApplyST(mat, "_BaseMap", "_MainTex", uv), true) * baseCol;
AdoptAlbedo(albedo);
// --- Metallic / Smoothness: MetallicGloss map (linear) ---
// URP/Lit: metallic = map.r ; smoothness = map.a * _Smoothness (or _Metallic/_Glossiness scalars).
float metallicVal = GetFloatProp(mat, "_Metallic", null, 0f);
float smoothVal = GetFloatProp(mat, "_Smoothness", "_Glossiness", 0.5f);
Texture mgTex = GetTexProp(mat, "_MetallicGlossMap", null);
if (hasUv && mgTex)
{
Color mg = SampleTexel(mgTex, ApplyST(mat, "_MetallicGlossMap", null, uv), false);
metallicVal = mg.r;
smoothVal = mg.a * smoothVal;
}
metallic = Mathf.Clamp01(metallicVal);
smoothness = Mathf.Clamp01(smoothVal);
// Occlusion is deliberately NOT sampled (that channel drives painted height now, see FinalizePick).
// --- Emission: EmissionMap (sRGB) * EmissionColor ---
Color emColor = GetColorProp(mat, "_EmissionColor", null, Color.black);
Texture emTex = GetTexProp(mat, "_EmissionMap", null);
Color emCol = emColor;
if (hasUv && emTex)
emCol = SampleTexel(emTex, ApplyST(mat, "_EmissionMap", null, uv), true) * emColor;
emissionColor = new Color(Mathf.Clamp01(emCol.r), Mathf.Clamp01(emCol.g), Mathf.Clamp01(emCol.b), emissionColor.a);
return true;
}
/// <summary>
/// Picks at a terrain hit. The TerrainCollider also carries the painted TREES (which sit above
/// the heightfield and expose no per-instance renderer/UV here), and grass/mushroom DETAILS have
/// no collider at all:
/// - hit above the surface -> a tree: adopt the rendered on-screen colour;
/// - hit on a cell that has grass/mushroom detail -> adopt the on-screen colour;
/// - otherwise (bare ground) -> blend the terrain layers by their splat weights (full PBR).
/// </summary>
private bool PickFromTerrain(Terrain terrain, RaycastHit hit, Vector2 screenPos)
{
TerrainData td = terrain.terrainData;
if (!td) return false;
Vector3 tpos = terrain.transform.position;
Vector3 size = td.size;
if (size.x <= 1e-4f || size.z <= 1e-4f) return false;
// Trees share the terrain collider but stand proud of the heightfield.
float surfaceY = terrain.SampleHeight(hit.point) + tpos.y;
if (hit.point.y > surfaceY + TerrainFoliageHeightEpsilon)
return PickFromScreen(screenPos);
Vector3 local = hit.point - tpos;
float nx = Mathf.Clamp01(local.x / size.x);
float nz = Mathf.Clamp01(local.z / size.z);
// Grass/mushroom detail rendered on this ground cell -> what's visible there is the
// foliage (which has no collider of its own), so trust the screen pixel.
if (TerrainHasDetail(td, nx, nz))
return PickFromScreen(screenPos);
return PickFromTerrainLayers(td, local, nx, nz);
}
/// <summary>
/// Blends the terrain's layers at a ground point by their splat (alphamap) weights: the weighted
/// diffuse colour becomes albedo and the weighted per-layer metallic/smoothness scalars feed
/// those channels; emission is black (occlusion is left as the brush's height value). Terrain has no Renderer/material, so this
/// is the only way to read its surface.
/// </summary>
private bool PickFromTerrainLayers(TerrainData td, Vector3 local, float nx, float nz)
{
int aw = td.alphamapWidth, ah = td.alphamapHeight;
int mapX = Mathf.Clamp(Mathf.FloorToInt(nx * aw), 0, aw - 1);
int mapZ = Mathf.Clamp(Mathf.FloorToInt(nz * ah), 0, ah - 1);
float[,,] weights = td.GetAlphamaps(mapX, mapZ, 1, 1);
TerrainLayer[] layers = td.terrainLayers;
int count = Mathf.Min(layers.Length, weights.GetLength(2));
Color albedo = Color.black;
float metallicVal = 0f, smoothVal = 0f, weightSum = 0f;
for (int i = 0; i < count; i++)
{
TerrainLayer L = layers[i];
if (L == null) continue;
float w = weights[0, 0, i];
if (w <= 0f) continue;
Color col = Color.gray;
if (L.diffuseTexture)
{
// Terrain tiles each layer across the world by tileSize (metres); sample that texel.
Vector2 uv = new Vector2(
(local.x - L.tileOffset.x) / Mathf.Max(1e-4f, L.tileSize.x),
(local.z - L.tileOffset.y) / Mathf.Max(1e-4f, L.tileSize.y));
col = SampleTexel(L.diffuseTexture, uv, true);
}
albedo += col * w;
metallicVal += L.metallic * w;
smoothVal += L.smoothness * w;
weightSum += w;
}
if (weightSum <= 1e-4f) return false;
float inv = 1f / weightSum; // alphamap weights normally sum to 1, but guard anyway
AdoptAlbedo(albedo * inv);
metallic = Mathf.Clamp01(metallicVal * inv);
smoothness = Mathf.Clamp01(smoothVal * inv);
ClearEmissionRgb(); // occlusion left untouched (now painted height)
return true;
}
/// <summary>
/// True when any grass/mushroom detail layer has coverage at the given normalized terrain
/// position - i.e. foliage (which carries no collider) is rendered on that ground cell.
/// </summary>
private static bool TerrainHasDetail(TerrainData td, float nx, float nz)
{
int dw = td.detailWidth, dh = td.detailHeight;
if (dw <= 0 || dh <= 0) return false;
int dx = Mathf.Clamp(Mathf.FloorToInt(nx * dw), 0, dw - 1);
int dy = Mathf.Clamp(Mathf.FloorToInt(nz * dh), 0, dh - 1);
int layerCount = td.detailPrototypes.Length;
for (int i = 0; i < layerCount; i++)
{
int[,] d = td.GetDetailLayer(dx, dy, 1, 1, i);
if (d[0, 0] > 0) return true;
}
return false;
}
/// <summary>
/// Fallback colour pick: reads the rendered pixel under the cursor (the paint camera re-rendered
/// to a temporary target, so it works on either render pipeline). Used for surfaces that can't be
/// sampled texel-exact - terrain trees and grass/mushroom details (no per-instance renderer/UV)
/// and anything hit without a readable material, plus rays that miss all geometry. Only albedo is
/// meaningful from a screen pixel; the data channels default to matte/unoccluded/no-emission.
/// </summary>
private bool PickFromScreen(Vector2 screenPos)
{
if (!TrySampleScreenColor(screenPos, out Color c)) return false;
AdoptAlbedo(c);
metallic = 0f;
smoothness = 0f;
ClearEmissionRgb(); // occlusion left untouched (now painted height)
return true;
}
/// <summary>
/// Reads the rendered pixel under the cursor (the paint camera re-rendered to a temporary target,
/// so it works on either render pipeline) without touching any brush channel. Returns the fully-lit
/// on-screen colour - what is visible, including baked lightmaps/ambient/SSAO. False when no camera
/// is bound (headless). Shared by <see cref="PickFromScreen"/> and the hybrid albedo override.
/// </summary>
private bool TrySampleScreenColor(Vector2 screenPos, out Color color)
{
color = default;
Camera cam = paintCamera;
if (!cam) return false;
int w = Mathf.Max(1, cam.pixelWidth), h = Mathf.Max(1, cam.pixelHeight);
RenderTexture rt = RenderTexture.GetTemporary(w, h, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
RenderTexture prevTarget = cam.targetTexture;
RenderTexture prevActive = RenderTexture.active;
cam.targetTexture = rt;
cam.Render();
RenderTexture.active = rt;
var tmp = new Texture2D(1, 1, TextureFormat.RGBA32, false, false);
int px = Mathf.Clamp(Mathf.RoundToInt(screenPos.x), 0, w - 1);
int py = Mathf.Clamp(Mathf.RoundToInt(screenPos.y), 0, h - 1);
tmp.ReadPixels(new Rect(px, py, 1, 1), 0, 0, false);
tmp.Apply(false);
color = tmp.GetPixel(0, 0);
cam.targetTexture = prevTarget;
RenderTexture.active = prevActive;
RenderTexture.ReleaseTemporary(rt);
if (Application.isPlaying) Destroy(tmp); else DestroyImmediate(tmp);
return true;
}
// --- Material read helpers (name-tolerant: URP and legacy Standard) ---
private static Color GetColorProp(Material m, string nameA, string nameB, Color fallback)
{
if (m.HasProperty(nameA)) return m.GetColor(nameA);
if (nameB != null && m.HasProperty(nameB)) return m.GetColor(nameB);
return fallback;
}
private static float GetFloatProp(Material m, string nameA, string nameB, float fallback)
{
if (m.HasProperty(nameA)) return m.GetFloat(nameA);
if (nameB != null && m.HasProperty(nameB)) return m.GetFloat(nameB);
return fallback;
}
private static Texture GetTexProp(Material m, string nameA, string nameB)
{
if (m.HasProperty(nameA)) { Texture t = m.GetTexture(nameA); if (t) return t; }
if (nameB != null && m.HasProperty(nameB)) { Texture t = m.GetTexture(nameB); if (t) return t; }
return null;
}
private static Vector2 ApplyST(Material m, string nameA, string nameB, Vector2 uv)
{
string prop = m.HasProperty(nameA) ? nameA : (nameB != null && m.HasProperty(nameB) ? nameB : null);
if (prop == null) return uv;
Vector2 s = m.GetTextureScale(prop);
Vector2 o = m.GetTextureOffset(prop);
return new Vector2(uv.x * s.x + o.x, uv.y * s.y + o.y);
}
/// <summary>
/// Samples a single texel from a texture at a UV (texel-exact). Uses GetPixelBilinear when the
/// texture is CPU-readable; otherwise blits it to a temporary RenderTexture and reads back the
/// one texel, so it works regardless of the texture's "Read/Write" import flag. <paramref
/// name="srgb"/> selects the colour space so the returned value matches the readable path
/// (gamma for colour maps, linear for data maps).
/// </summary>
private static Color SampleTexel(Texture tex, Vector2 uv, bool srgb)
{
Texture2D t2d = tex as Texture2D;
if (t2d != null && t2d.isReadable)
return t2d.GetPixelBilinear(uv.x, uv.y);
int w = Mathf.Max(1, tex.width), h = Mathf.Max(1, tex.height);
var rw = srgb ? RenderTextureReadWrite.sRGB : RenderTextureReadWrite.Linear;
RenderTexture rt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32, rw);
RenderTexture prev = RenderTexture.active;
Graphics.Blit(tex, rt);
RenderTexture.active = rt;
var tmp = new Texture2D(1, 1, TextureFormat.RGBA32, false, !srgb);
int px = Mathf.Clamp(Mathf.FloorToInt(Mathf.Repeat(uv.x, 1f) * w), 0, w - 1);
int py = Mathf.Clamp(Mathf.FloorToInt(Mathf.Repeat(uv.y, 1f) * h), 0, h - 1);
tmp.ReadPixels(new Rect(px, py, 1, 1), 0, 0, false);
tmp.Apply(false);
Color c = tmp.GetPixel(0, 0);
RenderTexture.active = prev;
RenderTexture.ReleaseTemporary(rt);
if (Application.isPlaying) Destroy(tmp); else DestroyImmediate(tmp);
return c;
}
/// <summary>
/// Recovers the UV at a hit point by intersecting the camera ray with the renderer's own mesh
/// (Moller-Trumbore against each triangle, nearest hit wins). Needed when the collider that was
/// hit cannot supply hit.textureCoord - a convex MeshCollider or any primitive collider. Requires
/// a MeshFilter whose mesh is readable and UV-mapped; returns false otherwise so the caller can
/// fall back. A one-shot click cost, so the O(triangles) sweep is acceptable.
/// </summary>
private bool TryRecoverMeshUv(Renderer rend, RaycastHit hit, out Vector2 uv, out int subMesh)
{
uv = default;
subMesh = 0;
if (!rend) return false;
MeshFilter mf = rend.GetComponent<MeshFilter>();
Mesh mesh = mf ? mf.sharedMesh : null;
if (!mesh || !mesh.isReadable) return false;
Vector2[] uvs = mesh.uv;
if (uvs == null || uvs.Length == 0) return false;
Vector3[] verts = mesh.vertices;
int[] tris = mesh.triangles;
// Ray from the paint camera through the surface point (its UV is what we want). Fall back to
// shooting along the inverted hit normal when no camera is bound.
Vector3 ro, rd;
if (paintCamera)
{
ro = paintCamera.transform.position;
rd = (hit.point - ro).normalized;
}
else
{
rd = -hit.normal;
ro = hit.point - rd * 0.01f;
}
Matrix4x4 l2w = rend.transform.localToWorldMatrix;
float bestT = float.MaxValue;
Vector3 bestBary = default;
int bestTri = -1;
for (int i = 0; i + 2 < tris.Length; i += 3)
{
Vector3 a = l2w.MultiplyPoint3x4(verts[tris[i]]);
Vector3 b = l2w.MultiplyPoint3x4(verts[tris[i + 1]]);
Vector3 c = l2w.MultiplyPoint3x4(verts[tris[i + 2]]);
if (RayTriangle(ro, rd, a, b, c, out float t, out float u, out float v) && t < bestT)
{
bestT = t;
bestBary = new Vector3(1f - u - v, u, v);
bestTri = i;
}
}
if (bestTri < 0) return false;
uv = uvs[tris[bestTri]] * bestBary.x + uvs[tris[bestTri + 1]] * bestBary.y + uvs[tris[bestTri + 2]] * bestBary.z;
subMesh = SubMeshFromIndex(mesh, bestTri); // bestTri is the offset into the flattened index buffer
return true;
}
/// <summary>
/// Resolves which submesh a triangle belongs to, given its offset into the mesh's flattened index
/// buffer (triangleNumber * 3, matching the <see cref="Mesh.triangles"/> layout). Submesh index then
/// maps 1:1 onto the renderer's material slots. Returns 0 for a single-material mesh (the common
/// case, no descriptor lookup) or when the mesh is missing.
/// </summary>
private static int SubMeshFromIndex(Mesh mesh, int indexOffset)
{
if (!mesh) return 0;
int subCount = mesh.subMeshCount;
if (subCount <= 1) return 0;
for (int s = 0; s < subCount; s++)
{
UnityEngine.Rendering.SubMeshDescriptor d = mesh.GetSubMesh(s);
if (indexOffset >= d.indexStart && indexOffset < d.indexStart + d.indexCount)
return s;
}
return 0;
}
/// <summary>
/// Moller-Trumbore ray/triangle intersection. On a hit, <paramref name="t"/> is the ray distance
/// and (<paramref name="u"/>, <paramref name="v"/>) are the barycentric weights of vertices b and
/// c (vertex a's weight is 1 - u - v). Small epsilons admit edge hits.
/// </summary>
private static bool RayTriangle(Vector3 ro, Vector3 rd, Vector3 a, Vector3 b, Vector3 c,
out float t, out float u, out float v)
{
t = u = v = 0f;
const float eps = 1e-7f;
Vector3 e1 = b - a, e2 = c - a;
Vector3 p = Vector3.Cross(rd, e2);
float det = Vector3.Dot(e1, p);
if (det > -eps && det < eps) return false; // ray parallel to the triangle
float inv = 1f / det;
Vector3 tvec = ro - a;
u = Vector3.Dot(tvec, p) * inv;
if (u < -1e-4f || u > 1f + 1e-4f) return false;
Vector3 q = Vector3.Cross(tvec, e1);
v = Vector3.Dot(rd, q) * inv;
if (v < -1e-4f || u + v > 1f + 1e-4f) return false;
t = Vector3.Dot(e2, q) * inv;
return t > eps;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 81a936ee72b5dab45b6c0f63237602cc
@@ -0,0 +1,292 @@
using UnityEngine;
public partial class MeshPainter
{
// --- Setup (shared) ---
private void SetupCollider()
{
_collider = GetComponent<MeshCollider>();
if (!_collider) _collider = gameObject.AddComponent<MeshCollider>();
_collider.convex = false; // non-convex provides triangleIndex / textureCoord
// An always-on SkinnedColliderBaker (skinned rigs only) owns the bake even outside paint
// mode; we delegate to it and only borrow its mesh. Without one we keep our own legacy bake.
_baker = _skinned ? GetComponent<SkinnedColliderBaker>() : null;
if (!_baker) _bakedMesh = new Mesh { name = "MeshPainter_Baked" };
BakeColliderMesh();
}
private void BakeColliderMesh()
{
if (_baker)
{
// Baker already baked (negative execution order) and keeps _collider.sharedMesh fresh;
// we only track its mesh for the GPU raster path. No bake here - that's the whole point.
_bakedMesh = _baker.BakedMesh;
}
else if (_skinned)
{
// Static skinned mesh: bake into local coordinates (scale = 1).
_skinned.BakeMesh(_bakedMesh);
_collider.sharedMesh = null;
_collider.sharedMesh = _bakedMesh;
}
else
{
var mf = GetComponent<MeshFilter>();
if (mf) _collider.sharedMesh = mf.sharedMesh;
}
}
// 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<Vector3> _vertScratch = new System.Collections.Generic.List<Vector3>();
private int _meshCacheId; // mesh instance the topology/UV caches were built for (0 = none)
/// <summary>Reads vertices/triangles/UVs of the collider mesh and computes culling data.
/// Only the vertices change across re-bakes of the same mesh; topology/UVs are fetched once.</summary>
private void BuildMeshCaches()
{
Mesh m = _collider.sharedMesh;
if (!m) return;
m.GetVertices(_vertScratch);
int vCount = _vertScratch.Count;
if (_verts == null || _verts.Length != vCount) _verts = new Vector3[vCount];
_vertScratch.CopyTo(_verts);
if (_meshCacheId != m.GetInstanceID())
{
_meshCacheId = m.GetInstanceID();
_tris = m.triangles;
_uvs = m.uv;
// Fallback to the original mesh UVs if the baked mesh dropped them.
if ((_uvs == null || _uvs.Length != vCount) && _skinned && _skinned.sharedMesh)
_uvs = _skinned.sharedMesh.uv;
}
if (_uvs == null || _uvs.Length != _verts.Length) return;
int triCount = _tris.Length / 3;
if (_triCenter == null || _triCenter.Length != triCount)
{
_triCenter = new Vector3[triCount];
_triRadius = new float[triCount];
}
double sumArea3D = 0.0, sumAreaUv = 0.0;
for (int t = 0; t < triCount; t++)
{
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 center = (a + b + c) / 3f;
_triCenter[t] = center;
float ra = (a - center).sqrMagnitude;
float rb = (b - center).sqrMagnitude;
float rc = (c - center).sqrMagnitude;
_triRadius[t] = Mathf.Sqrt(Mathf.Max(ra, Mathf.Max(rb, rc)));
sumArea3D += 0.5 * Vector3.Cross(b - a, c - a).magnitude;
Vector2 ua = _uvs[i0], ub = _uvs[i1], uc = _uvs[i2];
sumAreaUv += 0.5 * Mathf.Abs(Cross2D(ub - ua, uc - ua));
}
if (sumAreaUv > 1e-9) _avgUvToLocal = (float)System.Math.Sqrt(sumArea3D / sumAreaUv);
}
/// <summary>
/// Rasterizes the mesh UV islands into a per-texel coverage marker - the dilation SOURCE / overwrite
/// guard (island texels are real content edge padding must never overwrite; the gutter stays
/// paddable). Derived from the STATIC UV layout instead of transient per-stroke coverage, so every
/// seed (fresh start, load, backend switch) marks the same content and the first stroke after a
/// load/switch no longer bleeds over existing paint. Built once and cached.
/// </summary>
private bool[] GetIslandMask()
{
int n = textureSize * textureSize;
if (_islandMask != null && _islandMask.Length == n) return _islandMask;
var mask = new bool[n];
if (_tris == null || _uvs == null) { _islandMask = mask; return mask; }
float ts = textureSize;
const float eps = -1e-4f; // matches PaintStamp3D: tolerate 1px cracks between triangles
int triCount = _tris.Length / 3;
for (int t = 0; t < triCount; t++)
{
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
Vector2 uv0 = _uvs[i0], uv1 = _uvs[i1], uv2 = _uvs[i2];
float p0x = uv0.x * ts, p0y = uv0.y * ts;
float p1x = uv1.x * ts, p1y = uv1.y * ts;
float p2x = uv2.x * ts, p2y = uv2.y * ts;
int minX = Mathf.Clamp(Mathf.FloorToInt(Mathf.Min(p0x, Mathf.Min(p1x, p2x))), 0, textureSize - 1);
int maxX = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(p0x, Mathf.Max(p1x, p2x))), 0, textureSize - 1);
int minY = Mathf.Clamp(Mathf.FloorToInt(Mathf.Min(p0y, Mathf.Min(p1y, p2y))), 0, textureSize - 1);
int maxY = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(p0y, Mathf.Max(p1y, p2y))), 0, textureSize - 1);
if (maxX < minX || maxY < minY) continue;
float d = (p1y - p2y) * (p0x - p2x) + (p2x - p1x) * (p0y - p2y);
if (Mathf.Abs(d) < 1e-7f) continue;
float invD = 1f / d;
for (int y = minY; y <= maxY; y++)
{
float py = y + 0.5f;
int row = y * textureSize;
for (int x = minX; x <= maxX; x++)
{
float px = x + 0.5f;
float a = ((p1y - p2y) * (px - p2x) + (p2x - p1x) * (py - p2y)) * invD;
if (a < eps) continue;
float b = ((p2y - p0y) * (px - p2x) + (p0x - p2x) * (py - p2y)) * invD;
if (b < eps) continue;
if (1f - a - b < eps) continue;
mask[row + x] = true;
}
}
}
_islandMask = mask;
return mask;
}
/// <summary>Builds the per-map seed colors (in canonical/material layout) from the existing material.</summary>
private void BuildSeedColors()
{
_seedCanonical = new Color32[MAP_COUNT][];
_seedCanonical[MAP_ALBEDO] = BuildBaseColors();
// Mask map seed from the material scalars: R = metallic, G = occlusion/height, B = 0,
// A = smoothness. G = 128 (= 0.5) is the NEUTRAL height: no parallax/relief and AO =
// saturate(2*0.5) = 1 (no darkening), so the unpainted look is unchanged. Painting G
// below 0.5 carves a hole (and darkens), above 0.5 raises a hill (see PlayerCamoLit).
float metallic0 = GetMatFloat(MetallicId, 0f);
float smoothness0 = GetMatFloat(SmoothnessId, 0.5f);
_seedCanonical[MAP_MSO] = FillCanonical(new Color32(
(byte)(Mathf.Clamp01(metallic0) * 255f), 128, 0, (byte)(Mathf.Clamp01(smoothness0) * 255f)));
// Emission: keep the existing look (black if the material's emission is off).
Color emission0 = Color.black;
if (_materialInstance.HasProperty(EmissionColorId) &&
(_materialInstance.globalIlluminationFlags & MaterialGlobalIlluminationFlags.EmissiveIsBlack) == 0)
emission0 = _materialInstance.GetColor(EmissionColorId);
_seedCanonical[MAP_EM] = FillCanonical(new Color32(
(byte)(Mathf.Clamp01(emission0.r) * 255f),
(byte)(Mathf.Clamp01(emission0.g) * 255f),
(byte)(Mathf.Clamp01(emission0.b) * 255f), 255));
}
private Color32[] FillCanonical(Color32 c)
{
var px = new Color32[textureSize * textureSize];
for (int i = 0; i < px.Length; i++) px[i] = c;
return px;
}
/// <summary>Builds the albedo base-color texels from the existing material texture or baseColor.</summary>
private Color32[] BuildBaseColors()
{
Texture sourceTex = null;
var shared = _renderer.sharedMaterial;
if (shared)
{
if (shared.HasProperty(BaseMapId)) sourceTex = shared.GetTexture(BaseMapId);
if (!sourceTex && shared.HasProperty(MainTexId)) sourceTex = shared.GetTexture(MainTexId);
}
var src = sourceTex as Texture2D;
if (src)
{
try
{
if (src.width == textureSize && src.height == textureSize && src.isReadable)
return src.GetPixels32();
}
catch { /* not readable -> fall back to baseColor */ }
}
return FillCanonical(baseColor);
}
/// <summary>Enables the URP keywords for the painted PBR maps and seeds the material scalars.</summary>
private void ConfigureMaterialPbr()
{
if (!_materialInstance) return;
// Metallic-smoothness map: metallic from R, smoothness from the map's alpha
// (NOT the albedo alpha) scaled by _Smoothness (= 1 so the map drives it as-is).
if (_materialInstance.HasProperty(MetallicGlossMapId))
{
_materialInstance.EnableKeyword("_METALLICSPECGLOSSMAP");
_materialInstance.DisableKeyword("_SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A");
SetMatFloat(SmoothnessId, 1f);
}
// Emission map x _EmissionColor (white * intensity). Clear the "emissive is black"
// GI flag so the emission is actually used.
if (_materialInstance.HasProperty(EmissionMapId))
{
_materialInstance.EnableKeyword("_EMISSION");
_materialInstance.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
_lastEmissionIntensity = float.NaN; // fresh material instance -> force the first write
RefreshEmissionColor();
}
// Occlusion map: URP samples the G channel (LerpWhiteTo(map.g, _OcclusionStrength)).
// Strength 1 so the painted value drives the occlusion as-is.
if (_materialInstance.HasProperty(OcclusionMapId))
{
_materialInstance.EnableKeyword("_OCCLUSIONMAP");
SetMatFloat(OcclusionStrengthId, 1f);
}
// Height: the same mask map is also bound to _ParallaxMap, so the painted occlusion (G) doubles
// as a 0.5-centred height. _PARALLAXMAP enables URP's parallax AND our relief-normal/AO remap in
// PlayerCamoLit; _Parallax is lifted off its floor so the displacement is visible. _NORMALMAP is
// REQUIRED for the relief normal to show - URP only transforms normalTS into world space when it
// (or _DETAIL) is set, otherwise the relief is invisible. _BumpMap stays unbound (flat default
// normal), so the keyword only enables the tangent->world path.
if (_materialInstance.HasProperty(ParallaxMapId))
{
_materialInstance.EnableKeyword("_PARALLAXMAP");
_materialInstance.EnableKeyword("_NORMALMAP");
if (GetMatFloat(ParallaxId, 0.02f) < 0.02f)
SetMatFloat(ParallaxId, 0.02f);
}
}
private float _lastEmissionIntensity = float.NaN;
/// <summary>Pushes the global emission intensity to the material's _EmissionColor (live-tunable).
/// Runs per frame, so it only writes the material when the intensity actually changed.</summary>
private void RefreshEmissionColor()
{
if (emissionIntensity == _lastEmissionIntensity) return;
if (_materialInstance && _materialInstance.HasProperty(EmissionColorId))
{
_materialInstance.SetColor(EmissionColorId, Color.white * Mathf.Max(0f, emissionIntensity));
_lastEmissionIntensity = emissionIntensity;
}
}
/// <summary>Eases the see-through X-ray glow's alpha toward its on/off target (<see cref="XRayRevealed"/>)
/// at a constant rate, so the reveal fades in/out over <c>xrayFadeDuration</c> seconds instead of popping.
/// Rgb is left untouched (the authored glow colour). Only writes the material on an actual change, and
/// snaps instantly when the duration is 0.</summary>
private void TickXRayFade()
{
if (!_materialInstance || !_materialInstance.HasProperty(XRayColorId)) return;
Color c = _materialInstance.GetColor(XRayColorId);
float step = xrayFadeDuration > 0f ? Time.deltaTime / xrayFadeDuration : 1f;
float a = Mathf.MoveTowards(c.a, _xrayTarget, step);
if (a == c.a) return; // already at target -> no material write
c.a = a;
_materialInstance.SetColor(XRayColorId, c);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f3dd1a2d423bb5a46931b0e689fa779a
@@ -0,0 +1,157 @@
using UnityEngine;
public partial class MeshPainter
{
// --- Spray can brush ---
//
// Instead of one solid stamp, the spray scatters many small dots (random position,
// size and alpha) within the brush footprint. It is implemented by baking those dots
// into a fresh procedural MASK each stamp and then painting that mask with ONE normal
// brush stamp. This reuses the entire seam-bridging / curvature-following / per-channel
// pipeline as-is and costs a single mesh draw per stamp (not one per dot). Because the
// per-dot alpha lives in the mask value, it flows into every channel (albedo, mask map,
// emission) automatically - on both the GPU and CPU backends.
[Header("Brush - spray can")]
[Tooltip("Spray-can mode: scatter random dots (position, size, alpha) within the brush " +
"radius instead of a single solid stamp. The dots are baked into the brush mask, " +
"so this works on all channels and both backends.")]
public bool spray = false;
[Tooltip("Number of dots scattered per stamp.")]
[Range(1, 128)]
public int sprayDots = 24;
[Tooltip("Dot diameter as a fraction of the brush diameter (randomized per dot between min and max).")]
[Range(0.01f, 1f)]
public float sprayDotSizeMin = 0.05f;
[Tooltip("Dot diameter as a fraction of the brush diameter (randomized per dot between min and max).")]
[Range(0.01f, 1f)]
public float sprayDotSizeMax = 0.18f;
[Tooltip("Per-dot opacity (randomized per dot between min and max). Multiplied on top of the channel color's alpha.")]
[Range(0f, 1f)]
public float sprayDotAlphaMin = 0.25f;
[Tooltip("Per-dot opacity (randomized per dot between min and max). Multiplied on top of the channel color's alpha.")]
[Range(0f, 1f)]
public float sprayDotAlphaMax = 1f;
// Resolution of the per-stamp spray mask (square). Small + reused so regenerating it
// every stamp stays cheap.
private const int SprayRes = 128;
private const float SprayDotSoftEdge = 0.4f; // fraction of the dot radius that fades to 0
private Texture2D _sprayTex; // the baked spray mask uploaded to the brush
private Color32[] _sprayPixels; // reused upload buffer
private float[] _sprayOpacity; // reused per-texel opacity (CPU sampling)
// --- Active stamp mask (indirection shared by both backends) ---
// The standard mask cache (_resolvedMask / _maskOpacity / _maskW / _maskH) stays the
// canonical resolved mask used by the preview. _stamp* is what the CURRENT stamp paints
// with - the resolved mask for normal painting, or a fresh spray mask in spray mode.
private Texture2D _stampMask;
private float[] _stampOpacity;
private int _stampW, _stampH;
/// <summary>Points the active stamp mask at the resolved standard mask (normal painting).</summary>
private void UseResolvedStampMask()
{
_stampMask = _resolvedMask;
_stampOpacity = _maskOpacity;
_stampW = _maskW;
_stampH = _maskH;
}
/// <summary>
/// Bakes a fresh random spray pattern into the spray mask and makes it the active stamp
/// mask. Each dot gets a random center inside the brush disc, a random diameter and a
/// random opacity; dots composite by max so overlaps never exceed full opacity.
/// </summary>
private void BuildSprayStampMask()
{
EnsureSprayBuffers();
System.Array.Clear(_sprayOpacity, 0, _sprayOpacity.Length);
int dots = Mathf.Max(1, sprayDots);
float sizeLo = Mathf.Clamp(Mathf.Min(sprayDotSizeMin, sprayDotSizeMax), 0.01f, 1f);
float sizeHi = Mathf.Clamp(Mathf.Max(sprayDotSizeMin, sprayDotSizeMax), 0.01f, 1f);
float alphaLo = Mathf.Clamp01(Mathf.Min(sprayDotAlphaMin, sprayDotAlphaMax));
float alphaHi = Mathf.Clamp01(Mathf.Max(sprayDotAlphaMin, sprayDotAlphaMax));
for (int k = 0; k < dots; k++)
{
// Diameter is a fraction of the brush diameter -> uv radius = fraction * 0.5.
float dotR = Mathf.Max(0.5f / SprayRes, Random.Range(sizeLo, sizeHi) * 0.5f);
float alpha = Random.Range(alphaLo, alphaHi);
// Keep the WHOLE dot inside the brush disc (uv radius 0.5 = the brush radius) so the
// spray footprint matches the normal brush instead of bleeding past it by dotR.
float centerR = Mathf.Max(0f, 0.5f - dotR);
Vector2 c = Random.insideUnitCircle * centerR + new Vector2(0.5f, 0.5f);
SplatDot(c.x, c.y, dotR, alpha);
}
for (int i = 0; i < _sprayOpacity.Length; i++)
{
byte g = (byte)(Mathf.Clamp01(_sprayOpacity[i]) * 255f + 0.5f);
_sprayPixels[i] = new Color32(g, g, g, 255);
}
_sprayTex.SetPixels32(_sprayPixels);
_sprayTex.Apply(false);
_stampMask = _sprayTex;
_stampOpacity = _sprayOpacity;
_stampW = SprayRes;
_stampH = SprayRes;
}
/// <summary>Rasterizes one soft dot (center/radius in uv, value = alpha) into the opacity buffer, compositing by max.</summary>
private void SplatDot(float cx, float cy, float r, float alpha)
{
if (alpha <= 0f) return;
float inner = 1f - SprayDotSoftEdge; // solid core up to this normalized distance, then fade
int minX = Mathf.Max(0, Mathf.FloorToInt((cx - r) * SprayRes));
int maxX = Mathf.Min(SprayRes - 1, Mathf.CeilToInt((cx + r) * SprayRes));
int minY = Mathf.Max(0, Mathf.FloorToInt((cy - r) * SprayRes));
int maxY = Mathf.Min(SprayRes - 1, Mathf.CeilToInt((cy + r) * SprayRes));
float invR = 1f / r;
for (int y = minY; y <= maxY; y++)
{
float vY = (y + 0.5f) / SprayRes;
int row = y * SprayRes;
for (int x = minX; x <= maxX; x++)
{
float vX = (x + 0.5f) / SprayRes;
float dx = vX - cx, dy = vY - cy;
float dist = Mathf.Sqrt(dx * dx + dy * dy) * invR; // 0 = center, 1 = edge
if (dist >= 1f) continue;
float falloff = dist <= inner ? 1f : 1f - Mathf.SmoothStep(inner, 1f, dist);
float val = alpha * falloff;
int idx = row + x;
if (val > _sprayOpacity[idx]) _sprayOpacity[idx] = val;
}
}
}
private void EnsureSprayBuffers()
{
if (_sprayTex) return;
_sprayTex = NewMaskTexture(SprayRes, "MeshPainter_SprayMask");
_sprayPixels = new Color32[SprayRes * SprayRes];
_sprayOpacity = new float[SprayRes * SprayRes];
}
private void TeardownSpray()
{
if (_sprayTex) { Destroy(_sprayTex); _sprayTex = null; }
_sprayPixels = null;
_sprayOpacity = null;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5b607cbaf7556b3489d0ccfbd4f926dc
@@ -0,0 +1,260 @@
using System.Collections.Generic;
using UnityEngine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
public partial class MeshPainter
{
// --- Undo / redo: per-stroke history of the canonical maps, mirrored on every peer ---
//
// One stroke (mouse-down -> mouse-up) = one undo step; each entry is the full canonical map set AFTER
// that edit, restored by re-seeding the backend. Index 0 is the baseline (material seed, or a
// loaded/snapshotted one).
//
// Networked: every replica keeps the SAME history, so undo/redo replicates as a tiny command. The
// owner tags each committed stroke with a monotonic CHECKPOINT ID; remotes capture history at the same
// boundaries under that id. Undo/redo carries the TARGET id; a peer lacking it (a late joiner) falls
// back to a snapshot. PlayerPaintSync drives this via OnHistoryCheckpoint / OnHistoryRestore.
//
// Memory: unchanged maps are shared by reference with the previous entry (copy-on-write) - safe
// because both backends CLONE their seed input and ReadCanonical returns fresh arrays.
[Header("Undo")]
[Tooltip("Maximum number of undoable edits kept in memory (strokes + loads). Older edits drop off; " +
"the oldest kept state is the undo floor. Higher = more undo depth but more memory (only the " +
"maps a stroke changed are stored per step).")]
[Min(1)]
public int maxUndoSteps = 20;
/// <summary>
/// Fires on the owner when a stroke is committed as a new history entry, carrying its checkpoint id.
/// PlayerPaintSync forwards a marker so every remote captures the same entry under the same id. No
/// subscriber = no-op (single-player).
/// </summary>
public event System.Action<int> OnHistoryCheckpoint;
/// <summary>
/// Fires on the owner when the painting is restored to a history entry (undo / redo), carrying that
/// entry's checkpoint id. PlayerPaintSync forwards the id so peers reproduce the move locally (or fetch
/// a snapshot if they lack that id). No subscriber = no-op (single-player).
/// </summary>
public event System.Action<int> OnHistoryRestore;
private List<Color32[][]> _undoStates; // index 0 = oldest kept state; null until InitUndoHistory ran
private List<int> _undoIds; // parallel checkpoint id per state (owner-assigned, peer-shared)
private int _undoIndex; // the state the painter currently shows
private int _nextCheckpointId; // owner: next id to assign; remotes set ids from the network
private bool _strokeActive; // a stroke has painted >=1 stamp and is not yet committed to history
private int _strokeDirtyMaps; // bitmask of map indices touched by the current stroke
/// <summary>The checkpoint id of the state currently shown. Used as the baseline tag when snapshotting.</summary>
public int CurrentCheckpointId =>
(_undoIds != null && _undoIndex >= 0 && _undoIndex < _undoIds.Count) ? _undoIds[_undoIndex] : 0;
/// <summary>Seeds the history with the material baseline (id 0). Called once after the backend is set up.</summary>
private void InitUndoHistory()
{
if (_seedCanonical == null) return;
SetSingleBaseline(_seedCanonical, 0);
_nextCheckpointId = 1;
}
/// <summary>
/// Rebuilds the whole history to a single baseline entry holding <paramref name="maps"/> (per-map null
/// falls back to the live backend), tagged <paramref name="id"/>. Clears all undo/redo. Used by the
/// material seed, a load (owner) and an applied snapshot (remote). Takes ownership of the passed arrays.
/// </summary>
private void SetSingleBaseline(Color32[][] maps, int id)
{
if (_undoStates == null) _undoStates = new List<Color32[][]>(Mathf.Max(2, maxUndoSteps + 1));
if (_undoIds == null) _undoIds = new List<int>(Mathf.Max(2, maxUndoSteps + 1));
_undoStates.Clear();
_undoIds.Clear();
int n = textureSize * textureSize;
var baseline = new Color32[MAP_COUNT][];
for (int mi = 0; mi < MAP_COUNT; mi++)
{
var s = (maps != null) ? maps[mi] : null;
baseline[mi] = (s != null && s.Length == n) ? s : ReadCanonical(mi);
}
_undoStates.Add(baseline);
_undoIds.Add(id);
_undoIndex = 0;
_strokeActive = false;
_strokeDirtyMaps = 0;
}
/// <summary>Owner-only: replace the whole history with the current maps as the sole baseline (used by load).</summary>
private int ResetHistoryToCurrent(Color32[][] maps)
{
int id = _nextCheckpointId++;
SetSingleBaseline(maps, id);
return id;
}
/// <summary>Remote-only: make the just-applied snapshot maps the sole history baseline under the authority's id.</summary>
public void SetHistoryBaseline(Color32[][] maps, int baselineId)
{
if (!_setupValid) return;
SetSingleBaseline(maps, baselineId);
}
/// <summary>Marks that the current stroke painted a stamp (called per committed stamp, local or replicated).</summary>
private void MarkStrokePainted()
{
_strokeActive = true;
if (paintAlbedo) _strokeDirtyMaps |= 1 << MAP_ALBEDO;
if (paintMetallicSmoothness || paintOcclusion) _strokeDirtyMaps |= 1 << MAP_MSO;
if (paintEmission) _strokeDirtyMaps |= 1 << MAP_EM;
}
/// <summary>
/// Owner: commits an in-progress stroke as one undo step and announces its checkpoint id (no-op if
/// nothing was painted). Remotes use RemoteCommitCheckpoint instead.
/// </summary>
private void EndStrokeIfActive()
{
if (!_strokeActive) return;
// Stroke held but nothing actually painted (no active channel / no stamp landed): drop it without
// consuming a checkpoint id, so the owner only ever announces ids it really holds an entry for.
if (_strokeDirtyMaps == 0)
{
_strokeActive = false;
return;
}
int id = _nextCheckpointId++;
CaptureUndoState(_strokeDirtyMaps, id);
_strokeActive = false;
_strokeDirtyMaps = 0;
OnHistoryCheckpoint?.Invoke(id);
}
/// <summary>
/// Remote: commits the stamps replayed since the last checkpoint as one undo step under the owner's id,
/// keeping this peer's history in lockstep. Always records the id - even if (rarely) no stamp landed
/// here for the stroke - so every replica's history stays 1:1 aligned with the owner's and a later
/// undo/redo to this id reproduces locally instead of forcing a snapshot fallback.
/// </summary>
public void RemoteCommitCheckpoint(int id)
{
if (!_setupValid) return;
CaptureUndoState(_strokeDirtyMaps, id, forceEntry: true);
_strokeActive = false;
_strokeDirtyMaps = 0;
}
/// <summary>
/// Snapshots the current maps as a new history entry under <paramref name="id"/>, dropping any redo
/// branch. With <paramref name="forceEntry"/> an entry is added even when nothing changed (it then
/// shares every map with the previous entry via COW) - used by remote checkpoints to keep ids aligned.
/// </summary>
private void CaptureUndoState(int dirtyMaps, int id, bool forceEntry = false)
{
if (_undoStates == null) return;
if (dirtyMaps == 0 && !forceEntry) return;
// A fresh edit invalidates everything ahead of the cursor (the redo branch).
if (_undoIndex < _undoStates.Count - 1)
{
int from = _undoIndex + 1;
int n = _undoStates.Count - from;
_undoStates.RemoveRange(from, n);
_undoIds.RemoveRange(from, n);
}
Color32[][] prev = _undoStates[_undoIndex];
var state = new Color32[MAP_COUNT][];
for (int mi = 0; mi < MAP_COUNT; mi++)
{
bool changed = (dirtyMaps & (1 << mi)) != 0;
state[mi] = changed ? ReadCanonical(mi) : prev[mi]; // COW: share maps the stroke didn't touch
}
_undoStates.Add(state);
_undoIds.Add(id);
_undoIndex = _undoStates.Count - 1;
// Bound memory: keep at most maxUndoSteps edits beyond the oldest kept state.
int maxEntries = Mathf.Max(1, maxUndoSteps) + 1;
while (_undoStates.Count > maxEntries)
{
_undoStates.RemoveAt(0);
_undoIds.RemoveAt(0);
_undoIndex--;
}
}
/// <summary>Owner: steps back one edit and announces the restore. Returns false if already at the floor.</summary>
public bool Undo()
{
if (_undoStates == null || _undoIndex <= 0) return false;
_undoIndex--;
RestoreCurrentState();
return true;
}
/// <summary>Owner: steps forward one edit (after an undo). Returns false if already at the newest state.</summary>
public bool Redo()
{
if (_undoStates == null || _undoIndex >= _undoStates.Count - 1) return false;
_undoIndex++;
RestoreCurrentState();
return true;
}
private void RestoreCurrentState()
{
RestoreEntry(_undoIndex);
OnHistoryRestore?.Invoke(_undoIds[_undoIndex]); // owner -> peers reproduce the move by id
}
/// <summary>Shows history entry <paramref name="idx"/>: re-seeds the backend and clears any in-progress stroke.</summary>
private void RestoreEntry(int idx)
{
_undoIndex = idx;
ReseedCanonical(_undoStates[idx]);
_strokeActive = false;
_strokeDirtyMaps = 0;
}
/// <summary>
/// Remote: restores to the history entry tagged <paramref name="id"/>. Returns false (and does nothing)
/// if this peer doesn't hold that id - the caller then falls back to fetching a snapshot.
/// </summary>
public bool RemoteRestoreTo(int id)
{
if (_undoStates == null || _undoIds == null) return false;
int idx = _undoIds.IndexOf(id);
if (idx < 0) return false;
RestoreEntry(idx);
return true;
}
/// <summary>Reads Ctrl+Z / Ctrl+Y this frame. Modifier-gated so plain Z/Y never trigger it.</summary>
private void GetUndoRedoInput(out bool undo, out bool redo)
{
undo = false;
redo = false;
if (TypingGuard.IsTyping) return; // let Ctrl+Z/Y edit text instead of the painting
if (!InputUtil.CtrlHeld()) return;
#if ENABLE_INPUT_SYSTEM
var kb = Keyboard.current;
if (kb != null)
{
undo = kb.zKey.wasPressedThisFrame;
redo = kb.yKey.wasPressedThisFrame;
return;
}
#endif
#if ENABLE_LEGACY_INPUT_MANAGER
undo = Input.GetKeyDown(KeyCode.Z);
redo = Input.GetKeyDown(KeyCode.Y);
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9d7335962dd88b844bdaac92a7cb8d85
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5fc799adb27d5ea45990b5522b75ddda
+116
View File
@@ -0,0 +1,116 @@
using UnityEngine;
/// <summary>
/// One knob that resizes the whole player. Setting <see cref="Scale"/> drives <c>transform.localScale</c>
/// — which Unity already propagates to the visible mesh, the CharacterController capsule, the per-pose
/// capsules (written as local values) and the camera target — and additionally rescales the movement,
/// jump, slide and wall tunables on <see cref="PlayerCharacterController"/> proportionally, so a smaller
/// or larger player still moves at the same body-relative pace instead of zipping or crawling. Without
/// this the speeds (units/second, not geometry) stay put and a half-size player feels twice as fast.
///
/// The authored values on the controller ARE the scale-1 design values: tune them normally there. They are
/// captured as the base in Awake and multiplied by Scale at runtime, so the serialized design values are
/// never overwritten on disk. In the editor only <c>localScale</c> is previewed live (geometry is the only
/// visual part); the speed rescale happens on play. The skinned mesh collider scales correctly because
/// <see cref="SkinnedColliderBaker"/> divides the player scale back out of its bake.
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(PlayerCharacterController))]
public class PlayerScaler : MonoBehaviour
{
[Tooltip("Uniform size multiplier for the whole player. 1 = authored size. Drives transform.localScale " +
"(mesh, CharacterController capsule, per-pose capsules and camera target follow automatically) " +
"and rescales the controller's movement/jump/slide/wall speeds proportionally.")]
[Min(0.01f)]
[SerializeField] private float scale = 1f;
[Tooltip("Also scale jump height, gravity and the wall top-out hop so the motion is geometrically " +
"self-similar: identical trajectory shape and air-time, just smaller/larger. Off keeps the " +
"authored gravity, giving a small player a floatier, relatively higher jump.")]
[SerializeField] private bool scaleJumpAndGravity = true;
/// <summary>Current uniform scale. Use <see cref="SetScale"/> to change it at runtime.</summary>
public float Scale => scale;
private PlayerCharacterController _pcc;
private bool _captured;
// Authored (scale-1) base values, snapshotted once from the controller in Awake. All are world-unit
// scalars (units, units/s, units/s^2) that do NOT follow transform.localScale; dimensionless ratios
// (sprint/climb multipliers), angular rates (rotation/turn speed) and times (exit grace) are left
// untouched — they are already scale-invariant.
private float _bMove, _bAccel, _bDecel, _bStick, _bSlideAcc, _bMaxSlide;
private float _bWallOffset, _bWallInset, _bWallReturn, _bWallSens, _bCeilGrab;
private float _bJump, _bGravity, _bTopOut;
void Awake()
{
_pcc = GetComponent<PlayerCharacterController>();
CaptureBase();
ApplyScale(scale);
}
/// <summary>Sets the scale at runtime (e.g. a grow/shrink mechanic) and re-applies from the base values.</summary>
public void SetScale(float newScale)
{
scale = Mathf.Max(0.01f, newScale);
if (!_pcc) _pcc = GetComponent<PlayerCharacterController>();
if (!_captured) CaptureBase();
ApplyScale(scale);
}
private void CaptureBase()
{
if (!_pcc) return;
_bMove = _pcc.moveSpeed;
_bAccel = _pcc.acceleration;
_bDecel = _pcc.deceleration;
_bStick = _pcc.groundedStickVelocity;
_bSlideAcc = _pcc.slideAcceleration;
_bMaxSlide = _pcc.maxSlideSpeed;
_bWallOffset = _pcc.wallMaxOffset;
_bWallInset = _pcc.wallMaxInset;
_bWallReturn = _pcc.wallOffsetReturnSpeed;
_bWallSens = _pcc.wallOffsetSensitivity;
_bCeilGrab = _pcc.ceilingGrabDistance;
_bJump = _pcc.jumpHeight;
_bGravity = _pcc.gravity;
_bTopOut = _pcc.wallTopOutBoost;
_captured = true;
}
private void ApplyScale(float s)
{
transform.localScale = Vector3.one * s;
if (!_pcc) return;
_pcc.moveSpeed = _bMove * s;
_pcc.acceleration = _bAccel * s;
_pcc.deceleration = _bDecel * s;
_pcc.groundedStickVelocity = _bStick * s;
_pcc.slideAcceleration = _bSlideAcc * s;
_pcc.maxSlideSpeed = _bMaxSlide * s;
_pcc.wallMaxOffset = _bWallOffset * s;
_pcc.wallMaxInset = _bWallInset * s;
_pcc.wallOffsetReturnSpeed = _bWallReturn * s;
_pcc.wallOffsetSensitivity = _bWallSens * s;
_pcc.ceilingGrabDistance = _bCeilGrab * s;
if (scaleJumpAndGravity)
{
_pcc.jumpHeight = _bJump * s;
_pcc.gravity = _bGravity * s;
_pcc.wallTopOutBoost = _bTopOut * s;
}
}
#if UNITY_EDITOR
void OnValidate()
{
// Live geometry preview in edit mode only; the speed rescale runs at play time from the authored
// base so the design values on the controller are never serialized in their scaled form.
if (!Application.isPlaying)
transform.localScale = Vector3.one * Mathf.Max(0.01f, scale);
}
#endif
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 26105206120a119419bee8bb5f810902
@@ -0,0 +1,86 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Sole owner of the animated character's collider bake: keeps a non-convex MeshCollider in sync with
/// the SkinnedMeshRenderer's current pose every frame, independent of any paint/game mode. MeshPainter's
/// own per-frame bake sits behind its PaintingEnabled gate, so the collider would otherwise freeze in
/// player-control / insect mode. When this component is present MeshPainter delegates to it (it stops
/// baking and only borrows <see cref="BakedMesh"/> for its GPU raster path), so the deformed-pose
/// collider stays fresh for everyone - paint raycasts, physics, future seeker / line-of-sight queries.
///
/// Bakes in the renderer's LOCAL space (BakeMesh, scale = 1) - the same convention MeshPainter and
/// ConcealmentMeter use (world = transform.localToWorldMatrix). Runs at a negative execution order so
/// the collider is fresh before MeshPainter (and other consumers) read it in the same frame. Owns the
/// baked Mesh (creates + destroys it); MeshPainter never destroys a borrowed mesh.
/// </summary>
[DisallowMultipleComponent]
[DefaultExecutionOrder(-50)]
[RequireComponent(typeof(SkinnedMeshRenderer))]
public class SkinnedColliderBaker : MonoBehaviour
{
[Min(0f)]
[Tooltip("Seconds between collider re-bakes. 0 = every frame (matches MeshPainter's per-frame bake). " +
"Raise it to trade collider freshness for CPU on a high-poly rig.")]
[SerializeField] private float bakeInterval = 0f;
/// <summary>The mesh baked from the current pose, in local space. Valid after the first bake.</summary>
public Mesh BakedMesh => _baked;
private SkinnedMeshRenderer _skinned;
private MeshCollider _collider;
private Mesh _baked;
private float _timer;
private readonly List<Vector3> _vbuf = new List<Vector3>(); // reused vertex scratch so the scale fixup allocates nothing per bake
private void Awake()
{
_skinned = GetComponent<SkinnedMeshRenderer>();
if (!_skinned)
{
Debug.LogError("[SkinnedColliderBaker] No SkinnedMeshRenderer on this GameObject.", this);
enabled = false;
return;
}
_collider = GetComponent<MeshCollider>();
if (!_collider) _collider = gameObject.AddComponent<MeshCollider>();
_collider.convex = false; // non-convex provides triangleIndex / textureCoord for paint raycasts
_baked = new Mesh { name = "SkinnedColliderBaker_Baked" };
Bake(); // valid collider on frame 0, before any consumer reads it
}
private void Update()
{
if (bakeInterval <= 0f) { Bake(); return; }
_timer += Time.deltaTime;
if (_timer < bakeInterval) return;
_timer = 0f;
Bake();
}
private void Bake()
{
if (!_skinned) return;
_skinned.BakeMesh(_baked);
// BakeMesh freezes the bone deformation — which already carries this transform's world scale —
// into the mesh. The MeshCollider below then applies the same lossyScale a SECOND time, so a
// scaled player (see PlayerScaler) would get a collider half/double the size of the visible
// mesh. Divide the scale back out so the baked mesh is true scale-1 local geometry (the contract
// this class already documents): the MeshCollider re-applies it exactly once (matching the
// renderer), and MeshPainter / ConcealmentMeter — which raise the baked mesh to world via
// localToWorldMatrix — stay correct at any player scale. No-op (and untouched) at scale 1; the
// uniform divide is rotation-independent, which the player's uniform scale guarantees.
GeometryUtil.RescaleToUnitScale(_baked, transform.lossyScale, _vbuf);
_collider.sharedMesh = null; // force a physics re-cook of the new geometry
_collider.sharedMesh = _baked;
}
private void OnDestroy()
{
if (_baked) Destroy(_baked);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 161b7cf82dd54e74d92b5ca72d3be3af
@@ -0,0 +1,82 @@
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// Sits on the Lobby's "Start Match" trigger volume (a BoxCollider with isTrigger on). When the match
/// AUTHORITY's avatar stands inside it, it asks the <see cref="MatchManager"/> to start the match.
///
/// Detection POLLS a server-side physics overlap every frame instead of relying on OnTriggerEnter.
/// Why: the authority can be a REMOTE client (a dedicated server with no host player hands authority to
/// the first client). On the server that client's avatar is a NetworkTransform-driven replica - its
/// CharacterController is disabled (<see cref="NetworkPlayerSetup"/>) and it carries no Rigidbody, so it
/// is moved by direct transform writes. Unity only sends OnTriggerEnter when one of the two colliders has
/// a Rigidbody (or is a CharacterController moving via Move()); a transform-moved non-Rigidbody collider
/// against a static trigger fires nothing. That is exactly why a host's own (Move()-driven) avatar tripped
/// the trigger but a remote authority never did. An explicit overlap query finds the avatar's (baked)
/// collider regardless of HOW it is moved, so start works for a host and a dedicated server alike.
///
/// Server-gated: pure clients query nothing. Only the authority avatar starts the match; a passing
/// non-authority avatar is ignored. Runs only while in the lobby phase and stops once it has started a
/// match. The trigger lives in the additively-loaded Lobby and is destroyed when the lobby unloads, so a
/// re-match (back in the lobby) gets a fresh instance with its guard reset.
/// </summary>
[RequireComponent(typeof(Collider))]
[DisallowMultipleComponent]
public class StartMatchTrigger : MonoBehaviour
{
private Collider _collider;
private readonly Collider[] _overlap = new Collider[32];
private bool _started;
private bool _overlapSaturated; // log-once: a full buffer could silently drop the authority's avatar
private void Awake() => _collider = GetComponent<Collider>();
private void Update()
{
if (_started) return;
var nm = NetworkManager.Singleton;
if (!nm || !nm.IsServer) return; // detection + start are server-side
var match = MatchManager.Instance;
if (!match || match.Phase != MatchManager.MatchPhase.Lobby) return;
int count = OverlapTrigger();
if (count == _overlap.Length && !_overlapSaturated)
{
_overlapSaturated = true;
Debug.LogWarning($"StartMatchTrigger: overlap buffer saturated ({count}); the authority avatar may be missed.", this);
}
for (int i = 0; i < count; i++)
{
var netObj = _overlap[i].GetComponentInParent<NetworkObject>();
if (!netObj || !netObj.IsPlayerObject) continue; // must be a player avatar
if (!match.IsMatchAuthority(netObj.OwnerClientId)) continue; // ...the authority's
_started = true;
match.StartMatch();
return;
}
}
// Overlaps the trigger's own volume. Uses the oriented box for a BoxCollider (the authored shape);
// falls back to the collider's world AABB for any other collider type. Triggers are ignored so the
// query skips this volume itself and reports only solid avatar colliders (e.g. the baked mesh).
private int OverlapTrigger()
{
if (_collider is BoxCollider box)
{
Vector3 center = transform.TransformPoint(box.center);
Vector3 scale = transform.lossyScale;
Vector3 half = new Vector3(
Mathf.Abs(box.size.x * scale.x),
Mathf.Abs(box.size.y * scale.y),
Mathf.Abs(box.size.z * scale.z)) * 0.5f;
return Physics.OverlapBoxNonAlloc(
center, half, _overlap, transform.rotation, ~0, QueryTriggerInteraction.Ignore);
}
Bounds b = _collider.bounds;
return Physics.OverlapBoxNonAlloc(
b.center, b.extents, _overlap, Quaternion.identity, ~0, QueryTriggerInteraction.Ignore);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dbc3790cccc4958458bb38d883b69afb
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5dd11660d05b5284b8015c842079c2ac
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,120 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
/// <summary>
/// Makes a UI panel draggable. Attach to the panel itself: it follows the pointer from any of the panel's
/// non-interactive surface (title bar, padding, labels, background) but ignores presses that begin on a
/// widget - a <see cref="Selectable"/> (toggle, slider, ...) or a <see cref="UiPointerSurface"/> (the
/// colour-picker field) - so those keep their own click/drag behaviour. It keeps a slice of the panel on
/// screen and raises it above its siblings while interacting.
///
/// Works under any canvas scale (it tracks the pointer in the drag area's local space and applies the
/// movement delta) and under any anchor/pivot (the on-screen clamp is derived from the panel's real
/// corners in its parent, not from a fixed top-left assumption).
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class DraggablePanel : MonoBehaviour, IPointerDownHandler, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[Tooltip("Panel moved by the drag. Empty = this object's RectTransform.")]
public RectTransform target;
[Tooltip("Panel pixels (canvas units) kept on screen on every edge so it can never be lost.")]
public float keepVisible = 48f;
// Raised once when a real panel drag ends (not for gestures that began on a widget). The HUD uses
// this to persist the layout the moment a panel is dropped. Optional - left null by other users.
public System.Action Moved;
private RectTransform _area; // the panel's parent; pointer positions are tracked in its local space
private Vector2 _grabOffset;
private bool _dragging;
private void Awake() => Resolve();
private void Resolve()
{
if (!target) target = transform as RectTransform;
if (target) _area = target.parent as RectTransform;
}
public void OnPointerDown(PointerEventData eventData)
{
// Raise the panel when pressing its own surface. (A press that lands on a widget is consumed by
// that widget's own handler and never reaches us, so this rarely needs the guard - but it is cheap.)
if (!StartedOnWidget(eventData)) BringToFront();
}
public void OnBeginDrag(PointerEventData eventData)
{
_dragging = false;
if (!target) Resolve();
if (!target || !_area) return;
if (StartedOnWidget(eventData)) return; // a toggle/slider/picker owns this gesture, not the panel
_dragging = true;
BringToFront();
// Capture grab offset; the constant frame difference between the area pivot and the panel anchor
// cancels out because we only ever apply the pointer delta on top of this offset.
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
_area, eventData.position, eventData.pressEventCamera, out var local))
_grabOffset = target.anchoredPosition - local;
}
public void OnDrag(PointerEventData eventData)
{
if (!_dragging || !target || !_area) return;
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
_area, eventData.position, eventData.pressEventCamera, out var local))
return;
target.anchoredPosition = Clamp(local + _grabOffset);
}
public void OnEndDrag(PointerEventData eventData)
{
if (!_dragging) return; // gesture began on a widget -> not our drag, nothing moved
_dragging = false;
Moved?.Invoke();
}
// True when the press started on an interactive widget inside the panel (which then owns the gesture).
private bool StartedOnWidget(PointerEventData eventData)
{
var hit = eventData.pointerPressRaycast.gameObject;
for (var t = hit ? hit.transform : null; t && t != target; t = t.parent)
if (t.GetComponent<Selectable>() || t.GetComponent<UiPointerSurface>())
return true;
return false;
}
private void BringToFront()
{
if (target) target.SetAsLastSibling();
}
// Anchor/pivot-agnostic clamp: keep at least `keepVisible` px of the panel inside the parent on every
// edge, computed from the panel's real corners so it is correct wherever the panel is anchored.
private Vector2 Clamp(Vector2 pos)
{
if (!_area) return pos;
Rect area = _area.rect;
Rect size = target.rect;
Vector2 pivot = target.pivot;
// Anchor reference point in the area's local space (panels are point-anchored: anchorMin == anchorMax).
Vector2 anchorRef = new Vector2(
area.xMin + target.anchorMin.x * area.width,
area.yMin + target.anchorMin.y * area.height);
Vector2 pivotToCorner = new Vector2(pivot.x * size.width, pivot.y * size.height);
// Bottom-left corner of the panel for this anchoredPosition.
Vector2 corner = anchorRef + pos - pivotToCorner;
float keepX = Mathf.Min(keepVisible, size.width);
float keepY = Mathf.Min(keepVisible, size.height);
corner.x = Mathf.Clamp(corner.x, area.xMin + keepX - size.width, area.xMax - keepX);
corner.y = Mathf.Clamp(corner.y, area.yMin + keepY - size.height, area.yMax - keepY);
return corner + pivotToCorner - anchorRef;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 14b82ffde5db5c547b5f454ac2791815
@@ -0,0 +1,132 @@
using System;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// Compact runtime HSV colour picker: a saturation/value square, a hue bar and a live swatch. The
/// square is a pure-hue base with two static gradient overlays on top (white-out -> saturation,
/// black-out -> value), so only the base tint changes per hue - no per-frame texture rebuilds. Hue is
/// never lost while dragging the square (the square only edits S/V, the bar only edits H). Alpha is
/// edited separately by a standalone slider in the HUD, not by this picker; <see cref="SetColor"/>
/// still stores the incoming alpha so <see cref="Value"/> round-trips it.
///
/// The child structure (stable names) comes from the baked HUD prefab. <see cref="Rebind"/>
/// (re)assigns the runtime gradient sprites (which a prefab cannot store) and (re)hooks the drag
/// relays. A leftover alpha bar from a pre-split prefab is hidden by <see cref="Rebind"/>, so no
/// re-bake is required.
/// </summary>
public class HsvColorPicker : MonoBehaviour
{
/// <summary>Raised with the new colour (RGB; alpha is preserved, not edited here) when the user moves
/// the square or hue bar.</summary>
public event Action<Color> Changed;
private float _h, _s, _v, _a = 1f;
private Image _hueBase; // pure-hue background of the SV square
private RectTransform _svHandle;
private RectTransform _hueHandle;
private Image _swatch;
/// <summary>Current colour (HSV -> RGB, with the preserved alpha).</summary>
public Color Value
{
get
{
Color c = Color.HSVToRGB(_h, _s, _v);
c.a = _a;
return c;
}
}
/// <summary>Shows <paramref name="c"/> without raising <see cref="Changed"/> (external set).</summary>
public void SetColor(Color c)
{
Color.RGBToHSV(c, out _h, out _s, out _v);
_a = c.a;
Refresh();
}
/// <summary>
/// Re-finds the child widgets, (re)assigns the runtime gradient sprites and (re)hooks the drag
/// relays. Idempotent (unsubscribe-then-subscribe), so it is safe whether the structure was just
/// built in code or instantiated from a prefab. Call before <see cref="SetColor"/>.
/// </summary>
public void Rebind()
{
var sv = transform.Find("SV");
if (sv)
{
_hueBase = sv.GetComponent<Image>();
var sat = sv.Find("SatOverlay");
if (sat && sat.TryGetComponent(out Image satImg)) satImg.sprite = PaintHud.SaturationSprite();
var val = sv.Find("ValOverlay");
if (val && val.TryGetComponent(out Image valImg)) valImg.sprite = PaintHud.ValueSprite();
_svHandle = sv.Find("Handle") as RectTransform;
Rehook(sv, OnSvPicked);
}
var hue = transform.Find("Hue") as RectTransform;
if (hue)
{
if (hue.TryGetComponent(out Image hueImg)) hueImg.sprite = PaintHud.HueSprite();
_hueHandle = hue.Find("Handle") as RectTransform;
Rehook(hue, OnHuePicked);
}
// Alpha now lives on a standalone slider (PaintHud), not in the picker. Hide any alpha bar a
// pre-split prefab still carries so it does not show as a dead, non-functional strip.
var leftoverAlpha = transform.Find("Alpha");
if (leftoverAlpha) leftoverAlpha.gameObject.SetActive(false);
var swatch = transform.Find("Swatch");
if (swatch) _swatch = swatch.GetComponent<Image>();
Refresh();
}
// Idempotently hooks the handler to the surface's drag relay (unsubscribe-then-subscribe).
private static void Rehook(Component surface, Action<Vector2> handler)
{
if (surface.TryGetComponent(out UiPointerSurface pointer))
{
pointer.Picked -= handler;
pointer.Picked += handler;
}
}
private void OnSvPicked(Vector2 p)
{
_s = p.x;
_v = p.y;
Refresh();
Changed?.Invoke(Value);
}
private void OnHuePicked(Vector2 p)
{
_h = p.x;
Refresh();
Changed?.Invoke(Value);
}
private void Refresh()
{
if (_hueBase) _hueBase.color = Color.HSVToRGB(_h, 1f, 1f);
if (_svHandle)
{
_svHandle.anchorMin = _svHandle.anchorMax = new Vector2(_s, _v);
_svHandle.anchoredPosition = Vector2.zero;
}
if (_hueHandle)
{
_hueHandle.anchorMin = _hueHandle.anchorMax = new Vector2(_h, 0.5f);
_hueHandle.anchoredPosition = Vector2.zero;
}
if (_swatch) _swatch.color = Color.HSVToRGB(_h, _s, _v);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2a35dda49401e4045ba2165e15116a5f
@@ -0,0 +1,37 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
/// Binds a UI Slider (and optional value label) to the global MouseSensitivity
/// multiplier. The slider's range is authored in the inspector (0.1 - 4.0);
/// this only reads/writes the value so scene-side range edits are respected.
public class MouseSensitivitySlider : MonoBehaviour
{
[SerializeField] private Slider slider;
[Tooltip("Optional TMP label showing the current multiplier (e.g. \"1,00\").")]
[SerializeField] private TMP_Text valueLabel;
void OnEnable()
{
if (!slider) return;
slider.SetValueWithoutNotify(MouseSensitivity.Multiplier);
UpdateLabel(MouseSensitivity.Multiplier);
slider.onValueChanged.AddListener(OnSliderChanged);
}
void OnDisable()
{
if (slider) slider.onValueChanged.RemoveListener(OnSliderChanged);
}
void OnSliderChanged(float value)
{
MouseSensitivity.Multiplier = value;
UpdateLabel(value);
}
void UpdateLabel(float value)
{
if (valueLabel) valueLabel.text = value.ToString("0.00");
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fc6da376d2cfebe47beeb9b6012e76dd
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d09de72a6314d2d40bd136e616623b80
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,307 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// Binder for the "Paintings" library panel. The panel's visual layout (Panel_Paintings, the scrollable
/// list, an entry template, and the Save / Confirm dialogs) is authored inside the PaintHudPanel layout
/// prefab - this code only finds those objects by their stable names and wires the behaviour, exactly like
/// <see cref="WirePanel"/> does for the brush widgets. The list is populated by cloning the authored
/// Lib_EntryTemplate per saved slot. Save opens a name dialog (an existing name is overwritten, with a
/// warning shown while typing); Load applies the selected slot immediately; Delete goes through a
/// confirmation dialog. Loading replaces the current painting and, when networked, re-broadcasts the
/// loaded state to every other peer.
/// </summary>
public partial class PaintHud
{
// Stable widget names, matching the objects authored in the PaintHudPanel prefab.
private const string LibListContent = "Lib_ListContent";
private const string LibEntryTemplate = "Lib_EntryTemplate";
private const string LibSaveButton = "Lib_Save";
private const string LibLoadButton = "Lib_Load";
private const string LibDeleteButton = "Lib_Delete";
private const string LibResetButton = "Lib_ResetHud";
private const string LibSaveDialog = "Lib_SaveDialog";
private const string LibNameInput = "Lib_NameInput";
private const string LibSaveInfo = "Lib_SaveInfo";
private const string LibSaveOk = "Lib_SaveOk";
private const string LibSaveCancel = "Lib_SaveCancel";
private const string LibConfirmDialog = "Lib_ConfirmDialog";
private const string LibConfirmText = "Lib_ConfirmText";
private const string LibConfirmOk = "Lib_ConfirmOk";
private const string LibConfirmCancel = "Lib_ConfirmCancel";
private bool _libraryBound;
private RectTransform _listContent;
private GameObject _entryTemplate;
private Button _saveButton, _loadButton, _deleteButton, _resetButton;
private string _selectedPainting;
// Save dialog
private GameObject _saveDialog;
private TMP_InputField _nameInput;
private TMP_Text _saveInfo;
private Button _saveOkButton;
// Confirmation dialog (reused for Load + Delete)
private GameObject _confirmDialog;
private TMP_Text _confirmText;
private Button _confirmButton;
private Action _confirmAction;
private struct EntryRef { public string name; public Image bg; }
private readonly List<EntryRef> _entries = new List<EntryRef>();
private static readonly Color EntryNormal = new Color(0.18f, 0.18f, 0.22f, 1f);
private static readonly Color EntrySelected = new Color(0.20f, 0.45f, 0.80f, 1f);
private static readonly Color OkDanger = new Color(0.62f, 0.26f, 0.26f, 1f);
// --- Bind (find authored objects, hook listeners) ---
/// <summary>
/// Binds the authored library panel under <paramref name="root"/> (the instantiated layout prefab).
/// Idempotent: binds exactly once. No-op if the prefab carries no library objects.
/// </summary>
private void BindLibrary(Transform root)
{
if (_libraryBound || !root) return;
_listContent = GeometryUtil.FindDeep(root, LibListContent) as RectTransform;
var template = GeometryUtil.FindDeep(root, LibEntryTemplate);
_entryTemplate = template ? template.gameObject : null;
if (!_listContent || !_entryTemplate) return; // panel not present in this layout
_entryTemplate.SetActive(false);
_saveButton = Find<Button>(root, LibSaveButton);
_loadButton = Find<Button>(root, LibLoadButton);
_deleteButton = Find<Button>(root, LibDeleteButton);
_resetButton = Find<Button>(root, LibResetButton); // optional: present only if authored in the layout
Hook(_saveButton, OnSaveClicked);
Hook(_loadButton, OnLoadClicked);
Hook(_deleteButton, OnDeleteClicked);
Hook(_resetButton, OnResetClicked);
var sd = GeometryUtil.FindDeep(root, LibSaveDialog);
_saveDialog = sd ? sd.gameObject : null;
_nameInput = Find<TMP_InputField>(root, LibNameInput);
_saveInfo = Find<TMP_Text>(root, LibSaveInfo);
_saveOkButton = Find<Button>(root, LibSaveOk);
Hook(_saveOkButton, ConfirmSave);
Hook(Find<Button>(root, LibSaveCancel), () => { if (_saveDialog) _saveDialog.SetActive(false); });
if (_nameInput) _nameInput.onValueChanged.AddListener(_ => ValidateSaveName());
var cd = GeometryUtil.FindDeep(root, LibConfirmDialog);
_confirmDialog = cd ? cd.gameObject : null;
_confirmText = Find<TMP_Text>(root, LibConfirmText);
_confirmButton = Find<Button>(root, LibConfirmOk);
Hook(_confirmButton, OnConfirmClicked);
Hook(Find<Button>(root, LibConfirmCancel), () => { if (_confirmDialog) _confirmDialog.SetActive(false); });
HideDialogs();
_libraryBound = true;
UpdateButtonStates();
}
private static void Hook(Button button, Action onClick)
{
if (!button || onClick == null) return;
button.onClick.RemoveAllListeners();
button.onClick.AddListener(() => onClick());
}
// --- List ---
private void RefreshList()
{
if (!_listContent || !_entryTemplate) return;
for (int i = _listContent.childCount - 1; i >= 0; i--)
{
var child = _listContent.GetChild(i).gameObject;
if (child != _entryTemplate) Destroy(child);
}
_entries.Clear();
var names = MeshPainter.ListSavedPaintings();
if (!string.IsNullOrEmpty(_selectedPainting) && !names.Contains(_selectedPainting))
_selectedPainting = null;
foreach (var name in names)
{
var go = Instantiate(_entryTemplate, _listContent);
go.name = "Lib_Entry";
go.SetActive(true);
var bg = go.GetComponent<Image>();
if (bg) bg.color = name == _selectedPainting ? EntrySelected : EntryNormal;
var label = go.GetComponentInChildren<TMP_Text>(true);
if (label) label.text = name;
var btn = go.GetComponent<Button>();
if (btn)
{
string captured = name;
Hook(btn, () => SelectPainting(captured));
}
_entries.Add(new EntryRef { name = name, bg = bg });
}
UpdateButtonStates();
}
private void SelectPainting(string name)
{
_selectedPainting = name;
RefreshHighlights();
UpdateButtonStates();
}
private void RefreshHighlights()
{
foreach (var e in _entries)
if (e.bg) e.bg.color = e.name == _selectedPainting ? EntrySelected : EntryNormal;
}
private void UpdateButtonStates()
{
bool hasPainter = painter;
bool hasSel = !string.IsNullOrEmpty(_selectedPainting);
if (_saveButton) _saveButton.interactable = hasPainter;
if (_loadButton) _loadButton.interactable = hasPainter && hasSel;
if (_deleteButton) _deleteButton.interactable = hasSel;
}
private void HideDialogs()
{
if (_saveDialog) _saveDialog.SetActive(false);
if (_confirmDialog) _confirmDialog.SetActive(false);
}
// --- Actions ---
private void OnSaveClicked()
{
if (!painter || !_saveDialog) return;
// Pre-fill the currently selected painting's name so "Save" defaults to overwriting it (empty when
// nothing is selected) and select that text so the first keystroke replaces it for a fresh name.
if (_nameInput) _nameInput.text = _selectedPainting ?? "";
if (_saveInfo) _saveInfo.text = "";
_saveDialog.SetActive(true);
_saveDialog.transform.SetAsLastSibling(); // a dragged panel may have moved itself in front; stay on top
ValidateSaveName();
if (_nameInput)
{
_nameInput.ActivateInputField();
StartCoroutine(SelectNameFieldNextFrame());
}
}
// ActivateInputField only takes focus on a later update and resets the caret, so selecting the text this
// frame would be cleared. Wait one frame, then select the whole pre-filled name - done explicitly here so
// it works regardless of the field's authored "On Focus Select All" toggle.
private System.Collections.IEnumerator SelectNameFieldNextFrame()
{
yield return null;
if (!_nameInput) yield break;
_nameInput.selectionStringAnchorPosition = _nameInput.text.Length;
_nameInput.selectionStringFocusPosition = 0;
_nameInput.ForceLabelUpdate();
}
private void ValidateSaveName()
{
string name = _nameInput ? _nameInput.text.Trim() : "";
bool empty = string.IsNullOrEmpty(name);
bool exists = !empty && MeshPainter.PaintExists(name);
// Overwrite is allowed -> the OK button stays enabled even when the name exists; the warning is
// purely informational so you know you are about to replace an existing slot.
if (_saveOkButton) _saveOkButton.interactable = !empty && painter;
if (_saveInfo) _saveInfo.text = exists ? "A painting with this name already exists and will be overwritten." : "";
}
private void ConfirmSave()
{
if (!painter || !_nameInput) return;
string name = _nameInput.text.Trim();
if (string.IsNullOrEmpty(name)) { ValidateSaveName(); return; }
if (painter.SavePaintAs(name, overwrite: true))
{
_selectedPainting = name;
if (_saveDialog) _saveDialog.SetActive(false);
RefreshList();
}
else if (_saveInfo)
{
_saveInfo.text = "Save failed.";
}
}
private void OnLoadClicked()
{
if (!painter || string.IsNullOrEmpty(_selectedPainting)) return;
DoLoad(_selectedPainting); // load immediately, no confirmation
}
private void OnDeleteClicked()
{
if (string.IsNullOrEmpty(_selectedPainting)) return;
string name = _selectedPainting;
ShowConfirm($"Delete \"{name}\"?", OkDanger, () => DoDelete(name));
}
/// <summary>Reset the panels + brush values to their authored defaults (confirmed; the dialog reuses the
/// library's confirm path). Falls back to an immediate reset if no confirm dialog is authored.</summary>
private void OnResetClicked()
{
if (_confirmDialog)
ShowConfirm("Reset the HUD panels and brush values to their defaults?", OkDanger, ResetHudToDefaults);
else
ResetHudToDefaults();
}
private void DoLoad(string name)
{
if (!painter || !painter.LoadPaintByName(name)) return;
// The load also restored the slot's material scalars onto the painter (emission + camo from v3,
// the painted-height knobs from v4) -> push them into the sliders so the HUD reflects the loaded
// look, exactly like a pipette pick does. SyncCamoSliders covers the camo + height sliders.
SyncFromPainter();
SyncCamoSliders();
// Re-sync the loaded baseline to every other peer (no-op offline / single-player). The restored
// emission/camo replicate on their own via PlayerPaintSync's owner-authoritative NetworkVariables.
var sync = painter.GetComponentInParent<PlayerPaintSync>();
if (sync) sync.BroadcastLoadedPaint();
}
private void DoDelete(string name)
{
MeshPainter.DeletePaint(name);
if (_selectedPainting == name) _selectedPainting = null;
RefreshList();
}
private void ShowConfirm(string message, Color okColor, Action onConfirm)
{
if (!_confirmDialog) return;
if (_confirmText) _confirmText.text = message;
_confirmAction = onConfirm;
if (_confirmButton && _confirmButton.targetGraphic is Image okImg) okImg.color = okColor;
_confirmDialog.SetActive(true);
_confirmDialog.transform.SetAsLastSibling(); // a dragged panel may have moved itself in front; stay on top
}
private void OnConfirmClicked()
{
var act = _confirmAction;
_confirmAction = null;
if (_confirmDialog) _confirmDialog.SetActive(false);
act?.Invoke();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 059854c655f028c4ba5f9b4b0bccdbb6
@@ -0,0 +1,526 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
public partial class PaintHud
{
// --- Persistence: panel positions + the widget-bound painter values ---
//
// Saved when state actually changes (panel drop = instant, brush edits = debounced), restored on the
// next start. Deliberately NO save on quit/teardown: standalone builds don't guarantee a clean
// shutdown (OnApplicationQuit is skipped on force-close/kill/crash), which silently lost the whole
// session's layout in the build. Small structured state -> human-readable JSON, independent of the
// painter's bulk pixel maps (MeshPainter.Persistence). The brush values are also mirrored in-memory
// (_carry) so they survive the avatar respawn a scene switch triggers.
[Header("Persistence")]
[Tooltip("Save the panel positions and their values when you move a panel or change a brush value, " +
"and restore them on the next start. Off = the HUD opens at its authored layout / painter " +
"defaults every session.")]
public bool persistHud = true;
[Tooltip("Save file identifier. Empty = derived from the HUD's own scene name. Set explicitly to keep " +
"the layout stable across scene renames or to share one layout between scenes.")]
public string hudSaveId = "";
private const int HudFileVersion = 1;
// Auto-save bookkeeping. Brush edits are debounced (sliders fire every frame while dragged): the due
// time is pushed forward and written once they settle.
private const float HudSaveDebounce = 0.5f;
private readonly HudValues _savedValues = new HudValues(); // mirror of what is currently on disk
private readonly HudValues _lastSeenValues = new HudValues(); // painter values observed last frame (settle detection)
private bool _hasSavedBaseline;
private bool _hudSavePending;
private float _hudSaveDueTime;
// In-memory mirror seeding a freshly respawned painter (instead of its prefab defaults). Single
// instance with mutable fields -> no per-frame allocation.
private readonly HudValues _carry = new HudValues();
private bool _hasCarry;
// Camo knobs live on the material instance, which only exists after the painter's Start: a restored
// (disk) or carried (respawn) value waits here and is applied in the LateUpdate ready-gate.
// _camoEverReady gates carry/save so the not-yet-ready 0 never overwrites the authored value.
private Vector3 _pendingCamo;
private bool _hasPendingCamo;
private bool _camoEverReady;
// Painted-height knobs ride the exact same material-ready deferral as the camo knobs above.
private Vector3 _pendingHeight;
private bool _hasPendingHeight;
// Authored defaults for the runtime reset ("as if the save file had been deleted"): prefab panel
// positions + prefab/material default brush values, captured before restore/carry mutate them. Camo
// is material-backed (readable only once IsReady), so it is filled in the ready-gate instead.
private readonly Dictionary<string, Vector2> _authoredPanelPos = new Dictionary<string, Vector2>();
private bool _hasAuthoredLayout;
private readonly HudValues _defaults = new HudValues();
private bool _hasDefaults; // non-camo defaults captured (first bind, before restore/carry)
private bool _hasDefaultCamo; // camo defaults captured (first ready-gate, before any pending camo apply)
private static string HudSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint");
private string HudSavePath()
{
// Derive from the HUD's OWN scene (a stable persistent scene), not the active scene: the latter
// now changes across the additive lobby/map loads, so a key from it would split save and restore
// into different files and silently lose the saved layout.
string key = string.IsNullOrEmpty(hudSaveId)
? SanitizeHudKey(gameObject.scene.name + "_hud")
: SanitizeHudKey(hudSaveId);
return Path.Combine(HudSaveDir, key + ".hudlayout");
}
private static string SanitizeHudKey(string s) => MeshPainter.SanitizeFileKey(s);
[Serializable]
private class HudState
{
public int version = HudFileVersion;
public bool hasValues; // false = no painter was bound when saved -> restore positions only
public bool hasCamoValues; // false = pre-camo save file -> keep the material's authored camo (no clobber)
public bool hasHeightValues; // false = pre-height save file -> keep the material's authored height knobs (no clobber)
public List<PanelPos> panels = new List<PanelPos>();
public HudValues values = new HudValues();
}
[Serializable]
private struct PanelPos
{
public string name;
public Vector2 pos; // anchoredPosition (point-anchored panels -> fully determines placement)
}
// Mirrors exactly the painter fields the HUD widgets bind to (PaintHud.WirePanel).
[Serializable]
private class HudValues
{
public bool paintAlbedo, paintMetallicSmoothness, paintOcclusion, paintEmission, spray;
public Color brushColor;
public float metallic, smoothness, metallicSmoothnessOpacity, occlusion, occlusionOpacity;
public Color emissionColor;
public float emissionIntensity;
public int brushSize;
public int activeBrushIndex;
public int sprayDots;
public float sprayDotSizeMin, sprayDotSizeMax, sprayDotAlphaMin, sprayDotAlphaMax;
public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen; // PlayerCamoLit material knobs
public float occlusionThreshold, heightNormalIntensity, parallaxIntensity; // painted-height knobs (material-backed)
}
/// <summary>A draggable panel was dropped -> persist the layout immediately.</summary>
private void OnPanelMoved() => SaveHudState();
/// <summary>Writes the current panel positions and bound painter values to disk.</summary>
public void SaveHudState()
{
if (!persistHud || !_panel) return;
try
{
var state = new HudState();
var panel = _panel.transform;
foreach (var name in DraggablePanels)
{
if (GeometryUtil.FindDeep(panel, name) is RectTransform rt)
state.panels.Add(new PanelPos { name = name, pos = rt.anchoredPosition });
}
if (painter)
{
CopyValues(painter, state.values);
state.hasValues = true;
state.hasCamoValues = painter.IsReady; // only claim camo when it was actually readable
state.hasHeightValues = painter.IsReady; // height knobs share the camo readiness
}
else if (_hasCarry)
{
// No painter bound at teardown (e.g. quit with no avatar) -> persist the last mirrored values.
state.values = _carry;
state.hasValues = true;
state.hasCamoValues = _camoEverReady; // _carry holds valid camo only after a ready painter
state.hasHeightValues = _camoEverReady;
}
Directory.CreateDirectory(HudSaveDir);
File.WriteAllText(HudSavePath(), JsonUtility.ToJson(state, true));
// Refresh the on-disk baseline so the debounce does not immediately re-save unchanged values.
if (painter) CopyValues(painter, _savedValues);
_hasSavedBaseline = painter;
_hudSavePending = false;
}
catch (Exception e)
{
Debug.LogWarning($"[PaintHud] Saving HUD state failed: {e.Message}", this);
}
}
/// <summary>
/// Restores the saved panel positions and brush values. Values are written into the painter (the
/// source of truth the widgets read) and then pushed to every widget via <see cref="SyncFromPainter"/>,
/// reusing the exact same painter -> HUD path the pipette pick uses. Positions are applied last.
/// </summary>
public void RestoreHudState()
{
if (!persistHud || !_panel) return;
var state = LoadHudState();
if (state == null) return;
if (painter && state.hasValues && state.values != null)
{
ApplyValues(painter, state.values);
SyncFromPainter();
}
// Camo is material-backed -> stash for the LateUpdate ready-gate. hasCamoValues guards pre-camo
// save files (applying their 0 would clobber the authored camo).
if (state.hasCamoValues && state.values != null)
{
_pendingCamo = new Vector3(state.values.camoHalfLambert,
state.values.camoHalfLambertBrightness,
state.values.camoSpecularDampen);
_hasPendingCamo = true;
}
// Same deferral + guard for the painted-height knobs.
if (state.hasHeightValues && state.values != null)
{
_pendingHeight = new Vector3(state.values.occlusionThreshold,
state.values.heightNormalIntensity,
state.values.parallaxIntensity);
_hasPendingHeight = true;
}
// Point-anchored panels: anchoredPosition is resolution-independent under the canvas'
// ScaleWithScreenSize scaler, so the raw saved value places them identically next session.
var root = _panel.transform;
if (state.panels != null)
foreach (var p in state.panels)
if (GeometryUtil.FindDeep(root, p.name) is RectTransform rt)
rt.anchoredPosition = p.pos;
}
// --- In-session carry: brush values survive the avatar respawn a scene switch triggers ---
private void LateUpdate()
{
if (painter)
{
// Apply restored/carried camo + height to the material once it is ready, then seed the
// sliders. Runs BEFORE the carry mirror + change detection below, so the just-applied value
// is already the baseline and never re-triggers a redundant auto-save.
if (!_camoSynced && painter.IsReady)
{
// Capture the authored camo as the reset default BEFORE any pending value is applied over
// it - once per HUD, so a reset always returns to the genuine material default.
if (!_hasDefaultCamo)
{
CaptureCamoDefaults();
_hasDefaultCamo = true;
}
if (_hasPendingCamo)
{
painter.CamoKnobs = _pendingCamo;
_hasPendingCamo = false;
}
if (_hasPendingHeight)
{
painter.HeightKnobs = _pendingHeight;
_hasPendingHeight = false;
}
SyncCamoSliders();
_savedValues.camoHalfLambert = _lastSeenValues.camoHalfLambert = painter.CamoHalfLambert;
_savedValues.camoHalfLambertBrightness = _lastSeenValues.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
_savedValues.camoSpecularDampen = _lastSeenValues.camoSpecularDampen = painter.CamoSpecularDampen;
_savedValues.occlusionThreshold = _lastSeenValues.occlusionThreshold = painter.OcclusionThreshold;
_savedValues.heightNormalIntensity = _lastSeenValues.heightNormalIntensity = painter.HeightNormalIntensity;
_savedValues.parallaxIntensity = _lastSeenValues.parallaxIntensity = painter.ParallaxIntensity;
_camoEverReady = true;
_camoSynced = true;
}
// Mirror the live painter every frame; the next BindPainter (after a respawn) seeds the fresh
// painter from this snapshot. Captured while the painter lives - immune to despawn timing.
CaptureCarry();
// Debounced auto-save: (re)arm the settle timer only on frames where a value actually CHANGED
// since the last frame - comparing against the on-disk baseline instead would push the
// deadline forward forever (an edit differs from disk until written), so it would never fire.
if (persistHud && _hasSavedBaseline && !ValuesEqual(painter, _lastSeenValues))
{
CopyValues(painter, _lastSeenValues);
if (!ValuesEqual(painter, _savedValues))
{
_hudSavePending = true;
_hudSaveDueTime = Time.unscaledTime + HudSaveDebounce;
}
}
}
if (_hudSavePending && Time.unscaledTime >= _hudSaveDueTime)
SaveHudState();
}
/// <summary>Marks the current brush values as the on-disk baseline so auto-save only fires on real edits.</summary>
private void SnapshotSavedBaseline()
{
if (painter)
{
CopyValues(painter, _savedValues);
CopyValues(painter, _lastSeenValues);
}
_hasSavedBaseline = painter;
_hudSavePending = false;
}
/// <summary>True when every brush value on the painter still matches the snapshot last written to disk.</summary>
private static bool ValuesEqual(MeshPainter p, HudValues v)
{
return p.paintAlbedo == v.paintAlbedo
&& p.paintMetallicSmoothness == v.paintMetallicSmoothness
&& p.paintOcclusion == v.paintOcclusion
&& p.paintEmission == v.paintEmission
&& p.spray == v.spray
&& p.brushColor == v.brushColor
&& p.metallic == v.metallic
&& p.smoothness == v.smoothness
&& p.metallicSmoothnessOpacity == v.metallicSmoothnessOpacity
&& p.occlusion == v.occlusion
&& p.occlusionOpacity == v.occlusionOpacity
&& p.emissionColor == v.emissionColor
&& p.emissionIntensity == v.emissionIntensity
&& p.brushSize == v.brushSize
&& p.activeBrushIndex == v.activeBrushIndex
&& p.sprayDots == v.sprayDots
&& p.sprayDotSizeMin == v.sprayDotSizeMin
&& p.sprayDotSizeMax == v.sprayDotSizeMax
&& p.sprayDotAlphaMin == v.sprayDotAlphaMin
&& p.sprayDotAlphaMax == v.sprayDotAlphaMax
&& p.CamoHalfLambert == v.camoHalfLambert
&& p.CamoHalfLambertBrightness == v.camoHalfLambertBrightness
&& p.CamoSpecularDampen == v.camoSpecularDampen
&& p.OcclusionThreshold == v.occlusionThreshold
&& p.HeightNormalIntensity == v.heightNormalIntensity
&& p.ParallaxIntensity == v.parallaxIntensity;
}
/// <summary>Snapshots the bound painter's brush values into the in-memory mirror.</summary>
public void CaptureCarry()
{
if (!painter) return;
CopyValues(painter, _carry);
_hasCarry = true;
}
/// <summary>Writes the mirrored brush values onto the (freshly bound) painter and pushes them to the widgets.</summary>
public void ApplyCarry()
{
if (!painter || !_hasCarry) return;
ApplyValues(painter, _carry);
SyncFromPainter();
// Camo is material-backed -> defer to the ready-gate using the carried (last live) values. Guarded
// by _camoEverReady so a carry taken before any material was ready can't apply a stale 0.
if (_camoEverReady)
{
_pendingCamo = new Vector3(_carry.camoHalfLambert,
_carry.camoHalfLambertBrightness,
_carry.camoSpecularDampen);
_hasPendingCamo = true;
_pendingHeight = new Vector3(_carry.occlusionThreshold,
_carry.heightNormalIntensity,
_carry.parallaxIntensity);
_hasPendingHeight = true;
}
}
private static void CopyValues(MeshPainter from, HudValues v)
{
v.paintAlbedo = from.paintAlbedo;
v.paintMetallicSmoothness = from.paintMetallicSmoothness;
v.paintOcclusion = from.paintOcclusion;
v.paintEmission = from.paintEmission;
v.spray = from.spray;
v.brushColor = from.brushColor;
v.metallic = from.metallic;
v.smoothness = from.smoothness;
v.metallicSmoothnessOpacity = from.metallicSmoothnessOpacity;
v.occlusion = from.occlusion;
v.occlusionOpacity = from.occlusionOpacity;
v.emissionColor = from.emissionColor;
v.emissionIntensity = from.emissionIntensity;
v.brushSize = from.brushSize;
v.activeBrushIndex = from.activeBrushIndex;
v.sprayDots = from.sprayDots;
v.sprayDotSizeMin = from.sprayDotSizeMin;
v.sprayDotSizeMax = from.sprayDotSizeMax;
v.sprayDotAlphaMin = from.sprayDotAlphaMin;
v.sprayDotAlphaMax = from.sprayDotAlphaMax;
// Camo getters read 0 until the material exists - only mirror once ready, otherwise preserve the
// desired values already in the target (a restore/carry the ready-gate still has to apply).
if (from.IsReady)
{
v.camoHalfLambert = from.CamoHalfLambert;
v.camoHalfLambertBrightness = from.CamoHalfLambertBrightness;
v.camoSpecularDampen = from.CamoSpecularDampen;
v.occlusionThreshold = from.OcclusionThreshold;
v.heightNormalIntensity = from.HeightNormalIntensity;
v.parallaxIntensity = from.ParallaxIntensity;
}
}
private static void ApplyValues(MeshPainter to, HudValues v)
{
to.paintAlbedo = v.paintAlbedo;
to.paintMetallicSmoothness = v.paintMetallicSmoothness;
to.paintOcclusion = v.paintOcclusion;
to.paintEmission = v.paintEmission;
to.spray = v.spray;
to.brushColor = v.brushColor;
to.metallic = v.metallic;
to.smoothness = v.smoothness;
to.metallicSmoothnessOpacity = v.metallicSmoothnessOpacity;
to.occlusion = v.occlusion;
to.occlusionOpacity = v.occlusionOpacity;
to.emissionColor = v.emissionColor;
to.emissionIntensity = v.emissionIntensity;
to.brushSize = v.brushSize;
to.activeBrushIndex = v.activeBrushIndex;
to.sprayDots = v.sprayDots;
to.sprayDotSizeMin = v.sprayDotSizeMin;
to.sprayDotSizeMax = v.sprayDotSizeMax;
to.sprayDotAlphaMin = v.sprayDotAlphaMin;
to.sprayDotAlphaMax = v.sprayDotAlphaMax;
// Camo + height setters are a no-op until the material exists; the guaranteed deferred apply runs
// from the LateUpdate ready-gate via _pendingCamo/_pendingHeight. Writing here too keeps Copy/Apply/Equal symmetric.
to.CamoHalfLambert = v.camoHalfLambert;
to.CamoHalfLambertBrightness = v.camoHalfLambertBrightness;
to.CamoSpecularDampen = v.camoSpecularDampen;
to.OcclusionThreshold = v.occlusionThreshold;
to.HeightNormalIntensity = v.heightNormalIntensity;
to.ParallaxIntensity = v.parallaxIntensity;
}
private HudState LoadHudState()
{
string path = HudSavePath();
if (!File.Exists(path)) return null;
try
{
var state = JsonUtility.FromJson<HudState>(File.ReadAllText(path));
if (state == null || state.version != HudFileVersion) return null;
return state;
}
catch (Exception e)
{
Debug.LogWarning($"[PaintHud] Loading HUD state failed: {e.Message}", this);
return null;
}
}
// --- Reset to authored defaults (drives the HUD's reset button) ---
/// <summary>
/// Snapshots the authored panel positions from the freshly instantiated layout, before any saved
/// layout is restored over them. The runtime reset restores exactly these. Called once from Start
/// (right after the layout prefab is instantiated); idempotent.
/// </summary>
public void CaptureAuthoredLayout()
{
if (_hasAuthoredLayout || !_panel) return;
var root = _panel.transform;
foreach (var name in DraggablePanels)
if (GeometryUtil.FindDeep(root, name) is RectTransform rt)
_authoredPanelPos[name] = rt.anchoredPosition;
_hasAuthoredLayout = true;
}
/// <summary>
/// Snapshots the painter's prefab-default brush values on the first bind, before carry/restore mutate
/// the live painter. Non-camo only here (a freshly spawned painter holds its prefab defaults); the
/// material-backed camo is not readable yet and is captured later in the LateUpdate ready-gate.
/// </summary>
private void CaptureDefaults()
{
if (_hasDefaults || !painter) return;
CopyValues(painter, _defaults); // CopyValues skips camo until IsReady -> camo stays for the ready-gate
_hasDefaults = true;
}
/// <summary>Snapshots the live camo + height knobs into the reset defaults (caller decides _hasDefaultCamo).</summary>
private void CaptureCamoDefaults()
{
_defaults.camoHalfLambert = painter.CamoHalfLambert;
_defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
_defaults.camoSpecularDampen = painter.CamoSpecularDampen;
_defaults.occlusionThreshold = painter.OcclusionThreshold;
_defaults.heightNormalIntensity = painter.HeightNormalIntensity;
_defaults.parallaxIntensity = painter.ParallaxIntensity;
}
/// <summary>
/// Resets the HUD to its authored defaults, exactly as if the saved layout file had been deleted: the
/// panels jump back to their prefab positions and every brush value returns to the painter's prefab /
/// material defaults. The save file is deleted too, so the reset also survives the next start.
/// </summary>
[ContextMenu("Reset HUD Layout And Values")]
public void ResetHudToDefaults()
{
ClearSavedHudState();
if (_panel && _hasAuthoredLayout)
{
var root = _panel.transform;
foreach (var kv in _authoredPanelPos)
if (GeometryUtil.FindDeep(root, kv.Key) is RectTransform rt)
rt.anchoredPosition = kv.Value;
}
if (painter && _hasDefaults)
{
// Reset clicked before the ready-gate captured the camo defaults? keep the live camo rather than
// slamming it to the not-yet-filled 0 (which would flatten the material's lighting).
if (!_hasDefaultCamo && painter.IsReady)
CaptureCamoDefaults();
ApplyValues(painter, _defaults);
SyncFromPainter();
SyncCamoSliders();
// ApplyValues wrote the camo + height knobs straight to the material (painter is ready at a
// runtime reset), so drop any still-pending restored values the ready-gate would re-apply over the default.
_hasPendingCamo = false;
_hasPendingHeight = false;
CaptureCarry(); // a later respawn now carries the defaults, not the pre-reset values
SnapshotSavedBaseline(); // values match the (now deleted) save -> the reset itself triggers no auto-save
}
}
/// <summary>Deletes the saved HUD layout (next start opens at the authored layout / defaults). Editor convenience.</summary>
[ContextMenu("Clear Saved HUD Layout")]
public void ClearSavedHudState()
{
try
{
string path = HudSavePath();
if (File.Exists(path)) File.Delete(path);
Debug.Log($"[PaintHud] Cleared saved HUD layout: {path}", this);
}
catch (Exception e)
{
Debug.LogWarning($"[PaintHud] Clearing saved HUD layout failed: {e.Message}", this);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4fb3415c5348e5c49955e222fd43a55c
@@ -0,0 +1,632 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem.UI;
#endif
/// <summary>
/// In-game paint UI. Shows its panels only while the active <see cref="GameMode"/> is
/// <see cref="PaintMode"/> (follows the manager's ModeChanged) and binds its widgets directly to the
/// live <see cref="MeshPainter"/> fields, so edits apply immediately.
///
/// The layout (independently draggable sub-panels per channel + a global brush card) comes from the
/// authored prefab (<see cref="layoutPrefab"/>). The BINDINGS live in code and are (re)applied at
/// runtime by <see cref="WirePanel"/> against stable widget names - the prefab stores only the
/// visual layout, never behaviour.
/// </summary>
[DisallowMultipleComponent]
public partial class PaintHud : MonoBehaviour
{
[Tooltip("Mode manager driving visibility. Empty = found automatically.")]
public GameModeManager modeManager;
[Tooltip("Painter the HUD edits. Empty = the manager's painter, else found automatically.")]
public MeshPainter painter;
[Tooltip("Authored panel layout. Instantiated and bound at startup.")]
public GameObject layoutPrefab;
// Stable widget names shared by the baked prefab and the binder.
private const string PanelGeneral = "Panel_General";
private const string PanelSpray = "Panel_Spray";
private const string PanelAlbedo = "Panel_Albedo";
private const string PanelMetallicSmoothness = "Panel_MetallicSmoothness";
private const string PanelOcclusion = "Panel_Occlusion";
private const string PanelEmission = "Panel_Emission";
private const string PanelBrushSize = "Panel_BrushSize";
private const string PanelPaintings = "Panel_Paintings";
private const string ToggleAlbedo = "Toggle_Albedo";
private const string ToggleMetallicSmoothness = "Toggle_MetallicSmoothness";
private const string ToggleOcclusion = "Toggle_Occlusion";
private const string ToggleEmission = "Toggle_Emission";
private const string ToggleSpray = "Toggle_Spray";
private const string TogglePipette = "Toggle_Pipette";
private const string PickerAlbedo = "Picker_Albedo";
private const string PickerEmission = "Picker_Emission";
private const string SliderAlbedoAlpha = "Slider_AlbedoAlpha";
private const string SliderEmissionAlpha = "Slider_EmissionAlpha";
private const string SliderMetallic = "Slider_Metallic";
private const string SliderSmoothness = "Slider_Smoothness";
private const string SliderMetallicSmoothnessAlpha = "Slider_MetallicSmoothnessAlpha";
private const string SliderOcclusion = "Slider_Occlusion";
private const string SliderOcclusionAlpha = "Slider_OcclusionAlpha";
private const string SliderEmissionIntensity = "Slider_EmissionIntensity";
private const string SliderBrushSize = "Slider_BrushSize";
private const string SliderSprayDots = "Slider_SprayDots";
private const string SliderSprayDotSizeMin = "Slider_SprayDotSizeMin";
private const string SliderSprayDotSizeMax = "Slider_SprayDotSizeMax";
private const string SliderSprayDotAlphaMin = "Slider_SprayDotAlphaMin";
private const string SliderSprayDotAlphaMax = "Slider_SprayDotAlphaMax";
private const string SliderHalfLambert = "Slider_HalfLambert";
private const string SliderHalfLambertBrightness = "Slider_HalfLambertBrightness";
private const string SliderSpecularDamp = "Slider_SpecularDamp";
private const string SliderOcclusionThreshold = "Slider_OcclusionThreshold";
private const string SliderNormalIntensity = "Slider_NormalIntensity";
private const string SliderParallaxIntensity = "Slider_ParallaxIntensity";
private const string MaskSelector = "MaskSelector";
private const string MaskSwatchPrefix = "MaskSwatch_";
// Panels made draggable at runtime - grabbable from any non-widget surface (both prefab and code-gen paths).
private static readonly string[] DraggablePanels =
{
PanelAlbedo,
PanelMetallicSmoothness,
PanelOcclusion,
PanelEmission,
PanelBrushSize,
PanelSpray,
PanelGeneral,
PanelPaintings
};
private GameObject _panel;
private Font _font;
private bool _camoSynced; // camo sliders are material-backed -> seeded once the painter's material instance exists
private void Awake()
{
if (!modeManager) modeManager = FindFirstObjectByType<GameModeManager>();
}
private void OnEnable()
{
if (modeManager)
{
modeManager.ModeChanged += OnModeChanged;
modeManager.PainterChanged += OnPainterChanged;
}
}
private void OnDisable()
{
if (modeManager)
{
modeManager.ModeChanged -= OnModeChanged;
modeManager.PainterChanged -= OnPainterChanged;
}
}
private void Start()
{
_font = LoadFont();
EnsureEventSystem();
EnsureCanvas();
if (layoutPrefab)
{
var go = Instantiate(layoutPrefab, transform);
go.name = layoutPrefab.name; // strip the "(Clone)" suffix
_panel = go;
}
// Snapshot the authored panel positions now, while the layout is untouched - before RestoreHudState
// (in BindPainter) overwrites them. The reset button restores exactly these.
CaptureAuthoredLayout();
// The paint library now lives inside the authored layout prefab; bind its widgets like the brush panel.
BindLibrary(_panel ? _panel.transform : null);
// Bind as soon as a painter exists. With networked spawn-on-connect there is none at scene
// start; PainterChanged then wires the widgets once the local avatar spawns.
var p = painter;
if (!p) p = modeManager ? modeManager.Painter : null;
if (!p) p = FindFirstObjectByType<MeshPainter>();
if (p) BindPainter(p);
// Set initial visibility (the manager's first SwitchTo may have fired before this Start).
OnModeChanged(modeManager ? modeManager.ActiveMode : null);
}
private void OnPainterChanged(MeshPainter newPainter)
{
if (newPainter) BindPainter(newPainter);
}
/// <summary>
/// (Re)binds the HUD's widgets to a live painter. Called from Start when one already exists
/// (non-networked scene) or from the manager's PainterChanged event when the local networked
/// avatar spawns. Binds exactly once per painter: re-binding the same painter is a no-op, so
/// the deferred network path and the immediate scene path never double-add widget listeners.
/// </summary>
private void BindPainter(MeshPainter newPainter)
{
if (!newPainter || newPainter == painter || !_panel) return;
UnhookPainterEvents();
painter = newPainter;
_camoSynced = false; // re-seed the camo sliders once this painter's material instance is ready
WirePanel(_panel.transform);
// Re-sync every widget when the pipette adopts a sampled material (painter -> HUD).
painter.Picked += SyncFromPainter;
// Re-sync the brush-size slider when Shift+wheel resizes the brush in-world (painter -> HUD).
painter.BrushSizeChanged += SyncBrushSize;
// Snapshot the prefab-default brush values before carry/restore mutate them (the reset button
// returns to exactly these). Once per HUD.
CaptureDefaults();
// First bind this session: restore from disk. A later bind is an avatar respawn -> carry the
// in-session values onto the fresh painter instead.
if (_hasCarry) ApplyCarry();
else RestoreHudState();
CaptureCarry(); // seed/refresh the mirror from the now-current painter
SnapshotSavedBaseline(); // values now match disk -> auto-save only fires on a real edit
UpdateButtonStates(); // enable Save now that a painter is bound
}
private void OnDestroy() => UnhookPainterEvents();
private void UnhookPainterEvents()
{
if (!painter) return;
painter.Picked -= SyncFromPainter;
painter.BrushSizeChanged -= SyncBrushSize;
}
private void OnModeChanged(GameMode mode)
{
// Require a bound painter too: pre-spawn the manager sits in a player-less Paint mode, but there
// is nothing to paint yet - keep the HUD hidden until a painter is bound.
bool paint = mode is PaintMode && painter;
if (_panel) _panel.SetActive(paint); // toggles the brush panels and the library panel together
if (paint)
{
if (_libraryBound) RefreshList(); // pick up slots saved/changed since last shown
}
else HideDialogs();
}
private static Font LoadFont()
{
var f = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
if (!f) f = Resources.GetBuiltinResource<Font>("Arial.ttf");
return f;
}
// --- Canvas ---
private void EnsureCanvas()
{
var canvas = GetComponent<Canvas>();
if (!canvas) canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 100;
var scaler = GetComponent<CanvasScaler>();
if (!scaler) scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920f, 1080f);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand;
if (!GetComponent<GraphicRaycaster>()) gameObject.AddComponent<GraphicRaycaster>();
}
private void EnsureEventSystem()
{
if (FindFirstObjectByType<EventSystem>()) return;
var es = new GameObject("EventSystem", typeof(EventSystem));
#if ENABLE_INPUT_SYSTEM
var module = es.AddComponent<InputSystemUIInputModule>();
if (!module.actionsAsset) module.AssignDefaultActions();
#else
es.AddComponent<StandaloneInputModule>();
#endif
}
// --- Wiring (behaviour; shared by the code-gen and prefab paths) ---
private void WirePanel(Transform panel)
{
EnsureFonts(panel); // builtin font references may not survive prefab serialization
WireToggle(panel, ToggleAlbedo, painter && painter.paintAlbedo, v => { if (painter) painter.paintAlbedo = v; });
WireToggle(panel, ToggleMetallicSmoothness, painter && painter.paintMetallicSmoothness, v => { if (painter) painter.paintMetallicSmoothness = v; });
WireToggle(panel, ToggleOcclusion, painter && painter.paintOcclusion, v => { if (painter) painter.paintOcclusion = v; });
WireToggle(panel, ToggleEmission, painter && painter.paintEmission, v => { if (painter) painter.paintEmission = v; });
WireToggle(panel, ToggleSpray, painter && painter.spray, v => { if (painter) painter.spray = v; });
WireToggle(panel, TogglePipette, painter && painter.pipette, v => { if (painter) painter.pipette = v; });
WirePanelFade(panel, PanelAlbedo, ToggleAlbedo);
WirePanelFade(panel, PanelMetallicSmoothness, ToggleMetallicSmoothness);
WirePanelFade(panel, PanelOcclusion, ToggleOcclusion);
WirePanelFade(panel, PanelEmission, ToggleEmission);
WirePanelFade(panel, PanelSpray, ToggleSpray);
// Picker drives only RGB (keeping the painter's current alpha); a standalone slider drives alpha.
WirePicker(panel, PickerAlbedo, painter ? painter.brushColor : Color.red,
c => { if (painter) painter.brushColor = WithRgb(painter.brushColor, c); });
WireSlider(panel, SliderAlbedoAlpha, painter ? painter.brushColor.a : .6f,
v => { if (painter) painter.brushColor = WithAlpha(painter.brushColor, v); });
WireSlider(panel, SliderMetallic, painter ? painter.metallic : 0f, v => { if (painter) painter.metallic = v; });
WireSlider(panel, SliderSmoothness, painter ? painter.smoothness : 0.5f, v => { if (painter) painter.smoothness = v; });
WireSlider(panel, SliderMetallicSmoothnessAlpha, painter ? painter.metallicSmoothnessOpacity : .6f, v => { if (painter) painter.metallicSmoothnessOpacity = v; });
WireSlider(panel, SliderOcclusion, painter ? painter.occlusion : 0.5f, v => { if (painter) painter.occlusion = v; });
WireSlider(panel, SliderOcclusionAlpha, painter ? painter.occlusionOpacity : .6f, v => { if (painter) painter.occlusionOpacity = v; });
WirePicker(panel, PickerEmission, painter ? painter.emissionColor : Color.black,
c => { if (painter) painter.emissionColor = WithRgb(painter.emissionColor, c); });
WireSlider(panel, SliderEmissionIntensity, painter ? painter.emissionIntensity : 1f, v => { if (painter) painter.emissionIntensity = v; });
WireSlider(panel, SliderEmissionAlpha, painter ? painter.emissionColor.a : .6f,
v => { if (painter) painter.emissionColor = WithAlpha(painter.emissionColor, v); });
WireSlider(panel, SliderBrushSize, painter ? painter.brushSize : 16f, v => { if (painter) painter.brushSize = Mathf.RoundToInt(v); });
WireMaskSelector(panel);
WireSlider(panel, SliderSprayDots, painter ? painter.sprayDots : 24f, v => { if (painter) painter.sprayDots = Mathf.RoundToInt(v); });
WireSlider(panel, SliderSprayDotSizeMin, painter ? painter.sprayDotSizeMin : .05f, v => { if (painter) painter.sprayDotSizeMin = v; });
WireSlider(panel, SliderSprayDotSizeMax, painter ? painter.sprayDotSizeMax : .18f, v => { if (painter) painter.sprayDotSizeMax = v; });
WireSlider(panel, SliderSprayDotAlphaMin, painter ? painter.sprayDotAlphaMin : .2f, v => { if (painter) painter.sprayDotAlphaMin = v; });
WireSlider(panel, SliderSprayDotAlphaMax, painter ? painter.sprayDotAlphaMax : .6f, v => { if (painter) painter.sprayDotAlphaMax = v; });
// Camo knobs are material-backed and not readable yet (the HUD binds before the painter's Start);
// SyncCamoSliders re-seeds them once IsReady. Setting a slider before then is a safe no-op.
WireSlider(panel, SliderHalfLambert, painter ? painter.CamoHalfLambert : 0f, v => { if (painter) painter.CamoHalfLambert = v; });
WireSlider(panel, SliderHalfLambertBrightness, painter ? painter.CamoHalfLambertBrightness : 0f, v => { if (painter) painter.CamoHalfLambertBrightness = v; });
WireSlider(panel, SliderSpecularDamp, painter ? painter.CamoSpecularDampen : 0f, v => { if (painter) painter.CamoSpecularDampen = v; });
// Painted-height knobs: material-backed like the camo sliders above.
WireSlider(panel, SliderOcclusionThreshold, painter ? painter.OcclusionThreshold : 0.2f, v => { if (painter) painter.OcclusionThreshold = v; });
WireSlider(panel, SliderNormalIntensity, painter ? painter.HeightNormalIntensity : 1.4f, v => { if (painter) painter.HeightNormalIntensity = v; });
WireSlider(panel, SliderParallaxIntensity, painter ? painter.ParallaxIntensity : 1f, v => { if (painter) painter.ParallaxIntensity = v; });
foreach (var name in DraggablePanels)
MakeDraggable(panel, name);
}
/// <summary>
/// Pushes the painter's current values back into every widget (painter -> HUD), e.g. after a
/// pipette pick changed them outside the UI. No listeners are added here: setting a toggle/slider
/// value fires its existing change listener, which keeps the painter, value labels and panel-fade
/// alphas in sync; the colour pickers update silently via SetColor (it does not raise Changed).
/// </summary>
private void SyncFromPainter()
{
if (!_panel || !painter) return;
var panel = _panel.transform;
SetToggle(panel, ToggleAlbedo, painter.paintAlbedo);
SetToggle(panel, ToggleMetallicSmoothness, painter.paintMetallicSmoothness);
SetToggle(panel, ToggleOcclusion, painter.paintOcclusion);
SetToggle(panel, ToggleEmission, painter.paintEmission);
SetToggle(panel, ToggleSpray, painter.spray);
SetToggle(panel, TogglePipette, painter.pipette);
SetPicker(panel, PickerAlbedo, painter.brushColor);
SetPicker(panel, PickerEmission, painter.emissionColor);
SetSlider(panel, SliderAlbedoAlpha, painter.brushColor.a);
SetSlider(panel, SliderEmissionAlpha, painter.emissionColor.a);
SetSlider(panel, SliderMetallic, painter.metallic);
SetSlider(panel, SliderSmoothness, painter.smoothness);
SetSlider(panel, SliderMetallicSmoothnessAlpha, painter.metallicSmoothnessOpacity);
SetSlider(panel, SliderOcclusion, painter.occlusion);
SetSlider(panel, SliderOcclusionAlpha, painter.occlusionOpacity);
SetSlider(panel, SliderEmissionIntensity, painter.emissionIntensity);
SetSlider(panel, SliderBrushSize, painter.brushSize);
SetSlider(panel, SliderSprayDots, painter.sprayDots);
SetSlider(panel, SliderSprayDotSizeMin, painter.sprayDotSizeMin);
SetSlider(panel, SliderSprayDotSizeMax, painter.sprayDotSizeMax);
SetSlider(panel, SliderSprayDotAlphaMin, painter.sprayDotAlphaMin);
SetSlider(panel, SliderSprayDotAlphaMax, painter.sprayDotAlphaMax);
RefreshMaskHighlights();
}
/// <summary>
/// Pushes only the brush size back into its slider (painter -> HUD), e.g. after Shift+wheel
/// resized the brush in-world. The slider clamps to its own range and its listener writes the
/// clamped value back to the painter, so the wheel and the slider stay in agreement.
/// </summary>
private void SyncBrushSize()
{
if (!_panel || !painter) return;
SetSlider(_panel.transform, SliderBrushSize, painter.brushSize);
}
/// <summary>
/// Seeds the camo + height sliders from the live material (painter -> HUD). The HUD binds before the
/// painter's Start builds the instance, so the values are not readable at WirePanel time; this runs
/// once the painter reports <see cref="MeshPainter.IsReady"/> (see LateUpdate).
/// </summary>
private void SyncCamoSliders()
{
if (!_panel || !painter) return;
var panel = _panel.transform;
SetSlider(panel, SliderHalfLambert, painter.CamoHalfLambert);
SetSlider(panel, SliderHalfLambertBrightness, painter.CamoHalfLambertBrightness);
SetSlider(panel, SliderSpecularDamp, painter.CamoSpecularDampen);
// Painted-height knobs share the same material-ready timing as the camo sliders.
SetSlider(panel, SliderOcclusionThreshold, painter.OcclusionThreshold);
SetSlider(panel, SliderNormalIntensity, painter.HeightNormalIntensity);
SetSlider(panel, SliderParallaxIntensity, painter.ParallaxIntensity);
}
private static void SetToggle(Transform panel, string name, bool value)
{
var toggle = Find<Toggle>(panel, name);
if (toggle) toggle.isOn = value; // only fires onValueChanged when it actually changes
}
private static void SetSlider(Transform panel, string name, float value)
{
var slider = FindSlider(panel, name, out _);
if (!slider) return;
var curve = slider.GetComponent<SliderCurve>();
if (curve) curve.SetOutput(value); // inverts the value onto the handle, then fires the slider listener
else slider.value = value; // listener refreshes the value label + painter
}
private static void SetPicker(Transform panel, string name, Color color)
{
var picker = Find<HsvColorPicker>(panel, name);
if (picker) picker.SetColor(color);
}
// Split colour editing: the picker drives RGB, the alpha slider drives alpha - neither clobbers the other.
private static Color WithRgb(Color current, Color rgb) => new Color(rgb.r, rgb.g, rgb.b, current.a);
private static Color WithAlpha(Color current, float a) => new Color(current.r, current.g, current.b, a);
// Active = accent frame, inactive = subtle frame. Selection lives on the painter (activeBrushIndex);
// the swatch click only sets it - the painter resolves the new mask on its next Update.
private static readonly Color MaskFrameSelected = new Color(0.30f, 0.62f, 1f, 1f);
private static readonly Color MaskFrameUnselected = new Color(0.18f, 0.18f, 0.21f, 1f);
/// <summary>
/// Binds the baked mask swatches (one per <c>MaskSwatch_i</c>) to the painter's brush masks:
/// each swatch previews <c>painter.brushMasks[i]</c> and selects it on click. A swatch with no
/// matching mask is hidden, so the prefab can carry more swatches than the painter has masks.
/// </summary>
private void WireMaskSelector(Transform panel)
{
var selector = GeometryUtil.FindDeep(panel, MaskSelector);
if (!selector) return;
var masks = painter ? painter.brushMasks : null;
for (int i = 0; ; i++)
{
var swatch = GeometryUtil.FindDeep(selector, MaskSwatchPrefix + i);
if (!swatch) break;
bool has = masks != null && i < masks.Count && masks[i];
swatch.gameObject.SetActive(has);
if (!has) continue;
var preview = swatch.Find("Preview");
var previewImg = preview ? preview.GetComponent<Image>() : null;
if (previewImg) previewImg.sprite = MaskSprite(masks[i]);
var btn = swatch.GetComponent<Button>();
if (btn)
{
int idx = i; // capture per swatch
Hook(btn, () => SelectMask(idx));
}
}
RefreshMaskHighlights();
}
private void SelectMask(int index)
{
if (painter) painter.activeBrushIndex = index;
RefreshMaskHighlights();
}
private void RefreshMaskHighlights()
{
if (!_panel || !painter) return;
var selector = GeometryUtil.FindDeep(_panel.transform, MaskSelector);
if (!selector) return;
for (int i = 0; ; i++)
{
var swatch = GeometryUtil.FindDeep(selector, MaskSwatchPrefix + i);
if (!swatch) break;
var frame = swatch.GetComponent<Image>();
if (frame) frame.color = i == painter.activeBrushIndex ? MaskFrameSelected : MaskFrameUnselected;
}
}
// One sprite per mask texture, reused across rewires (display only - no CPU readback needed).
private static readonly System.Collections.Generic.Dictionary<Texture2D, Sprite> _maskSprites
= new System.Collections.Generic.Dictionary<Texture2D, Sprite>();
private static Sprite MaskSprite(Texture2D tex)
{
if (!tex) return null;
if (_maskSprites.TryGetValue(tex, out var cached) && cached) return cached;
var s = ToSprite(tex);
_maskSprites[tex] = s;
return s;
}
/// <summary>
/// Attaches (idempotently) a <see cref="DraggablePanel"/> to the whole panel so it can be dragged
/// from any of its non-interactive surface (header, padding, labels, background); the component
/// ignores presses that start on a widget (toggle, slider, colour picker).
/// </summary>
private void MakeDraggable(Transform root, string panelName)
{
var panel = GeometryUtil.FindDeep(root, panelName) as RectTransform;
if (!panel) return;
var drag = panel.GetComponent<DraggablePanel>();
if (!drag) drag = panel.gameObject.AddComponent<DraggablePanel>();
drag.target = panel;
drag.Moved = OnPanelMoved; // persist the layout the moment a panel is dropped
}
private void EnsureFonts(Transform panel)
{
foreach (var t in panel.GetComponentsInChildren<Text>(true))
if (!t.font) t.font = _font;
}
private static void WireToggle(Transform panel, string name, bool value, Action<bool> onChanged)
{
var toggle = Find<Toggle>(panel, name);
if (!toggle) return;
toggle.isOn = value;
toggle.onValueChanged.AddListener(v => onChanged(v));
}
/// <summary>
/// Ensures a <see cref="CanvasGroup"/> on the channel panel and drives its alpha from the toggle:
/// 1 while the channel is enabled, 0.5 while disabled. Call after <see cref="WireToggle"/> so the
/// initial alpha reflects the toggle's painter-synced state. The group stays interactive (default
/// interactable/blocksRaycasts), so a dimmed panel can still be clicked to re-enable its channel.
/// </summary>
private static void WirePanelFade(Transform root, string panelName, string toggleName)
{
var panel = GeometryUtil.FindDeep(root, panelName);
if (!panel) return;
var group = panel.GetComponent<CanvasGroup>();
if (!group) group = panel.gameObject.AddComponent<CanvasGroup>();
var toggle = Find<Toggle>(panel, toggleName);
if (!toggle) { group.alpha = 1f; return; }
group.alpha = toggle.isOn ? 1f : 0.5f;
toggle.onValueChanged.AddListener(v => group.alpha = v ? 1f : 0.5f);
}
/// <summary>Finds a slider cell by name and the Slider inside it (null when either is missing).</summary>
private static Slider FindSlider(Transform panel, string name, out Transform cell)
{
cell = GeometryUtil.FindDeep(panel, name);
return cell ? cell.GetComponentInChildren<Slider>() : null;
}
private static void WireSlider(Transform panel, string name, float value, Action<float> onChanged)
{
var slider = FindSlider(panel, name, out Transform cell);
if (!slider) return;
var curve = slider.GetComponent<SliderCurve>(); // optional non-linear response on the same object
var valueRt = cell.Find("Value");
var valueText = valueRt ? valueRt.GetComponent<TMPro.TMP_Text>() : null;
// With a curve the slider handle is linear but the bound value is the curved output; whole-number
// quantisation moves onto the curve so the slider itself stays continuous.
if (curve) curve.SetOutputWithoutNotify(value); // seed the handle by inverting the value (no listener yet)
else slider.value = value;
Func<float> read = () => curve ? curve.Value : slider.value;
Action<float> setText = v =>
{
if (valueText) valueText.text = slider.wholeNumbers ? Mathf.RoundToInt(v).ToString() : v.ToString("0.00");
};
setText(read()); // show the actual (clamped/curved) value, not the unclamped input
slider.onValueChanged.AddListener(_ => { float v = read(); setText(v); onChanged(v); });
}
private void WirePicker(Transform panel, string name, Color color, Action<Color> onChanged)
{
var picker = Find<HsvColorPicker>(panel, name);
if (!picker) return;
picker.Rebind(); // re-find children, re-assign runtime gradient sprites, re-hook drag relays
picker.SetColor(color);
picker.Changed += onChanged;
}
private static T Find<T>(Transform panel, string name) where T : Component
{
var child = GeometryUtil.FindDeep(panel, name);
return child ? child.GetComponent<T>() : null;
}
// Cached gradient sprites for the colour picker.
private static Sprite _satSprite, _valSprite, _hueSprite;
/// <summary>Horizontal white-out: opaque white (left) -> transparent (right) = saturation.</summary>
public static Sprite SaturationSprite()
{
if (_satSprite) return _satSprite;
const int n = 64;
var tex = NewGradientTex(n, 1);
for (int x = 0; x < n; x++)
{
byte a = (byte)Mathf.RoundToInt(255f * (1f - x / (float)(n - 1)));
tex.SetPixel(x, 0, new Color32(255, 255, 255, a));
}
tex.Apply(false);
_satSprite = ToSprite(tex);
return _satSprite;
}
/// <summary>Vertical black-out: opaque black (bottom) -> transparent (top) = value.</summary>
public static Sprite ValueSprite()
{
if (_valSprite) return _valSprite;
const int n = 64;
var tex = NewGradientTex(1, n);
for (int y = 0; y < n; y++)
{
byte a = (byte)Mathf.RoundToInt(255f * (1f - y / (float)(n - 1)));
tex.SetPixel(0, y, new Color32(0, 0, 0, a));
}
tex.Apply(false);
_valSprite = ToSprite(tex);
return _valSprite;
}
/// <summary>Horizontal full-saturation hue ramp 0..1.</summary>
public static Sprite HueSprite()
{
if (_hueSprite) return _hueSprite;
const int n = 64;
var tex = NewGradientTex(n, 1);
for (int x = 0; x < n; x++)
tex.SetPixel(x, 0, Color.HSVToRGB(x / (float)(n - 1), 1f, 1f));
tex.Apply(false);
_hueSprite = ToSprite(tex);
return _hueSprite;
}
private static Texture2D NewGradientTex(int w, int h)
{
return new Texture2D(w, h, TextureFormat.RGBA32, false, false) // sRGB sampling for correct colours
{
name = "PaintHud_Gradient",
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear,
hideFlags = HideFlags.HideAndDontSave
};
}
private static Sprite ToSprite(Texture2D tex)
{
var s = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f);
s.hideFlags = HideFlags.HideAndDontSave;
return s;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d0635b02a5454ec408df3a69b04f85a7
@@ -0,0 +1,84 @@
using System;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// Reshapes a UI <see cref="Slider"/>'s response with an <see cref="AnimationCurve"/> so the value runs
/// non-linearly between the slider's authored <c>minValue</c>/<c>maxValue</c>. The curve maps the handle
/// position (x, 0..1 - linear in screen space) to a value fraction (y, 0..1) that is then scaled to the
/// slider's range. The slider's own min/max stays the single source of truth for the range; this only
/// bends the mapping inside it - so dropping the component onto an existing slider needs no range re-entry.
///
/// <see cref="Value"/> is the curved output, <see cref="SetOutput"/> positions the handle to show a given
/// output (inverting the curve), and <see cref="ValueChanged"/> raises the curved value whenever the
/// handle moves - so the component is usable both standalone (wire <see cref="ValueChanged"/>) and from
/// code that drives the raw slider (read <see cref="Value"/>).
///
/// The curve should be monotonic increasing (the default linear is); the inverse used by
/// <see cref="SetOutput"/> assumes that.
/// </summary>
[RequireComponent(typeof(Slider))]
[DisallowMultipleComponent]
public class SliderCurve : MonoBehaviour
{
[Tooltip("Maps the handle position (x, 0..1) to a value fraction (y, 0..1), scaled to the slider's " +
"minValue/maxValue. Default linear = no change. Should be monotonic increasing.")]
public AnimationCurve response = AnimationCurve.Linear(0f, 0f, 1f, 1f);
/// <summary>Raised with the curved output whenever the handle moves.</summary>
public event Action<float> ValueChanged;
private Slider _slider;
private Slider Slider => _slider ? _slider : (_slider = GetComponent<Slider>());
/// <summary>The curved output value for the current handle position.</summary>
public float Value => Slider ? Curve(Slider.normalizedValue) : 0f;
private void OnEnable()
{
if (Slider) Slider.onValueChanged.AddListener(OnSliderChanged);
}
private void OnDisable()
{
if (_slider) _slider.onValueChanged.RemoveListener(OnSliderChanged);
}
private void OnSliderChanged(float _) => ValueChanged?.Invoke(Value);
/// <summary>Positions the handle so <see cref="Value"/> reads <paramref name="output"/>, raising the
/// slider's <c>onValueChanged</c> (and thus <see cref="ValueChanged"/>).</summary>
public void SetOutput(float output)
{
if (Slider) Slider.normalizedValue = Position(output); // normalizedValue setter notifies
}
/// <summary>As <see cref="SetOutput"/> but without raising any change event (for silent seeding).</summary>
public void SetOutputWithoutNotify(float output)
{
if (Slider) Slider.SetValueWithoutNotify(Mathf.Lerp(Slider.minValue, Slider.maxValue, Position(output)));
}
// handle position (0..1) -> curved output value in [minValue, maxValue]
private float Curve(float t)
{
float y = Mathf.Clamp01(response.Evaluate(Mathf.Clamp01(t)));
float v = Mathf.Lerp(Slider.minValue, Slider.maxValue, y);
return Slider && Slider.wholeNumbers ? Mathf.Round(v) : v;
}
// curved output value -> handle position (0..1), inverting the (monotonic) curve via bisection
private float Position(float output)
{
var s = Slider;
if (!s) return 0f;
float target = Mathf.Clamp01(Mathf.InverseLerp(s.minValue, s.maxValue, output)); // desired y
float lo = 0f, hi = 1f;
for (int i = 0; i < 24; i++)
{
float mid = 0.5f * (lo + hi);
if (Mathf.Clamp01(response.Evaluate(mid)) < target) lo = mid; else hi = mid;
}
return 0.5f * (lo + hi);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2e66618c2739718459e4ecd2253c4ba7
@@ -0,0 +1,39 @@
using System;
using UnityEngine;
using UnityEngine.EventSystems;
/// <summary>
/// Turns a pointer press/drag on a UI graphic into a normalized (x, y) position in
/// [0,1] within its RectTransform (origin bottom-left). Added in code to the colour
/// picker's saturation/value field and hue bar so they react to both clicking and
/// dragging. The graphic on the same object must be a raycast target.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class UiPointerSurface : MonoBehaviour, IPointerDownHandler, IDragHandler
{
/// <summary>Fired on press and while dragging with the normalized (x, y) in [0,1].</summary>
public event Action<Vector2> Picked;
private RectTransform _rect;
private void Awake() => _rect = (RectTransform)transform;
public void OnPointerDown(PointerEventData eventData) => Report(eventData);
public void OnDrag(PointerEventData eventData) => Report(eventData);
private void Report(PointerEventData eventData)
{
// pressEventCamera is null for a Screen Space - Overlay canvas, which is the
// correct camera argument there.
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
_rect, eventData.position, eventData.pressEventCamera, out Vector2 local))
return;
Rect r = _rect.rect;
if (r.width <= 0f || r.height <= 0f) return;
float x = Mathf.Clamp01((local.x - r.xMin) / r.width);
float y = Mathf.Clamp01((local.y - r.yMin) / r.height);
Picked?.Invoke(new Vector2(x, y));
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3aefb85fd241856408808aa5401c9d64