
1. 项目概述为什么我们需要一个实验框架如果你在Unity里做过任何需要收集数据、控制实验流程或者管理被试参与者的项目比如心理学实验、人机交互研究、用户体验测试甚至是游戏里的A/B测试那你一定经历过这种混乱脚本里到处散落着计时器、数据记录、条件判断的代码每次修改实验流程都得小心翼翼地调整一堆协程和状态机数据导出时又要写一堆CSV拼接的代码还容易出错。整个项目就像一锅粥逻辑纠缠不清后期维护和扩展简直是噩梦。UXFUnity Experiment Framework就是为了终结这种混乱而生的。它不是一个炫酷的图形化工具而是一个严谨、结构化、开箱即用的代码框架。你可以把它理解为你实验项目的“骨架”和“神经系统”。它帮你把实验中最通用、最繁琐的部分——比如试次Trial的创建与循环、数据的自动记录与存储、实验条件的随机化与平衡——全部抽象出来封装成一套清晰的API。你的工作就从“从头造轮子”变成了“在坚固的骨架上填充血肉”专注于设计实验刺激和交互逻辑本身。我最初接触UXF是在一个多模态感知的研究项目中需要记录用户在几十种不同视听刺激下的反应时间和正确率。如果没有框架光是管理这上百个试次和对应的数据文件就足以让人崩溃。用了UXF之后整个项目的代码量减少了至少40%逻辑清晰得像教科书目录数据自动按会话Session、试次Trial层级保存为标准的CSV和JSON文件导师看了都直呼专业。对于学生、研究员和任何需要在Unity中进行可控实验的开发者来说UXF能极大提升效率、减少错误并让整个项目具备可重复性和专业性。2. 核心概念与架构拆解理解UXF的“世界观”在深入代码之前必须理解UXF定义的几个核心概念。这是用好它的关键否则你只会对着API文档感到困惑。2.1 会话、试次与数据收集单元UXF将一次实验运行过程组织成一个三层结构这非常符合认知科学实验的通用范式。会话是一次完整的实验运行。比如一个被试来到实验室完成全部实验任务这就算一个会话。在UXF中一个Session对象管理着整个实验的生命周期。它负责创建试次、管理实验状态如暂停、结束并且是所有数据的顶级容器。一个会话会产生一个独立的文件夹里面存放本次运行的所有数据。试次是实验的基本单元。比如一次刺激呈现、一次用户反应、一次判断任务就是一个试次。Trial对象由会话创建它有自己的开始和结束时间并携带一组“条件”。在UXF中你通过编写代码来定义每个试次中应该发生什么例如显示一个红色方块等待用户按键。数据收集是UXF的自动化核心。你不需要手动写File.WriteAllText。UXF提供了Trial和Session上的.BeginCollectingData()和.EndCollectingData()方法。你只需要在开始收集时指定一个唯一的标识符如“response_time”和一个数据对象可以是floatstring 甚至自定义类的实例UXF会自动将其记录到对应试次或会话的数据表中并在结束时写入文件。数据会自动与试次编号、时间戳等元数据关联。2.2 组件化的工作流UXF如何融入你的场景UXF不强制要求你改变整个项目结构而是通过几个核心的MonoBehaviour组件以非侵入的方式工作。UXF Session 组件这是场景中的“大脑”。你把它挂在一个空的GameObject上比如命名为“ExperimentManager”。它的Inspector面板是你配置整个实验的主要界面比如设置数据存储路径、定义试次列表等。一个场景中通常有且只有一个Session组件。Trackers 追踪器这是UXF设计中最精妙的部分之一。追踪器是专门用于自动收集特定类型数据的组件。例如PositionRotationTracker自动记录某个GameObject每一帧的位置和旋转。WebCamTracker记录摄像头的图像数据。EyeTracker与眼动仪插件桥接记录注视点数据。你不需要写一行收集代码只需要把对应的Tracker组件拖到目标物体上并关联到当前的Session它就会在试次期间自动运行。你也可以轻松编写自己的自定义追踪器来收集任何你需要的数据。UI 与事件UXF提供了一套简单的UI预制件如开始会话按钮、试次信息显示但更强大的方式是通过C#事件。Session和Trial对象暴露了诸如OnSessionBegin、OnTrialBegin、OnTrialEnd等C#事件。你可以在自己的脚本中订阅这些事件来在精确的时刻触发你的实验逻辑例如在试次开始时生成刺激物在试次结束时销毁它。这种基于事件的编程模式让实验逻辑与UXF框架实现了优雅的解耦。3. 从零开始一个简单反应时实验的完整实现理论说再多不如动手做一遍。让我们来实现一个最经典的实验简单视觉反应时任务。屏幕上会随机间隔后出现一个刺激比如一个绿色圆形用户需要尽快按下空格键系统记录从刺激出现到按键的反应时间。3.1 项目初始化与UXF安装创建Unity项目使用Unity Hub创建一个新的3D或2D项目本例以2D为例更简单。安装UXF最推荐的方式是通过Unity的Package Manager从Git URL安装这能确保你获得最新版本。打开Window Package Manager。点击左上角的号选择Add package from git URL...。输入UXF的Git仓库地址https://github.com/immersivecognition/unity-experiment-framework.git。点击Add。Unity会下载并导入UXF。完成后你会在Package Manager中看到“Unity Experiment Framework”。注意如果Git方式失败你也可以从GitHub Releases页面下载.unitypackage文件并手动导入。但Git方式能更方便地更新。基础场景搭建创建一个简单的2D场景。创建一个UI Canvas在里面添加一个全屏的Image作为刺激物将其颜色设置为白色或背景色并默认禁用取消勾选Inspector顶部的复选框。我们稍后通过代码来启用它。再添加一个Text - TextMeshPro如果提示导入TMP Essentials点确认来显示指导语比如“准备开始”。3.2 配置实验会话与试次列表创建Session对象在场景中创建一个空GameObject命名为“ExperimentManager”。点击Add Component搜索并添加“Session”组件。理解Session InspectorSettings Profile: 可以创建不同的配置预设我们先不管。Participant List: 输入被试编号如[p001, p002]。运行时UXF会按顺序分配。Write Path 数据保存路径。默认是%DATA_PATH%/UXF/Data其中%DATA_PATH%在编辑器中指向项目的Assets/UXF_Data文件夹在打包后指向可执行文件同级目录。你可以按需修改。最关键的部分Trial List。这里定义了你整个实验的试次结构。我们可以通过代码动态生成但对于简单实验可以直接在Inspector中手动创建。手动定义试次在Session组件的Trial List下方点击号新增一个试次。你可以为试次添加“条件”。例如我们可以添加一个条件叫“stimulus_color”值为“green”。再添加一个条件叫“delay_time”值为“1.5”。这个值代表刺激出现前的等待时间秒。你可以点击多次创建多个具有不同delay_time的试次以实现随机间隔。例如创建5个试次delay_time分别设为1.0 1.5 2.0 1.2 1.8。实操心得对于更复杂的实验设计如完全随机、区组随机、拉丁方设计强烈建议通过编写脚本在运行时动态生成Trial List。你可以创建一个ExperimentBuilder脚本在Awake或Start中访问Session.Instance.settings的SetValue方法或者直接操作Session.Instance.customTrialList来编程式地构建试次列表。这比在Inspector里手动输入几十上百个试次要可靠和高效得多。3.3 编写核心实验逻辑脚本现在创建最重要的脚本SimpleReactionTimeTask.cs。将其挂载到ExperimentManager或另一个专门的控制器对象上。using System.Collections; using System.Collections.Generic; using UnityEngine; using UXF; // 引入UXF命名空间 public class SimpleReactionTimeTask : MonoBehaviour { public Session session; // 在Inspector中关联Session组件 public GameObject stimulus; // 关联那个作为刺激物的UI Image public TMPro.TextMeshProUGUI instructionText; // 关联指导语Text private Trial currentTrial; private float stimulusStartTime; private bool waitingForResponse; void Start() { // 订阅Session的重要事件 session.OnSessionBegin.AddListener(InitializeExperiment); session.OnTrialBegin.AddListener(OnTrialStart); session.OnTrialEnd.AddListener(OnTrialEnd); } void Update() { // 在等待反应时检测按键 if (waitingForResponse Input.GetKeyDown(KeyCode.Space)) { float reactionTime Time.time - stimulusStartTime; RecordReactionTime(reactionTime); EndTrial(); } } void InitializeExperiment(Session startedSession) { instructionText.text 实验即将开始请注视屏幕中央。; // 可以在这里设置一些会话级别的数据收集 session.BeginCollectingData(session_info); session.RecordData(session_info, new { start_time System.DateTime.Now.ToString() }); } void OnTrialStart(Trial startedTrial) { currentTrial startedTrial; instructionText.text ; waitingForResponse false; // 从当前试次的条件中获取延迟时间 float delay (float)currentTrial.settings.GetObject(delay_time, 1.0f); // 开始为该试次收集数据指定一个表格名如“trial_data” currentTrial.BeginCollectingData(trial_data); // 记录试次开始时间和条件 currentTrial.RecordData(trial_data, new { trial_num currentTrial.number, condition_delay delay, stimulus_shown false }); // 启动延迟显示刺激物的协程 StartCoroutine(ShowStimulusAfterDelay(delay)); } IEnumerator ShowStimulusAfterDelay(float delay) { // 等待指定的延迟时间 yield return new WaitForSeconds(delay); // 显示刺激物 stimulus.SetActive(true); stimulusStartTime Time.time; waitingForResponse true; // 更新数据标记刺激已呈现 currentTrial.RecordData(trial_data, new { stimulus_shown true, stimulus_time stimulusStartTime }); } void RecordReactionTime(float rt) { // 记录反应时数据到当前试次的同一张表中 currentTrial.RecordData(trial_data, new { reaction_time rt, key_pressed Space }); waitingForResponse false; stimulus.SetActive(false); // 隐藏刺激物 } void EndTrial() { // 结束当前试次的数据收集非常重要 currentTrial.EndCollectingData(trial_data); // 告诉Session结束当前试次这将自动触发下一个试次或结束会话 session.EndCurrentTrial(); } void OnTrialEnd(Trial endedTrial) { // 试次结束后的清理工作本例中已在EndTrial中完成 Debug.Log($Trial {endedTrial.number} ended.); } }3.4 关联与运行将SimpleReactionTimeTask.cs脚本挂载到ExperimentManager上。在Inspector中将该脚本的Session字段拖拽赋值或通过代码GetComponent获取。将场景中的刺激物Image和指导语Text分别拖拽赋值到对应的公共字段。确保Session组件上的Participant List不为空。运行游戏。你应该会看到指导语然后刺激物会按设定的延迟时间出现和消失并在你按下空格键后记录数据自动进入下一个试次。4. 数据管理与高级功能超越基础教程当你的实验顺利运行起来后下一步就是确保数据可靠、实验设计科学并能处理更复杂的需求。4.1 理解生成的数据文件实验结束后去项目目录下的Assets/UXF_Data或你自定义的路径查看。你会找到一个以日期时间命名的文件夹里面是类似“p001”的被试文件夹。再进去你会看到session_results.csv: 会话级别的数据汇总。trial_results.csv:这是最重要的文件。它包含了所有试次的所有数据。你会看到列如trial_num,start_time,condition_delay,stimulus_shown,reaction_time,key_pressed等。UXF巧妙地将你在一个试次内多次RecordData记录的同名字典对象自动扁平化并合并到同一行。每一行代表一个完整的试次。可能还有其他追踪器生成的文件如position_rotation_tracker.csv。这种结构化的数据输出让你可以直接用Pythonpandas、R或Excel进行统计分析无需繁琐的数据清洗和匹配。4.2 实现复杂的实验设计简单的固定试次列表很快会碰到瓶颈。UXF的强大之处在于其编程灵活性。动态生成试次列表在会话开始前你可以完全用代码构建一个试次列表。void GenerateComplexTrials(Session session) { Liststring colors new Liststring { red, green, blue }; Listint sizes new Listint { 1, 2, 3 }; ListUXF.Trial trialList new ListUXF.Trial(); // 创建一个完全随机的设计所有颜色和尺寸组合每个组合重复2次 for (int rep 0; rep 2; rep) { foreach (var color in colors) { foreach (var size in sizes) { // 创建一个新的Trial设置对象 var settings new UXF.Settings(); settings.SetValue(stimulus_color, color); settings.SetValue(stimulus_size, size); // 可以添加更多条件... // 创建Trial并加入列表 trialList.Add(new UXF.Trial(settings)); } } } // 打乱列表顺序以实现随机化 trialList trialList.OrderBy(t Random.value).ToList(); // 将列表分配给Session session.customTrialList trialList; } // 在Session的OnSessionBegin事件监听器中调用此方法区组化设计你可以在生成列表时先按区组分组在组内随机然后拼接列表从而实现区组随机化。4.3 创建自定义追踪器当内置追踪器不满足需求时创建自定义追踪器是终极解决方案。这比在每个试次逻辑里手动记录数据要整洁和可复用得多。例如创建一个记录自定义游戏物体特定属性的追踪器创建一个新脚本CustomObjectTracker.cs继承自UXF.Tracker。实现抽象属性MeasurementDescriptor返回描述如“custom_object”和DataType返回数据类型如“JSON”。在Start方法中调用base.Start()进行自动设置。在OnEnable和OnDisable中订阅和取消订阅OnTrialBegin和OnTrialEnd事件。在追踪过程中例如Update或协程中使用RecordRow方法记录数据。using UXF; public class CustomObjectTracker : Tracker { public Transform targetObject; // 要追踪的物体 public bool trackPosition true; public bool trackRotation true; protected override void SetupDescriptorAndData() { // 设置描述符这将是生成文件名的前缀 measurementDescriptor custom_tracker; // 设置数据类型 dataType JSON; } protected override void OnTrialStart() { // 试次开始时可以记录一次初始状态 if (trackPosition trackRotation) { RecordRow(new { time Time.time, pos_x targetObject.position.x, pos_y targetObject.position.y, pos_z targetObject.position.z, rot_x targetObject.eulerAngles.x, rot_y targetObject.eulerAngles.y, rot_z targetObject.eulerAngles.z }); } } void Update() { // 如果当前有试次正在运行且正在收集数据 if (CurrentTrial ! null CurrentTrial.InProgress) { // 每一帧或按需记录 if (trackPosition) { // 注意高频记录可能会产生大量数据需谨慎。 // 通常建议在FixedUpdate中记录或降低频率。 RecordRow(new { time Time.time, position targetObject.position }); } } } }将这个脚本挂到任意物体上在Inspector中关联好Session和目标物体它就会自动工作。5. 常见问题、调试技巧与性能优化即使框架再完善实际开发中总会遇到坑。下面是我在多个项目中总结的一些典型问题和解决方案。5.1 安装与基础配置问题问题导入UXF后编译错误提示命名空间UXF不存在。排查检查Package Manager中UXF是否成功安装。有时需要关闭并重新打开Unity项目或等待Unity重新编译脚本。确保你的脚本文件顶部有using UXF;。问题运行实验时数据没有保存。排查首先检查Session组件的Write Path是否有写入权限。在Windows上避免使用C:\Program Files等受保护目录。确保每个试次的数据收集有始有终。最常见的错误是调用了BeginCollectingData但忘记调用对应的EndCollectingData。数据只有在EndCollectingData被调用后才会被写入磁盘。确保它在OnTrialEnd或你的结束逻辑中被调用。检查Unity编辑器控制台是否有任何关于文件写入的异常错误。5.2 实验流程与控制问题问题试次没有按预期自动开始或循环。排查UXF不会自动开始第一个试次。你需要手动调用session.BeginNextTrial()或session.BeginNextTrialOrEndSession()来启动流程。通常你在UI的“开始实验”按钮事件或OnSessionBegin事件末尾调用它。同样一个试次结束后你需要调用session.EndCurrentTrial()来手动结束当前试次并触发下一个。问题我想在试次之间插入一个休息界面或反馈界面。方案不要试图在单个试次的OnTrialEnd和下一个试次的OnTrialBegin之间“暂停”UXF。正确做法是将休息界面作为实验流程的一部分。例如在OnTrialEnd中如果满足休息条件如试次编号是10的倍数则显示休息UI并设置一个标志位。当用户点击“继续”按钮时再调用session.BeginNextTrial()。这意味着“休息”本身不是一个由UXF管理的试次而是你实验逻辑中的一个状态。5.3 数据记录相关问题问题我的自定义追踪器记录的数据是空的或者文件没有生成。排查确保追踪器组件上的Session字段已正确关联。确保重写了MeasurementDescriptor和DataType属性。检查RecordRow方法是否在试次进行期间被调用CurrentTrial.InProgress为true。检查你记录的数据对象是否是简单类型数字、字符串、布尔值或可序列化为JSON的对象。复杂的Unity对象如GameObject、Texture不能直接记录。问题trial_results.csv文件中的列很多但有些行数据不全。理解这是正常现象。UXF使用“稀疏数据”记录。每个试次记录的数据字段可能不同。例如试次1记录了{“a”: 1, “b”: 2}试次2记录了{“a”: 3, “c”: 4}。最终CSV会有abc三列试次1的c列为空试次2的b列为空。数据分析时需要用适当的方法如pandas处理缺失值。5.4 性能与最佳实践高频数据记录如果你的追踪器需要在Update中每一帧都记录数据如位置追踪这会产生海量数据60行/秒。对于长时间实验可能导致内存和磁盘问题。优化考虑在FixedUpdate中记录以降低频率或者实现一个采样机制例如每0.1秒记录一次。对于极其高频的需求可以考虑先将数据缓存在内存列表中在试次结束时批量写入但这需要修改追踪器的工作方式。场景管理对于刺激物众多的实验不要在试次开始时实例化(Instantiate)结束时销毁(Destroy)。这会产生GC垃圾回收压力可能导致卡顿。优化使用对象池。在会话开始时预先实例化好所有可能用到的刺激物对象并设置为禁用状态。在试次中只是从池中取出启用和放回禁用避免频繁的创建与销毁。异步操作与UXF避免在试次进行的关键路径上如OnTrialBegin中使用阻塞性的同步操作如同步加载大型资源。这会导致帧率下降影响计时精度尤其是反应时实验。方案使用Unity的异步加载Addressables.LoadAssetAsync或Resources.LoadAsync并结合协程妥善管理状态。可以考虑在会话开始前的指导语阶段就预加载所有需要的资源。UXF是一个需要你稍微改变一下思维模式去适应其“会话-试次-数据收集”范式的工具。一旦掌握它带来的条理性和效率提升是巨大的。它强迫你写出更模块化、更易维护的实验代码。对于严肃的科研或工业界实验项目花时间学习和应用UXF是一项非常值得的投资。