- MeshPainter: Paint Particles Prefab, lazy Instanz, Play/Stop am Stroke, MinMaxGradient (Albedo +-25%/15% dunkel/hell) - PaintSplatter.prefab + Paint Particle Material/Textur, Wiring am Player.prefab - FurShellRenderer: Kill-Switch-Destroy nur im Play-Mode (Edit-Mode-Error beim Prefab-Load) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1290 lines
60 KiB
C#
1290 lines
60 KiB
C#
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
|
|
|
|
/// <summary>
|
|
/// Live Play-mode mesh painting of albedo, an HDRP-style mask map (metallic/occlusion/smoothness)
|
|
/// and emission, with live-switchable GPU/CPU backends (<see cref="useGpu"/>) that paint identically
|
|
/// and seamlessly across UV seams: the masked square brush is evaluated in 3D local space at the hit.
|
|
/// </summary>
|
|
[DisallowMultipleComponent]
|
|
public partial class MeshPainter : MonoBehaviour
|
|
{
|
|
/// <summary>How the square stamp is oriented around the surface normal.</summary>
|
|
public enum BrushAlignment
|
|
{
|
|
/// <summary>Stamp "up"/"right" follow the camera (projected onto the surface) - natural decal feel.</summary>
|
|
Camera,
|
|
/// <summary>Stamp "up" = world up (projected onto the surface) - stable, does not rotate with the camera.</summary>
|
|
World
|
|
}
|
|
|
|
/// <summary>How the brush preview at the hover point is rendered.</summary>
|
|
public enum BrushPreviewMode
|
|
{
|
|
/// <summary>No preview.</summary>
|
|
Off,
|
|
/// <summary>A flat quad in the tangent plane (cheap, does not follow curvature).</summary>
|
|
FlatDecal,
|
|
/// <summary>Real on-surface overlay: wraps the mesh exactly like the paint would land.</summary>
|
|
OnSurface
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
public bool PaintingEnabled { get; set; } = false;
|
|
|
|
/// <summary>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.</summary>
|
|
public bool IsReady => _setupValid;
|
|
|
|
/// <summary>
|
|
/// Live camouflage knobs on the player's <c>Custom/PlayerCamoLit</c> 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 <see cref="PlayerPaintSync"/>.
|
|
/// </summary>
|
|
public float CamoHalfLambert
|
|
{
|
|
get => GetMatFloat(HalfLambertId, 0f);
|
|
set => SetMatFloat(HalfLambertId, value);
|
|
}
|
|
|
|
/// <summary>Companion to <see cref="CamoHalfLambert"/>: additive brightness shift (-1..1) of the
|
|
/// flattened diffuse term, so the body tone can be tuned to match the scene.</summary>
|
|
public float CamoHalfLambertBrightness
|
|
{
|
|
get => GetMatFloat(HalfLambertBrightnessId, 0f);
|
|
set => SetMatFloat(HalfLambertBrightnessId, value);
|
|
}
|
|
|
|
/// <summary>Companion to <see cref="CamoHalfLambert"/>: scales down the direct specular highlight.</summary>
|
|
public float CamoSpecularDampen
|
|
{
|
|
get => GetMatFloat(SpecularDampenId, 0.1f);
|
|
set => SetMatFloat(SpecularDampenId, value);
|
|
}
|
|
|
|
/// <summary>General colour saturation of the shaded body (0 = neutral, -1 = greyscale, >0 = punchier).
|
|
/// A camo body-tone knob alongside <see cref="CamoHalfLambertBrightness"/>; drives the material's
|
|
/// <c>_Saturation</c> (offset: shader lerps to grey by 1 + value). Persisted + replicated with the
|
|
/// other camo knobs (see <see cref="CamoKnobs"/>).</summary>
|
|
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.
|
|
|
|
/// <summary>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.</summary>
|
|
public float OcclusionThreshold
|
|
{
|
|
get => GetMatFloat(OcclusionThresholdId, 0.2f);
|
|
set => SetMatFloat(OcclusionThresholdId, value);
|
|
}
|
|
|
|
/// <summary>Multiplier on the painted-height relief NORMAL strength (1 = base, 0 = flat).</summary>
|
|
public float HeightNormalIntensity
|
|
{
|
|
get => GetMatFloat(HeightNormalIntensityId, 1.4f);
|
|
set => SetMatFloat(HeightNormalIntensityId, value);
|
|
}
|
|
|
|
/// <summary>Multiplier on the painted-height UV PARALLAX amplitude (1 = base <see cref="ParallaxBase"/>,
|
|
/// 0 = no displacement). Backed by the material's _Parallax, so it round-trips like the others.</summary>
|
|
public float ParallaxIntensity
|
|
{
|
|
get => GetMatFloat(ParallaxId, ParallaxBase) / ParallaxBase;
|
|
set => SetMatFloat(ParallaxId, Mathf.Max(0f, value) * ParallaxBase);
|
|
}
|
|
|
|
/// <summary>The four camo knobs as one vector (x = half-Lambert, y = brightness, z = specular dampen,
|
|
/// w = saturation) - one payload for persistence + network replication.</summary>
|
|
public Vector4 CamoKnobs
|
|
{
|
|
get => new(CamoHalfLambert, CamoHalfLambertBrightness, CamoSpecularDampen, CamoSaturation);
|
|
set { CamoHalfLambert = value.x; CamoHalfLambertBrightness = value.y; CamoSpecularDampen = value.z; CamoSaturation = value.w; }
|
|
}
|
|
|
|
/// <summary>The three painted-height knobs as one vector (x = occlusion threshold, y = relief normal intensity, z = parallax intensity).</summary>
|
|
public Vector3 HeightKnobs
|
|
{
|
|
get => new(OcclusionThreshold, HeightNormalIntensity, ParallaxIntensity);
|
|
set { OcclusionThreshold = value.x; HeightNormalIntensity = value.y; ParallaxIntensity = value.z; }
|
|
}
|
|
|
|
/// <summary>Drives the see-through-walls X-ray overlay: sets the TARGET for the alpha of the authored
|
|
/// <c>_XRayColor</c> (the rgb glow colour is preserved); Update eases the material alpha toward it
|
|
/// over <see cref="xrayFadeDuration"/> seconds. A per-avatar runtime reveal, NOT a painted channel
|
|
/// (neither persists nor round-trips the HUD). <see cref="NetworkPlayerSetup"/> raises it during the
|
|
/// resolution phase and for a hider buried too deep in geometry.</summary>
|
|
public bool XRayRevealed
|
|
{
|
|
get => _xrayTarget > 0f;
|
|
set => SetXRayReveal(value, false);
|
|
}
|
|
|
|
/// <summary>Drives the X-ray reveal with an optional hunter-hit <paramref name="flash"/> mode: the flash
|
|
/// multiplies the authored glow by <see cref="xrayFlashBoost"/> 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.</summary>
|
|
public void SetXRayReveal(bool revealed, bool flash)
|
|
{
|
|
_xrayTarget = revealed ? 1f : 0f;
|
|
if (flash != _xrayFlash) { _xrayFlash = flash; ApplyXRayFlashState(); }
|
|
}
|
|
|
|
/// <summary>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 <see cref="Chameleon.Components.FurShellRenderer"/>
|
|
/// 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.</summary>
|
|
public Color CurrentXRayColor =>
|
|
_materialInstance && _materialInstance.HasProperty(XRayColorId)
|
|
? _materialInstance.GetColor(XRayColorId)
|
|
: new Color(0f, 0f, 0f, 0f);
|
|
|
|
/// <summary>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.</summary>
|
|
public float XRayPulseSpeed =>
|
|
_materialInstance && _materialInstance.HasProperty(XRayPulseSpeedId)
|
|
? _materialInstance.GetFloat(XRayPulseSpeedId) : 0f;
|
|
|
|
/// <summary>Live X-ray pulse strength (the hit flash drops this to 0 for a steady pop), mirrored onto the fur.</summary>
|
|
public float XRayPulseStrength =>
|
|
_materialInstance && _materialInstance.HasProperty(XRayPulseStrengthId)
|
|
? _materialInstance.GetFloat(XRayPulseStrengthId) : 0f;
|
|
|
|
/// <summary>Authored X-ray pulse shape, mirrored onto the fur so its breathing matches the body.</summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Raised after Shift+wheel changes <see cref="brushSize"/> so the in-game HUD can re-sync its
|
|
/// brush-size slider (the slider then re-clamps the value to its own configured range).
|
|
/// </summary>
|
|
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<Texture2D> brushMasks = new List<Texture2D>();
|
|
|
|
[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("Brush - corner guard")]
|
|
[Tooltip("Suppresses a whole brush dab when too much of its footprint would wrap around a " +
|
|
"corner onto an adjacent, steeply angled surface (the classic 'painted around the " +
|
|
"corner' bleed, worst with large brushes). The dab is blocked entirely, not clipped: " +
|
|
"you simply cannot paint at a spot that would smear around the corner - aim cleaner or " +
|
|
"shrink the brush. OFF = paint anywhere (old behavior).")]
|
|
public bool cornerGuard = true;
|
|
|
|
[Tooltip("Surface under the brush whose normal deviates from the hit face by more than this " +
|
|
"angle counts as 'around the corner'. Lower = stricter (gentle bends already block); " +
|
|
"higher = only sharp corners count. 180 = effectively off.")]
|
|
[Range(0f, 180f)]
|
|
public float cornerGuardAngle = 50f;
|
|
|
|
[Tooltip("Block the dab once this share of the brush footprint is around the corner. " +
|
|
"0 = block at the slightest wrap, 1 = effectively off (only a fully wrapped dab blocks).")]
|
|
[Range(0f, 1f)]
|
|
public float cornerGuardMaxFraction = 0.25f;
|
|
|
|
[Header("Preview")]
|
|
[Tooltip("Brush preview at the hover point (shows size + mask without painting). " +
|
|
"Flat Decal = a flat quad in the tangent plane (cheap). On Surface = a real " +
|
|
"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;
|
|
|
|
[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;
|
|
|
|
/// <summary>CPU backend per map: a Texture2D mirror + rasterization/dilation state.</summary>
|
|
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 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<int> _dilateScratch = new List<int>(512);
|
|
private readonly RaycastHit[] _rayHits = new RaycastHit[64];
|
|
private bool _rayHitsSaturated; // log-once: a saturated buffer can silently drop our own collider
|
|
|
|
// --- preview overlay ---
|
|
/// <summary>True while the brush preview decal is actually drawn this frame (hover hits the paintable surface).</summary>
|
|
public bool BrushPreviewVisible { get; private set; }
|
|
/// <summary>True while the paint button is held on the paint path (stroke running). The cursor hides entirely.</summary>
|
|
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 const float SquareReach = 1.7321f; // sqrt(3): max 3D distance from center to a painted box texel (CPU culling)
|
|
|
|
private static readonly int BaseMapId = Shader.PropertyToID("_BaseMap");
|
|
private static readonly int MainTexId = Shader.PropertyToID("_MainTex");
|
|
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 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<Renderer>();
|
|
_skinned = GetComponent<SkinnedMeshRenderer>();
|
|
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 ---
|
|
|
|
/// <summary>Builds the built-in circle mask (white inscribed circle on black), used when no mask is assigned.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Creates a square RGBA32 brush-mask texture (linear: raw mask values, not colors).</summary>
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>Picks the active mask (list entry or procedural circle) and refreshes the CPU opacity cache on change.</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Bilinear sample of the active stamp mask opacity at uv in [0,1]. Returns 1 (full) if no mask.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>Unit quad in its XY plane ([-1,1], normal +Z, uv 0..1) - oriented per hit in the flat-decal mode.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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);
|
|
|
|
// 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)
|
|
{
|
|
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();
|
|
}
|
|
|
|
_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 hitTri))
|
|
return;
|
|
|
|
// Corner guard: if too much of the footprint would wrap around a corner, suppress the
|
|
// whole dab here - before drawing, emitting or marking the stroke painted, so nothing is
|
|
// clipped and remotes never replay a dab the owner refused to paint.
|
|
if (cornerGuard && DabWrapsAroundCorner(pLocal, radiusLocal, u, v, nLocal, hitTri))
|
|
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);
|
|
UpdatePaintParticles(pLocal, radiusLocal, nLocal);
|
|
|
|
EmitStamp(pLocal, radiusLocal, u, v, nLocal);
|
|
MarkStrokePainted(); // a stamp landed -> this stroke is undoable
|
|
}
|
|
|
|
/// <summary>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 (alpha forced opaque - the brush
|
|
/// alpha is paint opacity, not particle fade).</summary>
|
|
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;
|
|
c.a = 1f;
|
|
// Per-particle random tint offset around the albedo (darker <-> lighter).
|
|
Color dark = Color.Lerp(c, Color.black, 0.13f);
|
|
Color light = Color.Lerp(c, Color.white, 0.09f);
|
|
dark.a = light.a = 1f;
|
|
main.startColor = new ParticleSystem.MinMaxGradient(dark, light);
|
|
|
|
if (!_paintParticles.isPlaying) _paintParticles.Play();
|
|
}
|
|
|
|
/// <summary>Stops emitting stroke particles (already-spawned ones live out their lifetime).</summary>
|
|
private void StopPaintParticles()
|
|
{
|
|
if (_paintParticles && _paintParticles.isPlaying)
|
|
_paintParticles.Stop();
|
|
}
|
|
|
|
/// <summary>
|
|
/// True if a brush dab at this placement would wrap excessively around a corner: more than
|
|
/// <see cref="cornerGuardMaxFraction"/> of its footprint sits on surface whose face normal
|
|
/// deviates from the hit face by more than <see cref="cornerGuardAngle"/>. Used to suppress
|
|
/// the whole dab (not clip it). Geometry-only, so it behaves identically on the GPU and CPU
|
|
/// backends and needs no texture readback.
|
|
/// </summary>
|
|
private bool DabWrapsAroundCorner(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n, int hitTri)
|
|
{
|
|
if (_verts == null || _tris == null || _triCenter == null || _triRadius == null) return false;
|
|
if (hitTri < 0 || hitTri * 3 + 2 >= _tris.Length) return false;
|
|
|
|
// Reference normal = the hit triangle's geometric face normal, built with the SAME
|
|
// cross-product convention as every candidate below, so mesh winding cancels in the
|
|
// dot comparison (we only judge relative deviation from the surface we actually hit).
|
|
int h0 = _tris[hitTri * 3], h1 = _tris[hitTri * 3 + 1], h2 = _tris[hitTri * 3 + 2];
|
|
Vector3 fnHit = Vector3.Cross(_verts[h1] - _verts[h0], _verts[h2] - _verts[h0]);
|
|
if (fnHit.sqrMagnitude < 1e-12f) return false; // degenerate hit triangle -> allow
|
|
fnHit.Normalize();
|
|
|
|
float cosThresh = Mathf.Cos(Mathf.Clamp(cornerGuardAngle, 0f, 180f) * Mathf.Deg2Rad);
|
|
float frac = CornerWrapFraction(_verts, _tris, _triCenter, _triRadius,
|
|
pLocal, radiusLocal, u, v, n, fnHit, cosThresh);
|
|
return frac > cornerGuardMaxFraction;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Estimates the share (0..1) of the brush footprint sitting on surface whose geometric face
|
|
/// normal deviates from <paramref name="fnHit"/> by more than the threshold (given as its
|
|
/// cosine). Per in-range triangle (same circumscribed-circle cull as the painter), the area is
|
|
/// weighted by how many of {v0,v1,v2,centroid} fall inside the brush box - a cheap, backend-
|
|
/// agnostic footprint estimate (no GPU readback). Winding cancels because both normals use the
|
|
/// same cross convention. Returns 0 when nothing is in range (caller then allows the dab).
|
|
/// Static + array-parameterized so the geometry math is unit-testable without a live component.
|
|
/// </summary>
|
|
private static float CornerWrapFraction(
|
|
Vector3[] verts, int[] tris, Vector3[] triCenter, float[] triRadius,
|
|
Vector3 center, float r, Vector3 u, Vector3 v, Vector3 n, Vector3 fnHit, float cosThresh)
|
|
{
|
|
double totalW = 0.0, cornerW = 0.0;
|
|
int triCount = tris.Length / 3;
|
|
for (int t = 0; t < triCount; t++)
|
|
{
|
|
float reach = r * SquareReach + triRadius[t];
|
|
if ((triCenter[t] - center).sqrMagnitude > reach * reach) continue;
|
|
|
|
int i0 = tris[t * 3], i1 = tris[t * 3 + 1], i2 = tris[t * 3 + 2];
|
|
Vector3 a = verts[i0], b = verts[i1], c = verts[i2];
|
|
Vector3 ctr = (a + b + c) * (1f / 3f);
|
|
|
|
int inside = (InBrushBox(a, center, u, v, n, r) ? 1 : 0)
|
|
+ (InBrushBox(b, center, u, v, n, r) ? 1 : 0)
|
|
+ (InBrushBox(c, center, u, v, n, r) ? 1 : 0)
|
|
+ (InBrushBox(ctr, center, u, v, n, r) ? 1 : 0);
|
|
if (inside == 0) continue;
|
|
|
|
Vector3 cross = Vector3.Cross(b - a, c - a);
|
|
float area2 = cross.magnitude; // 2 x triangle area (relative weight is enough)
|
|
if (area2 < 1e-12f) continue;
|
|
|
|
double w = inside * 0.25 * area2;
|
|
totalW += w;
|
|
|
|
float aligned = Vector3.Dot(cross / area2, fnHit); // -1..1, winding-cancelled
|
|
if (aligned < cosThresh) cornerW += w; // deviates past the threshold angle
|
|
}
|
|
|
|
return totalW > 1e-9 ? (float)(cornerW / totalW) : 0f;
|
|
}
|
|
|
|
/// <summary>True if a local-space point lies inside the brush box (same |du|,|dv|,|dn| <= r bound the stamp paints).</summary>
|
|
private static bool InBrushBox(Vector3 p, Vector3 center, Vector3 u, Vector3 v, Vector3 n, float r)
|
|
{
|
|
Vector3 d = p - center;
|
|
return Mathf.Abs(Vector3.Dot(d, u)) <= r
|
|
&& Mathf.Abs(Vector3.Dot(d, v)) <= r
|
|
&& Mathf.Abs(Vector3.Dot(d, n)) <= r;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raycasts at a screen position and returns the nearest hit that belongs to THIS paint
|
|
/// mesh. A plain raycast would return only the closest collider, so anything in front of
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 stays a 3D box, so seams are bridged as before.
|
|
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
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|