第 17 课
语音解说功能开发(下)
本课把作品解说从单一距离触发升级为“距离 + 凝视”双条件判断。你会使用 Gaze Interactor(凝视交互对象)的朝向,编写 CalloutGazeController 计算视线方向,扩展 ArtWorkIntroController 合并距离与凝视状态,再为五件代表性作品配置感应区、作品引用和解说音频。
让作品在靠近并面向时开始解说
在距离感应的基础上加入凝视判断,只有学生进入作品感应区并面向作品时才播放解说,减少路过和背对作品时的误触发。
学完你将能够
学习目标
- 说明为什么靠近作品不能单独代表学生正在观看。
- 根据距离和凝视的四种组合判断解说结果。
- 说明 Gaze Interactor 如何提供视线位置与前方方向。
- 使用 Vector3.Dot(向量点积)判断学生是否面向作品。
- 解释 Facing Threshold(朝向阈值)变大或变小时的影响。
- 使用 UnityEvent 报告开始面向和移开视线。
- 使用只读属性向其他脚本提供凝视事件。
- 说明 Awake、OnEnable 和 OnDisable 的执行分工。
- 使用 Player 标签排除可抓取物体等无关进入者。
- 保留碰撞体计数,正确处理学生装置的多个碰撞体。
- 把距离和凝视状态集中交给 UpdatePlayback 处理。
- 为画作和雕塑选择合适的盒形或球形感应区。
- 使用预制体完成五件作品的凝视解说配置。
- 根据运行现象定位引用、标签、阈值和感应区问题。
开始操作前
准备工作
- 确认第 16 课的背景音乐与作品解说双播放器能够正常工作。
- 确认 AudioManager 可以播放、暂停并继续同一段作品解说。
- 确认 ArtWorkIntro 预制体带有触发碰撞体和 Art Work Intro Controller。
- 确认 XR Origin Hands 可以在场景中移动,并具有能够进入触发区的碰撞结构。
- 在 XR Origin Hands 的 Camera Offset 下找到与 Main Camera 同级的 Gaze Interactor。
- 确认 RoomA、RoomB 中的作品对象和 21 段解说音频均已准备完成。
- 打开 Hierarchy(层级面板)、Inspector(属性面板)、Scene、Game 和 Console 窗口。
- 保存 MainScene,并等待 Unity 完成现有脚本编译。
本课学习路线
按照学习模块逐步完成
每个模块都包含理解、跟随操作和独立实践。靠近说明位置,面向说明观看意图
建立距离与凝视双条件规则
只使用距离感应时,学生从作品旁边经过或背对作品进入区域也可能听到解说。加入凝视状态后,系统只有在“学生仍在感应区内”和“学生正在面向作品”同时成立时才播放。四种状态的播放结果
把距离与凝视分别看成一个条件,只有最后一行会把当前作品登记为可播放。
左右滑动表格,可查看完整内容
| 是否在感应区 | 是否面向作品 | 当前作品请求 | 原因 |
|---|---|---|---|
| 否 | 否 | 撤销 | 既没有靠近,也没有观看 |
| 否 | 是 | 撤销 | 虽然面向作品,但距离过远 |
| 是 | 否 | 撤销 | 虽然靠近,但可能只是路过 |
| 是 | 是 | 登记 | 位置与观看意图同时满足 |
两种操作顺序会得到相同结果
学生可以先走进感应区,再抬头看向作品;也可以先在远处看向作品,再继续走近。每次距离或凝视状态发生变化,系统都会重新计算两个条件。
最终规则写成 collidersInside > 0 && isFacing。其中 && 表示“并且”,左、右两个条件必须同时成立。
- 先靠近:距离先成立,凝视成立后开始播放。
- 先看向:凝视先成立,距离成立后开始播放。
- 任意条件失效:撤销当前作品的播放请求,由 AudioManager 重选仍满足条件的作品。
沿箭头阅读:学生碰撞体提供距离状态,Gaze Interactor 提供视线方向,CalloutGazeController 判断是否面向作品,ArtWorkIntroController 合并两个状态后再向 AudioManager 登记或撤销当前作品。
手机上可左右滑动;点击图片可以查看完整图五个对象各自负责什么
每个对象只解决一个主要问题,出现异常时可以沿着调用关系逐层定位。
左右滑动表格,可查看完整内容
| 对象或脚本 | 主要职责 | 不负责的内容 |
|---|---|---|
| 学生碰撞体 | 报告进入和离开感应区 | 不判断观看方向 |
| Gaze Interactor | 提供视线位置与前方方向 | 不直接播放解说 |
| CalloutGazeController | 判断视线是否朝向当前作品 | 不判断学生距离 |
| ArtWorkIntroController | 保存距离与凝视状态并做最终决策 | 不管理背景音乐优先级 |
| AudioManager | 播放、暂停解说并协调背景音乐 | 不计算距离和方向 |
推演两条状态变化路径
用纸笔分别记录“先靠近再看向”和“先看向再靠近”的状态变化。
- 每一步写出 collidersInside 是否大于 0。
- 每一步写出 isFacing 是 true 还是 false。
- 在两个条件同时成立的那一步标出“播放”。
- 说明为什么两条路径最终得到相同结果。
把视线方向和作品方向变成可以比较的数值
编写视线方向与点积判断
CalloutGazeController 挂在作品对象上。它读取 Gaze Transform 的位置和前方方向,再计算从视线起点指向作品的方向,通过向量点积判断两者是否足够接近。本课不要求眼动硬件
Gaze Interactor 可以在没有可用眼动数据时使用头部朝向。本课只读取它的 Transform,也就是位置和前方方向,因此普通 PICO 头显同样可以完成判断。
这里判断的是学生是否大致面向作品,不是用一条射线精确命中作品表面。作品本身不必为了这段方向计算额外添加碰撞体。
using System;
using Unity.XR.CoreUtils;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Serialization;
namespace Unity.VRTemplate
{
/// <summary>
/// Fires events when this object is is within the field of view of the gaze transform. This is currently used to
/// hide and show tooltip callouts on the controllers when the controllers are within the field of view.
/// </summary>
public class CalloutGazeController : MonoBehaviour
{
[SerializeField, Tooltip("The transform which the forward direction will be used to evaluate as the gaze direction.")]
Transform m_GazeTransform;
[SerializeField, Tooltip("Threshold for the dot product when determining if the Gaze Transform is facing this object. The lower the threshold, the wider the field of view."), Range(0.0f, 1.0f)]
float m_FacingThreshold = 0.85f;脚本放在 Unity.VRTemplate 命名空间中,并保存代表视线方向的 Transform 与朝向阈值。
[SerializeField, Tooltip("Events fired when the Gaze Transform begins facing this game object")]
UnityEvent m_FacingEntered;
[SerializeField, Tooltip("Events fired when the Gaze Transform stops facing this game object")]
UnityEvent m_FacingExited;
public UnityEvent FacingEnteredEvent => m_FacingEntered;
public UnityEvent FacingExitedEvent => m_FacingExited;
[SerializeField, Tooltip("Distance threshold for movement in a single frame that determines a large movement that will trigger Facing Exited events.")]
float m_LargeMovementDistanceThreshold = 0.05f;
[SerializeField, Tooltip("Cool down time after a large movement for Facing Entered events to fire again.")]
float m_LargeMovementCoolDownTime = 0.25f;
bool m_IsFacing;
float m_LargeMovementCoolDown;
Vector3 m_LastPosition;
两个只读属性允许其他脚本注册监听,但不会把内部 UnityEvent 字段直接暴露为可替换字段。
凝视字段与事件
先理解每个字段的含义,再阅读每帧执行的方向判断。
左右滑动表格,可查看完整内容
| 字段或事件 | 作用 | 初始建议 |
|---|---|---|
| m_GazeTransform | 提供视线起点和前方方向 | Gaze Interactor |
| m_FacingThreshold | 决定多接近作品方向才算面向 | 0.85 |
| m_FacingEntered | 从未面向变为面向时触发 | 保留事件 |
| m_FacingExited | 从面向变为不再面向时触发 | 保留事件 |
| m_IsFacing | 防止每帧重复触发相同事件 | false |
void Update()
{
if (m_GazeTransform == null)
return;
CheckLargeMovement();
if (m_LargeMovementCoolDown < m_LargeMovementCoolDownTime)
return;
var dotProduct = Vector3.Dot(m_GazeTransform.forward, (transform.position - m_GazeTransform.position).normalized);
if (dotProduct > m_FacingThreshold && !m_IsFacing)
FacingEntered();
else if (dotProduct < m_FacingThreshold && m_IsFacing)
FacingExited();
}
作品方向先标准化,只保留方向;点积越接近 1,视线就越接近作品方向。
每一帧怎样完成一次凝视判断
Update 每帧从上向下执行,任一次 return 都只结束当前帧这次调用。
左右滑动表格,可查看完整内容
| 顺序 | 代码行为 | 为什么 |
|---|---|---|
| 1 | 确认 m_GazeTransform 是否存在 | 没有视线来源就无法计算方向,立即结束本帧 |
| 2 | 调用 CheckLargeMovement | 先处理作品突然移动和冷却状态 |
| 3 | 确认冷却时间是否结束 | 仍在冷却时不重新进入面向状态 |
| 4 | 计算视线方向与作品方向的点积 | 把两个三维方向转换为可比较的数值 |
| 5 | 比较阈值和 m_IsFacing | 只在面向状态真正改变时调用进入或离开方法 |
认识 var、空引用判断与单行条件
var 表示让编译器根据右侧表达式推断变量类型。dotProduct 的右侧是 Vector3.Dot 的计算结果,因此它仍然是明确的 float,并不是没有类型。
if (m_GazeTransform == null) 用于判断视线引用是否不可用。这里采用与第 14 课一致的显式空引用写法,让条件含义可以直接读出来。
这份脚本把部分 if 写成没有大括号的单行形式。此时 if 只控制紧随其后的第一条语句;如果要增加第二条语句,应补上大括号,避免新代码意外变成每次都会执行。
点积与朝向阈值
Facing Threshold 越高,学生需要更准确地朝向作品。
左右滑动表格,可查看完整内容
| 点积或阈值 | 方向关系 | 使用感受 |
|---|---|---|
| 接近 1 | 视线与作品方向接近一致 | 明确面向作品 |
| 接近 0 | 两个方向接近垂直 | 不算面向作品 |
| 接近 -1 | 两个方向接近相反 | 背对作品 |
| 阈值 0.85 | 允许约 31.8° 的夹角 | 适合作为初始值 |
| 更低阈值 | 允许更大的方向差异 | 更容易触发,也更易误判 |
逐步解释点积表达式
从右向左解释作品方向、标准化和点积比较的作用。
- 指出 m_GazeTransform.position 为什么是视线起点。
- 说明 transform.position - m_GazeTransform.position 得到什么方向。
- 说明 normalized 为什么能够排除距离对结果的影响。
- 把阈值从 0.85 改为 0.80,预测凝视范围的变化。
只在面向状态真正改变时通知其他脚本
完成凝视状态事件与移动保护
凝视脚本每帧计算方向,但 ArtWorkIntroController 不需要每帧重复查询。CalloutGazeController 只在“未面向 → 面向”或“面向 → 未面向”时触发一次事件。 void CheckLargeMovement()
{
// Check if there is large movement
var currentPosition = transform.position;
var positionDelta = Vector3.Distance(m_LastPosition, currentPosition);
if (positionDelta > m_LargeMovementDistanceThreshold)
{
m_LargeMovementCoolDown = 0.0f;
FacingExited();
}
m_LargeMovementCoolDown += Time.deltaTime;
m_LastPosition = currentPosition;
}
作品一帧内移动较远时先退出面向状态,等待短暂冷却后再恢复方向判断。
单帧移动与冷却时间怎样计算
位移使用相邻两帧的位置差,冷却使用真实经过的秒数,两者不要混为一项。
左右滑动表格,可查看完整内容
| 代码或字段 | 它表示什么 | 每帧怎样变化 |
|---|---|---|
| m_LastPosition | 上一帧记录的作品位置 | 本帧结束前更新为 currentPosition |
| positionDelta | 当前位置与上一帧位置之间的距离 | 大于 0.05 米时判定为单帧较大移动 |
| m_LargeMovementCoolDown = 0 | 从头开始计算冷却 | 较大移动发生时执行 |
| Time.deltaTime | 上一帧到当前帧实际经过的游戏时间 | 每帧累加,使冷却按秒推进而不是按帧数推进 |
| m_LargeMovementCoolDownTime | 允许再次进入面向状态前需要等待的时间 | 达到 0.25 秒后恢复方向判断 |
void FacingEntered()
{
m_IsFacing = true;
m_FacingEntered.Invoke();
}
void FacingExited()
{
if (!m_IsFacing)
{
return;
}
m_IsFacing = false;
m_FacingExited.Invoke();
}
}
}m_IsFacing 记录当前状态。FacingExited 会先确认原来确实处于面向状态,避免作品移动时重复发出离开事件。
离开事件也要保护状态切换
CheckLargeMovement 可能在连续多个画面帧中发现作品移动较大。如果 FacingExited 不检查 m_IsFacing,作品已经处于未面向状态时仍可能重复通知监听者。
FacingExited 开头先判断 !m_IsFacing,已经是未面向状态就 return。只有从面向变成未面向时,才更新状态并执行 m_FacingExited.Invoke()。
凝视状态切换
m_IsFacing 避免学生持续看着作品时每帧重复触发进入事件。
左右滑动表格,可查看完整内容
| 原状态 | 点积条件 | 执行方法 | 新状态 |
|---|---|---|---|
| 未面向 | 高于 Facing Threshold | FacingEntered | 面向 |
| 面向 | 低于 Facing Threshold | FacingExited | 未面向 |
| 面向 | 仍高于阈值 | 不重复触发 | 面向 |
| 未面向 | 仍低于阈值 | 不重复触发 | 未面向 |
下载完整凝视判断脚本
如果课堂上需要直接使用完整文件,可以下载后放入 Assets/_Scripts,并确认文件名与类名一致。
- 包含视线方向、点积和阈值判断。
- 包含 Facing Entered 与 Facing Exited 事件。
- 包含供 ArtWorkIntroController 使用的只读事件入口。
完成 CalloutGazeController
把五段代码按顺序组合为一个脚本文件,并在保存前检查大括号。
- 确认 namespace Unity.VRTemplate 包含完整类定义。
- 确认两个 UnityEvent 字段和两个只读事件属性都存在。
- 确认 Update 会先检查 Gaze Transform 是否为空。
- 确认 FacingEntered 与 FacingExited 都更新 m_IsFacing。
先找到作品上的凝视组件,再成对注册和取消事件
连接作品引用与凝视事件生命周期
ArtWorkIntroController 需要知道当前感应区对应哪件作品,并取得该作品上的 CalloutGazeController。它还会保存统一的 AudioManager 引用;组件停用时既取消事件,也撤销自身的解说请求和旧状态。using UnityEngine;
using Unity.VRTemplate;
[RequireComponent(typeof(Collider))]
public class ArtWorkIntroController : MonoBehaviour
{
[SerializeField] private AudioClip introClip;
[SerializeField, Tooltip("对应的艺术品,例如 Painting7")]
private GameObject artwork;
private int collidersInside;
private bool isFacing;
private CalloutGazeController gazeController;
private AudioManager audioManager;using Unity.VRTemplate 让脚本能够识别凝视控制器类型;Artwork 字段保存当前解说对应的作品对象;audioManager 保存统一声音管理器。
作品解说控制器的状态字段
距离和凝视字段保存条件,其余字段保存作品上的凝视组件与统一声音管理器。
左右滑动表格,可查看完整内容
| 字段 | 含义 | 数据来源 |
|---|---|---|
| collidersInside | 仍在感应区内的 Player 碰撞体数量 | 触发事件 |
| isFacing | 当前是否面向这件作品 | 凝视事件 |
| gazeController | 当前作品上的凝视控制组件 | Awake 中查找 |
| artwork | 这一个感应区负责解说的作品 | Inspector 绑定 |
| audioManager | 统一登记和选择作品解说的管理器 | Start 中取得 |
private void Awake()
{
if (artwork != null)
{
gazeController = artwork.GetComponent<CalloutGazeController>();
}
}
private void OnEnable()
{
if (gazeController == null)
{
return;
}
gazeController.FacingEnteredEvent.AddListener(OnFacingEntered);
gazeController.FacingExitedEvent.AddListener(OnFacingExited);
}
private void Start()
{
audioManager = AudioManager.Instance;
}
private void OnDisable()
{
if (audioManager != null)
{
audioManager.PauseArtWorkIntro(introClip);
}
collidersInside = 0;
isFacing = false;
if (gazeController != null)
{
gazeController.FacingEnteredEvent.RemoveListener(OnFacingEntered);
gazeController.FacingExitedEvent.RemoveListener(OnFacingExited);
}
}Awake 取得作品上的凝视组件,OnEnable 注册事件,Start 保存 AudioManager;OnDisable 撤销该作品的解说请求、清零旧状态并取消同一组监听。
为什么 Artwork 必须指向正确对象
GetComponent<CalloutGazeController>() 只在 Artwork 指向的对象本身查找,不会自动搜索父对象或子对象。
例如 ArtWorkIntro_圣彼得的 Artwork 应指向 RoomB / Painting7,并且 Painting7 本身带有 Callout Gaze Controller。如果误指向 RoomB 或作品的其他子节点,gazeController 会保持为空。
按生命周期顺序完成代码
依次完成作品引用、组件查找、声音管理器保存、事件注册和停用清理。
- 确认顶部已经引入 Unity.VRTemplate。
- 在 Awake 中从 artwork 本身取得 CalloutGazeController。
- 在 OnEnable 中分别注册 OnFacingEntered 与 OnFacingExited。
- 在 Start 中保存 AudioManager.Instance。
- 在 OnDisable 中撤销当前作品请求,把距离和朝向恢复为初始状态,再取消完全相同的两个监听函数。
四种状态变化都交给同一个方法重新计算
合并玩家距离、凝视状态与播放决策
进入、离开、看向和移开视线都会改变播放条件。每个事件只更新自己的状态,再统一调用 UpdatePlayback,避免四处重复编写声音控制逻辑。 private void OnTriggerEnter(Collider other)
{
if (!isActiveAndEnabled || !other.CompareTag("Player"))
{
return;
}
collidersInside++;
UpdatePlayback();
}
private void OnTriggerExit(Collider other)
{
if (!isActiveAndEnabled || !other.CompareTag("Player"))
{
return;
}
collidersInside = Mathf.Max(0, collidersInside - 1);
UpdatePlayback();
}CompareTag 只统计真正的学生对象;Mathf.Max 防止退出次数异常时数量变成负数。
private void OnFacingEntered()
{
isFacing = true;
UpdatePlayback();
}
private void OnFacingExited()
{
isFacing = false;
UpdatePlayback();
}
private void UpdatePlayback()
{
if (audioManager == null)
{
return;
}
if (collidersInside > 0 && isFacing)
{
audioManager.PlayArtWorkIntro(introClip);
}
else
{
audioManager.PauseArtWorkIntro(introClip);
}
}只有 collidersInside > 0 与 isFacing 同时成立时登记当前作品;任意条件不满足都撤销该作品,再由 AudioManager 从剩余候选中选择。
private void Reset()
{
GetComponent<Collider>().isTrigger = true;
}
}Reset 在添加组件或重置组件时执行,帮助把同对象上的 Collider 设置为触发器。
四个事件都先更新状态,再进入同一条判断链。距离不满足或凝视不满足都会撤销当前作品;两个条件都满足才登记,并由 AudioManager 选择当前解说。
手机上可左右滑动;点击图片可以查看完整图四种事件分别更新什么
事件方法只更新一个状态,播放规则始终集中在 UpdatePlayback。
左右滑动表格,可查看完整内容
| 事件 | 状态变化 | 随后执行 |
|---|---|---|
| Player 进入 | collidersInside + 1 | UpdatePlayback |
| Player 离开 | collidersInside - 1,最低为 0 | UpdatePlayback |
| 开始面向 | isFacing = true | UpdatePlayback |
| 移开视线 | isFacing = false | UpdatePlayback |
下载完整双条件解说控制脚本
该文件已经包含作品引用、事件生命周期、停用清理、Player 过滤、碰撞体计数和双条件播放请求。
- 使用本课版本替换第 16 课的同名脚本。
- 替换后需要为每个实例重新确认 Artwork 字段。
- 等待 Unity 完成编译后再继续场景绑定。
组合并讲解完整脚本
把五段 ArtWorkIntroController 代码组合为一个文件,再沿着一次“进入并看向”的过程逐段说明。
- Player 进入后,数量增加并重新计算。
- 开始面向后,isFacing 变为 true 并重新计算。
- 两个条件成立时,请求 AudioManager 播放。
- 移开视线或最后一个碰撞体离开时,撤销这件作品的请求并让 AudioManager 重选。
让多件作品共享同一个头部朝向来源
启用凝视对象并批量绑定作品
完成代码后,再进行场景绑定。先启用 Camera Offset 下与 Main Camera 同级的 Gaze Interactor,再给作品添加一个 Callout Gaze Controller,并把同一个凝视对象指定到 Gaze Transform。启用 Gaze Interactor
- 01
展开 XR Origin Hands (XR Rig) > Camera Offset。
- 02
确认 Main Camera 与 Gaze Interactor 位于同一层级。
- 03
选中 Gaze Interactor。
- 04
在 Inspector 顶部开启该游戏对象。
- 05
保留现有 XR Gaze Interactor 与 Gaze Input Manager。
- 06
确认眼动不可用时允许使用头部朝向。

