using System; using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.HighDefinition; namespace UnityEditor.Rendering.HighDefinition { /// /// Formats the provided descriptor into a linear slider with contextual slider markers, tooltips, and icons. /// class LightUnitSlider { protected SerializedObject m_SerializedObject; static class SliderConfig { public const float k_IconSeparator = 0; public const float k_MarkerWidth = 2; public const float k_MarkerHeight = 2; public const float k_MarkerTooltipScale = 4; public const float k_ThumbTooltipSize = 10; public const float k_KnobSize = 10; } protected static class SliderStyles { public static GUIStyle k_IconButton = new GUIStyle("IconButton"); public static GUIStyle k_TemperatureBorder = new GUIStyle("ColorPickerSliderBackground"); public static GUIStyle k_TemperatureThumb = new GUIStyle("ColorPickerHorizThumb"); } protected readonly LightUnitSliderUIDescriptor m_Descriptor; public LightUnitSlider(LightUnitSliderUIDescriptor descriptor) { m_Descriptor = descriptor; } public void SetSerializedObject(SerializedObject serialized) { m_SerializedObject = serialized; } public virtual void Draw(Rect rect, SerializedProperty value, ref float floatValue) { BuildRects(rect, out var sliderRect, out var iconRect); if (m_Descriptor.clampValue) ClampValue(ref floatValue, m_Descriptor.sliderRange); var level = CurrentRange(floatValue); DoSlider(sliderRect, ref floatValue, m_Descriptor.sliderRange, level.value); if (m_Descriptor.hasMarkers) { foreach (var r in m_Descriptor.valueRanges) { var markerValue = r.value.y; var markerPosition = GetPositionOnSlider(markerValue, r.value); var markerTooltip = r.content.tooltip; DoSliderMarker(sliderRect, markerPosition, markerValue, markerTooltip); } } var levelIconContent = level.content; var levelRange = level.value; DoIcon(iconRect, levelIconContent, value, floatValue, levelRange.y); var thumbValue = floatValue; var thumbPosition = GetPositionOnSlider(thumbValue, level.value); var thumbTooltip = levelIconContent.tooltip; DoThumbTooltip(sliderRect, thumbPosition, thumbValue, thumbTooltip); } LightUnitSliderUIRange CurrentRange(float value) { foreach (var l in m_Descriptor.valueRanges) { if (value >= l.value.x && value <= l.value.y) { return l; } } var cautionValue = value < m_Descriptor.sliderRange.x ? m_Descriptor.sliderRange.x : m_Descriptor.sliderRange.y; var cautionTooltip = value < m_Descriptor.sliderRange.x ? m_Descriptor.belowRangeTooltip : m_Descriptor.aboveRangeTooltip; return LightUnitSliderUIRange.CautionRange(cautionTooltip, cautionValue); } void BuildRects(Rect baseRect, out Rect sliderRect, out Rect iconRect) { sliderRect = baseRect; sliderRect.width -= EditorGUIUtility.singleLineHeight + SliderConfig.k_IconSeparator; iconRect = baseRect; iconRect.x += sliderRect.width + SliderConfig.k_IconSeparator; iconRect.width = EditorGUIUtility.singleLineHeight; } void ClampValue(ref float value, Vector2 range) => value = Mathf.Clamp(value, range.x, range.y); private static Color k_DarkThemeColor = new Color32(153, 153, 153, 255); private static Color k_LiteThemeColor = new Color32(97, 97, 97, 255); static Color GetMarkerColor() => EditorGUIUtility.isProSkin ? k_DarkThemeColor : k_LiteThemeColor; void DoSliderMarker(Rect rect, float position, float value, string tooltip) { const float width = SliderConfig.k_MarkerWidth; const float height = SliderConfig.k_MarkerHeight; var markerRect = rect; markerRect.width = width; markerRect.height = height; // Vertically align with slider. markerRect.y += (EditorGUIUtility.singleLineHeight / 2f) - 1; // Horizontally place on slider. We need to take into account the "knob" size when doing this, because position 0 and 1 starts // at the center of the knob when it's placed at the left and right corner respectively. We don't do this adjustment when placing // the marker at the corners (to avoid havind the slider slightly extend past the marker) float knobSize = (position > 0f && position < 1f) ? SliderConfig.k_KnobSize : 0f; float start = rect.x + knobSize / 2f; float range = rect.width - knobSize; markerRect.x = start + range * position; // Center the marker on value. const float halfWidth = width * 0.5f; markerRect.x -= halfWidth; // Clamp to the slider edges. float min = rect.x; float max = (rect.x + rect.width) - width; markerRect.x = Mathf.Clamp(markerRect.x, min, max); // Draw marker by manually drawing the rect, and an empty label with the tooltip. EditorGUI.DrawRect(markerRect, GetMarkerColor()); // Scale the marker tooltip for easier discovery const float markerTooltipRectScale = SliderConfig.k_MarkerTooltipScale; var markerTooltipRect = markerRect; markerTooltipRect.width *= markerTooltipRectScale; markerTooltipRect.height *= markerTooltipRectScale; markerTooltipRect.x -= (markerTooltipRect.width * 0.5f) - 1; markerTooltipRect.y -= (markerTooltipRect.height * 0.5f) - 1; EditorGUI.LabelField(markerTooltipRect, GetLightUnitTooltip(tooltip, value, m_Descriptor.unitName)); } void DoIcon(Rect rect, GUIContent icon, SerializedProperty value, float floatValue, float range) { // Draw the context menu feedback before the icon GUI.Box(rect, GUIContent.none, SliderStyles.k_IconButton); var oldColor = GUI.color; GUI.color = Color.clear; EditorGUI.DrawTextureTransparent(rect, icon.image); GUI.color = oldColor; EditorGUI.LabelField(rect, GetLightUnitTooltip(icon.tooltip, range, m_Descriptor.unitName)); // Handle events for context menu var e = Event.current; if (e.type == EventType.MouseDown && e.button == 0) { if (rect.Contains(e.mousePosition)) { var menuPosition = rect.position + rect.size; DoContextMenu(menuPosition, value, floatValue); e.Use(); } } } void DoContextMenu(Vector2 pos, SerializedProperty value, float floatValue) { var menu = new GenericMenu(); foreach (var preset in m_Descriptor.valueRanges) { // Indicate a checkmark if the value is within this preset range. var isInPreset = CurrentRange(floatValue).value == preset.value; menu.AddItem(EditorGUIUtility.TrTextContent(preset.content.tooltip), isInPreset, () => SetValueToPreset(value, preset)); } menu.DropDown(new Rect(pos, Vector2.zero)); } void DoThumbTooltip(Rect rect, float position, float value, string tooltip) { const float size = SliderConfig.k_ThumbTooltipSize; const float halfSize = SliderConfig.k_ThumbTooltipSize * 0.5f; var thumbMarkerRect = rect; thumbMarkerRect.width = size; thumbMarkerRect.height = size; // Vertically align with slider thumbMarkerRect.y += halfSize - 1f; // Horizontally place tooltip on the wheel, thumbMarkerRect.x = rect.x + (rect.width - size) * position; EditorGUI.LabelField(thumbMarkerRect, GetLightUnitTooltip(tooltip, value, m_Descriptor.unitName)); } protected virtual void SetValueToPreset(SerializedProperty value, LightUnitSliderUIRange preset) { m_SerializedObject?.Update(); // Set the value to the average of the preset range. value.floatValue = preset.presetValue; m_SerializedObject?.ApplyModifiedProperties(); } protected virtual GUIContent GetLightUnitTooltip(string baseTooltip, float value, string unit) { var formatValue = value < 100 ? $"{value:n}" : $"{value:n0}"; var tooltip = $"{baseTooltip} | {formatValue} {unit}"; return new GUIContent(string.Empty, tooltip); } protected virtual void DoSlider(Rect rect, ref float value, Vector2 sliderRange, Vector2 valueRange) { DoSlider(rect, ref value, sliderRange); } /// /// Draws a linear slider mapped to the min/max value range. Override this for different slider behavior (texture background, power). /// protected virtual void DoSlider(Rect rect, ref float value, Vector2 sliderRange) { value = GUI.HorizontalSlider(rect, value, sliderRange.x, sliderRange.y); } // Remaps value in the domain { Min0, Max0 } to { Min1, Max1 } (by default, normalizes it to (0, 1). static float Remap(float v, float x0, float y0, float x1 = 0f, float y1 = 1f) => x1 + (v - x0) * (y1 - x1) / (y0 - x0); protected virtual float GetPositionOnSlider(float value, Vector2 valueRange) { return GetPositionOnSlider(value); } /// /// Maps a light unit value onto the slider. Keeps in sync placement of markers and tooltips with the slider power. /// Override this in case of non-linear slider. /// protected virtual float GetPositionOnSlider(float value) { return Remap(value, m_Descriptor.sliderRange.x, m_Descriptor.sliderRange.y); } } /// /// Formats the provided descriptor into a piece-wise linear slider with contextual slider markers, tooltips, and icons. /// class PiecewiseLightUnitSlider : LightUnitSlider { struct Piece { public Vector2 domain; public Vector2 range; public float directM; public float directB; public float inverseM; public float inverseB; } // Piecewise function indexed by value ranges. private readonly Dictionary m_PiecewiseFunctionMap = new Dictionary(); static void ComputeTransformationParameters(float x0, float x1, float y0, float y1, out float m, out float b) { m = (y0 - y1) / (x0 - x1); b = (m * -x0) + y0; } static float DoTransformation(in float x, in float m, in float b) => (m * x) + b; // Ensure clamping to (0,1) as sometimes the function evaluates to slightly below 0 (breaking the handle). static float ValueToSlider(Piece p, float x) => Mathf.Clamp01(DoTransformation(x, p.inverseM, p.inverseB)); static float SliderToValue(Piece p, float x) => DoTransformation(x, p.directM, p.directB); // Ideally we want a continuous, monotonically increasing function, but this is useful as we can easily fit a // distribution to a set of (huge) value ranges onto a slider. public PiecewiseLightUnitSlider(LightUnitSliderUIDescriptor descriptor) : base(descriptor) { // Sort the ranges into ascending order var sortedRanges = m_Descriptor.valueRanges.OrderBy(x => x.value.x).ToArray(); var sliderDistribution = m_Descriptor.sliderDistribution; // Compute the transformation for each value range. for (int i = 0; i < sortedRanges.Length; i++) { var r = sortedRanges[i].value; var x0 = sliderDistribution[i + 0]; var x1 = sliderDistribution[i + 1]; var y0 = r.x; var y1 = r.y; Piece piece; piece.domain = new Vector2(x0, x1); piece.range = new Vector2(y0, y1); ComputeTransformationParameters(x0, x1, y0, y1, out piece.directM, out piece.directB); // Compute the inverse ComputeTransformationParameters(y0, y1, x0, x1, out piece.inverseM, out piece.inverseB); m_PiecewiseFunctionMap.Add(sortedRanges[i].value, piece); } } protected override float GetPositionOnSlider(float value, Vector2 valueRange) { if (!m_PiecewiseFunctionMap.TryGetValue(valueRange, out var piecewise)) return -1f; return ValueToSlider(piecewise, value); } // Search for the corresponding piece-wise function to a value on the domain and update the input piece to it. // Returns true if search was successful and an update was made, false otherwise. bool UpdatePiece(ref Piece piece, float x) { foreach (var pair in m_PiecewiseFunctionMap) { var p = pair.Value; if (x >= p.domain.x && x <= p.domain.y) { piece = p; return true; } } return false; } void SliderOutOfBounds(Rect rect, ref float value) { EditorGUI.BeginChangeCheck(); var internalValue = GUI.HorizontalSlider(rect, value, 0f, 1f); if (EditorGUI.EndChangeCheck()) { Piece p = new Piece(); UpdatePiece(ref p, internalValue); value = SliderToValue(p, internalValue); } } protected override void DoSlider(Rect rect, ref float value, Vector2 sliderRange, Vector2 valueRange) { // Map the internal slider value to the current piecewise function if (!m_PiecewiseFunctionMap.TryGetValue(valueRange, out var piece)) { // Assume that if the piece is not found, that means the unit value is out of bounds. SliderOutOfBounds(rect, ref value); return; } // Maintain an internal value to support a single linear continuous function EditorGUI.BeginChangeCheck(); var internalValue = GUI.HorizontalSlider(rect, ValueToSlider(piece, value), 0f, 1f); if (EditorGUI.EndChangeCheck()) { // Ensure that the current function piece is being used to transform the value UpdatePiece(ref piece, internalValue); value = SliderToValue(piece, internalValue); } } } /// /// Formats the provided descriptor into a punctual light unit slider with contextual slider markers, tooltips, and icons. /// class PunctualLightUnitSlider : PiecewiseLightUnitSlider { public PunctualLightUnitSlider(LightUnitSliderUIDescriptor descriptor) : base(descriptor) {} private SerializedHDLight m_Light; private Editor m_Editor; private LightUnit m_Unit; private bool m_SpotReflectorEnabled; // Note: these should be in sync with LightUnit private static string[] k_UnitNames = { "Lumen", "Candela", "Lux", "Nits", "EV", }; public void Setup(LightUnit unit, SerializedHDLight light, Editor owner) { m_Unit = unit; m_Light = light; m_Editor = owner; // Cache the spot reflector state as we will need to revert back to it after treating the slider as point light. m_SpotReflectorEnabled = light.enableSpotReflector.boolValue; } public override void Draw(Rect rect, SerializedProperty value, ref float floatValue) { // Convert the incoming unit value into Lumen as the punctual slider is always in these terms (internally) float convertedValue = UnitToLumen(floatValue); EditorGUI.BeginChangeCheck(); base.Draw(rect, value, ref convertedValue); if (EditorGUI.EndChangeCheck()) floatValue = LumenToUnit(convertedValue); } protected override GUIContent GetLightUnitTooltip(string baseTooltip, float value, string unit) { // Convert the internal lumens into the actual light unit value value = LumenToUnit(value); unit = k_UnitNames[(int)m_Unit]; return base.GetLightUnitTooltip(baseTooltip, value, unit); } float UnitToLumen(float value) { if (m_Unit == LightUnit.Lumen) return value; // Punctual slider currently does not have any regard for spot shape/reflector. // Conversions need to happen as if light is a point, and this is the only setting that influences that. m_Light.enableSpotReflector.boolValue = false; return HDLightUI.ConvertLightIntensity(m_Unit, LightUnit.Lumen, m_Light, m_Editor, value); } float LumenToUnit(float value) { if (m_Unit == LightUnit.Lumen) return value; // Once again temporarily disable reflector in case we called this for tooltip or context menu preset. m_Light.enableSpotReflector.boolValue = false; value = HDLightUI.ConvertLightIntensity(LightUnit.Lumen, m_Unit, m_Light, m_Editor, value); // Restore the state of spot reflector on the light. m_Light.enableSpotReflector.boolValue = m_SpotReflectorEnabled; return value; } protected override void SetValueToPreset(SerializedProperty value, LightUnitSliderUIRange preset) { m_Light?.Update(); // Convert to the actual unit value. value.floatValue = LumenToUnit(preset.presetValue); m_Light?.Apply(); } } /// /// Formats the provided descriptor into a temperature unit slider with contextual slider markers, tooltips, and icons. /// class TemperatureSlider : LightUnitSlider { private Vector3 m_ExponentialConstraints; private LightEditor.Settings m_Settings; private static Texture2D s_KelvinGradientTexture; /// /// Exponential slider modeled to set a f(0.5) value. /// ref: https://stackoverflow.com/a/17102320 /// void PrepareExponentialConstraints(float lo, float mi, float hi) { // float x = lo; // float y = mi; // float z = hi; // // // https://www.desmos.com/calculator/yx2yf4huia // m_ExponentialConstraints.x = ((x * z) - (y * y)) / (x - (2 * y) + z); // m_ExponentialConstraints.y = ((y - x) * (y - x)) / (x - (2 * y) + z); // m_ExponentialConstraints.z = 2 * Mathf.Log((z - y) / (y - x)); // Warning: These are the coefficients for a system of equation fit for a continuous, monotonic curve that fits a f(0.44) value. // f(0.44) is required instead of f(0.5) due to the location of the white in the temperature gradient texture. // The equation is solved to get the coefficient for the following constraint for low, mid, hi: // f(0) = 1500 // f(0.44) = 6500 // f(1.0) = 20000 // If for any reason the constraints are changed, then the function must be refit and the new coefficients found. // Note that we can't re-use the original PowerSlider instead due to how it forces a text field, which we don't want in this case. m_ExponentialConstraints.x = -3935.53965427f; m_ExponentialConstraints.y = 5435.53965427f; m_ExponentialConstraints.z = 1.48240556f; } protected float ValueToSlider(float x) => Mathf.Log((x - m_ExponentialConstraints.x) / m_ExponentialConstraints.y) / m_ExponentialConstraints.z; protected float SliderToValue(float x) => m_ExponentialConstraints.x + m_ExponentialConstraints.y * Mathf.Exp(m_ExponentialConstraints.z * x); protected override float GetPositionOnSlider(float value, Vector2 valueRange) { return ValueToSlider(value); } static Texture2D GetKelvinGradientTexture(LightEditor.Settings settings) { if (s_KelvinGradientTexture == null) { var kelvinTexture = (Texture2D)typeof(LightEditor.Settings).GetField("m_KelvinGradientTexture", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(settings); // This seems to be the only way to gamma-correct the internal gradient tex (aside from drawing it manually). var kelvinTextureLinear = new Texture2D(kelvinTexture.width, kelvinTexture.height, TextureFormat.RGBA32, true); kelvinTextureLinear.SetPixels(kelvinTexture.GetPixels()); kelvinTextureLinear.Apply(); s_KelvinGradientTexture = kelvinTextureLinear; } return s_KelvinGradientTexture; } public TemperatureSlider(LightUnitSliderUIDescriptor descriptor) : base(descriptor) { var halfValue = 6500; PrepareExponentialConstraints(m_Descriptor.sliderRange.x, halfValue, m_Descriptor.sliderRange.y); } public void Setup(LightEditor.Settings settings) { m_Settings = settings; } // The serialized property for color temperature is stored in the build-in light editor, and we need to use this object to apply the update. protected override void SetValueToPreset(SerializedProperty value, LightUnitSliderUIRange preset) { m_Settings.Update(); base.SetValueToPreset(value, preset); m_Settings.ApplyModifiedProperties(); } protected override void DoSlider(Rect rect, ref float value, Vector2 sliderRange) { SliderWithTextureNoTextField(rect, ref value, sliderRange, m_Settings); } // Note: We could use the internal SliderWithTexture, however: the internal slider func forces a text-field (and no ability to opt-out of it). void SliderWithTextureNoTextField(Rect rect, ref float value, Vector2 range, LightEditor.Settings settings) { GUI.DrawTexture(rect, GetKelvinGradientTexture(settings)); EditorGUI.BeginChangeCheck(); // Draw the exponential slider that fits 6500K to the white point on the gradient texture. var internalValue = GUI.HorizontalSlider(rect, ValueToSlider(value), 0f, 1f, SliderStyles.k_TemperatureBorder, SliderStyles.k_TemperatureThumb); // Round to nearest since so much precision is not necessary for kelvin while sliding. if (EditorGUI.EndChangeCheck()) { // Map the value back into kelvin. value = SliderToValue(internalValue); value = Mathf.Round(value); } } } internal class LightUnitSliderUIDrawer { static PiecewiseLightUnitSlider k_DirectionalLightUnitSlider; static PunctualLightUnitSlider k_PunctualLightUnitSlider; static PiecewiseLightUnitSlider k_ExposureSlider; static TemperatureSlider k_TemperatureSlider; static LightUnitSliderUIDrawer() { // Maintain a unique slider for directional/lux. k_DirectionalLightUnitSlider = new PiecewiseLightUnitSlider(LightUnitSliderDescriptors.LuxDescriptor); // Internally, slider is always in terms of lumens, so that the slider is uniform for all light units. k_PunctualLightUnitSlider = new PunctualLightUnitSlider(LightUnitSliderDescriptors.LumenDescriptor); // Exposure is in EV100, but we load a separate due to the different icon set. k_ExposureSlider = new PiecewiseLightUnitSlider(LightUnitSliderDescriptors.ExposureDescriptor); // Kelvin is not classified internally as a light unit so we handle it independently as well. k_TemperatureSlider = new TemperatureSlider(LightUnitSliderDescriptors.TemperatureDescriptor); } // Need to cache the serialized object on the slider, to add support for the preset selection context menu (need to apply changes to serialized) // TODO: This slider drawer is getting kind of bloated. Break up the implementation into where it is actually used? public void SetSerializedObject(SerializedObject serializedObject) { k_DirectionalLightUnitSlider.SetSerializedObject(serializedObject); k_PunctualLightUnitSlider.SetSerializedObject(serializedObject); k_ExposureSlider.SetSerializedObject(serializedObject); k_TemperatureSlider.SetSerializedObject(serializedObject); } public void Draw(HDLightType type, LightUnit lightUnit, SerializedProperty value, Rect rect, SerializedHDLight light, Editor owner) { using (new EditorGUI.IndentLevelScope(-EditorGUI.indentLevel)) { if (type == HDLightType.Directional) DrawDirectionalUnitSlider(value, rect); else DrawPunctualLightUnitSlider(lightUnit, value, rect, light, owner); } } void DrawDirectionalUnitSlider(SerializedProperty value, Rect rect) { float val = value.floatValue; k_DirectionalLightUnitSlider.Draw(rect, value, ref val); if (val != value.floatValue) value.floatValue = val; } void DrawPunctualLightUnitSlider(LightUnit lightUnit, SerializedProperty value, Rect rect, SerializedHDLight light, Editor owner) { k_PunctualLightUnitSlider.Setup(lightUnit, light, owner); float val = value.floatValue; k_PunctualLightUnitSlider.Draw(rect, value, ref val); if (val != value.floatValue) value.floatValue = val; } public void DrawExposureSlider(SerializedProperty value, Rect rect) { using (new EditorGUI.IndentLevelScope(-EditorGUI.indentLevel)) { float val = value.floatValue; k_ExposureSlider.Draw(rect, value, ref val); if (val != value.floatValue) value.floatValue = val; } } public void DrawTemperatureSlider(LightEditor.Settings settings, SerializedProperty value, Rect rect) { using (new EditorGUI.IndentLevelScope(-EditorGUI.indentLevel)) { k_TemperatureSlider.Setup(settings); float val = value.floatValue; k_TemperatureSlider.Draw(rect, value, ref val); if (val != value.floatValue) value.floatValue = val; } } } }