Persist material scalars, reset the HUD, and split alpha from the picker

Save and restore the emission intensity and the three camo knobs
(half-Lambert, half-Lambert brightness, specular dampen) with a named
painting. The .paint format moves to v3 with a small scalar block
between the header and the pixel maps; v2 paintings still load with
their painter values left untouched. Loading re-syncs the emission and
camo sliders, and peers pick the values up for free through the
existing owner-authoritative paint NetworkVariables.

Add a HUD reset (Lib_ResetHud button plus context-menu fallback) that
behaves as if the saved hud-layout file were deleted: it removes the
file, restores panels to their authored prefab positions, and resets
the brush values to the painter/material defaults captured before any
carry/restore runs.

Remove the alpha bar from HsvColorPicker so alpha is driven by
standalone Slider_AlbedoAlpha / Slider_EmissionAlpha bindings; the
picker now writes RGB only and the sliders write alpha only, with no
feedback between them. Add a generic SliderCurve component that reshapes
a slider's handle position into a non-linear value (with an inverse for
setting values back), and make the slider wiring curve-aware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-30 18:20:13 +02:00
1 parent fe8f12b8bb
commit 057c99fa1d
7 files changed
+313 -128

No files matched your search

+16 -91
View File
@@ -3,35 +3,33 @@ using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// Compact runtime HSV colour picker: a saturation/value square, a hue bar, an alpha bar and a
/// live swatch. The square is a pure-hue base with two static gradient overlays on top (white-out
/// -> saturation, black-out -> value), so only the base tint changes per hue - no per-frame
/// texture rebuilds. Hue is never lost while dragging the square (the square only edits S/V,
/// the bar only edits H, the alpha bar only edits A).
/// Compact runtime HSV colour picker: a saturation/value square, a hue bar and a live swatch. The
/// square is a pure-hue base with two static gradient overlays on top (white-out -> saturation,
/// black-out -> value), so only the base tint changes per hue - no per-frame texture rebuilds. Hue is
/// never lost while dragging the square (the square only edits S/V, the bar only edits H). Alpha is
/// edited separately by a standalone slider in the HUD, not by this picker; <see cref="SetColor"/>
/// still stores the incoming alpha so <see cref="Value"/> round-trips it.
///
/// <see cref="Build"/> creates the child structure (stable names, no sprites or hooks); it can
/// be baked into a prefab. <see cref="Rebind"/> then (re)assigns the runtime gradient sprites
/// (which a prefab cannot store) and (re)hooks the drag relays - it runs for both the code-built
/// picker and one instantiated from a prefab, so the two are behaviourally identical. For a prefab
/// baked before the alpha bar existed it also builds that bar on the fly (and grows the picker's
/// reserved layout height), so no re-bake is required.
/// picker and one instantiated from a prefab, so the two are behaviourally identical. A leftover
/// alpha bar from a pre-split prefab is hidden by <see cref="Rebind"/>, so no re-bake is required.
/// </summary>
public class HsvColorPicker : MonoBehaviour
{
/// <summary>Raised with the new colour when the user moves the square, hue bar or alpha bar.</summary>
/// <summary>Raised with the new colour (RGB; alpha is preserved, not edited here) when the user moves
/// the square or hue bar.</summary>
public event Action<Color> Changed;
private float _h, _s, _v, _a = 1f;
// Gap between the stacked square / hue / alpha rows (matches the layout in Build).
// Gap between the stacked square / hue rows (matches the layout in Build).
private const float RowGap = 6f;
private static readonly Color AlphaBackdrop = new Color(0.5f, 0.5f, 0.5f, 1f);
private Image _hueBase; // pure-hue background of the SV square
private RectTransform _svHandle;
private RectTransform _hueHandle;
private Image _alphaGrad; // colour-tinted transparency ramp of the alpha bar
private RectTransform _alphaHandle;
private Image _swatch;
/// <summary>Current colour (HSV -> RGB, with the preserved alpha).</summary>
@@ -54,7 +52,7 @@ public class HsvColorPicker : MonoBehaviour
}
/// <summary>
/// Builds the picker structure (square + hue bar + alpha bar + swatch) under this object,
/// Builds the picker structure (square + hue bar + swatch) under this object,
/// top-left aligned. Layout only - no gradient sprites and no event hooks, so the result is
/// safe to bake into a prefab. Call <see cref="Rebind"/> afterwards (at runtime) to make it
/// functional.
@@ -87,41 +85,12 @@ public class HsvColorPicker : MonoBehaviour
hueHandle.sizeDelta = new Vector2(4f, hueHeight + 4f);
PaintHud.AddImage(hueHandle.gameObject, Color.white).raycastTarget = false;
// Alpha bar below the hue bar.
BuildAlphaBar(sv, hue);
// Swatch, to the right of the square.
var sw = PaintHud.NewRect("Swatch", transform);
TopLeft(sw, new Vector2(squareSize + 10f, 0f), new Vector2(34f, 34f));
PaintHud.AddImage(sw.gameObject, Color.white).raycastTarget = false;
}
/// <summary>
/// Builds the alpha bar (grey backdrop + colour-tinted transparency ramp + drag relay + handle)
/// directly below the hue bar, sized/positioned from the existing SV square and hue bar so it
/// works for both a fresh build and an older baked prefab. Sprites/hooks are applied by
/// <see cref="Rebind"/>.
/// </summary>
private RectTransform BuildAlphaBar(RectTransform sv, RectTransform hue)
{
float width = sv.sizeDelta.x;
float barHeight = hue.sizeDelta.y;
float y = hue.anchoredPosition.y - (barHeight + RowGap);
var alpha = PaintHud.NewRect("Alpha", transform);
TopLeft(alpha, new Vector2(0f, y), new Vector2(width, barHeight));
PaintHud.AddImage(alpha.gameObject, AlphaBackdrop); // raycast target for the drag relay
PaintHud.AddOverlay(alpha, "AlphaGrad"); // tinted alpha ramp, sprite assigned in Rebind
alpha.gameObject.AddComponent<UiPointerSurface>();
var handle = PaintHud.NewRect("Handle", alpha);
handle.sizeDelta = new Vector2(4f, barHeight + 4f);
PaintHud.AddImage(handle.gameObject, Color.white).raycastTarget = false;
return alpha;
}
/// <summary>
/// Re-finds the child widgets, (re)assigns the runtime gradient sprites and (re)hooks the drag
/// relays. Idempotent (unsubscribe-then-subscribe), so it is safe whether the structure was just
@@ -161,32 +130,10 @@ public class HsvColorPicker : MonoBehaviour
}
}
// Alpha bar: build it if the (older) prefab lacks it, then (re)assign sprite + relay.
var alpha = transform.Find("Alpha") as RectTransform;
bool builtAlpha = false;
if (!alpha && sv is RectTransform svRect && hue)
{
alpha = BuildAlphaBar(svRect, hue);
builtAlpha = true;
}
if (alpha)
{
var grad = alpha.Find("AlphaGrad");
if (grad && grad.TryGetComponent(out Image gradImg))
{
gradImg.sprite = PaintHud.AlphaSprite();
_alphaGrad = gradImg;
}
_alphaHandle = alpha.Find("Handle") as RectTransform;
if (alpha.TryGetComponent(out UiPointerSurface alphaPointer))
{
alphaPointer.Picked -= OnAlphaPicked;
alphaPointer.Picked += OnAlphaPicked;
}
if (builtAlpha) GrowReservedHeight(alpha.sizeDelta.y + RowGap);
}
// Alpha now lives on a standalone slider (PaintHud), not in the picker. Hide any alpha bar a
// pre-split prefab still carries so it does not show as a dead, non-functional strip.
var leftoverAlpha = transform.Find("Alpha");
if (leftoverAlpha) leftoverAlpha.gameObject.SetActive(false);
var swatch = transform.Find("Swatch");
if (swatch) _swatch = swatch.GetComponent<Image>();
@@ -194,15 +141,6 @@ public class HsvColorPicker : MonoBehaviour
Refresh();
}
/// <summary>Grows this picker's LayoutElement so a runtime-added alpha bar does not overlap the
/// widget below it; the panel's ContentSizeFitter then expands to match. No-op without a LayoutElement.</summary>
private void GrowReservedHeight(float extra)
{
if (!TryGetComponent(out LayoutElement le)) return;
if (le.minHeight > 0f) le.minHeight += extra;
if (le.preferredHeight > 0f) le.preferredHeight += extra;
}
private void OnSvPicked(Vector2 p)
{
_s = p.x;
@@ -218,13 +156,6 @@ public class HsvColorPicker : MonoBehaviour
Changed?.Invoke(Value);
}
private void OnAlphaPicked(Vector2 p)
{
_a = p.x;
Refresh();
Changed?.Invoke(Value);
}
private void Refresh()
{
if (_hueBase) _hueBase.color = Color.HSVToRGB(_h, 1f, 1f);
@@ -238,12 +169,6 @@ public class HsvColorPicker : MonoBehaviour
_hueHandle.anchorMin = _hueHandle.anchorMax = new Vector2(_h, 0.5f);
_hueHandle.anchoredPosition = Vector2.zero;
}
if (_alphaGrad) _alphaGrad.color = Color.HSVToRGB(_h, _s, _v); // tint the transparency ramp to the current colour
if (_alphaHandle)
{
_alphaHandle.anchorMin = _alphaHandle.anchorMax = new Vector2(_a, 0.5f);
_alphaHandle.anchoredPosition = Vector2.zero;
}
if (_swatch) _swatch.color = Color.HSVToRGB(_h, _s, _v);
}
+50 -5
View File
@@ -16,9 +16,21 @@ public partial class MeshPainter
// map's canonical Color32 bytes. Raw (not PNG) avoids any sRGB/alpha-premultiply ambiguity and
// round-trips the exact canonical bytes ReadCanonical produces.
private const uint PaintFileMagic = 0x504D4843; // 'CHMP' little-endian
private const int PaintFileVersion = 2; // v2: added the display-name header
private const int PaintFileVersion = 3; // v3: added the per-player material scalars (emission + camo)
private const string PaintFileExt = ".paint";
/// <summary>
/// Per-player material scalars stored alongside the maps from v3 on. These are NOT baked into the
/// painted pixels (a global emission multiplier + the PlayerCamoLit knobs), so they ride the slot
/// header and are re-applied onto the painter when the slot loads.
/// </summary>
private struct PaintScalars
{
public bool has; // false = legacy v2 slot with no scalars -> leave the painter's values untouched
public float emissionIntensity;
public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen;
}
private static string PaintLibraryDir =>
Path.Combine(Application.persistentDataPath, "MeshPaint", "Saved");
@@ -83,6 +95,14 @@ public partial class MeshPainter
w.Write(textureSize);
w.Write(MAP_COUNT);
// v3: per-player material scalars that are NOT baked into the maps (a global emission
// multiplier + the PlayerCamoLit knobs). The painter is ready here (_setupValid), so the
// material-backed camo getters return the live values rather than the not-ready 0.
w.Write(emissionIntensity);
w.Write(CamoHalfLambert);
w.Write(CamoHalfLambertBrightness);
w.Write(CamoSpecularDampen);
var bytes = new byte[n * 4];
for (int mi = 0; mi < MAP_COUNT; mi++)
{
@@ -116,7 +136,7 @@ public partial class MeshPainter
{
if (!_setupValid) return false;
Color32[][] canonical = ReadMaps(PathForName(name));
Color32[][] canonical = ReadMaps(PathForName(name), out PaintScalars scalars);
if (canonical == null) return false;
// Re-seed exactly like SwitchBackend / ApplyPaintSnapshot, so the island/dilation seeding path
@@ -126,6 +146,19 @@ public partial class MeshPainter
// A load replaces the whole painting and is NOT undoable: the loaded maps become the new history
// baseline, clearing any prior undo/redo. Peers reset to the same baseline via the load broadcast.
ResetHistoryToCurrent(canonical);
// v3 slots also carry the per-player material scalars; apply them so the loaded look is complete.
// Legacy v2 slots have none -> leave the current emission/camo untouched. The painter is ready here
// (_setupValid), so the material-backed camo setters take effect; PlayerPaintSync replicates the
// changed values to peers via their owner-authoritative NetworkVariables on its next LateUpdate.
if (scalars.has)
{
emissionIntensity = scalars.emissionIntensity;
RefreshEmissionColor();
CamoHalfLambert = scalars.camoHalfLambert;
CamoHalfLambertBrightness = scalars.camoHalfLambertBrightness;
CamoSpecularDampen = scalars.camoSpecularDampen;
}
return true;
}
@@ -155,7 +188,8 @@ public partial class MeshPainter
using (var r = new BinaryReader(fs, Encoding.UTF8))
{
if (r.ReadUInt32() != PaintFileMagic) return null;
if (r.ReadInt32() != PaintFileVersion) return null;
int ver = r.ReadInt32();
if (ver != 2 && ver != PaintFileVersion) return null; // list legacy v2 slots + current
return r.ReadString();
}
}
@@ -169,8 +203,9 @@ public partial class MeshPainter
/// Reads all maps from a slot file in canonical layout, or null if missing / incompatible. Per-map
/// null entries are tolerated by both backends (they fall back to the material seed).
/// </summary>
private Color32[][] ReadMaps(string path)
private Color32[][] ReadMaps(string path, out PaintScalars scalars)
{
scalars = default;
if (!File.Exists(path)) return null;
try
@@ -180,12 +215,22 @@ public partial class MeshPainter
{
if (r.ReadUInt32() != PaintFileMagic) return null;
int ver = r.ReadInt32();
if (ver != PaintFileVersion) return null;
if (ver != 2 && ver != PaintFileVersion) return null; // read legacy v2 + current
r.ReadString(); // display name (header only; not needed for the pixel load)
int w = r.ReadInt32();
int h = r.ReadInt32();
int maps = r.ReadInt32();
// v3+: the per-player material scalars sit between the header and the pixel maps.
if (ver >= 3)
{
scalars.has = true;
scalars.emissionIntensity = r.ReadSingle();
scalars.camoHalfLambert = r.ReadSingle();
scalars.camoHalfLambertBrightness = r.ReadSingle();
scalars.camoSpecularDampen = r.ReadSingle();
}
// Resolution change -> ignore rather than smear mismatched data; the brush re-seeds
// from the material instead.
if (w != textureSize || h != textureSize)
+21 -2
View File
@@ -21,6 +21,7 @@ public partial class PaintHud
private const string LibSaveButton = "Lib_Save";
private const string LibLoadButton = "Lib_Load";
private const string LibDeleteButton = "Lib_Delete";
private const string LibResetButton = "Lib_ResetHud";
private const string LibSaveDialog = "Lib_SaveDialog";
private const string LibNameInput = "Lib_NameInput";
private const string LibSaveInfo = "Lib_SaveInfo";
@@ -34,7 +35,7 @@ public partial class PaintHud
private bool _libraryBound;
private RectTransform _listContent;
private GameObject _entryTemplate;
private Button _saveButton, _loadButton, _deleteButton;
private Button _saveButton, _loadButton, _deleteButton, _resetButton;
private string _selectedPainting;
// Save dialog
@@ -76,9 +77,11 @@ public partial class PaintHud
_saveButton = Find<Button>(root, LibSaveButton);
_loadButton = Find<Button>(root, LibLoadButton);
_deleteButton = Find<Button>(root, LibDeleteButton);
_resetButton = Find<Button>(root, LibResetButton); // optional: present only if authored in the layout
Hook(_saveButton, OnSaveClicked);
Hook(_loadButton, OnLoadClicked);
Hook(_deleteButton, OnDeleteClicked);
Hook(_resetButton, OnResetClicked);
var sd = FindDeep(root, LibSaveDialog);
_saveDialog = sd ? sd.gameObject : null;
@@ -233,11 +236,27 @@ public partial class PaintHud
ShowConfirm($"Delete \"{name}\"?", OkDanger, () => DoDelete(name));
}
/// <summary>Reset the panels + brush values to their authored defaults (confirmed; the dialog reuses the
/// library's confirm path). Falls back to an immediate reset if no confirm dialog is authored.</summary>
private void OnResetClicked()
{
if (_confirmDialog)
ShowConfirm("Reset the HUD panels and brush values to their defaults?", OkDanger, ResetHudToDefaults);
else
ResetHudToDefaults();
}
private void DoLoad(string name)
{
if (!painter || !painter.LoadPaintByName(name)) return;
// Re-sync the loaded baseline to every other peer (no-op offline / single-player).
// The load also restored the slot's emission intensity + camo knobs onto the painter (v3 slots) ->
// push them into the sliders so the HUD reflects the loaded look, exactly like a pipette pick does.
SyncFromPainter();
SyncCamoSliders();
// Re-sync the loaded baseline to every other peer (no-op offline / single-player). The restored
// emission/camo replicate on their own via PlayerPaintSync's owner-authoritative NetworkVariables.
var sync = painter.GetComponentInParent<PlayerPaintSync>();
if (sync) sync.BroadcastLoadedPaint();
}
+95
View File
@@ -60,6 +60,16 @@ public partial class PaintHud
private bool _hasPendingCamo;
private bool _camoEverReady;
// Authored defaults for the runtime reset (= "as if the saved layout file had been deleted"): the
// prefab's panel positions, captured before any saved layout is restored over them, and the painter's
// prefab/material default brush values, captured before carry/restore mutate the live painter. Camo is
// material-backed (readable only once IsReady), so it is filled in the LateUpdate ready-gate instead.
private readonly Dictionary<string, Vector2> _authoredPanelPos = new Dictionary<string, Vector2>();
private bool _hasAuthoredLayout;
private readonly HudValues _defaults = new HudValues();
private bool _hasDefaults; // non-camo defaults captured (first bind, before restore/carry)
private bool _hasDefaultCamo; // camo defaults captured (first ready-gate, before any pending camo apply)
private static string HudSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint");
private string HudSavePath()
@@ -211,6 +221,17 @@ public partial class PaintHud
// save file) the material keeps its authored camo and the sliders just reflect it.
if (!_camoSynced && painter.IsReady)
{
// Capture the material's authored camo as the reset default BEFORE any pending (restored or
// carried) camo is applied over it. Once per HUD: a later session/respawn keeps the very
// first authored value, so a reset always returns to the genuine material default.
if (!_hasDefaultCamo)
{
_defaults.camoHalfLambert = painter.CamoHalfLambert;
_defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
_defaults.camoSpecularDampen = painter.CamoSpecularDampen;
_hasDefaultCamo = true;
}
if (_hasPendingCamo)
{
painter.CamoHalfLambert = _pendingCamo.x;
@@ -402,6 +423,80 @@ public partial class PaintHud
}
}
// --- Reset to authored defaults (drives the HUD's reset button) ---
/// <summary>
/// Snapshots the authored panel positions from the freshly instantiated layout, before any saved
/// layout is restored over them. The runtime reset restores exactly these. Called once from Start
/// (right after the layout prefab is instantiated); idempotent.
/// </summary>
public void CaptureAuthoredLayout()
{
if (_hasAuthoredLayout || !_panel) return;
var root = _panel.transform;
foreach (var name in DraggablePanels)
if (FindDeep(root, name) is RectTransform rt)
_authoredPanelPos[name] = rt.anchoredPosition;
_hasAuthoredLayout = true;
}
/// <summary>
/// Snapshots the painter's prefab-default brush values on the first bind, before carry/restore mutate
/// the live painter. Non-camo only here (a freshly spawned painter holds its prefab defaults); the
/// material-backed camo is not readable yet and is captured later in the LateUpdate ready-gate.
/// </summary>
private void CaptureDefaults()
{
if (_hasDefaults || !painter) return;
CopyValues(painter, _defaults); // CopyValues skips camo until IsReady -> camo stays for the ready-gate
_hasDefaults = true;
}
/// <summary>
/// Resets the HUD to its authored defaults, exactly as if the saved layout file had been deleted: the
/// panels jump back to their prefab positions and every brush value returns to the painter's prefab /
/// material defaults. The save file is deleted too, so the reset also survives the next start.
/// </summary>
[ContextMenu("Reset HUD Layout And Values")]
public void ResetHudToDefaults()
{
// Delete the saved layout so a restart opens at the authored defaults as well (the literal intent).
ClearSavedHudState();
// Panels -> authored prefab positions.
if (_panel && _hasAuthoredLayout)
{
var root = _panel.transform;
foreach (var kv in _authoredPanelPos)
if (FindDeep(root, kv.Key) is RectTransform rt)
rt.anchoredPosition = kv.Value;
}
// Brush values -> painter prefab/material defaults, then push them to every widget.
if (painter && _hasDefaults)
{
// Reset clicked before the ready-gate captured the camo defaults? keep the live camo rather than
// slamming it to the not-yet-filled 0 (which would flatten the material's lighting).
if (!_hasDefaultCamo && painter.IsReady)
{
_defaults.camoHalfLambert = painter.CamoHalfLambert;
_defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
_defaults.camoSpecularDampen = painter.CamoSpecularDampen;
}
ApplyValues(painter, _defaults);
SyncFromPainter();
SyncCamoSliders();
// ApplyValues wrote the camo straight to the material (painter is ready at a runtime reset), so
// drop any still-pending restored camo the ready-gate would otherwise re-apply over the default.
_hasPendingCamo = false;
CaptureCarry(); // a later respawn now carries the defaults, not the pre-reset values
SnapshotSavedBaseline(); // values match the (now deleted) save -> the reset itself triggers no auto-save
}
}
/// <summary>Deletes the saved HUD layout (next start opens at the authored layout / defaults). Editor convenience.</summary>
[ContextMenu("Clear Saved HUD Layout")]
public void ClearSavedHudState()
+45 -30
View File
@@ -51,6 +51,8 @@ public partial class PaintHud : MonoBehaviour
private const string TogglePipette = "Toggle_Pipette";
private const string PickerAlbedo = "Picker_Albedo";
private const string PickerEmission = "Picker_Emission";
private const string SliderAlbedoAlpha = "Slider_AlbedoAlpha";
private const string SliderEmissionAlpha = "Slider_EmissionAlpha";
private const string SliderMetallic = "Slider_Metallic";
private const string SliderSmoothness = "Slider_Smoothness";
private const string SliderMetallicSmoothnessAlpha = "Slider_MetallicSmoothnessAlpha";
@@ -121,6 +123,10 @@ public partial class PaintHud : MonoBehaviour
_panel = go;
}
// Snapshot the authored panel positions now, while the layout is untouched - before RestoreHudState
// (in BindPainter) overwrites them. The reset button restores exactly these.
CaptureAuthoredLayout();
// The paint library now lives inside the authored layout prefab; bind its widgets like the brush panel.
BindLibrary(_panel ? _panel.transform : null);
@@ -167,6 +173,11 @@ public partial class PaintHud : MonoBehaviour
// Re-sync the brush-size slider when Shift+wheel resizes the brush in-world (painter -> HUD).
painter.BrushSizeChanged += SyncBrushSize;
// Snapshot the painter's prefab-default brush values before carry/restore mutate them; the reset
// button returns to exactly these. Once per HUD (a freshly spawned respawn painter holds the same
// prefab defaults, so re-capturing would be redundant).
CaptureDefaults();
// First bind this session: restore panel positions + brush values from disk (cross-session).
// A later bind is an avatar respawn (scene switch) -> carry the in-session brush values onto the
// fresh painter instead, so they are not reset to its prefab defaults.
@@ -259,18 +270,23 @@ public partial class PaintHud : MonoBehaviour
WirePanelFade(panel, PanelEmission, ToggleEmission);
WirePanelFade(panel, PanelSpray, ToggleSpray);
// Picker drives only RGB (keeping the painter's current alpha); a standalone slider drives alpha.
WirePicker(panel, PickerAlbedo, painter ? painter.brushColor : Color.red,
c => { if (painter) painter.brushColor = c; });
c => { if (painter) painter.brushColor = WithRgb(painter.brushColor, c); });
WireSlider(panel, SliderAlbedoAlpha, painter ? painter.brushColor.a : .6f,
v => { if (painter) painter.brushColor = WithAlpha(painter.brushColor, v); });
WireSlider(panel, SliderMetallic, painter ? painter.metallic : 0f, v => { if (painter) painter.metallic = v; });
WireSlider(panel, SliderSmoothness, painter ? painter.smoothness : 0.5f, v => { if (painter) painter.smoothness = v; });
WireSlider(panel, SliderMetallicSmoothnessAlpha, painter ? painter.metallicSmoothnessOpacity : 1f, v => { if (painter) painter.metallicSmoothnessOpacity = v; });
WireSlider(panel, SliderMetallicSmoothnessAlpha, painter ? painter.metallicSmoothnessOpacity : .6f, v => { if (painter) painter.metallicSmoothnessOpacity = v; });
WireSlider(panel, SliderOcclusion, painter ? painter.occlusion : 0.5f, v => { if (painter) painter.occlusion = v; });
WireSlider(panel, SliderOcclusionAlpha, painter ? painter.occlusionOpacity : 1f, v => { if (painter) painter.occlusionOpacity = v; });
WireSlider(panel, SliderOcclusionAlpha, painter ? painter.occlusionOpacity : .6f, v => { if (painter) painter.occlusionOpacity = v; });
WirePicker(panel, PickerEmission, painter ? painter.emissionColor : Color.black,
c => { if (painter) painter.emissionColor = c; });
c => { if (painter) painter.emissionColor = WithRgb(painter.emissionColor, c); });
WireSlider(panel, SliderEmissionIntensity, painter ? painter.emissionIntensity : 1f, v => { if (painter) painter.emissionIntensity = v; });
WireSlider(panel, SliderEmissionAlpha, painter ? painter.emissionColor.a : .6f,
v => { if (painter) painter.emissionColor = WithAlpha(painter.emissionColor, v); });
WireSlider(panel, SliderBrushSize, painter ? painter.brushSize : 16f, v => { if (painter) painter.brushSize = Mathf.RoundToInt(v); });
@@ -279,8 +295,8 @@ public partial class PaintHud : MonoBehaviour
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; });
WireSlider(panel, SliderSprayDotAlphaMin, painter ? painter.sprayDotAlphaMin : .2f, v => { if (painter) painter.sprayDotAlphaMin = v; });
WireSlider(panel, SliderSprayDotAlphaMax, painter ? painter.sprayDotAlphaMax : .6f, v => { if (painter) painter.sprayDotAlphaMax = v; });
// Camouflage knobs on the player's PlayerCamoLit material instance (material-backed, not painter
// fields). The values are not readable here yet - the HUD binds in the avatar's OnNetworkSpawn,
@@ -314,6 +330,8 @@ public partial class PaintHud : MonoBehaviour
SetPicker(panel, PickerAlbedo, painter.brushColor);
SetPicker(panel, PickerEmission, painter.emissionColor);
SetSlider(panel, SliderAlbedoAlpha, painter.brushColor.a);
SetSlider(panel, SliderEmissionAlpha, painter.emissionColor.a);
SetSlider(panel, SliderMetallic, painter.metallic);
SetSlider(panel, SliderSmoothness, painter.smoothness);
@@ -369,7 +387,10 @@ public partial class PaintHud : MonoBehaviour
{
var cell = FindDeep(panel, name);
var slider = cell ? cell.GetComponentInChildren<Slider>() : null;
if (slider) slider.value = value; // listener refreshes the value label + painter
if (!slider) return;
var curve = slider.GetComponent<SliderCurve>();
if (curve) curve.SetOutput(value); // inverts the value onto the handle, then fires the slider listener
else slider.value = value; // listener refreshes the value label + painter
}
private static void SetPicker(Transform panel, string name, Color color)
@@ -378,6 +399,12 @@ public partial class PaintHud : MonoBehaviour
if (picker) picker.SetColor(color);
}
// Albedo/emission colour is split across two widgets: the picker drives RGB (keeping the painter's
// current alpha), the standalone alpha slider drives alpha (keeping RGB). Kept independent so neither
// widget's edit clobbers the other's channel.
private static Color WithRgb(Color current, Color rgb) => new Color(rgb.r, rgb.g, rgb.b, current.a);
private static Color WithAlpha(Color current, float a) => new Color(current.r, current.g, current.b, a);
// Active = accent frame, inactive = subtle frame. Selection lives on the painter (activeBrushIndex);
// the swatch click only sets it - the painter resolves the new mask on its next Update.
private static readonly Color MaskFrameSelected = new Color(0.30f, 0.62f, 1f, 1f);
@@ -509,18 +536,23 @@ public partial class PaintHud : MonoBehaviour
if (!cell) return;
var slider = cell.GetComponentInChildren<Slider>();
if (!slider) return;
var curve = slider.GetComponent<SliderCurve>(); // optional non-linear response on the same object
var valueRt = cell.Find("Value");
var valueText = valueRt ? valueRt.GetComponent<TMPro.TMP_Text>() : null;
bool whole = slider.wholeNumbers;
slider.value = value;
// With a curve the slider handle is linear but the bound value is the curved output; whole-number
// quantisation moves onto the curve so the slider itself stays continuous.
if (curve) curve.SetOutputWithoutNotify(value); // seed the handle by inverting the value (no listener yet)
else slider.value = value;
Func<float> read = () => curve ? curve.Value : slider.value;
Action<float> setText = v =>
{
if (valueText) valueText.text = whole ? Mathf.RoundToInt(v).ToString() : v.ToString("0.00");
if (valueText) valueText.text = slider.wholeNumbers ? 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); });
setText(read()); // show the actual (clamped/curved) value, not the unclamped input
slider.onValueChanged.AddListener(_ => { float v = read(); setText(v); onChanged(v); });
}
private void WirePicker(Transform panel, string name, Color color, Action<Color> onChanged)
@@ -581,7 +613,7 @@ public partial class PaintHud : MonoBehaviour
}
// Cached gradient sprites for the colour picker.
private static Sprite _satSprite, _valSprite, _hueSprite, _alphaSprite;
private static Sprite _satSprite, _valSprite, _hueSprite;
/// <summary>Horizontal white-out: opaque white (left) -> transparent (right) = saturation.</summary>
public static Sprite SaturationSprite()
@@ -615,23 +647,6 @@ public partial class PaintHud : MonoBehaviour
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()
{
+84
View File
@@ -0,0 +1,84 @@
using System;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// Reshapes a UI <see cref="Slider"/>'s response with an <see cref="AnimationCurve"/> so the value runs
/// non-linearly between the slider's authored <c>minValue</c>/<c>maxValue</c>. The curve maps the handle
/// position (x, 0..1 - linear in screen space) to a value fraction (y, 0..1) that is then scaled to the
/// slider's range. The slider's own min/max stays the single source of truth for the range; this only
/// bends the mapping inside it - so dropping the component onto an existing slider needs no range re-entry.
///
/// <see cref="Value"/> is the curved output, <see cref="SetOutput"/> positions the handle to show a given
/// output (inverting the curve), and <see cref="ValueChanged"/> raises the curved value whenever the
/// handle moves - so the component is usable both standalone (wire <see cref="ValueChanged"/>) and from
/// code that drives the raw slider (read <see cref="Value"/>).
///
/// The curve should be monotonic increasing (the default linear is); the inverse used by
/// <see cref="SetOutput"/> assumes that.
/// </summary>
[RequireComponent(typeof(Slider))]
[DisallowMultipleComponent]
public class SliderCurve : MonoBehaviour
{
[Tooltip("Maps the handle position (x, 0..1) to a value fraction (y, 0..1), scaled to the slider's " +
"minValue/maxValue. Default linear = no change. Should be monotonic increasing.")]
public AnimationCurve response = AnimationCurve.Linear(0f, 0f, 1f, 1f);
/// <summary>Raised with the curved output whenever the handle moves.</summary>
public event Action<float> ValueChanged;
private Slider _slider;
private Slider Slider => _slider ? _slider : (_slider = GetComponent<Slider>());
/// <summary>The curved output value for the current handle position.</summary>
public float Value => Slider ? Curve(Slider.normalizedValue) : 0f;
private void OnEnable()
{
if (Slider) Slider.onValueChanged.AddListener(OnSliderChanged);
}
private void OnDisable()
{
if (_slider) _slider.onValueChanged.RemoveListener(OnSliderChanged);
}
private void OnSliderChanged(float _) => ValueChanged?.Invoke(Value);
/// <summary>Positions the handle so <see cref="Value"/> reads <paramref name="output"/>, raising the
/// slider's <c>onValueChanged</c> (and thus <see cref="ValueChanged"/>).</summary>
public void SetOutput(float output)
{
if (Slider) Slider.normalizedValue = Position(output); // normalizedValue setter notifies
}
/// <summary>As <see cref="SetOutput"/> but without raising any change event (for silent seeding).</summary>
public void SetOutputWithoutNotify(float output)
{
if (Slider) Slider.SetValueWithoutNotify(Mathf.Lerp(Slider.minValue, Slider.maxValue, Position(output)));
}
// handle position (0..1) -> curved output value in [minValue, maxValue]
private float Curve(float t)
{
float y = Mathf.Clamp01(response.Evaluate(Mathf.Clamp01(t)));
float v = Mathf.Lerp(Slider.minValue, Slider.maxValue, y);
return Slider && Slider.wholeNumbers ? Mathf.Round(v) : v;
}
// curved output value -> handle position (0..1), inverting the (monotonic) curve via bisection
private float Position(float output)
{
var s = Slider;
if (!s) return 0f;
float target = Mathf.Clamp01(Mathf.InverseLerp(s.minValue, s.maxValue, output)); // desired y
float lo = 0f, hi = 1f;
for (int i = 0; i < 24; i++)
{
float mid = 0.5f * (lo + hi);
if (Mathf.Clamp01(response.Evaluate(mid)) < target) lo = mid; else hi = mid;
}
return 0.5f * (lo + hi);
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2e66618c2739718459e4ecd2253c4ba7