观察两个位置:左侧 Gaze Interactor 与 Main Camera 同级;右侧对象名称前的复选框已经开启。
点击图片可以查看完整截图为 Painting7 添加凝视组件
- 01
展开 ArtWorks > RoomB,选中 Painting7。
- 02
添加 Callout Gaze Controller。
- 03
把 Gaze Interactor 拖到 Gaze Transform。
- 04
Facing Threshold 暂时保持 0.85。
- 05
Large Movement Distance Threshold 保持 0.05。
- 06
Large Movement Cool Down Time 保持 0.25。
为多件作品指定同一个视线对象
- 01
多选 RoomA 与 RoomB 中已经带有凝视组件的作品。
- 02
确认每件作品都只有一个 Callout Gaze Controller。
- 03
把同一个 Gaze Interactor 拖到公共的 Gaze Transform 字段。
- 04
保存 MainScene。

左侧多选需要配置的作品,右侧把同一个 Gaze Interactor 指定给公共的 Gaze Transform 字段。
点击图片可以查看完整截图Callout Gaze Controller 初始设置
先使用统一初始值完成运行,再根据作品间距小幅调整。
左右滑动表格,可查看完整内容
| 属性 | 初始值 | 作用 |
|---|---|---|
| Gaze Transform | Gaze Interactor | 提供视线位置与方向 |
| Facing Threshold | 0.85 | 决定面向作品的角度范围 |
| Large Movement Distance Threshold | 0.05 | 识别作品单帧较大移动 |
| Large Movement Cool Down Time | 0.25 | 移动后短暂等待再判断凝视 |
每件作品只保留一个凝视组件
如果先批量添加组件,又对单件作品再次添加,可能出现两个 Callout Gaze Controller。重复组件会进行两次方向计算,而且其中一个 Gaze Transform 可能仍为空。
正确结果是每件作品只有一个组件,并且该组件的 Gaze Transform 指向 Gaze Interactor。
完成三件画作的凝视配置
先为 Painting7、Painting8 和 Painting9 配置凝视组件。
- 三件作品上的 Callout Gaze Controller 数量都为一个。
- 三个 Gaze Transform 都指向同一个 Gaze Interactor。
- 三个 Facing Threshold 暂时保持 0.85。
- 保存场景并确认 Console 没有新增编译错误。
只让真正的学生对象改变距离状态
设置 Player 标签并连接作品感应区
ArtWorkIntroController 使用 CompareTag("Player") 过滤进入者。标签必须设置在实际带着 Collider 进入感应区的对象上,不能只看层级名称猜测。为学生装置设置 Player 标签
- 01
选中 XR Origin Hands (XR Rig)。
- 02
打开 Inspector 顶部的 Tag 下拉框。
- 03
选择 Player。
- 04
确认实际产生触发事件的 Collider 位于这个对象上。
- 05
如果 Collider 位于子对象,确认该子对象是否也需要 Player 标签。

