第 27 课
实现画面调节功能
本课使用 UIManager 直接管理画面设置:Slider(滑块)提供输入,UIManager 把滑块值转换到合适的后处理范围,再修改 Volume Profile 中的 White Balance(白平衡)与 Color Adjustments(颜色调整)。整条功能链同时处理事件监听、初始状态同步、数值文字显示和异常情况。
用三个滑块实时调节博物馆画面
把设置页面中的色温、曝光和饱和度滑块连接到通用渲染管线的后处理 Volume。你会理解滑块数值如何经过范围转换,再写入白平衡和颜色调整参数,并让界面、数值文字与场景画面始终保持一致。
学完你将能够
学习目标
- 说明后处理、Volume 与 Volume Profile 之间的关系。
- 区分色温、曝光和饱和度对最终画面的不同影响。
- 说明 UIManager、GameManager、AudioManager 与 Volume 系统各自承担的职责。
- 理解 Slider 的 onValueChanged 为什么会向处理方法传入 float 数值。
- 说明 WhiteBalance 与 ColorAdjustments 为什么需要从 Volume Profile 中取得。
- 理解 TryGet 与 out 在取得后处理覆盖项时分别做了什么。
- 使用 InverseLerp 与 Lerp 在滑块范围和后处理范围之间双向转换。
- 解释 overrideState 与 SetValueWithoutNotify 解决的问题。
- 读懂数值文字的查找规则和最多保留两位小数的格式。
- 完成完整脚本、场景引用和三项画面调节的运行验证。
开始操作前
准备工作
- 打开第26课完成的主场景,确认系统菜单、声音开关和风格切换可以正常使用。
- 在层级面板中找到 PostProcessing 对象,并确认它带有 Volume 组件和可用的 Profile。
- 打开 Volume Profile,确认其中存在 White Balance 和 Color Adjustments。
- 在设置页面中找到 ColorTemperatureSlider、ExposureSlider 和 SaturationSlider。
- 确认每个滑块后面都有一个用于显示当前数值的 Text 文本对象。
- 打开 UIManager.cs、层级面板、属性面板和控制台,并退出运行状态后再修改脚本。
本课学习路线
按照学习模块逐步完成
每个模块都包含理解、跟随操作和独立实践。不改模型和材质,也能统一调整整个画面
先看懂后处理怎样改变最终画面
色温、曝光和饱和度都属于画面完成绘制后的统一调整。先理解 Volume 与 Profile 的职责,后面的代码就能沿着“滑块输入—参数转换—画面输出”这条路径逐步阅读。什么是后处理
Unity 先绘制场景中的模型、灯光和材质,再对已经形成的画面进行额外处理,这一步称为后处理。它类似于给整张照片统一调整冷暖、亮度和颜色浓度,而不是逐个修改每一面墙和每一件展品。
在通用渲染管线中,Volume 组件负责决定后处理配置在哪里生效,Volume Profile 保存具体有哪些效果以及每个效果使用什么参数。脚本需要先找到 Volume,再进入它的 Profile 取得目标效果。
Volume 可以设为 Global(全局),表示整个场景都受到影响;也可以设为 Local(局部),表示体验者进入指定范围后才受到影响。本项目使用全局画面设置,因此设置页面改变参数后,整个博物馆画面都会变化。
Unity 这里的 temperature 是画面冷暖调节量,不是摄氏温度或开尔文温度。postExposure 提高会放大整体亮度,数值过大容易让亮部变成一片白;saturation 为-100时接近黑白,为0时保留原本颜色,正值会让颜色更浓。
三个滑块分别控制什么
三个输入都进入同一个 Volume Profile,但最终写入的效果和参数不同。
左右滑动表格,可查看完整内容
| 界面控件 | 后处理效果 | 目标参数 | 主要视觉变化 |
|---|---|---|---|
| 色温滑块 | White Balance | temperature | 负值偏冷,正值偏暖 |
| 曝光滑块 | Color Adjustments | postExposure | 降低时变暗,提高时变亮 |
| 饱和度滑块 | Color Adjustments | saturation | 降低时颜色变淡,提高时颜色更浓 |
从左向右阅读:三个滑块把数值交给 UIManager,数值经过范围转换后写入 Volume Profile,最终由白平衡和颜色调整共同影响画面。
手机上可左右滑动;点击图片可以查看完整图在场景中找到后处理配置
选择 PostProcessing 对象,沿着 Volume 到 Profile 的路径找到两个目标效果。
- 确认 Volume 的模式能够影响整个博物馆。
- 在 Profile 中找到 White Balance,并指出 temperature。
- 在 Color Adjustments 中指出 postExposure 和 saturation。
- 暂时不要修改参数,先说明三个参数各自会改变什么。
让界面输入、画面参数和其他系统各守职责
明确画面设置应该由谁处理
三个滑块都位于设置页面,因此由 UIManager 接收输入、转换数值并更新文字。玩家、声音和后处理系统仍分别承担自己的任务,整条调用链会更容易理解和维护。为什么滑块事件直接进入 UIManager
UIManager 已经能够找到设置页面中的控件。色温、曝光和饱和度滑块发生变化时,它会立即收到一个 float 数值,再完成范围换算、数值文字更新和后处理参数赋值。
GameManager 继续保存玩家引用并处理菜单按键;AudioManager 继续管理背景音乐和语音解说。它们不需要知道三个画面滑块的当前值。
Volume 与 Volume Profile 负责保存并应用真正的后处理效果。UIManager 不重新实现渲染算法,只把换算后的参数写入 WhiteBalance 或 ColorAdjustments。
完整调用链是:Slider 改变数值 → UIManager 接收并换算 → 更新 ValueText → 写入 Volume Profile → 后处理系统呈现新画面。
画面调节调用链中的职责
每一层只处理自己最熟悉的信息,避免一个脚本同时承担玩家、声音和画面全部任务。
左右滑动表格,可查看完整内容
| 对象或脚本 | 负责什么 | 本课中的具体动作 |
|---|---|---|
| Slider | 提供学生当前拖动位置 | 发出一个 float 数值 |
| UIManager | 管理设置页面输入与显示 | 换算数值、更新文字、写入后处理参数 |
| GameManager | 保存玩家引用并处理菜单按键 | 不参与色温、曝光和饱和度计算 |
| AudioManager | 管理背景音乐和语音解说 | 保持声音状态,不参与画面调节 |
| Volume Profile | 保存后处理效果和参数 | 让 WhiteBalance 与 ColorAdjustments 影响最终画面 |
把每一步放到正确位置
沿调用链给下面的动作分配负责人,并说明前后怎样连接。
- 把“产生0到1之间的位置值”分配给 Slider。
- 把“换算并更新 ValueText”分配给 UIManager。
- 把“保存并应用曝光参数”分配给 Volume Profile。
- 说明 GameManager 和 AudioManager 为什么不需要接收滑块数值。
字段先描述这条功能需要保存哪些对象和状态
准备滑块、范围、文字和效果引用
代码要修改 Volume Profile,必须认识渲染命名空间中的类型;要响应设置页面,又必须保存三个滑块。其余字段分别承担范围转换、数值显示和效果缓存。using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.UI;
[Header("后处理设置")]
[SerializeField] private Volume postProcessVolume;
[SerializeField] private Slider colorTemperatureSlider;
[SerializeField] private Slider exposureSlider;
[SerializeField] private Slider saturationSlider;
[Header("后处理滑块对应范围")]
[SerializeField] private Vector2 colorTemperatureRange = new Vector2(-100f, 100f);
[SerializeField] private Vector2 exposureRange = new Vector2(-5f, 5f);
[SerializeField] private Vector2 saturationRange = new Vector2(-100f, 100f);
private Text colorTemperatureValueText;
private Text exposureValueText;
private Text saturationValueText;
private WhiteBalance whiteBalance;
private ColorAdjustments colorAdjustments;把命名空间放在文件顶部,把字段放在原有风格设置字段之后。此时方法还没有补全,继续完成后续模块后再统一编译。
两个命名空间为什么都需要
UnityEngine.Rendering 提供 Volume 等通用渲染类型。UnityEngine.Rendering.Universal 提供通用渲染管线中的 WhiteBalance 和 ColorAdjustments。缺少其中一个命名空间,编译器就无法识别对应类型。
三个 Slider 是属性面板中需要连接的界面控件。三个 Vector2 各保存两个端点:x 是允许写入的最小后处理值,y 是最大后处理值。它们与滑块自己的 minValue 和 maxValue 相互独立。
三个 Text 字段没有 SerializeField,因此不会出现在属性面板中。脚本会在运行时自动查找它们。WhiteBalance 与 ColorAdjustments 也不在属性面板手动连接,而是从 Volume Profile 中取得后保存在字段里。
字段按职责分组
同一组字段共同解决一个问题,阅读代码时不要把它们看成互不相关的变量。
左右滑动表格,可查看完整内容
| 字段组 | 字段 | 保存内容 |
|---|---|---|
| 后处理入口 | postProcessVolume | 场景中的 Volume 组件 |
| 界面输入 | 三个 Slider | 学生拖动后的浮点数值 |
| 目标范围 | 三个 Vector2 | 每项后处理允许使用的最小值与最大值 |
| 界面输出 | 三个 Text | 每个滑块旁边的当前后处理数值 |
| 效果缓存 | whiteBalance、colorAdjustments | 从 Profile 取得的两个效果对象 |
核对字段之间的对应关系
逐组指出输入、范围、文字和最终参数,确保三条链没有串错。
- 把 colorTemperatureSlider 与 colorTemperatureRange、colorTemperatureValueText 对应起来。
- 把 exposureSlider 与 exposureRange、exposureValueText 对应起来。
- 把 saturationSlider 与 saturationRange、saturationValueText 对应起来。
- 说明为什么两个效果字段不需要拖入属性面板。
添加监听、同步初始值和移除监听各有自己的时机
让三个滑块在正确的生命周期响应
Slider 的 onValueChanged 会在数值变化时发出一个 float(浮点数,也就是能够带小数的数值)。UIManager 需要在对象唤醒时订阅事件,在所有对象准备完成后同步状态,并在对象销毁时解除订阅。 protected override void Awake()
{
base.Awake();
if (Instance != this)
{
return;
}
if (menuUI == null)
{
menuUI = GameObject.Find("MenuUI");
}
if (menuUI != null)
{
menuUI.SetActive(false);
}
else
{
Debug.LogError("UIManager:没有找到 MenuUI。", this);
}
if (backgroundMusicToggle != null)
{
backgroundMusicToggle.onValueChanged.AddListener(SetBackgroundMusicEnabled);
}
if (narrationToggle != null)
{
narrationToggle.onValueChanged.AddListener(SetNarrationEnabled);
}
if (visualStyleDropdown != null)
{
visualStyleDropdown.onValueChanged.AddListener(ApplyVisualStyle);
}
colorTemperatureValueText = FindValueText(colorTemperatureSlider);
exposureValueText = FindValueText(exposureSlider);
saturationValueText = FindValueText(saturationSlider);
if (colorTemperatureSlider != null)
{
colorTemperatureSlider.onValueChanged.AddListener(ApplyColorTemperature);
}
if (exposureSlider != null)
{
exposureSlider.onValueChanged.AddListener(ApplyExposure);
}
if (saturationSlider != null)
{
saturationSlider.onValueChanged.AddListener(ApplySaturation);
}
}完整 Awake 先完成菜单和已有设置的初始化,再查找三个数值文字,并把三个 float 事件连接到对应处理方法。当前类的其他方法还未组合完整,请继续学习后续模块,不要立即返回 Unity 编译。
private void Start()
{
if (backgroundMusicToggle != null)
{
SetBackgroundMusicEnabled(backgroundMusicToggle.isOn);
}
if (narrationToggle != null)
{
SetNarrationEnabled(narrationToggle.isOn);
}
if (visualStyleDropdown != null)
{
ApplyVisualStyle(visualStyleDropdown.value);
}
InitializePostProcessing();
SyncSlidersWithPostProcessing();
}
private void OnDestroy()
{
if (backgroundMusicToggle != null)
{
backgroundMusicToggle.onValueChanged.RemoveListener(SetBackgroundMusicEnabled);
}
if (narrationToggle != null)
{
narrationToggle.onValueChanged.RemoveListener(SetNarrationEnabled);
}
if (visualStyleDropdown != null)
{
visualStyleDropdown.onValueChanged.RemoveListener(ApplyVisualStyle);
}
if (colorTemperatureSlider != null)
{
colorTemperatureSlider.onValueChanged.RemoveListener(ApplyColorTemperature);
}
if (exposureSlider != null)
{
exposureSlider.onValueChanged.RemoveListener(ApplyExposure);
}
if (saturationSlider != null)
{
saturationSlider.onValueChanged.RemoveListener(ApplySaturation);
}
}Start 中先取得后处理效果再同步滑块。OnDestroy 使用与 AddListener 完全相同的方法名移除监听。这仍是完整文件的一部分,请继续完成后续方法,暂不要返回 Unity 编译。
一条完整生命周期
这些方法不是逐帧执行。只有初始化、用户拖动或对象销毁时才会进入对应阶段。
左右滑动表格,可查看完整内容
| 阶段 | 发生什么 | 为什么需要 |
|---|---|---|
| Awake | 查找三个文字并添加三个监听 | 先建立界面输入通道 |
| Start | 取得 Profile 效果并同步初始状态 | 让界面与当前画面一致 |
| 用户拖动 | 对应 Apply 方法收到 float 数值 | 实时更新文字和画面 |
| OnDestroy | 移除三个监听 | 避免失效对象继续收到事件 |
按执行顺序复述一次滑块响应
从场景开始运行到拖动曝光滑块,再到退出场景,完整说出方法顺序。
- 指出曝光文字在什么时候被查找。
- 指出曝光事件在什么时候连接。
- 说明用户拖动时传入的是哪一种数据类型。
- 说明退出场景时为什么还要使用同一个 ApplyExposure 方法名。
Profile 中的效果不是普通场景组件
从 Volume Profile 取得两个效果
WhiteBalance 和 ColorAdjustments 位于 Volume Profile 的效果列表中,不能在 PostProcessing 对象上直接使用 GetComponent 查找。当前代码通过 TryGet 尝试取得目标类型。 private void InitializePostProcessing()
{
if (postProcessVolume == null || postProcessVolume.profile == null)
{
Debug.LogWarning("UIManager:没有指定可用的后处理 Volume。", this);
return;
}
postProcessVolume.profile.TryGet(out whiteBalance);
postProcessVolume.profile.TryGet(out colorAdjustments);
}先确认 Volume 引用可用,再让 TryGet 把找到的对象写入两个字段。读取 profile 时会自动准备运行时实例,此方法在 Start 中只调用一次。这仍是完整文件的一部分,请继续完成后续方法,暂不要返回 Unity 编译。
TryGet 与 out 应怎样理解
初始化方法先判断 postProcessVolume 是否为空。条件中的 || 会从左向右短路判断:如果 Volume 已经为空,右侧就不会继续读取 profile,从而避免访问不存在的对象。return 会立即结束本次初始化。
TryGet 的意思是“尝试取得指定类型”。它会在 Profile 的效果列表中寻找类型匹配的项目。找到时返回 true,找不到时返回 false,因此方法名称强调这次查找可能失败。
out 表示方法可以把结果写回括号中的变量。profile.TryGet(out whiteBalance) 会根据 whiteBalance 的字段类型寻找 WhiteBalance,并把找到的对象保存到该字段。ColorAdjustments 也使用相同规则。
这里访问的是 postProcessVolume.profile。运行时首次读取它时,Unity 会为这个 Volume 准备可修改的配置实例;如果属性面板连接了共享 Profile(项目中保存的原始配置资源),实例会复制其中的效果。拖动滑块改变的是本次运行使用的参数,不会直接把共享资源改成当前滑块值。
在本项目使用的通用渲染管线中,即使没有连接共享 Profile,读取 profile 通常也会得到一个空的运行时实例,而不是 null。因此真正没有连接 Volume 时会输出警告并提前结束;没有共享 Profile 或缺少某个效果时,TryGet 取不到目标对象,后续 Apply 方法通过空值判断跳过画面写入。
不同缺失情况会发生什么
先判断是哪一级没有连接,再处理对应对象,不需要随意修改其他代码。
左右滑动表格,可查看完整内容
| 情况 | 代码状态 | 运行表现 |
|---|---|---|
| Volume 没有连接 | 初始化提前结束并输出警告 | 文字仍可更新,画面参数不变 |
| 没有连接共享 Profile | 得到空的运行时 Profile,两次 TryGet 均失败 | 不会出现这条 Volume 警告,三个画面参数不变 |
| 缺少 White Balance | whiteBalance 保持为空 | 色温不能改变,另外两项仍可能工作 |
| 缺少 Color Adjustments | colorAdjustments 保持为空 | 曝光和饱和度不能改变 |
核对 Profile 中的效果
选择 PostProcessing 的 Profile,逐项确认脚本真正需要的对象。
- 确认 Profile 引用不是空的。
- 确认效果列表中存在 White Balance。
- 确认效果列表中存在 Color Adjustments。
- 分别指出三个目标参数所在的效果。
先求当前位置比例,再把比例放进另一个范围
在滑块范围与后处理范围之间双向换算
滑块可以使用自己的最小值和最大值,后处理参数也有独立范围。当前代码不直接复制数值,而是用一个 0 到 1 的比例连接两个范围。 private static float GetPostProcessValue(Slider slider, float sliderValue, Vector2 valueRange)
{
if (slider == null || Mathf.Approximately(slider.minValue, slider.maxValue))
{
return valueRange.x;
}
float normalizedValue = Mathf.InverseLerp(slider.minValue, slider.maxValue, sliderValue);
return Mathf.Lerp(valueRange.x, valueRange.y, normalizedValue);
}
private static float GetSliderValue(Slider slider, float postProcessValue, Vector2 valueRange)
{
if (slider == null || Mathf.Approximately(valueRange.x, valueRange.y))
{
return 0f;
}
float normalizedValue = Mathf.InverseLerp(valueRange.x, valueRange.y, postProcessValue);
return Mathf.Lerp(slider.minValue, slider.maxValue, normalizedValue);
}GetPostProcessValue 把滑块值转换为后处理值;GetSliderValue 把 Profile 当前值转换回滑块位置。这仍是完整文件的一部分,请继续完成后续方法,暂不要返回 Unity 编译。
InverseLerp 与 Lerp 分别完成哪一步
Mathf.InverseLerp 接收一个范围和其中的当前值,返回当前值位于范围中的比例。最小端得到0,最大端得到1,中点得到0.5。这个比例称为归一化值。
Mathf.Lerp 接收另一个范围和刚才的比例,再算出新范围中的对应值。因此,先 InverseLerp、后 Lerp,就能把一个范围中的位置完整搬到另一个范围。
例如滑块范围是0到1,曝光范围是-5到5。滑块值0.75先得到比例0.75,再映射到曝光范围得到2.5。反向同步时使用相反的两个范围,就能从曝光2.5还原到滑块0.75。
InverseLerp 会把超出原范围的数值限制到0或1,因此 Profile 参数超出目标范围时,反向同步会把滑块停在最近的端点;随后手动调用 Apply 方法,也会把本次运行使用的参数收回到配置范围内。
两个帮助方法使用 static,表示计算只依赖传入的 Slider、当前值和范围,不需要读取某一个 UIManager 实例的其他状态。Mathf.Approximately 用来判断两个浮点数是否近似相等;当范围两端相同时,代码提前返回,避免对一个没有长度的范围继续换算。
两个方向都遵循相同原则:先用 InverseLerp 求出 0 到 1 的位置比例,再用 Lerp 把比例放入目标范围。
手机上可左右滑动;点击图片可以查看完整图当前三项后处理范围
这些范围可以在 UIManager 的属性面板中调整,而不需要改动映射代码。
左右滑动表格,可查看完整内容
| 项目 | 目标范围 | 范围中点 | 观察重点 |
|---|---|---|---|
| 色温 | -100 到 100 | 0 | 从冷色调逐渐过渡到暖色调 |
| 曝光 | -5 到 5 | 0 | 范围较大时两端变化非常明显 |
| 饱和度 | -100 到 100 | 0 | 负端趋近灰度,正端颜色增强 |
手算一次曝光映射
假设曝光滑块范围是0到1,后处理范围是-5到5,计算三个位置。
- 滑块为0时,说明归一化比例和最终曝光值。
- 滑块为0.5时,说明归一化比例和最终曝光值。
- 滑块为1时,说明归一化比例和最终曝光值。
- 再解释为什么修改滑块最大值后,映射方法仍然可以工作。
每个处理方法都完成转换、显示和赋值三步
把三个转换结果写入正确参数
三个 Apply 方法结构相同,但目标参数不同。逐段比较它们,可以看清哪些代码能够复用,哪些名称必须与当前画面功能准确对应。 private void ApplyColorTemperature(float value)
{
float temperature = GetPostProcessValue(colorTemperatureSlider, value, colorTemperatureRange);
UpdateValueText(colorTemperatureValueText, temperature);
if (whiteBalance != null)
{
whiteBalance.temperature.overrideState = true;
whiteBalance.temperature.value = temperature;
}
}
private void ApplyExposure(float value)
{
float exposure = GetPostProcessValue(exposureSlider, value, exposureRange);
UpdateValueText(exposureValueText, exposure);
if (colorAdjustments != null)
{
colorAdjustments.postExposure.overrideState = true;
colorAdjustments.postExposure.value = exposure;
}
}
private void ApplySaturation(float value)
{
float saturation = GetPostProcessValue(saturationSlider, value, saturationRange);
UpdateValueText(saturationValueText, saturation);
if (colorAdjustments != null)
{
colorAdjustments.saturation.overrideState = true;
colorAdjustments.saturation.value = saturation;
}
}三个方法都接收 Slider 事件传来的 float,先转换数值并更新文字,再在效果对象存在时启用覆盖并写入参数。这仍是完整文件的一部分,请继续完成后续方法,暂不要返回 Unity 编译。
以 ApplyExposure 为例逐行理解
参数 value 是曝光滑块当前值。GetPostProcessValue 同时接收曝光滑块、当前值和 exposureRange,返回真正要写入 Profile 的 exposure。
UpdateValueText 使用转换后的 exposure,而不是原始滑块值,因此学生看到的数字与真正写入后处理的数值一致。
colorAdjustments 可能因为 Profile 配置不完整而为空,所以赋值前先判断。overrideState 设为 true 表示当前 Volume 要覆盖这个参数;随后才把 exposure 写入 postExposure.value。
三条方法中不能混淆的名称
结构相似不等于可以随意复制名称,每条链必须从输入到输出保持一致。
左右滑动表格,可查看完整内容
| 方法 | 滑块与范围 | 数值文字 | 最终参数 |
|---|---|---|---|
| ApplyColorTemperature | colorTemperatureSlider / Range | colorTemperatureValueText | whiteBalance.temperature |
| ApplyExposure | exposureSlider / Range | exposureValueText | colorAdjustments.postExposure |
| ApplySaturation | saturationSlider / Range | saturationValueText | colorAdjustments.saturation |
跟踪一次饱和度拖动
从 SaturationSlider 发出数值开始,按实际代码顺序说明状态变化。
- 指出哪个方法首先收到原始滑块值。
- 指出哪个方法把数值映射到 saturationRange。
- 指出哪个 Text 显示转换后的结果。
- 指出哪个效果字段和参数最终发生变化。
先从 Profile 反向设置滑块,再统一应用当前滑块值
让首次进入场景时界面与画面一致
如果只响应用户拖动,场景刚开始运行时可能出现“画面已经使用一个参数,滑块却停在另一个位置”的情况。SyncSlidersWithPostProcessing 专门解决初始状态不一致。 private void SyncSlidersWithPostProcessing()
{
if (colorTemperatureSlider != null && whiteBalance != null)
{
colorTemperatureSlider.SetValueWithoutNotify(
GetSliderValue(colorTemperatureSlider, whiteBalance.temperature.value, colorTemperatureRange));
}
if (exposureSlider != null && colorAdjustments != null)
{
exposureSlider.SetValueWithoutNotify(
GetSliderValue(exposureSlider, colorAdjustments.postExposure.value, exposureRange));
}
if (saturationSlider != null && colorAdjustments != null)
{
saturationSlider.SetValueWithoutNotify(
GetSliderValue(saturationSlider, colorAdjustments.saturation.value, saturationRange));
}
if (colorTemperatureSlider != null)
{
ApplyColorTemperature(colorTemperatureSlider.value);
}
if (exposureSlider != null)
{
ApplyExposure(exposureSlider.value);
}
if (saturationSlider != null)
{
ApplySaturation(saturationSlider.value);
}
}前三段把后处理值反向映射为滑块值,后三段再调用 Apply 方法更新文字和最终参数。这仍是完整文件的一部分,请继续完成后续方法,暂不要返回 Unity 编译。
为什么使用 SetValueWithoutNotify
直接给 Slider.value 赋值可能触发 onValueChanged,而初始化阶段正在同时调整三个滑块。如果每次赋值都立即触发回调,代码会在同步尚未完成时反复进入 Apply 方法。
SetValueWithoutNotify 会改变滑块位置,但不会发出 onValueChanged 通知。这样可以先安静地完成三个滑块的位置同步,再由后半段按固定顺序手动调用三个 Apply 方法。
手动调用仍然必要,因为无通知赋值不会自动更新数值文字。Apply 方法既刷新文字,也确保后处理参数落在当前配置范围内。整个过程只在 Start 中执行一次,不会在下一帧重复。
本课只改变本次运行使用的 Profile 实例,不会把滑块结果保存到共享 Profile、账户或场景。停止运行后,这些临时参数会结束;下次运行仍从共享 Profile 中保存的初始值开始同步。
当前场景启动时的三次反向换算
三个 Slider 的范围都是0到1。程序读取 Profile 中的初始参数后,按相同位置比例还原滑块位置。
左右滑动表格,可查看完整内容
| 项目 | Profile 初始值 | 目标范围 | 启动后的滑块位置 |
|---|---|---|---|
| 色温 | 2 | -100 到 100 | 约0.51 |
| 曝光 | 1.5 | -5 到 5 | 约0.65 |
| 饱和度 | 0 | -100 到 100 | 0.50 |
Start 中的完整执行顺序
方法调用有明确先后关系,理解顺序比只记住方法名称更重要。
左右滑动表格,可查看完整内容
| 顺序 | 调用 | 状态变化 |
|---|---|---|
| 1 | InitializePostProcessing | 取得 WhiteBalance 和 ColorAdjustments |
| 2 | GetSliderValue | 把 Profile 参数换算为三个滑块位置 |
| 3 | SetValueWithoutNotify | 更新滑块但不触发事件 |
| 4 | 三个 Apply 方法 | 更新文字并把规范化后的值写回 Profile |
| 5 | Start 结束 | 界面、文字和画面保持一致 |
预测一次初始同步
假设 Profile 的曝光值不是滑块当前显示的位置,判断 Start 执行后的状态。
- 先说明 InitializePostProcessing 取得什么。
- 说明曝光值如何反向转换为滑块位置。
- 说明为什么设置滑块时暂时不触发事件。
- 说明最后是哪一个方法更新曝光文字和画面。
减少手动字段,但要保持清楚稳定的界面层级
自动找到数值文字并完成场景连接
本课不在属性面板中连接三个 Text,而是从每个 Slider 的同级对象中查找。理解查找规则后,再放入完整脚本并连接 Volume 与三个滑块。 private static Text FindValueText(Slider slider)
{
if (slider == null || slider.transform.parent == null)
{
return null;
}
Transform parent = slider.transform.parent;
Text textAfterSlider = null;
foreach (Transform sibling in parent)
{
Text siblingText = sibling.GetComponent<Text>();
if (siblingText == null)
{
continue;
}
if (sibling.name.IndexOf("Value", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return siblingText;
}
if (sibling.GetSiblingIndex() > slider.transform.GetSiblingIndex() && textAfterSlider == null)
{
textAfterSlider = siblingText;
}
}
return textAfterSlider;
}
private static void UpdateValueText(Text valueText, float value)
{
if (valueText == null)
{
return;
}
valueText.text = value.ToString("0.##");
}FindValueText 遍历同一父对象下的子对象;UpdateValueText 负责空值保护和显示格式。这仍是完整文件的一部分,请先看完下一段完整 UIManager.cs,再统一保存并返回 Unity 编译。
FindValueText 的查找规则
方法先确认 Slider 和它的父对象是否存在。随后遍历同一个父对象下的所有子对象,并尝试从每个同级对象取得 Text 组件。没有 Text 的对象通过 continue 跳过。
如果文字对象名称包含 Value,不区分大小写,方法立即返回这个 Text。若没有找到带 Value 的名称,则记住滑块后面出现的第一个 Text,遍历结束后把它作为后备结果。
这个方案减少了三个手动引用,但依赖界面结构:数值文字需要与 Slider 位于同一父对象下,并继续使用 Text 组件。若改成更深层级或其他文字组件,就要相应调整查找方法。
UpdateValueText 使用 0.## 格式,表示最多显示两位小数,同时去掉不需要的末尾零。例如2显示为2,2.5显示为2.5,2.536显示为2.54。
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.UI;
public class UIManager : Singleton<UIManager>
{
[SerializeField] private GameObject menuUI;
[SerializeField] private float distanceFromPlayer = 1.5f;
[Header("声音设置")]
[SerializeField] private Toggle backgroundMusicToggle;
[SerializeField] private Toggle narrationToggle;
[Header("风格设置")]
[SerializeField] private Dropdown visualStyleDropdown;
[SerializeField] private Renderer[] floorRenderers;
[SerializeField] private Renderer[] wallRenderers;
[SerializeField] private Material[] floorMaterials;
[SerializeField] private Material[] wallMaterials;
[Header("后处理设置")]
[SerializeField] private Volume postProcessVolume;
[SerializeField] private Slider colorTemperatureSlider;
[SerializeField] private Slider exposureSlider;
[SerializeField] private Slider saturationSlider;
[Header("后处理滑块对应范围")]
[SerializeField] private Vector2 colorTemperatureRange = new Vector2(-100f, 100f);
[SerializeField] private Vector2 exposureRange = new Vector2(-5f, 5f);
[SerializeField] private Vector2 saturationRange = new Vector2(-100f, 100f);
private Text colorTemperatureValueText;
private Text exposureValueText;
private Text saturationValueText;
private WhiteBalance whiteBalance;
private ColorAdjustments colorAdjustments;
protected override void Awake()
{
base.Awake();
if (Instance != this)
{
return;
}
if (menuUI == null)
{
menuUI = GameObject.Find("MenuUI");
}
if (menuUI != null)
{
menuUI.SetActive(false);
}
else
{
Debug.LogError("UIManager:没有找到 MenuUI。", this);
}
if (backgroundMusicToggle != null)
{
backgroundMusicToggle.onValueChanged.AddListener(SetBackgroundMusicEnabled);
}
if (narrationToggle != null)
{
narrationToggle.onValueChanged.AddListener(SetNarrationEnabled);
}
if (visualStyleDropdown != null)
{
visualStyleDropdown.onValueChanged.AddListener(ApplyVisualStyle);
}
colorTemperatureValueText = FindValueText(colorTemperatureSlider);
exposureValueText = FindValueText(exposureSlider);
saturationValueText = FindValueText(saturationSlider);
if (colorTemperatureSlider != null)
{
colorTemperatureSlider.onValueChanged.AddListener(ApplyColorTemperature);
}
if (exposureSlider != null)
{
exposureSlider.onValueChanged.AddListener(ApplyExposure);
}
if (saturationSlider != null)
{
saturationSlider.onValueChanged.AddListener(ApplySaturation);
}
}
private void Start()
{
if (backgroundMusicToggle != null)
{
SetBackgroundMusicEnabled(backgroundMusicToggle.isOn);
}
if (narrationToggle != null)
{
SetNarrationEnabled(narrationToggle.isOn);
}
if (visualStyleDropdown != null)
{
ApplyVisualStyle(visualStyleDropdown.value);
}
InitializePostProcessing();
SyncSlidersWithPostProcessing();
}
private void OnDestroy()
{
if (backgroundMusicToggle != null)
{
backgroundMusicToggle.onValueChanged.RemoveListener(SetBackgroundMusicEnabled);
}
if (narrationToggle != null)
{
narrationToggle.onValueChanged.RemoveListener(SetNarrationEnabled);
}
if (visualStyleDropdown != null)
{
visualStyleDropdown.onValueChanged.RemoveListener(ApplyVisualStyle);
}
if (colorTemperatureSlider != null)
{
colorTemperatureSlider.onValueChanged.RemoveListener(ApplyColorTemperature);
}
if (exposureSlider != null)
{
exposureSlider.onValueChanged.RemoveListener(ApplyExposure);
}
if (saturationSlider != null)
{
saturationSlider.onValueChanged.RemoveListener(ApplySaturation);
}
}
private void LateUpdate()
{
Transform player = GameManager.Instance.Player;
if (menuUI == null || menuUI.activeSelf || player == null)
{
return;
}
PlaceMenuInFrontOfPlayer(player);
}
public void ToggleMenu()
{
if (menuUI == null)
{
return;
}
bool shouldShow = !menuUI.activeSelf;
if (shouldShow && GameManager.Instance.Player != null)
{
PlaceMenuInFrontOfPlayer(GameManager.Instance.Player);
}
menuUI.SetActive(shouldShow);
}
private void SetBackgroundMusicEnabled(bool isEnabled)
{
AudioManager.Instance.SetBackgroundMusicEnabled(isEnabled);
}
private void SetNarrationEnabled(bool isEnabled)
{
AudioManager.Instance.SetNarrationEnabled(isEnabled);
}
private void ApplyVisualStyle(int styleIndex)
{
ApplyMaterial(floorRenderers, floorMaterials, styleIndex);
ApplyMaterial(wallRenderers, wallMaterials, styleIndex);
}
private void InitializePostProcessing()
{
if (postProcessVolume == null || postProcessVolume.profile == null)
{
Debug.LogWarning("UIManager:没有指定可用的后处理 Volume。", this);
return;
}
postProcessVolume.profile.TryGet(out whiteBalance);
postProcessVolume.profile.TryGet(out colorAdjustments);
}
private void ApplyColorTemperature(float value)
{
float temperature = GetPostProcessValue(colorTemperatureSlider, value, colorTemperatureRange);
UpdateValueText(colorTemperatureValueText, temperature);
if (whiteBalance != null)
{
whiteBalance.temperature.overrideState = true;
whiteBalance.temperature.value = temperature;
}
}
private void ApplyExposure(float value)
{
float exposure = GetPostProcessValue(exposureSlider, value, exposureRange);
UpdateValueText(exposureValueText, exposure);
if (colorAdjustments != null)
{
colorAdjustments.postExposure.overrideState = true;
colorAdjustments.postExposure.value = exposure;
}
}
private void ApplySaturation(float value)
{
float saturation = GetPostProcessValue(saturationSlider, value, saturationRange);
UpdateValueText(saturationValueText, saturation);
if (colorAdjustments != null)
{
colorAdjustments.saturation.overrideState = true;
colorAdjustments.saturation.value = saturation;
}
}
private void SyncSlidersWithPostProcessing()
{
if (colorTemperatureSlider != null && whiteBalance != null)
{
colorTemperatureSlider.SetValueWithoutNotify(
GetSliderValue(colorTemperatureSlider, whiteBalance.temperature.value, colorTemperatureRange));
}
if (exposureSlider != null && colorAdjustments != null)
{
exposureSlider.SetValueWithoutNotify(
GetSliderValue(exposureSlider, colorAdjustments.postExposure.value, exposureRange));
}
if (saturationSlider != null && colorAdjustments != null)
{
saturationSlider.SetValueWithoutNotify(
GetSliderValue(saturationSlider, colorAdjustments.saturation.value, saturationRange));
}
if (colorTemperatureSlider != null)
{
ApplyColorTemperature(colorTemperatureSlider.value);
}
if (exposureSlider != null)
{
ApplyExposure(exposureSlider.value);
}
if (saturationSlider != null)
{
ApplySaturation(saturationSlider.value);
}
}
private static float GetPostProcessValue(Slider slider, float sliderValue, Vector2 valueRange)
{
if (slider == null || Mathf.Approximately(slider.minValue, slider.maxValue))
{
return valueRange.x;
}
float normalizedValue = Mathf.InverseLerp(slider.minValue, slider.maxValue, sliderValue);
return Mathf.Lerp(valueRange.x, valueRange.y, normalizedValue);
}
private static float GetSliderValue(Slider slider, float postProcessValue, Vector2 valueRange)
{
if (slider == null || Mathf.Approximately(valueRange.x, valueRange.y))
{
return 0f;
}
float normalizedValue = Mathf.InverseLerp(valueRange.x, valueRange.y, postProcessValue);
return Mathf.Lerp(slider.minValue, slider.maxValue, normalizedValue);
}
private static Text FindValueText(Slider slider)
{
if (slider == null || slider.transform.parent == null)
{
return null;
}
Transform parent = slider.transform.parent;
Text textAfterSlider = null;
foreach (Transform sibling in parent)
{
Text siblingText = sibling.GetComponent<Text>();
if (siblingText == null)
{
continue;
}
if (sibling.name.IndexOf("Value", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return siblingText;
}
if (sibling.GetSiblingIndex() > slider.transform.GetSiblingIndex() && textAfterSlider == null)
{
textAfterSlider = siblingText;
}
}
return textAfterSlider;
}
private static void UpdateValueText(Text valueText, float value)
{
if (valueText == null)
{
return;
}
valueText.text = value.ToString("0.##");
}
private static void ApplyMaterial(Renderer[] renderers, Material[] materials, int styleIndex)
{
if (renderers == null || materials == null ||
styleIndex < 0 || styleIndex >= materials.Length ||
materials[styleIndex] == null)
{
return;
}
foreach (Renderer targetRenderer in renderers)
{
if (targetRenderer != null)
{
targetRenderer.sharedMaterial = materials[styleIndex];
}
}
}
private void PlaceMenuInFrontOfPlayer(Transform player)
{
menuUI.transform.position =
player.position + player.forward * distanceFromPlayer;
menuUI.transform.forward = player.forward;
}
}
现在所有字段、事件、初始化、映射、参数应用和文字方法都已经完整。用这份脚本统一核对后保存,让 Unity 完成编译。
下载完整 UIManager.cs
如果不手动组合分段代码,可以下载这份完整脚本,并用它替换现有 UIManager.cs 的文件内容。
- 完整文件同时包含菜单、声音、风格切换和画面调节功能。
- 文件名与类名必须继续使用 UIManager.cs 和 UIManager。
- 只替换现有 UIManager.cs 的内容,不要删除 Unity 项目中的脚本资源。
- 完整文件保存后再等待 Unity 编译,随后连接场景引用并保存场景。
连接 Volume 与三个滑块
- 01
保存完整 UIManager.cs,等待 Unity 编译结束,并先处理控制台中的红色错误。
- 02
选择挂载 UIManager 的 Scripts 对象,找到“后处理设置”与“后处理滑块对应范围”。
- 03
把 PostProcessing 对象的 Volume 连接到 Post Process Volume。
- 04
依次连接 ColorTemperatureSlider、ExposureSlider 和 SaturationSlider。
- 05
确认范围为色温 -100 到100、曝光 -5 到5、饱和度 -100 到100。
- 06
不用连接三个数值 Text;确认它们分别与对应 Slider 位于同一个 Control 父对象下。
- 07
保存当前场景。

观察重点:后处理设置只需要连接一个 Volume 和三个 Slider;下面三组 Vector2 保存目标后处理范围,数值 Text 由脚本自动查找。
点击图片可以查看完整截图按照代码反查每一个场景对象
连接完成后,不看箭头也能说出每个字段应接收什么对象。
- 指出 Post Process Volume 对应的场景对象和组件。
- 指出三个 Slider 与设置页面三行控件的对应关系。
- 解释为什么属性面板中没有三个 Value Text 字段。
- 确认三个范围的 x 小于 y,并保存场景。
既要观察画面,也要确认数值、初始状态和原有设置
验证三项画面调节与原有功能
最终验证不能只拖动一个滑块。要分别确认三种视觉变化、数值文字、重新进入时的状态,以及已有菜单功能是否继续正常工作。依次观察三个滑块的实际作用
- 01
运行场景并打开设置页面,先观察三个滑块位置、数值文字和当前画面是否一致。
- 02
把色温向两端缓慢拖动,观察画面从偏冷到偏暖,同时确认文字最多显示两位小数。
- 03
缓慢调整曝光,观察暗部和亮部变化,避免长时间停留在过亮或过暗位置。
- 04
降低饱和度观察颜色逐渐变淡,再提高饱和度观察颜色增强。

观察重点:三个滑块都显示真实参数,场景的冷暖、明暗和颜色浓度已经发生变化。图中的较大数值用于观察功能响应,不是作品必须采用的参数。
点击图片可以查看完整截图重新进入场景并确认其他设置
- 01
停止并重新运行,确认滑块会重新从共享 Profile 保存的初始参数同步;本课不会保存上一次拖动后的临时数值。
- 02
再次测试声音开关、风格切换、菜单导航和菜单显示。
完整成果确认表
每一项都同时观察输入、数值和画面输出,确认完整调用链真正工作。
左右滑动表格,可查看完整内容
| 操作 | 应看到的结果 | 说明了什么 |
|---|---|---|
| 进入场景 | 三个滑块、文字和当前画面一致 | 初始反向同步正常 |
| 拖动色温 | 文字更新,画面冷暖改变 | WhiteBalance 链正常 |
| 拖动曝光 | 文字更新,画面明暗改变 | postExposure 链正常 |
| 拖动饱和度 | 文字更新,颜色浓度改变 | saturation 链正常 |
| 连续往返拖动 | 每次都立即响应且没有红色错误 | 事件与映射可重复执行 |
| 操作其他设置 | 声音、风格和导航继续工作 | 各项设置功能可以共同运行 |
常见现象与处理方法
沿着 Slider、UIManager、Volume、Profile、效果参数的顺序定位,不要同时改动多个无关设置。
左右滑动表格,可查看完整内容
| 现象 | 常见原因 | 处理方法 |
|---|---|---|
| 三个滑块都不改变画面 | Volume 未连接或未生效,相机没有读取它,或 Profile 缺少目标效果 | 确认 Volume 已启用、Is Global 已开启、Weight 大于0;相机启用后处理且 Volume Layer Mask 包含对象所在层;再确认 Profile 中两个效果已启用 |
| 只有色温不工作 | Profile 缺少 White Balance | 加入并启用 White Balance |
| 曝光和饱和度都不工作 | Profile 缺少 Color Adjustments | 加入并启用 Color Adjustments |
| 文字不显示 | 文字不在 Slider 同级或没有 Text 组件 | 恢复同组层级与 ValueText 命名,或调整查找方法 |
| 画面变化方向相反 | 范围 x 与 y 顺序颠倒 | 让 x 表示最小值,y 表示最大值 |
| 滑块轻微移动但画面变化过大 | 后处理范围设置过宽 | 缩小对应 Vector2 范围后重新观察 |
| 运行开始时状态跳变 | Profile 当前值超出配置范围 | 核对 Profile 初值与三组目标范围 |
| 方法能运行但参数没有覆盖 | overrideState 没有启用 | 确认赋值前设置 overrideState=true |
| 属性面板看不到后处理字段 | 脚本仍有编译错误 | 先处理控制台红色错误再重新选择 Scripts |
| 其他设置失效 | UIManager.cs 内容不完整 | 使用本课完整 UIManager.cs 补齐菜单、声音和风格方法 |
| 画面正常但文字始终为空 | 界面使用 TextMeshPro,而代码查找的是 UnityEngine.UI.Text | 统一文字组件类型,或把查找代码改为对应的 TextMeshPro 类型 |
一次拖动的完整调用链
以曝光为例,用户拖动 ExposureSlider 后,onValueChanged 把一个 float 交给 ApplyExposure。方法先通过 GetPostProcessValue 计算真正的曝光值,再更新 exposureValueText。
如果 colorAdjustments 已成功取得,代码先启用 postExposure.overrideState,再把曝光值写入 postExposure.value。Volume 在当前画面中使用更新后的 Profile 参数,画面立即发生变化。
方法执行结束后不需要在 Update 或 LateUpdate 中重复写入。只有下一次滑块值变化时,事件才会再次进入同一条调用链。
完成一次可解释的成果演示
演示时不仅展示结果,还要能指出输入、转换、目标参数和初始同步。
- 说明 Volume、Profile、WhiteBalance 和 ColorAdjustments 的关系。
- 说明 Slider、UIManager 与 Volume Profile 各自负责什么。
- 说出一个 Volume、三个 Slider、三个 Vector2 和五个缓存字段的用途。
- 按顺序复述 Awake、Start、滑块事件与 OnDestroy 的职责。
- 解释 TryGet(out ...) 找到的两个效果分别保存在哪里。
- 解释一个滑块值怎样经过 InverseLerp 与 Lerp 得到后处理值。
- 解释 overrideState 和 SetValueWithoutNotify 各自解决什么问题。
- 说明数值 Text 为什么无需在属性面板手动连接。
- 分别展示色温、曝光和饱和度的数值文字与画面变化。
- 确认 Volume 与三个滑块引用已经保存,三组范围方向正确。
- 确认控制台没有红色错误,菜单、声音和风格切换继续正常工作。
把知识变成作品
本课练习
请按顺序完成以下任务,并保存自己的学习成果。- 01
不查看代码,画出 ExposureSlider 到最终画面的完整调用顺序。
- 02
用一个具体数值说明 InverseLerp 与 Lerp 怎样把滑块范围映射到曝光范围。
- 03
把色温目标范围临时缩小,观察滑块相同行程产生的画面变化后恢复设置。
- 04
临时把一个 ValueText 改名并移动到 Slider 前面,观察查找规则的后备行为后恢复层级。
- 05
分别说明 White Balance 缺失和 Color Adjustments 缺失会影响哪些功能。
- 06
完成色温、曝光、饱和度、声音开关和风格切换的连续演示,确认各功能互不干扰。
把本课进度保存下来
登录后可以保存浏览内容、有效学习时间、完成状态和答题参与记录。