using System; using System.Collections.Generic; using UnityEngine; #if ENABLE_INPUT_SYSTEM using UnityEngine.InputSystem; #endif using Random = UnityEngine.Random; // System (for Action) also defines Random -> keep the UnityEngine one /// /// Live Play-mode mesh painting of albedo, an HDRP-style mask map (metallic/occlusion/smoothness) /// and emission, with live-switchable GPU/CPU backends () that paint identically /// and seamlessly across UV seams: the masked square brush is evaluated in 3D local space at the hit. /// [DisallowMultipleComponent] public partial class MeshPainter : MonoBehaviour { /// How the square stamp is oriented around the surface normal. public enum BrushAlignment { /// Stamp "up"/"right" follow the camera (projected onto the surface) - natural decal feel. Camera, /// Stamp "up" = world up (projected onto the surface) - stable, does not rotate with the camera. World } /// How the brush preview at the hover point is rendered. public enum BrushPreviewMode { /// No preview. Off, /// A flat quad in the tangent plane (cheap, does not follow curvature). FlatDecal, /// Real on-surface overlay: wraps the mesh exactly like the paint would land. OnSurface } /// When false, mouse painting is ignored. Driven by the GameModeManager (true only in /// PaintMode on the local owner). Defaults OFF so a painter that is not (yet) managed by a mode - /// the pre-spawn window, a remote replica, or any binding gap - never paints from local input. public bool PaintingEnabled { get; set; } = false; /// True once Start has built the material instance and mesh caches (the camo knobs below /// become readable from the material). The HUD binds in the avatar's OnNetworkSpawn, which runs /// BEFORE this Start, so it must wait for this before reading the camo knobs into its sliders. public bool IsReady => _setupValid; /// /// Live camouflage knobs on the player's Custom/PlayerCamoLit material instance, surfaced for /// the paint HUD. The material is the source of truth (getter reads, setter writes); both are a /// no-op when the property is absent or before Start builds the instance, so the authored value is /// never clobbered. Persisted by PaintHud.Persistence, replicated by . /// public float CamoHalfLambert { get => GetMatFloat(HalfLambertId, 0f); set => SetMatFloat(HalfLambertId, value); } /// Companion to : additive brightness shift (-1..1) of the /// flattened diffuse term, so the body tone can be tuned to match the scene. public float CamoHalfLambertBrightness { get => GetMatFloat(HalfLambertBrightnessId, 0f); set => SetMatFloat(HalfLambertBrightnessId, value); } /// Companion to : scales down the direct specular highlight. public float CamoSpecularDampen { get => GetMatFloat(SpecularDampenId, 0.1f); set => SetMatFloat(SpecularDampenId, value); } /// General colour saturation of the shaded body (0 = neutral, -1 = greyscale, >0 = punchier). /// A camo body-tone knob alongside ; drives the material's /// _Saturation (offset: shader lerps to grey by 1 + value). Persisted + replicated with the /// other camo knobs (see ). public float CamoSaturation { get => GetMatFloat(SaturationId, 0f); set => SetMatFloat(SaturationId, value); } // Painted-height look knobs on the same material instance, surfaced/guarded/persisted/replicated // like the camo knobs above. Defaults match the shader so the unpainted look is unchanged. /// Painted-height AO darkening depth (shift, 0..1): AO fades from 1 down to (1 - this) at /// the deepest painted hole. 0 = no darkening (no black spots), 1 = full black at occ=0. public float OcclusionThreshold { get => GetMatFloat(OcclusionThresholdId, 0.2f); set => SetMatFloat(OcclusionThresholdId, value); } /// Multiplier on the painted-height relief NORMAL strength (1 = base, 0 = flat). public float HeightNormalIntensity { get => GetMatFloat(HeightNormalIntensityId, 1.4f); set => SetMatFloat(HeightNormalIntensityId, value); } /// Multiplier on the painted-height UV PARALLAX amplitude (1 = base , /// 0 = no displacement). Backed by the material's _Parallax, so it round-trips like the others. public float ParallaxIntensity { get => GetMatFloat(ParallaxId, ParallaxBase) / ParallaxBase; set => SetMatFloat(ParallaxId, Mathf.Max(0f, value) * ParallaxBase); } /// The four camo knobs as one vector (x = half-Lambert, y = brightness, z = specular dampen, /// w = saturation) - one payload for persistence + network replication. public Vector4 CamoKnobs { get => new(CamoHalfLambert, CamoHalfLambertBrightness, CamoSpecularDampen, CamoSaturation); set { CamoHalfLambert = value.x; CamoHalfLambertBrightness = value.y; CamoSpecularDampen = value.z; CamoSaturation = value.w; } } /// The three painted-height knobs as one vector (x = occlusion threshold, y = relief normal intensity, z = parallax intensity). public Vector3 HeightKnobs { get => new(OcclusionThreshold, HeightNormalIntensity, ParallaxIntensity); set { OcclusionThreshold = value.x; HeightNormalIntensity = value.y; ParallaxIntensity = value.z; } } /// Drives the see-through-walls X-ray overlay: sets the TARGET for the alpha of the authored /// _XRayColor (the rgb glow colour is preserved); Update eases the material alpha toward it /// over seconds. A per-avatar runtime reveal, NOT a painted channel /// (neither persists nor round-trips the HUD). raises it during the /// resolution phase and for a hider buried too deep in geometry. public bool XRayRevealed { get => _xrayTarget > 0f; set => SetXRayReveal(value, false); } /// Drives the X-ray reveal with an optional hunter-hit mode: the flash /// multiplies the authored glow by and disables the breathing pulse, so a hit /// reads as a strong, solid pop (the subtle breathing plateau otherwise leaves a short hit window looking /// dim). Leaving flash mode restores the authored colour + pulse. Only writes the material on a change. public void SetXRayReveal(bool revealed, bool flash) { _xrayTarget = revealed ? 1f : 0f; if (flash != _xrayFlash) { _xrayFlash = flash; ApplyXRayFlashState(); } } /// The live see-through X-ray glow currently on the body material (rgb = glow colour incl. any /// hit-flash boost, a = eased fade master), for a sibling /// to mirror onto the fur so the strands glow WITH the reveal instead of hiding it. Fully off /// (alpha 0) until a reveal fades in, or before the material instance is set up. public Color CurrentXRayColor => _materialInstance && _materialInstance.HasProperty(XRayColorId) ? _materialInstance.GetColor(XRayColorId) : new Color(0f, 0f, 0f, 0f); /// Authored X-ray breathing pulse speed (0 = steady), mirrored onto the fur so its glow breathes /// in phase with the body's forward/overlay halves. public float XRayPulseSpeed => _materialInstance && _materialInstance.HasProperty(XRayPulseSpeedId) ? _materialInstance.GetFloat(XRayPulseSpeedId) : 0f; /// Live X-ray pulse strength (the hit flash drops this to 0 for a steady pop), mirrored onto the fur. public float XRayPulseStrength => _materialInstance && _materialInstance.HasProperty(XRayPulseStrengthId) ? _materialInstance.GetFloat(XRayPulseStrengthId) : 0f; /// Authored X-ray pulse shape, mirrored onto the fur so its breathing matches the body. public float XRayPulseShape => _materialInstance && _materialInstance.HasProperty(XRayPulseShapeId) ? _materialInstance.GetFloat(XRayPulseShapeId) : 1f; [Tooltip("Seconds for the see-through X-ray reveal glow to fade fully in or out when toggled (0 = instant snap).")] [Min(0f)] [SerializeField] private float xrayFadeDuration = 0.3f; [Tooltip("Hunter-hit flash strength: multiplies the authored _XRayColor and turns off its breathing pulse for the flash window, so a hit is a strong solid pop instead of the subtle breathing reveal used elsewhere.")] [Min(1f)] [SerializeField] private float xrayFlashBoost = 3f; private float _xrayTarget; // desired _XRayColor alpha (0/1); the material eases toward it each frame private bool _xrayFlash; // hit-flash mode: boosted rgb + pulse off, applied on toggle private Color _xrayBaseColor = Color.white; // authored _XRayColor rgb, captured at setup (alpha ignored) private float _xrayBasePulseStrength; // authored _XRayPulseStrength, restored when the flash ends [Header("Backend")] [Tooltip("ON = GPU painting (fast), OFF = CPU painting. Can be toggled live in Play mode.")] public bool useGpu = true; [Header("Brush - channels")] [Tooltip("Paint the albedo (base color) map.")] public bool paintAlbedo = true; [Tooltip("Paint metallic + smoothness into the mask map (R = metallic, A = smoothness). " + "Shares one texture with occlusion but is toggled independently.")] public bool paintMetallicSmoothness = false; [Tooltip("Paint ambient occlusion into the mask map (G channel, also bound to _OcclusionMap). " + "Shares one texture with metallic/smoothness but is toggled independently.")] public bool paintOcclusion = false; [Tooltip("Paint the emission map (_EmissionMap).")] public bool paintEmission = false; [Header("Brush - albedo")] [Tooltip("Color used for painting. The alpha channel is the global opacity multiplier on " + "top of the mask (alpha 1 = mask opacity as-is, lower = fainter).")] public Color brushColor = Color.red; [Header("Brush - metallic / smoothness / occlusion")] [Tooltip("Metallic value painted into the mask map (0 = dielectric, 1 = metal).")] [Range(0f, 1f)] public float metallic = 0f; [Tooltip("Smoothness value painted into the mask map (0 = rough, 1 = glossy).")] [Range(0f, 1f)] public float smoothness = 0.5f; [Tooltip("Paint opacity for metallic + smoothness on top of the mask (1 = mask opacity " + "as-is, lower = blends the metallic/smoothness target in more faintly, 0 = no-op). " + "The albedo equivalent of brushColor.a, but for the metallic/smoothness channels.")] [Range(0f, 1f)] public float metallicSmoothnessOpacity = 1f; [Tooltip("Occlusion + HEIGHT painted into the mask map (one value, 0..1, 0.5 = neutral/no-op). " + "Drives parallax + a relief normal: 0.5 = flat, >0.5 = raised hill, <0.5 = carved hole. " + "Also drives ambient occlusion, but only the LOWER half darkens: 0.5..1 = fully lit, " + "0.5..0 = AO fades 1..0 (so a hole is also darker). See Custom/PlayerCamoLit.")] [Range(0f, 1f)] public float occlusion = 0.5f; [Tooltip("Paint opacity for occlusion on top of the mask (1 = mask opacity as-is, lower = " + "blends the occlusion target in more faintly, 0 = no-op). The albedo equivalent of " + "brushColor.a, but for the occlusion channel.")] [Range(0f, 1f)] public float occlusionOpacity = 1f; [Header("Brush - emission")] [Tooltip("Emission color painted into the map. Alpha = global opacity multiplier on top of the mask.")] public Color emissionColor = Color.black; [Tooltip("Global emission intensity multiplier on the painted emission (drives the material's _EmissionColor). " + "Above 1 makes the emission HDR/bloom-bright.")] public float emissionIntensity = 1f; [Header("Brush - fur")] [Tooltip("Enables fur painting. When on, each stroke eases the fur map's length (channel R) toward " + "furLength using the brush's alpha as the deposit weight; when off the fur map is left untouched. " + "The strand COLOUR comes from the albedo map, so the fur wears the painted camo. The pipette turns " + "this on when it samples fur length.")] public bool fur = false; [Tooltip("Target fur length the stroke eases toward (0 = bald .. 1 = full length). Painting over-writes the " + "map's length toward THIS value (raise it to grow fur, drop to 0 to shave it), weighted by the " + "brush's alpha. Drives a sibling FurShellRenderer via FurMapTexture; the base PlayerCamoLit " + "material is untouched.")] [Range(0f, 1f)] [UnityEngine.Serialization.FormerlySerializedAs("furOpacity")] public float furLength = 0.5f; [Header("Brush - shape")] [Tooltip("Brush half-extent in texels (= half the square side). Converted into a 3D size per hit.")] [Range(1, 256)] public int brushSize = 16; [Tooltip("Fraction of the current brush size added/removed per mouse-wheel notch while Shift is held. " + "The step scales with the size, so small brushes resize slowly (fine control) and large ones " + "resize faster. Always moves at least one texel per notch.")] [Range(0.01f, 1f)] public float brushSizeScrollFactor = 0.15f; [Tooltip("Max angle (deg) between a texel's surface normal and the clicked surface for the brush to " + "still paint it. Prevents a large brush from bleeding onto the opposite side of thin geometry " + "(that side faces ~180deg away). Lower = stricter separation; 180 = disabled (paint everything " + "inside the sphere, old behavior). ~120 keeps corners/rounded edges while cutting the back face.")] [Range(90f, 180f)] public float brushMaxSurfaceAngle = 120f; /// /// cos(brushMaxSurfaceAngle): a texel is painted only if dot(texelNormal, brushNormal) >= this. /// At 180deg the cutoff is -1 (always true) -> the normal test is effectively disabled. Returns -1 /// when the mesh has no normals (test not possible) so the brush degrades to the plain sphere. /// private float BrushNormalCutoff => _normals != null ? Mathf.Cos(brushMaxSurfaceAngle * Mathf.Deg2Rad) : -1f; /// /// Raised after Shift+wheel changes so the in-game HUD can re-sync its /// brush-size slider (the slider then re-clamps the value to its own configured range). /// public event Action BrushSizeChanged; [Tooltip("Brush masks: the mask value (luminance x alpha) is the per-texel opacity " + "(gray = 50%). Empty = built-in circle (= classic round brush). Import grayscale " + "masks as Non-Color so midtones map 1:1; alpha-based masks (e.g. soft particles) work too.")] public List brushMasks = new List(); [Tooltip("Index of the active mask in the list above. Out of range = built-in circle.")] public int activeBrushIndex = 0; [Tooltip("How the square stamp is oriented around the surface normal (only matters for non-symmetric masks).")] public BrushAlignment brushAlignment = BrushAlignment.Camera; [Tooltip("Extra rotation of the stamp around the surface normal, in degrees.")] [Range(-180f, 180f)] public float brushRotation = 0f; [Tooltip("ON = paint once on mouse-down, then only while the mouse moves (a held but " + "stationary button no longer keeps re-stamping the same spot). OFF = continuous painting while held.")] public bool paintOnMoveOnly = false; [Tooltip("Seconds between paint stamps while the button is held. 0 = paint every frame as " + "an interpolated, continuous stroke (as before). Above 0 = deposit one stamp at the " + "cursor every this many seconds (timed dabs, no movement interpolation - pairs well " + "with the spray can).")] [Min(0f)] public float paintInterval = 0f; [Header("Preview")] [Tooltip("Brush preview at the hover point (shows size + mask without painting). " + "Flat Decal = a flat quad in the tangent plane (cheap). On Surface = a real " + "overlay that wraps the mesh exactly like the paint would land. Off = no preview.")] public BrushPreviewMode previewMode = BrushPreviewMode.OnSurface; [Tooltip("Opacity of the preview overlay (the mask still modulates it per texel). " + "Can exceed 1 to make the preview show up more strongly.")] [Range(0f, 4f)] public float previewOpacity = 0.5f; [Header("Paint particles")] [Tooltip("Optional particle effect played at the brush while a stroke deposits paint. Instantiated " + "once on first use. Author the system for brush world-radius 1 with scaling mode Hierarchy: " + "the instance is scaled to the brush's world radius and tinted with the brush albedo color " + "(Start Color is overwritten). +Z of the instance points along the surface normal.")] public ParticleSystem paintParticlesPrefab; private ParticleSystem _paintParticles; private bool _stampLandedThisFrame; // any dab actually hit the mesh this frame (gates the particles) [Header("Canvas")] [Tooltip("Resolution of the paint textures (square).")] public int textureSize = 1024; [Tooltip("Base color of the albedo texture at startup (if none exists).")] public Color baseColor = Color.white; [Tooltip("Edge padding (texels) bled beyond UV island edges so bilinear sampling does not pull in the base color. 0 = off.")] [Range(0, 16)] public int paddingTexels = 4; [Header("Setup")] [Tooltip("Camera for the mouse ray. Empty = Camera.main.")] public Camera paintCamera; [Tooltip("Only for animated meshes: re-bake the collider every frame (expensive!).")] public bool updateColliderEveryFrame = false; // --- paint maps --- private const int MAP_ALBEDO = 0; // Mask map: R = metallic, G = occlusion, A = smoothness. One texture bound to BOTH // _MetallicGlossMap (reads R/A) and _OcclusionMap (reads G). private const int MAP_MSO = 1; private const int MAP_EM = 2; // emission (RGB) // Fur: R = fur length (0 = bald, 1 = full); G/B/A unused. A DATA map (linear, like MSO): seeded to 0, // never coverage-stamped, and skips edge-padding dilation. Length is painted whenever the fur toggle is // on (own gate); the strand colour comes from the albedo map. Not bound to the base material; a sibling // FurShellRenderer reads the live RTs via FurMapTexture (length) + AlbedoMapTexture (colour). private const int MAP_FUR = 3; private const int MAP_COUNT = 4; /// CPU backend per map: a Texture2D mirror + rasterization/dilation state. private class CpuMap { public Texture2D tex; public Color32[] pixels; public bool[] painted; // true = painted by the brush (dilation source) public int[] fillStamp; // per-call marker for padding texels public int dilateStamp; // bumped per call so old padding is not a source public int dx0, dy0, dx1, dy1; // dirty rectangle (CPU upload) } // --- shared references --- private Renderer _renderer; private SkinnedMeshRenderer _skinned; private MeshCollider _collider; private Mesh _bakedMesh; // When present, this always-on baker owns the collider bake (also outside paint mode); we // stop baking and only borrow its mesh. Null = no baker -> we keep our own legacy bake. private SkinnedColliderBaker _baker; private Material _materialInstance; // Mesh caches (in local/collider space). private Vector3[] _verts; private Vector3[] _normals; // per-vertex normals (brush back-face rejection); null if unavailable private int[] _tris; private Vector2[] _uvs; private Vector3[] _triCenter; // centroid per triangle (CPU culling) private float[] _triRadius; // circumscribed radius per triangle (CPU culling) private float _avgUvToLocal = 1f; // average UV unit -> local unit private bool[] _islandMask; // texel lies inside a UV island: dilation source / overwrite guard (built once) private bool _hasLastScreen; private Vector2 _lastScreen; private float _intervalTimer; // accumulates while held; gates timed-dab stamping (paintInterval > 0) private bool _gpuActive; private bool _setupValid; // Networked remote replicas set this (before Start) to SKIP the base seed during the initial backend // build: the RTs/materials are still created, but the grey/white base pixels are not uploaded. The body // is reveal-gated hidden until the painting snapshot is applied (which re-seeds the backend), so seeding // the base at Start is wasted work that stalls every observer's spawn frame (SetPixels/Apply/coverage- // raster per map). One-shot: consumed at the end of Start, so any later (re)build seeds normally. The // owner never defers (it shows immediately). public bool DeferInitialSeed { get; set; } // True once the active backend holds valid content. False only between a DEFERRED setup and the first // re-seed/stamp: during that window the reveal gate hides the body and TryCaptureSnapshot serves the // material seed instead of the still-uninitialized RTs (see EnsureBackendSeeded). private bool _backendSeeded; // Seed colors per map in canonical (material) layout, built once at Start: // albedo -> RGB color // MSO -> R = metallic, G = occlusion/height (0.5 = neutral: no relief, AO = 1), A = smoothness // emission -> RGB emission // fur -> R = length (0 = bald; painted in, never coverage-stamped), G/B/A unused private Color32[][] _seedCanonical; // --- backends --- private CpuMap[] _cpu; private readonly List _dilateScratch = new List(512); private readonly RaycastHit[] _rayHits = new RaycastHit[64]; private bool _rayHitsSaturated; // log-once: a saturated buffer can silently drop our own collider // --- preview overlay --- /// True while the brush preview decal is actually drawn this frame (hover hits the paintable surface). public bool BrushPreviewVisible { get; private set; } /// True while the paint button is held on the paint path (stroke running). The cursor hides entirely. public bool StrokeInProgress { get; private set; } public Shader previewShader; // optional; else Shader.Find("Hidden/MeshPaintPreview") private Material _previewMat; private Mesh _previewQuad; // unit quad in the tangent plane (flat decal mode) // --- brush mask (shared) --- private Texture2D _proceduralMask; // built-in circle, used when the list is empty/invalid private float[] _proceduralOpacity; // cached opacity (luma*alpha) of the procedural mask (CPU) private int _procW, _procH; private Texture2D _resolvedMask; // currently active mask (user or procedural) private float[] _maskOpacity; // opacity (luma*alpha) of the resolved mask (CPU sampling) private int _maskW, _maskH; private const float ScreenStepPixels = 5f; // interpolation step along mouse movement private const int MaxStepsPerFrame = 256; // raycast safeguard for fast movement private const float MoveOnlyMinPixels = 0.75f; // move-only mode: min mouse travel before re-stamping private static readonly int BaseMapId = Shader.PropertyToID("_BaseMap"); private static readonly int MainTexId = Shader.PropertyToID("_MainTex"); private static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"); private static readonly int ColorId = Shader.PropertyToID("_Color"); private static readonly int MetallicGlossMapId = Shader.PropertyToID("_MetallicGlossMap"); private static readonly int MetallicId = Shader.PropertyToID("_Metallic"); private static readonly int SmoothnessId = Shader.PropertyToID("_Smoothness"); private static readonly int EmissionMapId = Shader.PropertyToID("_EmissionMap"); private static readonly int EmissionColorId = Shader.PropertyToID("_EmissionColor"); private static readonly int OcclusionMapId = Shader.PropertyToID("_OcclusionMap"); private static readonly int OcclusionStrengthId = Shader.PropertyToID("_OcclusionStrength"); private static readonly int ParallaxMapId = Shader.PropertyToID("_ParallaxMap"); private static readonly int ParallaxId = Shader.PropertyToID("_Parallax"); private static readonly int HeightNormalIntensityId = Shader.PropertyToID("_HeightNormalIntensity"); private static readonly int OcclusionThresholdId = Shader.PropertyToID("_OcclusionThreshold"); // _Parallax amplitude that ParallaxIntensity = 1 maps to (the floor ConfigureMaterialPbr lifts to). private const float ParallaxBase = 0.02f; private static readonly int HalfLambertId = Shader.PropertyToID("_HalfLambert"); private static readonly int HalfLambertBrightnessId = Shader.PropertyToID("_HalfLambertBrightness"); private static readonly int SaturationId = Shader.PropertyToID("_Saturation"); private static readonly int SpecularDampenId = Shader.PropertyToID("_SpecularDampen"); private static readonly int XRayColorId = Shader.PropertyToID("_XRayColor"); private static readonly int XRayPulseSpeedId = Shader.PropertyToID("_XRayPulseSpeed"); private static readonly int XRayPulseStrengthId = Shader.PropertyToID("_XRayPulseStrength"); private static readonly int XRayPulseShapeId = Shader.PropertyToID("_XRayPulseShape"); private static readonly int BrushColorId = Shader.PropertyToID("_BrushColor"); private static readonly int ColorWriteMaskId = Shader.PropertyToID("_ColorWriteMask"); private static readonly int BrushCenterId = Shader.PropertyToID("_BrushCenter"); private static readonly int BrushRadiusId = Shader.PropertyToID("_BrushRadius"); private static readonly int BrushUId = Shader.PropertyToID("_BrushU"); private static readonly int BrushVId = Shader.PropertyToID("_BrushV"); private static readonly int BrushNormalId = Shader.PropertyToID("_BrushNormal"); private static readonly int BrushNormalCutoffId = Shader.PropertyToID("_BrushNormalCutoff"); private static readonly int MaskTexId = Shader.PropertyToID("_MaskTex"); private static readonly int PadId = Shader.PropertyToID("_Pad"); private static readonly int PreviewOpacityId = Shader.PropertyToID("_PreviewOpacity"); private static readonly int PreviewModeId = Shader.PropertyToID("_PreviewMode"); private void Start() { if (!paintCamera) paintCamera = Camera.main; _renderer = GetComponent(); _skinned = GetComponent(); if (!_renderer) { Debug.LogError("[MeshPainter] No Renderer found on this GameObject.", this); enabled = false; return; } textureSize = Mathf.Clamp(textureSize, 32, 4096); SetupCollider(); BuildMeshCaches(); if (_uvs == null || _uvs.Length == 0) { Debug.LogError("[MeshPainter] Mesh has no UVs - painting not possible.", this); enabled = false; return; } // Own material instance so the shared asset is not modified; tint base color to // white so the painted color appears unaltered. _materialInstance = _renderer.material; if (_materialInstance.HasProperty(BaseColorId)) _materialInstance.SetColor(BaseColorId, Color.white); else if (_materialInstance.HasProperty(ColorId)) _materialInstance.SetColor(ColorId, Color.white); // Seed the X-ray reveal target from the authored alpha so the fade baseline matches the material // (authored 0 = off). NetworkPlayerSetup drives this target at runtime; Update eases the alpha to it. // Also cache the authored glow rgb + pulse strength so the hit flash can boost/restore them. if (_materialInstance.HasProperty(XRayColorId)) { Color xc = _materialInstance.GetColor(XRayColorId); _xrayBaseColor = new Color(xc.r, xc.g, xc.b, 1f); _xrayTarget = xc.a; } if (_materialInstance.HasProperty(XRayPulseStrengthId)) _xrayBasePulseStrength = _materialInstance.GetFloat(XRayPulseStrengthId); BuildSeedColors(); // Always start fresh from the material seed. Saved paintings are loaded explicitly from the // HUD library (MeshPainter.Persistence), never auto-loaded on start. SetupBackend(useGpu, _seedCanonical); _backendSeeded = !DeferInitialSeed; DeferInitialSeed = false; // one-shot: only the initial build defers; later (re)builds seed normally ConfigureMaterialPbr(); InitUndoHistory(); // baseline = material seed, captured before any stroke EnsureProceduralMask(); ResolveActiveMask(); SetupPreview(); _setupValid = true; Debug.Log($"[MeshPainter] Ready ({(_gpuActive ? "GPU" : "CPU")} painting, seamless). " + "Hold the left mouse button and drag over the mesh.", this); } // --- Brush mask --- /// Builds the built-in circle mask (white inscribed circle on black), used when no mask is assigned. private void EnsureProceduralMask() { if (_proceduralMask) return; const int s = 256; _proceduralMask = NewMaskTexture(s, "MeshPainter_CircleMask"); var px = new Color32[s * s]; _proceduralOpacity = new float[s * s]; float half = (s - 1) * 0.5f; for (int y = 0; y < s; y++) { for (int x = 0; x < s; x++) { float nx = (x - half) / half; float ny = (y - half) / half; bool inside = nx * nx + ny * ny <= 1f; byte v = inside ? (byte)255 : (byte)0; int idx = y * s + x; px[idx] = new Color32(v, v, v, 255); _proceduralOpacity[idx] = inside ? 1f : 0f; } } _proceduralMask.SetPixels32(px); _proceduralMask.Apply(false); _procW = s; _procH = s; } /// Creates a square RGBA32 brush-mask texture (linear: raw mask values, not colors). private static Texture2D NewMaskTexture(int size, string texName) { return new Texture2D(size, size, TextureFormat.RGBA32, false, true) { name = texName, wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear, hideFlags = HideFlags.HideAndDontSave }; } /// Picks the active mask (list entry or procedural circle) and refreshes the CPU opacity cache on change. private void ResolveActiveMask() { Texture2D desired = null; if (brushMasks != null && activeBrushIndex >= 0 && activeBrushIndex < brushMasks.Count) desired = brushMasks[activeBrushIndex]; if (!desired) desired = _proceduralMask; if (desired == _resolvedMask) return; _resolvedMask = desired; if (_resolvedMask == _proceduralMask) { _maskOpacity = _proceduralOpacity; _maskW = _procW; _maskH = _procH; return; } // User mask: cache opacity (luma*alpha) for the CPU backend so it matches the GPU. Non-readable // textures are read back via the GPU, so even built-ins like Default-Particle work on the CPU path. var px = ReadMaskPixels(_resolvedMask, out int w, out int h); _maskW = w; _maskH = h; _maskOpacity = new float[px.Length]; for (int i = 0; i < px.Length; i++) { Color32 p = px[i]; float luma = (0.299f * p.r + 0.587f * p.g + 0.114f * p.b) / 255f; _maskOpacity[i] = luma * (p.a / 255f); } } /// /// Reads a mask texture's pixels. Readable textures are read directly (correct /// orientation, matches GPU tex2D); non-readable ones are blitted to a temp RT and /// read back, so any texture (incl. built-ins without Read/Write) works on the CPU. /// private static Color32[] ReadMaskPixels(Texture2D tex, out int w, out int h) { w = tex.width; h = tex.height; if (tex.isReadable) return tex.GetPixels32(); var tmp = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear); var prev = RenderTexture.active; Graphics.Blit(tex, tmp); RenderTexture.active = tmp; var readTex = new Texture2D(w, h, TextureFormat.RGBA32, false, true); readTex.ReadPixels(new Rect(0, 0, w, h), 0, 0, false); readTex.Apply(false); RenderTexture.active = prev; RenderTexture.ReleaseTemporary(tmp); var px = readTex.GetPixels32(); Destroy(readTex); return px; } /// Bilinear sample of the active stamp mask opacity at uv in [0,1]. Returns 1 (full) if no mask. private float SampleMaskOpacity(float u, float v) { if (_stampOpacity == null || _stampW <= 0 || _stampH <= 0) return 1f; float fx = Mathf.Clamp01(u) * (_stampW - 1); float fy = Mathf.Clamp01(v) * (_stampH - 1); int x0 = Mathf.FloorToInt(fx); int y0 = Mathf.FloorToInt(fy); int x1 = Mathf.Min(x0 + 1, _stampW - 1); int y1 = Mathf.Min(y0 + 1, _stampH - 1); float tx = fx - x0; float ty = fy - y0; float l00 = _stampOpacity[y0 * _stampW + x0]; float l10 = _stampOpacity[y0 * _stampW + x1]; float l01 = _stampOpacity[y1 * _stampW + x0]; float l11 = _stampOpacity[y1 * _stampW + x1]; float a = Mathf.Lerp(l00, l10, tx); float b = Mathf.Lerp(l01, l11, tx); return Mathf.Lerp(a, b, ty); } /// /// Builds an orthonormal tangent frame (u = right, v = up) for the stamp at a hit /// with the given local-space surface normal, honoring the alignment mode and the /// manual rotation. u/v span the tangent plane; the mask is sampled along them. /// private void ComputeBrushFrame(Vector3 nLocal, out Vector3 u, out Vector3 v) { nLocal = nLocal.normalized; if (brushAlignment == BrushAlignment.Camera && paintCamera) { // Stamp horizontal follows the camera: u = camera right projected onto the surface. Vector3 camRight = transform.InverseTransformDirection(paintCamera.transform.right); u = ProjectOntoPlane(camRight, nLocal); if (u.sqrMagnitude < 1e-8f) { Vector3 camUp = transform.InverseTransformDirection(paintCamera.transform.up); u = ProjectOntoPlane(camUp, nLocal); if (u.sqrMagnitude < 1e-8f) u = AnyTangent(nLocal); } u.Normalize(); // up = right x n, so the frame reads correctly when viewed from the front // (normal toward the camera): u x v = -n. Using n x u would flip v -> upside down. v = Vector3.Cross(u, nLocal).normalized; } else { // Stamp vertical = world up projected onto the surface. Vector3 worldUp = transform.InverseTransformDirection(Vector3.up); v = ProjectOntoPlane(worldUp, nLocal); if (v.sqrMagnitude < 1e-8f) v = AnyTangent(nLocal); v.Normalize(); // right = n x up (front-readable: u x v = -n). Using up x n would mirror u. u = Vector3.Cross(nLocal, v).normalized; } if (Mathf.Abs(brushRotation) > 1e-4f) { Quaternion q = Quaternion.AngleAxis(brushRotation, nLocal); u = q * u; v = q * v; } } private static Vector3 ProjectOntoPlane(Vector3 dir, Vector3 normal) => dir - normal * Vector3.Dot(dir, normal); private static Vector3 AnyTangent(Vector3 n) { Vector3 t = Vector3.Cross(n, Vector3.right); if (t.sqrMagnitude < 1e-6f) t = Vector3.Cross(n, Vector3.up); return t.normalized; } // --- Brush preview --- private void SetupPreview() { if (!previewShader) previewShader = Shader.Find("Hidden/MeshPaintPreview"); if (!previewShader) { Debug.LogWarning("[MeshPainter] Preview shader (Hidden/MeshPaintPreview) not found - brush preview disabled.", this); return; } _previewMat = new Material(previewShader) { hideFlags = HideFlags.HideAndDontSave }; _previewQuad = BuildPreviewQuad(); } /// Unit quad in its XY plane ([-1,1], normal +Z, uv 0..1) - oriented per hit in the flat-decal mode. private static Mesh BuildPreviewQuad() { var q = new Mesh { name = "MeshPainter_PreviewQuad", hideFlags = HideFlags.HideAndDontSave }; q.vertices = new[] { new Vector3(-1f, -1f, 0f), new Vector3(1f, -1f, 0f), new Vector3(1f, 1f, 0f), new Vector3(-1f, 1f, 0f) }; q.uv = new[] { new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(1f, 1f), new Vector2(0f, 1f) }; q.triangles = new[] { 0, 1, 2, 0, 2, 3 }; q.RecalculateBounds(); return q; } /// /// Draws the brush preview at the hover point for this frame (no painting). On-surface /// mode re-draws the baked mesh and clips it to the brush box in the shader; flat-decal /// mode draws the oriented quad. Both sample the active mask (size + shape preview). /// private void UpdatePreview(Vector2 screenPos) { if (!TryComputeBrushPlacement(screenPos, out Vector3 pLocal, out float radiusLocal, out Vector3 u, out Vector3 v, out Vector3 n, out int _)) return; _previewMat.SetColor(BrushColorId, brushColor); // Spray paints scattered dots, but the preview shows the round footprint (the area // the dots land in), so the hover ring stays a stable, readable size indicator. _previewMat.SetTexture(MaskTexId, spray ? _proceduralMask : _resolvedMask); // Fully opaque regardless of the brush's albedo alpha; only Preview Opacity scales it // (a size/shape indicator, not a paint-strength preview). _previewMat.SetFloat(PreviewOpacityId, Mathf.Max(0f, previewOpacity)); int layer = gameObject.layer; if (previewMode == BrushPreviewMode.OnSurface) { _previewMat.SetFloat(PreviewModeId, 0f); _previewMat.SetVector(BrushCenterId, pLocal); _previewMat.SetFloat(BrushRadiusId, radiusLocal); _previewMat.SetVector(BrushUId, u); _previewMat.SetVector(BrushVId, v); _previewMat.SetVector(BrushNormalId, n); _previewMat.SetFloat(BrushNormalCutoffId, BrushNormalCutoff); // Re-draw the collider mesh; the shader clips it to the brush box. The collider mesh is in // the same local space as the raycast hit and valid for skinned (baked) and static meshes. Mesh previewMesh = _collider ? _collider.sharedMesh : null; if (!previewMesh) return; BrushPreviewVisible = true; Matrix4x4 m = transform.localToWorldMatrix; int sub = previewMesh.subMeshCount; for (int i = 0; i < sub; i++) Graphics.DrawMesh(previewMesh, m, _previewMat, layer, paintCamera, i); } else // FlatDecal { _previewMat.SetFloat(PreviewModeId, 1f); // Quad frame in local space: x -> u*r, y -> v*r, lifted slightly along n so // it floats on the surface; uv 0..1 matches the on-surface mask mapping. Vector3 pos = pLocal + n * (radiusLocal * 0.02f); var frame = new Matrix4x4(); frame.SetColumn(0, new Vector4(u.x * radiusLocal, u.y * radiusLocal, u.z * radiusLocal, 0f)); frame.SetColumn(1, new Vector4(v.x * radiusLocal, v.y * radiusLocal, v.z * radiusLocal, 0f)); frame.SetColumn(2, new Vector4(n.x, n.y, n.z, 0f)); frame.SetColumn(3, new Vector4(pos.x, pos.y, pos.z, 1f)); Graphics.DrawMesh(_previewQuad, transform.localToWorldMatrix * frame, _previewMat, layer, paintCamera, 0); BrushPreviewVisible = true; } } // --- Update / input --- private void Update() { BrushPreviewVisible = false; // set again below only when the decal really draws this frame if (!_setupValid) return; if (useGpu != _gpuActive) SwitchBackend(useGpu); RefreshEmissionColor(); // live-tunable emission intensity TickXRayFade(); // ease the see-through X-ray glow toward its on/off target (runs on every avatar) if (!PaintingEnabled) { EndStrokeIfActive(); // finalize an in-progress stroke if the mode was left mid-paint StopPaintParticles(); _hasLastScreen = false; return; } // Undo / redo (Ctrl+Z / Ctrl+Y): only here, since PaintingEnabled is true only on the local // owner in paint mode. Commit any in-progress stroke first so it becomes its own history step. GetUndoRedoInput(out bool wantUndo, out bool wantRedo); if (wantUndo || wantRedo) { EndStrokeIfActive(); if (wantUndo) Undo(); else Redo(); return; } // Modifier keys: Shift or Ctrl = pipette, Shift/Ctrl + wheel resizes the brush. Runs before the // pipette/paint branches below so this frame's pipette state is current. HandleBrushModifiers(); if (updateColliderEveryFrame && _skinned) { BakeColliderMesh(); BuildMeshCaches(); } ResolveActiveMask(); if (!TryGetMouse(out bool pressed, out Vector2 screenPos)) { StrokeInProgress = false; StopPaintParticles(); return; } // Don't paint through the in-game UI (a HUD click must not also stamp the mesh behind it). if (pressed && InputUtil.PointerOverUi()) pressed = false; // While the button is held on the paint path the hardware cursor is hidden entirely // (CursorController); the preview decal itself stays visible during the stroke. StrokeInProgress = pressed && !pipette; if (!StrokeInProgress) StopPaintParticles(); // Brush preview at the hover point. if (previewMode != BrushPreviewMode.Off && _previewMat && paintCamera) UpdatePreview(screenPos); // Pipette: a fresh click samples the channel values at the hit instead of painting. It takes // over the click entirely; the brush preview above stays visible. if (pipette) { EndStrokeIfActive(); // switching to the eyedropper ends any active stroke if (pressed) { if (!_pickHeld) PickAtScreen(screenPos); _pickHeld = true; } else _pickHeld = false; _hasLastScreen = false; // keep the paint stroke state clean for when pipette turns off return; } _pickHeld = false; if (!pressed) { EndStrokeIfActive(); // mouse released -> commit the stroke as one undo step _hasLastScreen = false; return; } if (!paintCamera) return; bool anyChannel = AnyChannelActive(); // includes occlusion (matches IsChannelActive / PaintStamp3D) bool firstPress = !_hasLastScreen; float moveDist = firstPress ? 0f : Vector2.Distance(_lastScreen, screenPos); bool stamp; if (paintInterval > 0f) { // Timed-dab mode: one stamp on press, then one at the cursor every paintInterval // seconds while held (discrete dabs, no movement interpolation). if (firstPress) { _intervalTimer = 0f; stamp = anyChannel; } else { _intervalTimer += Time.deltaTime; stamp = anyChannel && _intervalTimer >= paintInterval; if (stamp) _intervalTimer = 0f; } } else { // Continuous mode: stamp every frame as an interpolated stroke. Move-only optionally // gates it - a held but stationary button no longer re-stamps the same spot. stamp = anyChannel && (firstPress || !paintOnMoveOnly || moveDist > MoveOnlyMinPixels); } if (stamp) { _stampLandedThisFrame = false; BeginBackendStroke(); if (!firstPress && paintInterval <= 0f) { // Interpolate along the mouse movement and re-raycast at each step so the // stroke follows the mesh surface. int steps = Mathf.Clamp(Mathf.CeilToInt(moveDist / ScreenStepPixels), 1, MaxStepsPerFrame); for (int s = 1; s <= steps; s++) PaintAtScreen(Vector2.Lerp(_lastScreen, screenPos, s / (float)steps)); } else { // First stamp of a stroke, or a timed dab: a single discrete stamp at the cursor. PaintAtScreen(screenPos); } EndBackendStroke(); // Held button but nothing landed (cursor off the mesh / corner guard): kill the splatter // immediately instead of letting it play at the last hit until release. if (!_stampLandedThisFrame) StopPaintParticles(); } _lastScreen = screenPos; _hasLastScreen = true; } private void PaintAtScreen(Vector2 screenPos) { if (!TryComputeBrushPlacement(screenPos, out Vector3 pLocal, out float radiusLocal, out Vector3 u, out Vector3 v, out Vector3 nLocal, out int _)) return; // Pick what this stamp paints with: a fresh random spray pattern, or the resolved // standard mask. Both backends read it through the _stamp* fields. When this stamp is // being replicated (networked owner), the spray is seeded so remotes scatter identically. if (spray) { if (WantsStampCapture) { _lastSpraySeed = Random.Range(1, int.MaxValue); BuildSprayStampMaskSeeded(_lastSpraySeed); } else BuildSprayStampMask(); } else UseResolvedStampMask(); DrawBackendStamp(pLocal, radiusLocal, u, v, nLocal); _stampLandedThisFrame = true; UpdatePaintParticles(pLocal, radiusLocal, nLocal); EmitStamp(pLocal, radiusLocal, u, v, nLocal); MarkStrokePainted(); // a stamp landed -> this stroke is undoable } /// Moves/scales/tints the stroke particle effect at a landed stamp and keeps it playing. /// Scale = brush world radius (author the prefab for radius 1, scaling mode Hierarchy), tint = /// brush albedo color with a small per-particle random offset; particle alpha = brush albedo /// alpha (faint paint = faint splatter). private void UpdatePaintParticles(Vector3 pLocal, float radiusLocal, Vector3 nLocal) { if (!paintParticlesPrefab) return; if (!_paintParticles) _paintParticles = Instantiate(paintParticlesPrefab); Transform t = _paintParticles.transform; t.SetPositionAndRotation(transform.TransformPoint(pLocal), Quaternion.LookRotation(transform.TransformDirection(nLocal))); t.localScale = Vector3.one * transform.TransformVector(nLocal * radiusLocal).magnitude; var main = _paintParticles.main; Color c = brushColor; // Per-particle random tint offset around the albedo (darker <-> lighter); // alpha follows the brush albedo alpha (paint opacity) so faint paint splatters faintly; // +0.4 bias keeps the splatter readable even at low paint opacity. Color dark = Color.Lerp(c, Color.black, 0.13f); Color light = Color.Lerp(c, Color.white, 0.09f); dark.a = light.a = Mathf.Clamp01(c.a + 0.4f); main.startColor = new ParticleSystem.MinMaxGradient(dark, light); if (!_paintParticles.isPlaying) _paintParticles.Play(); } /// Stops emitting stroke particles (already-spawned ones live out their lifetime). private void StopPaintParticles() { if (_paintParticles && _paintParticles.isPlaying) _paintParticles.Stop(); } /// /// Raycasts at a screen position and returns the nearest hit that belongs to THIS paint /// mesh. A plain raycast would return only the closest collider, so anything in front of /// the mesh (the player capsule, props, the camera-collision proxy) could block it; we /// scan all hits and keep the nearest on our own collider. Shared by painting, preview /// and the pipette. /// private bool TryRaycastPaintMesh(Vector2 screenPos, out RaycastHit hit) { hit = default; if (!paintCamera) return false; Ray ray = paintCamera.ScreenPointToRay(screenPos); int hitCount = Physics.RaycastNonAlloc(ray, _rayHits, 1000f, ~0, QueryTriggerInteraction.Ignore); if (hitCount == _rayHits.Length && !_rayHitsSaturated) { _rayHitsSaturated = true; Debug.LogWarning($"MeshPainter: raycast buffer saturated ({hitCount}); the paint mesh may be missed.", this); } float nearest = float.MaxValue; bool found = false; for (int i = 0; i < hitCount; i++) { if (_rayHits[i].collider != _collider) continue; if (_rayHits[i].distance < nearest) { nearest = _rayHits[i].distance; hit = _rayHits[i]; found = true; } } return found; } /// /// Raycasts at a screen position and returns the nearest hit on ANY collider (not just the /// paint mesh). Used by the pipette, which can sample foreign objects' materials too. /// private bool TryRaycastAny(Vector2 screenPos, out RaycastHit hit) { hit = default; if (!paintCamera) return false; Ray ray = paintCamera.ScreenPointToRay(screenPos); return Physics.Raycast(ray, out hit, 1000f, ~0, QueryTriggerInteraction.Ignore); } /// /// Raycasts at a screen position against the paint mesh and computes the brush /// placement: hit point (local space), local 3D radius (constant physical size from the /// mesh-wide average UV density) and the orthonormal tangent frame (u/v/n). Shared by /// painting and preview. /// private bool TryComputeBrushPlacement(Vector2 screenPos, out Vector3 pLocal, out float radiusLocal, out Vector3 u, out Vector3 v, out Vector3 nLocal, out int hitTri) { pLocal = default; radiusLocal = 0f; u = default; v = default; nLocal = default; hitTri = -1; if (!TryRaycastPaintMesh(screenPos, out RaycastHit hit)) return false; if (_verts == null || hit.triangleIndex < 0) return false; pLocal = transform.InverseTransformPoint(hit.point); hitTri = hit.triangleIndex; // Brush radius (texels) -> constant local 3D radius via the MESH-WIDE average UV density - // deliberately not the hit triangle's own (that varied up to ~7.6x at seams/stretched islands, // painting visibly different sizes). The stamp is a 3D sphere, so seams are bridged (both sides // of a shared edge) without bleeding past r onto adjacent surfaces. radiusLocal = (brushSize / (float)textureSize) * _avgUvToLocal; if (radiusLocal <= 0f) return false; // Tangent-plane frame for the square stamp / mask projection. nLocal = transform.InverseTransformDirection(hit.normal); ComputeBrushFrame(nLocal, out u, out v); nLocal = nLocal.normalized; return true; } // --- Channels (shared by both backends) --- private bool AnyChannelActive() { for (int mi = 0; mi < MAP_COUNT; mi++) if (IsChannelActive(mi)) return true; return false; } private bool IsChannelActive(int mi) { switch (mi) { case MAP_ALBEDO: return paintAlbedo; case MAP_MSO: return paintMetallicSmoothness || paintOcclusion; case MAP_EM: return paintEmission; case MAP_FUR: return fur; // fur has its own toggle gate (strand colour still comes from the albedo map) default: return false; } } // --- Helpers --- private static float Cross2D(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x; // Guarded material float access: absent instance/property -> the caller's fallback, no write. private float GetMatFloat(int id, float fallback) => _materialInstance && _materialInstance.HasProperty(id) ? _materialInstance.GetFloat(id) : fallback; private void SetMatFloat(int id, float value) { if (_materialInstance && _materialInstance.HasProperty(id)) _materialInstance.SetFloat(id, value); } private bool TryGetMouse(out bool pressed, out Vector2 screenPos) { pressed = false; screenPos = Vector2.zero; #if ENABLE_INPUT_SYSTEM var mouse = Mouse.current; if (mouse != null) { pressed = mouse.leftButton.isPressed; screenPos = mouse.position.ReadValue(); return true; } #endif #if ENABLE_LEGACY_INPUT_MANAGER pressed = Input.GetMouseButton(0); screenPos = Input.mousePosition; return true; #else return false; #endif } // --- Shift/Ctrl modifiers (paint mode) --- private bool _pipetteModHeld; // edge tracking for the momentary pipette (Shift or Ctrl) private bool _pipetteBeforeMod; // pipette toggle state to restore when the modifier is released /// /// While Shift or Ctrl is held the brush enters the pipette (eyedropper), restoring the toggle's /// prior state on release. Shift or Ctrl + the mouse wheel resizes the brush (the orbit camera /// ignores the wheel while either is held, so it does not also zoom). Called every paint-mode frame. /// private void HandleBrushModifiers() { // Momentary pipette: arm on the Shift/Ctrl press (remembering the toggle's state), force it on // while held (re-arms after a one-shot pick), and restore the prior state on release. bool shift = InputUtil.ShiftHeld(); bool ctrl = InputUtil.CtrlHeld(); bool mod = shift || ctrl; if (mod && !_pipetteModHeld) _pipetteBeforeMod = pipette; if (mod) pipette = true; else if (_pipetteModHeld) pipette = _pipetteBeforeMod; _pipetteModHeld = mod; if (!mod) return; float scroll = InputUtil.ReadScroll(); if (Mathf.Approximately(scroll, 0f)) return; // Geometric resize: the step scales with the current size, so small brushes change a texel // at a time (fine control) while large brushes jump in bigger increments. Min one texel. int step = Mathf.Max(1, Mathf.RoundToInt(brushSize * brushSizeScrollFactor)); int next = Mathf.Clamp(brushSize + (int)Mathf.Sign(scroll) * step, 1, 256); if (next == brushSize) return; brushSize = next; BrushSizeChanged?.Invoke(); } private void OnDestroy() { if (_bakedMesh && !_baker) Destroy(_bakedMesh); // _baker owns its mesh; only free our own if (_materialInstance) Destroy(_materialInstance); if (_proceduralMask) Destroy(_proceduralMask); if (_previewMat) Destroy(_previewMat); if (_previewQuad) Destroy(_previewQuad); if (_paintParticles) Destroy(_paintParticles.gameObject); TeardownSpray(); TeardownCpu(); TeardownGpu(); } }