Pipette samples all painted channel values (albedo, metallic, smoothness, occlusion, emission) at the clicked texel and adopts them into the brush with every opacity forced to 1, instead of painting. The screen raycast is extracted into a shared TryRaycastPaintMesh helper used by painting, preview and the pipette. A pipette toggle is added to the HUD panel prefab and wired in PaintHud. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
415 lines
17 KiB
C#
415 lines
17 KiB
C#
using System;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
using UnityEngine.EventSystems;
|
|
#if ENABLE_INPUT_SYSTEM
|
|
using UnityEngine.InputSystem.UI;
|
|
#endif
|
|
|
|
/// <summary>
|
|
/// In-game paint UI. Shows its panels only while the active <see cref="GameMode"/> is
|
|
/// <see cref="PaintMode"/> (it follows the manager's ModeChanged event) and binds its
|
|
/// widgets directly to the live <see cref="MeshPainter"/> fields, so edits apply immediately.
|
|
///
|
|
/// The layout is a transparent root holding one independently positioned sub-panel per channel
|
|
/// (Albedo, Metallic+Smoothness, Occlusion, Emission) plus a global Brush card, each with a title
|
|
/// bar and draggable from any of its non-widget surface. The layout comes from one of two sources:
|
|
/// - an authored prefab assigned to <see cref="layoutPrefab"/> (instantiated, then bound), or
|
|
/// - generated in code (the fallback when no prefab is set).
|
|
/// Either way the *bindings* (listeners, picker gradient sprites, painter values) live in code
|
|
/// and are (re)applied at runtime by <see cref="WirePanel"/>. A prefab stores only the visual
|
|
/// layout, never the behaviour, so both paths run the exact same wiring against the same stable
|
|
/// widget names - there is no separate "dead" prefab path.
|
|
///
|
|
/// Widgets: channel toggles, an HSV colour picker for albedo, metallic / smoothness / occlusion
|
|
/// sliders, an HSV picker + intensity for emission, and brush size. Attach to an empty GameObject.
|
|
/// </summary>
|
|
[DisallowMultipleComponent]
|
|
public class PaintHud : MonoBehaviour
|
|
{
|
|
[Tooltip("Mode manager driving visibility. Empty = found automatically.")]
|
|
public GameModeManager modeManager;
|
|
|
|
[Tooltip("Painter the HUD edits. Empty = the manager's painter, else found automatically.")]
|
|
public MeshPainter painter;
|
|
|
|
[Tooltip("Authored panel layout. If set it is instantiated and bound; empty = build the panel in code.")]
|
|
public GameObject layoutPrefab;
|
|
|
|
// Stable widget names shared by the code-gen builder, the baked prefab and the binder.
|
|
private const string PanelSpray = "Panel_Spray";
|
|
private const string PanelAlbedo = "Panel_Albedo";
|
|
private const string PanelMetallicSmoothness = "Panel_MetallicSmoothness";
|
|
private const string PanelOcclusion = "Panel_Occlusion";
|
|
private const string PanelEmission = "Panel_Emission";
|
|
private const string PanelBrushSize = "Panel_BrushSize";
|
|
private const string ToggleAlbedo = "Toggle_Albedo";
|
|
private const string ToggleMetallicSmoothness = "Toggle_MetallicSmoothness";
|
|
private const string ToggleOcclusion = "Toggle_Occlusion";
|
|
private const string ToggleEmission = "Toggle_Emission";
|
|
private const string ToggleSpray = "Toggle_Spray";
|
|
private const string TogglePipette = "Toggle_Pipette";
|
|
private const string PickerAlbedo = "Picker_Albedo";
|
|
private const string PickerEmission = "Picker_Emission";
|
|
private const string SliderMetallic = "Slider_Metallic";
|
|
private const string SliderSmoothness = "Slider_Smoothness";
|
|
private const string SliderMetallicSmoothnessAlpha = "Slider_MetallicSmoothnessAlpha";
|
|
private const string SliderOcclusion = "Slider_Occlusion";
|
|
private const string SliderOcclusionAlpha = "Slider_OcclusionAlpha";
|
|
private const string SliderEmissionIntensity = "Slider_EmissionIntensity";
|
|
private const string SliderBrushSize = "Slider_BrushSize";
|
|
private const string SliderSprayDots = "Slider_SprayDots";
|
|
private const string SliderSprayDotSizeMin = "Slider_SprayDotSizeMin";
|
|
private const string SliderSprayDotSizeMax = "Slider_SprayDotSizeMax";
|
|
private const string SliderSprayDotAlphaMin = "Slider_SprayDotAlphaMin";
|
|
private const string SliderSprayDotAlphaMax = "Slider_SprayDotAlphaMax";
|
|
|
|
// Panels made draggable at runtime - grabbable from any non-widget surface (both prefab and code-gen paths).
|
|
private static readonly string[] DraggablePanels =
|
|
{
|
|
PanelAlbedo,
|
|
PanelMetallicSmoothness,
|
|
PanelOcclusion,
|
|
PanelEmission,
|
|
PanelBrushSize,
|
|
PanelSpray
|
|
};
|
|
|
|
private GameObject _panel;
|
|
private Font _font;
|
|
|
|
private void Awake()
|
|
{
|
|
if (!modeManager) modeManager = FindFirstObjectByType<GameModeManager>();
|
|
}
|
|
|
|
private void OnEnable()
|
|
{
|
|
if (modeManager) modeManager.ModeChanged += OnModeChanged;
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
if (modeManager) modeManager.ModeChanged -= OnModeChanged;
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
if (!painter) painter = modeManager ? modeManager.Painter : null;
|
|
if (!painter) painter = FindFirstObjectByType<MeshPainter>();
|
|
|
|
_font = LoadFont();
|
|
|
|
EnsureEventSystem();
|
|
EnsureCanvas();
|
|
|
|
if (layoutPrefab)
|
|
{
|
|
var go = Instantiate(layoutPrefab, transform);
|
|
go.name = layoutPrefab.name; // strip the "(Clone)" suffix
|
|
_panel = go;
|
|
}
|
|
|
|
WirePanel(_panel.transform);
|
|
|
|
// Set initial visibility (the manager's first SwitchTo may have fired before this Start).
|
|
OnModeChanged(modeManager ? modeManager.ActiveMode : null);
|
|
}
|
|
|
|
private void OnModeChanged(GameMode mode)
|
|
{
|
|
if (_panel) _panel.SetActive(mode is PaintMode);
|
|
}
|
|
|
|
private static Font LoadFont()
|
|
{
|
|
var f = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
|
|
if (!f) f = Resources.GetBuiltinResource<Font>("Arial.ttf");
|
|
return f;
|
|
}
|
|
|
|
// --- Canvas ---
|
|
|
|
private void EnsureCanvas()
|
|
{
|
|
var canvas = GetComponent<Canvas>();
|
|
if (!canvas) canvas = gameObject.AddComponent<Canvas>();
|
|
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
|
canvas.sortingOrder = 100;
|
|
|
|
var scaler = GetComponent<CanvasScaler>();
|
|
if (!scaler) scaler = gameObject.AddComponent<CanvasScaler>();
|
|
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
|
scaler.referenceResolution = new Vector2(1920f, 1080f);
|
|
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand;
|
|
|
|
if (!GetComponent<GraphicRaycaster>()) gameObject.AddComponent<GraphicRaycaster>();
|
|
}
|
|
|
|
private void EnsureEventSystem()
|
|
{
|
|
if (FindFirstObjectByType<EventSystem>()) return;
|
|
|
|
var es = new GameObject("EventSystem", typeof(EventSystem));
|
|
#if ENABLE_INPUT_SYSTEM
|
|
var module = es.AddComponent<InputSystemUIInputModule>();
|
|
if (!module.actionsAsset) module.AssignDefaultActions();
|
|
#else
|
|
es.AddComponent<StandaloneInputModule>();
|
|
#endif
|
|
}
|
|
|
|
// --- Wiring (behaviour; shared by the code-gen and prefab paths) ---
|
|
|
|
private void WirePanel(Transform panel)
|
|
{
|
|
EnsureFonts(panel); // builtin font references may not survive prefab serialization
|
|
|
|
WireToggle(panel, ToggleAlbedo, painter && painter.paintAlbedo, v => { if (painter) painter.paintAlbedo = v; });
|
|
WireToggle(panel, ToggleMetallicSmoothness, painter && painter.paintMetallicSmoothness, v => { if (painter) painter.paintMetallicSmoothness = v; });
|
|
WireToggle(panel, ToggleOcclusion, painter && painter.paintOcclusion, v => { if (painter) painter.paintOcclusion = v; });
|
|
WireToggle(panel, ToggleEmission, painter && painter.paintEmission, v => { if (painter) painter.paintEmission = v; });
|
|
WireToggle(panel, ToggleSpray, painter && painter.spray, v => { if (painter) painter.spray = v; });
|
|
WireToggle(panel, TogglePipette, painter && painter.pipette, v => { if (painter) painter.pipette = v; });
|
|
|
|
// Dim a channel panel to 0.5 alpha while its toggle is off, full alpha while on.
|
|
WirePanelFade(panel, PanelAlbedo, ToggleAlbedo);
|
|
WirePanelFade(panel, PanelMetallicSmoothness, ToggleMetallicSmoothness);
|
|
WirePanelFade(panel, PanelOcclusion, ToggleOcclusion);
|
|
WirePanelFade(panel, PanelEmission, ToggleEmission);
|
|
WirePanelFade(panel, PanelSpray, ToggleSpray);
|
|
|
|
WirePicker(panel, PickerAlbedo, painter ? painter.brushColor : Color.red,
|
|
c => { if (painter) painter.brushColor = c; });
|
|
|
|
WireSlider(panel, SliderMetallic, painter ? painter.metallic : 0f, v => { if (painter) painter.metallic = v; });
|
|
WireSlider(panel, SliderSmoothness, painter ? painter.smoothness : 0.5f, v => { if (painter) painter.smoothness = v; });
|
|
WireSlider(panel, SliderMetallicSmoothnessAlpha, painter ? painter.metallicSmoothnessOpacity : 1f, v => { if (painter) painter.metallicSmoothnessOpacity = v; });
|
|
WireSlider(panel, SliderOcclusion, painter ? painter.occlusion : 0.5f, v => { if (painter) painter.occlusion = v; });
|
|
WireSlider(panel, SliderOcclusionAlpha, painter ? painter.occlusionOpacity : 1f, v => { if (painter) painter.occlusionOpacity = v; });
|
|
|
|
WirePicker(panel, PickerEmission, painter ? painter.emissionColor : Color.black,
|
|
c => { if (painter) painter.emissionColor = c; });
|
|
WireSlider(panel, SliderEmissionIntensity, painter ? painter.emissionIntensity : 1f, v => { if (painter) painter.emissionIntensity = v; });
|
|
|
|
WireSlider(panel, SliderBrushSize, painter ? painter.brushSize : 16f, v => { if (painter) painter.brushSize = Mathf.RoundToInt(v); });
|
|
|
|
WireSlider(panel, SliderSprayDots, painter ? painter.sprayDots : 24f, v => { if (painter) painter.sprayDots = Mathf.RoundToInt(v); });
|
|
WireSlider(panel, SliderSprayDotSizeMin, painter ? painter.sprayDotSizeMin : .05f, v => { if (painter) painter.sprayDotSizeMin = v; });
|
|
WireSlider(panel, SliderSprayDotSizeMax, painter ? painter.sprayDotSizeMax : .18f, v => { if (painter) painter.sprayDotSizeMax = v; });
|
|
WireSlider(panel, SliderSprayDotAlphaMin, painter ? painter.sprayDotAlphaMin : .25f, v => { if (painter) painter.sprayDotAlphaMin = v; });
|
|
WireSlider(panel, SliderSprayDotAlphaMax, painter ? painter.sprayDotAlphaMax : 1f, v => { if (painter) painter.sprayDotAlphaMax = v; });
|
|
|
|
foreach (var name in DraggablePanels)
|
|
MakeDraggable(panel, name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attaches (idempotently) a <see cref="DraggablePanel"/> to the whole panel so it can be dragged
|
|
/// from any of its non-interactive surface (header, padding, labels, background); the component
|
|
/// ignores presses that start on a widget (toggle, slider, colour picker).
|
|
/// </summary>
|
|
private static void MakeDraggable(Transform root, string panelName)
|
|
{
|
|
var panel = FindDeep(root, panelName) as RectTransform;
|
|
if (!panel) return;
|
|
var drag = panel.GetComponent<DraggablePanel>();
|
|
if (!drag) drag = panel.gameObject.AddComponent<DraggablePanel>();
|
|
drag.target = panel;
|
|
}
|
|
|
|
private void EnsureFonts(Transform panel)
|
|
{
|
|
foreach (var t in panel.GetComponentsInChildren<Text>(true))
|
|
if (!t.font) t.font = _font;
|
|
}
|
|
|
|
private static void WireToggle(Transform panel, string name, bool value, Action<bool> onChanged)
|
|
{
|
|
var toggle = Find<Toggle>(panel, name);
|
|
if (!toggle) return;
|
|
toggle.isOn = value;
|
|
toggle.onValueChanged.AddListener(v => onChanged(v));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures a <see cref="CanvasGroup"/> on the channel panel and drives its alpha from the toggle:
|
|
/// 1 while the channel is enabled, 0.5 while disabled. Call after <see cref="WireToggle"/> so the
|
|
/// initial alpha reflects the toggle's painter-synced state. The group stays interactive (default
|
|
/// interactable/blocksRaycasts), so a dimmed panel can still be clicked to re-enable its channel.
|
|
/// </summary>
|
|
private static void WirePanelFade(Transform root, string panelName, string toggleName)
|
|
{
|
|
var panel = FindDeep(root, panelName);
|
|
if (!panel) return;
|
|
var group = panel.GetComponent<CanvasGroup>();
|
|
if (!group) group = panel.gameObject.AddComponent<CanvasGroup>();
|
|
|
|
var toggle = Find<Toggle>(panel, toggleName);
|
|
if (!toggle) { group.alpha = 1f; return; }
|
|
|
|
group.alpha = toggle.isOn ? 1f : 0.5f;
|
|
toggle.onValueChanged.AddListener(v => group.alpha = v ? 1f : 0.5f);
|
|
}
|
|
|
|
private static void WireSlider(Transform panel, string name, float value, Action<float> onChanged)
|
|
{
|
|
var cell = FindDeep(panel, name);
|
|
if (!cell) return;
|
|
var slider = cell.GetComponentInChildren<Slider>();
|
|
if (!slider) return;
|
|
var valueRt = cell.Find("Value");
|
|
var valueText = valueRt ? valueRt.GetComponent<TMPro.TMP_Text>() : null;
|
|
|
|
bool whole = slider.wholeNumbers;
|
|
slider.value = value;
|
|
|
|
Action<float> setText = v =>
|
|
{
|
|
if (valueText) valueText.text = whole ? Mathf.RoundToInt(v).ToString() : v.ToString("0.00");
|
|
};
|
|
setText(slider.value); // show the actual (clamped) value, not the unclamped input
|
|
slider.onValueChanged.AddListener(v => { setText(v); onChanged(v); });
|
|
}
|
|
|
|
private void WirePicker(Transform panel, string name, Color color, Action<Color> onChanged)
|
|
{
|
|
var picker = Find<HsvColorPicker>(panel, name);
|
|
if (!picker) return;
|
|
picker.Rebind(); // re-find children, re-assign runtime gradient sprites, re-hook drag relays
|
|
picker.SetColor(color);
|
|
picker.Changed += onChanged;
|
|
}
|
|
|
|
private static T Find<T>(Transform panel, string name) where T : Component
|
|
{
|
|
var child = FindDeep(panel, name);
|
|
return child ? child.GetComponent<T>() : null;
|
|
}
|
|
|
|
/// <summary>Depth-first search by name (widgets now live nested inside per-channel sub-panels).</summary>
|
|
private static Transform FindDeep(Transform root, string name)
|
|
{
|
|
if (root.name == name) return root;
|
|
for (int i = 0; i < root.childCount; i++)
|
|
{
|
|
var found = FindDeep(root.GetChild(i), name);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- Static UI factory (shared with HsvColorPicker) ---
|
|
|
|
public static RectTransform NewRect(string name, Transform parent)
|
|
{
|
|
var go = new GameObject(name, typeof(RectTransform));
|
|
go.transform.SetParent(parent, false);
|
|
return (RectTransform)go.transform;
|
|
}
|
|
|
|
public static Image AddImage(GameObject go, Color color)
|
|
{
|
|
var img = go.AddComponent<Image>();
|
|
img.color = color;
|
|
return img;
|
|
}
|
|
|
|
/// <summary>Adds a full-rect, non-raycasting overlay image (sprite assigned by the caller).</summary>
|
|
public static Image AddOverlay(RectTransform parent, string name)
|
|
{
|
|
var rt = NewRect(name, parent);
|
|
rt.anchorMin = Vector2.zero;
|
|
rt.anchorMax = Vector2.one;
|
|
rt.offsetMin = Vector2.zero;
|
|
rt.offsetMax = Vector2.zero;
|
|
var img = rt.gameObject.AddComponent<Image>();
|
|
img.type = Image.Type.Simple;
|
|
img.raycastTarget = false;
|
|
return img;
|
|
}
|
|
|
|
// Cached gradient sprites for the colour picker.
|
|
private static Sprite _satSprite, _valSprite, _hueSprite, _alphaSprite;
|
|
|
|
/// <summary>Horizontal white-out: opaque white (left) -> transparent (right) = saturation.</summary>
|
|
public static Sprite SaturationSprite()
|
|
{
|
|
if (_satSprite) return _satSprite;
|
|
const int n = 64;
|
|
var tex = NewGradientTex(n, 1);
|
|
for (int x = 0; x < n; x++)
|
|
{
|
|
byte a = (byte)Mathf.RoundToInt(255f * (1f - x / (float)(n - 1)));
|
|
tex.SetPixel(x, 0, new Color32(255, 255, 255, a));
|
|
}
|
|
tex.Apply(false);
|
|
_satSprite = ToSprite(tex);
|
|
return _satSprite;
|
|
}
|
|
|
|
/// <summary>Vertical black-out: opaque black (bottom) -> transparent (top) = value.</summary>
|
|
public static Sprite ValueSprite()
|
|
{
|
|
if (_valSprite) return _valSprite;
|
|
const int n = 64;
|
|
var tex = NewGradientTex(1, n);
|
|
for (int y = 0; y < n; y++)
|
|
{
|
|
byte a = (byte)Mathf.RoundToInt(255f * (1f - y / (float)(n - 1)));
|
|
tex.SetPixel(0, y, new Color32(0, 0, 0, a));
|
|
}
|
|
tex.Apply(false);
|
|
_valSprite = ToSprite(tex);
|
|
return _valSprite;
|
|
}
|
|
|
|
/// <summary>Horizontal white transparency ramp: transparent (left) -> opaque (right) = alpha.
|
|
/// Tinted to the current colour by the picker so it reads as a colour-over-backdrop fade.</summary>
|
|
public static Sprite AlphaSprite()
|
|
{
|
|
if (_alphaSprite) return _alphaSprite;
|
|
const int n = 64;
|
|
var tex = NewGradientTex(n, 1);
|
|
for (int x = 0; x < n; x++)
|
|
{
|
|
byte a = (byte)Mathf.RoundToInt(255f * (x / (float)(n - 1)));
|
|
tex.SetPixel(x, 0, new Color32(255, 255, 255, a));
|
|
}
|
|
tex.Apply(false);
|
|
_alphaSprite = ToSprite(tex);
|
|
return _alphaSprite;
|
|
}
|
|
|
|
/// <summary>Horizontal full-saturation hue ramp 0..1.</summary>
|
|
public static Sprite HueSprite()
|
|
{
|
|
if (_hueSprite) return _hueSprite;
|
|
const int n = 64;
|
|
var tex = NewGradientTex(n, 1);
|
|
for (int x = 0; x < n; x++)
|
|
tex.SetPixel(x, 0, Color.HSVToRGB(x / (float)(n - 1), 1f, 1f));
|
|
tex.Apply(false);
|
|
_hueSprite = ToSprite(tex);
|
|
return _hueSprite;
|
|
}
|
|
|
|
private static Texture2D NewGradientTex(int w, int h)
|
|
{
|
|
return new Texture2D(w, h, TextureFormat.RGBA32, false, false) // sRGB sampling for correct colours
|
|
{
|
|
name = "PaintHud_Gradient",
|
|
wrapMode = TextureWrapMode.Clamp,
|
|
filterMode = FilterMode.Bilinear,
|
|
hideFlags = HideFlags.HideAndDontSave
|
|
};
|
|
}
|
|
|
|
private static Sprite ToSprite(Texture2D tex)
|
|
{
|
|
var s = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f);
|
|
s.hideFlags = HideFlags.HideAndDontSave;
|
|
return s;
|
|
}
|
|
}
|