代码判断的是实际进入触发区的对象。图中 XR Origin Hands 的 Tag 已设为 Player。
点击图片可以查看完整截图不要给所有手柄和道具都设置 Player
other 表示实际进入感应区的 Collider 所在对象。如果根对象是 Player,但实际进入的是仍为 Untagged 的子对象,CompareTag 会返回 false。
也不要把手、道具和控制器全部设为 Player,否则同一个学生装置可能产生过多计数。先找到真正负责身体碰撞的对象,再设置标签。
配置圣彼得作品感应区
- 01
选择 ArtWorkIntro_圣彼得。
- 02
确认 Collider 已启用并开启 Is Trigger。
- 03
确认 Art Work Intro Controller 已挂载。
- 04
把 Intro Clip 指定为圣彼得.mp3。
- 05
把 Artwork 指定为 RoomB / Painting7。
- 06
确认 Painting7 本身带有 Callout Gaze Controller。
圣彼得实例的完整对应关系
Artwork 指向的对象必须正好带有凝视组件,不能指向 RoomB 父对象或其他子节点。
左右滑动表格,可查看完整内容
| 配置位置 | 字段 | 正确对象 |
|---|---|---|
| 学生装置 | Tag | Player |
| ArtWorkIntro_圣彼得 | Is Trigger | 开启 |
| ArtWorkIntro_圣彼得 | Intro Clip | 圣彼得.mp3 |
| ArtWorkIntro_圣彼得 | Artwork | RoomB / Painting7 |
| Painting7 | Gaze Transform | Camera Offset / Gaze Interactor |
沿引用关系逐项确认
从学生装置开始,依次确认标签、感应区、作品和视线对象。
- 学生碰撞对象能够通过 Player 标签检查。
- 感应区可以被穿过,不会形成看不见的墙。
- Artwork 指向 Painting7 本身。
- Painting7 的 Gaze Transform 不为空。
- Intro Clip 与圣彼得作品一致。
画作使用盒形范围,雕塑允许从多个方向靠近
使用预制体配置五件代表性作品
预制体保存通用组件,每个实例仍要分别设置位置、感应区形状、Artwork 和 Intro Clip。先完成三幅墙面画作,再为思想者与玫瑰果酱药剂瓶选择合适的范围。五件作品的绑定关系
配置时同时确认房间、Unity 对象、作品名称和音频文件,避免只根据实例顺序猜测。
左右滑动表格,可查看完整内容
| 展厅 | Unity 对象 | 作品名称 | 解说音频 |
|---|---|---|---|
| RoomB | Painting7 | 圣彼得 | 圣彼得.mp3 |
| RoomB | Painting8 | 画室中的艺术家 | 画室中的艺术家.mp3 |
| RoomB | Painting9 | 冬日河畔景色 | 冬日河畔景色.mp3 |
| RoomA | TheThinker | 思想者 | 思想者.mp3 |
| RoomA | RosehipVase | 玫瑰果酱药剂瓶 | 玫瑰果酱药剂瓶.mp3 |
根据作品形态选择感应区
感应区负责距离判断,作品上的凝视脚本仍然负责方向判断。
左右滑动表格,可查看完整内容
| 作品类型 | 建议碰撞体 | 设置重点 |
|---|---|---|
| 墙面画作 | Box Collider | 覆盖正面观看位置,不延伸到主要通道 |
| 可从多方向接近的雕塑 | Sphere Collider | 围绕作品覆盖合理观赏距离 |
| 相邻作品 | 较小且独立的范围 | 减少两个感应区大面积重叠 |
为思想者改用球形感应区
- 01
选择思想者的 ArtWorkIntro 实例。
- 02
移除原有 Box Collider。
- 03
添加 Sphere Collider。
- 04
开启 Is Trigger。
- 05
调整 Center 与 Radius,覆盖合理观赏距离。
- 06
确认 Artwork 指向 RoomA / TheThinker。
- 07
确认 Intro Clip 指向思想者.mp3。
用清楚命名保持场景层级一致
建议把五个实例放在 ArtWorkIntro 父对象下,并按作品名称命名,例如 ArtWorkIntro_圣彼得、ArtWorkIntro_思想者。
清楚命名可以避免选错 Artwork、绑定错误音频,或误调整另一件作品的感应区。
完成五个作品实例
为三幅画作建立盒形范围,为思想者建立球形范围,并完成五组作品与音频绑定。
- Painting7、Painting8、Painting9 使用独立的盒形触发区。
- TheThinker 使用开启 Is Trigger 的球形触发区。
- RosehipVase 使用与展品位置匹配的独立触发区。
- 每个 Artwork 与 Intro Clip 都与表格同一行对应。
- 所有实例命名清楚并放入同一父对象。
用不同进入顺序、朝向和对象验证完整逻辑
组合测试并调整凝视体验
不要只测试“正面走近作品”这一种情况。完整测试需要覆盖两个条件成立的不同顺序,也要验证路过、背对、移开视线、非 Player 物体和多个碰撞体。八组组合测试
每次只改变一个条件,观察作品解说是否按照双条件规则播放。
左右滑动表格,可查看完整内容
| 测试动作 | 预期结果 | 主要验证内容 |
|---|---|---|
| 低头进入,再抬头看向作品 | 看向后开始 | 距离先成立 |
| 区域外看向,再保持朝向走近 | 进入后开始 | 凝视先成立 |
| 面向通道从作品旁路过 | 保持暂停 | 减少路过误触发 |
| 背对作品倒退进入 | 保持暂停 | 距离不能代替凝视 |
| 播放后移开视线,再看回来 | 暂停后继续 | 严格双条件与播放进度 |
| 非 Player 道具进入 | 不改变声音 | 标签过滤 |
| 多个 Player 碰撞体依次进出 | 最后离开才暂停 | 碰撞体计数 |
| 保持面向 A 并进入 B,再离开 B | B 播放后重新选择仍有效的 A | 候选列表与重叠退出 |
凝视感觉不自然时按顺序调整
先确认引用和目标位置,再调整参数。不要同时修改感应区、作品位置和阈值,否则很难判断哪一项真正解决了问题。
- 确认 Gaze Transform 指向正确的 Gaze Interactor。
- 确认作品 Transform 的位置接近视觉中心。
- 确认感应区没有覆盖相邻作品或主要通道。
- 最后再小幅调整 Facing Threshold。
- 每次只改变一个设置并重新运行。
常见现象与处理方法
从标签、引用、感应区和阈值依次排查,避免盲目修改代码。
左右滑动表格,可查看完整内容
| 现象 | 优先确认 | 处理方法 |
|---|---|---|
| 进入区域后始终没有声音 | Player、Is Trigger、Artwork、Intro Clip | 沿引用链逐项确认是否为空 |
| 看向作品仍不播放 | 是否已经进入感应区 | 确认距离后再检查 Gaze Transform 与阈值 |
| 区域外看向就播放 | UpdatePlayback 条件 | 保持 collidersInside > 0 && isFacing |
| 路过作品仍然播放 | 感应区大小与阈值 | 缩小范围,再适当提高阈值 |
| 必须非常准确才触发 | Facing Threshold 过高 | 从 0.85 小幅降低并重新测试 |
| 相邻作品声音来回切换 | 候选列表、感应区和凝视范围 | 先确认登记与撤销顺序正确,再根据观看体验调整范围和阈值 |
| 离开 B 后没有回到仍满足条件的 A | PauseArtWorkIntro 与 activeIntroClips | 确认 B 被移除后会从候选列表重新选择 A |
| 移开视线后仍播放 | OnFacingExited | 确认更新 isFacing 后调用 UpdatePlayback |
| 一个碰撞体离开就暂停 | 是否保留计数 | 使用加一和最低为零的减一逻辑 |
独立完成一件新作品
从尚未配置的作品中选择一件,完成凝视、感应区、作品和音频的全部连接。
- 作品上只有一个 Callout Gaze Controller。
- Gaze Transform 指向 Gaze Interactor。
- 根据作品形态选择 Box Collider 或 Sphere Collider。
- 感应区开启 Is Trigger,Artwork 与 Intro Clip 对应正确。
- 四种距离与凝视组合均得到预期结果。
- 与相邻作品交叉进入后,退出新作品仍能回到有效的上一件作品。
- 能够口述从凝视事件到 AudioManager 的完整调用链。
本课成果清单
运行结束前逐项确认,确保代码与场景配置都已完成。
- 两份脚本能够编译,十个代码段组合后与下载文件一致。
- Gaze Interactor 已启用,眼动不可用时能够使用头部朝向。
- 每件作品只有一个 Callout Gaze Controller。
- 每个 Gaze Transform 都指向正确对象。
- 实际进入感应区的学生碰撞对象带有 Player 标签。
- 五件代表性作品的 Artwork 与 Intro Clip 对应正确。
- 路过、背对和非 Player 物体不会触发解说。
- 进入并面向时播放,移开视线或离开后暂停。
- 重新看向同一作品时从暂停位置继续。
- MainScene 已保存,Console 没有新增错误。
把知识变成作品
本课练习
请按顺序完成以下任务,并保存自己的学习成果。- 01
不用查看真值表,口述距离与凝视四种组合的播放结果。
- 02
逐段说明 Vector3.Dot 如何把视线方向与作品方向变成可比较的数值。
- 03
把两份脚本的代码段分别组合,并与可下载的完整文件核对。
- 04
为一幅墙面画作和一件雕塑分别选择合适的感应区形状。
- 05
故意清空一个 Gaze Transform,再根据运行现象找到并修复引用。
- 06
对同一件作品依次完成先靠近、先看向、路过、背对和移开视线测试。
把本课进度保存下来
登录后可以保存浏览内容、有效学习时间、完成状态和答题参与记录。