
在量化社会学与人机交互研究中VR 仿真实验是近年非常热门的数据采集方式。项目标题为The Effect of Perceived Race and Gender on Police Language Use: Experimental Evidence from VR Simulations简单来说这是一项利用 VR 场景模拟警民互动来控制“被感知到的种族与性别”变量进而分析语言使用差异的研究。从技术角度看这类实验背后是一整套可复用的 VR 实验系统角色定制、场景搭建、语音录制、实验变量控制、数据同步与文本分析。本文不讨论研究中的敏感社会结论而是把重点放在“如何从零搭建一个 VR 交互实验系统”并用这段英文标题作为项目背景来拆解技术方案。无论你是做 VR 开发、人机交互研究还是想用沉浸式场景采集语音数据、分析对话文本这篇文章都会很实用。文中会包含 Unity 场景搭建、OpenXR 配置、麦克风录音、Python 音频转文本与语言特征提取的完整示例代码也会给出实验数据管理的注意事项。整个方案不依赖昂贵硬件普通 PC VR 头显即可跑通。1. 项目背景与研究动机1.1 为什么用 VR 做交互语言研究传统的面对面访谈或录像回放研究最大的问题是变量难以控制。两个人在真实场景中对话时语音、手势、背景、情绪都会有无数干扰因素。若研究者想单独观察“对面那个人的外观特征”如何影响语言表达几乎不可能在真实环境中制造两组完全相同的对话条件。VR 仿真实验刚好解决了这个问题。虚拟场景可以被完全复现角色外观可以根据实验设计调整语音内容可以标准化甚至连 NPC 的回复策略都可以保持一致。这样一来三次实验之间唯一变化的可能只是虚拟角色的一个可见属性。这种“可重复、可控制”的特性让 VR 成为社会语言学、沟通行为研究中非常理想的实验工具。具体到本文所描述的研究背景实验设计者需要搭建一套模拟执法场景的 VR 环境让被试进入场景后与某个虚拟角色进行对话。研究人员会录制被试的语音分析他们在面对不同外观角色时的遣词造句差异。技术侧需要完成以下几件事。1.2 技术挑战拆解从技术实现角度看整个项目可以拆成几个模块VR 场景构建包括环境模型、光照、交互逻辑确保沉浸感。角色定制能快速生成不同外观特征性别、年龄、肤色等的角色并把每次实验的角色条件记录下来。实验流程控制包括开始引导、正式交互、结束问卷等步骤。语音采集与同步录制被试语音同时保存触发时间、场景编号、角色编号。音频转文本与语言特征分析离线分析转写文本提取语速、礼貌用语、疑问句数量等特征。之所以需要把这块拆开来做是因为如果所有逻辑都堆在 Unity 场景里后续增加新场景、新角色、新分析指标都会很痛苦。下面我会先给出整体架构再逐个模块落地。2. VR 实验系统整体架构2.1 模块划分与数据流向先看一个简化后的架构图用文本方式描述Unity VR 客户端 ├── 场景管理模块加载虚拟环境 ├── 角色管理模块按实验条件生成角色 ├── 交互流程控制引导、提示、录音开关 ├── 语音采集模块Microphone 录制 WAV └── 实验日志模块输出 CSV 日志 │ ▼ 本地文件目录 ├── audio / 语音文件 ├── logs / 实验日志 └── metadata / 问卷与受试者信息 │ ▼ Python 数据分析流水线 ├── 音频转写ASR ├── 文本特征提取 └── 统计分析可选这样的好处是客户端与数据分析完全解耦。Unity 只负责产生原始数据Python 负责分析后续想替换 ASR 模型或增加特征指标都不需要重新改 VR 工程。2.2 为什么选择 Unity OpenXR当前主流的 VR 开发引擎包括 Unity、Unreal以及 WebXR 方案。对于实验研究场景Unity 社区资料多、生命周期管理简单尤其适合脚本驱动的实验逻辑。OpenXR 是跨平台标准接口支持多数 PC VR 头显在 Unity 中安装 XR Plugin Management 后即可快速适配。有一点需要提前说明Unity 和 XR 相关包版本迭代很快。本文示例使用的是常见稳定思路不绑定某个具体 Unity 版本。如果安装包时遇到兼容性问题优先参考 Unity 官方推荐版本组合。3. 环境准备与实验项目初始化3.1 软硬件环境清单本文方案按以下环境示例编写实际可根据项目情况调整操作系统Windows 10/11 或 Ubuntu 20.04/22.04。VR 头显支持 OpenXR 的 PC VR 设备如 Meta Quest 2/3Link/VD 串流、HTC Vive 系列。Unity 版本推荐 Unity 2021.3 LTS 或 Unity 2022.3 LTS。核心包XR Plugin Management、OpenXR Plugin。Python 版本Python 3.9 以上。ASR 引擎可选用本地 Whisper 模型或其他离线转写服务。如果暂时没有 VR 头显也可以先用 Unity 编辑器模拟模式跑通代码逻辑再接入头显测试。实际采集实验数据前一定要在目标设备上完整验证过。3.2 创建 Unity 工程并配置 XR下面从创建 Unity 工程开始。新建一个 3D 项目命名为VRInteractionStudy。创建完成后在菜单栏打开Window - Package Manager安装以下包XR Plugin ManagementOpenXR Plugin安装完成后打开Project Settings - XR Plug-in Management勾选 OpenXR。然后在OpenXR标签页中确保Interaction Profile包含当前设备对应的 Profile比如Oculus Touch Controller Profile或HTC Vive Controller Profile。这里有一个常见坑只装包不设置XR Plug-in Management头显无法正常启动。所以配置完成后建议先跑一下官方示例场景确认手柄、追踪正常再继续后续功能。3.3 项目目录结构为了便于管理代码和数据在 Unity 工程Assets下建议创建以下目录Assets/ ├── Scenes/ # 场景文件 ├── Scripts/ │ ├── Core/ # 核心控制脚本 │ ├── Avatar/ # 角色生成逻辑 │ └── Audio/ # 录音与音频处理 ├── Resources/ │ └── Avatars/ # 角色模型资源 └── Plugins/ # 第三方插件实验数据不要放到Assets下因为 Unity 会频繁导入资源。建议在工程外或Assets同级目录创建一个ExperimentData/文件夹用来存放语音和日志数据。4. 搭建 VR 模拟场景与角色定制4.1 创建基础实验场景实验场景不需要太复杂但要有足够的空间感。在Scenes下新建一个场景MainExperiment然后添加以下对象Floor地面使用 Plane 或 Box添加材质。Light场景主光源。PlayerRigXR OriginUnity 中称为 XR Origin。NPC虚拟角色所在位置。InteractionUICanvas 界面用来显示实验提示文字。如果使用的是新版本 Unity建议直接添加XR Origin对象而不是老的Camera Offset方式。添加方式在 Hierarchy 中右键 -XR - XR Origin (VR)。在场景中放置 NPC 位置时要控制好距离。一般建议角色眼睛高度与玩家的视线高度大致相同距离 1.2 到 1.8 米之间太远看不清表情太近会造成压迫感影响实验效果。4.2 角色外观的可配置设计角色外观是这类实验的关键变量。为了快速切换不同外观我这里用的方案是准备一组 Unity 官方的Basic Male/Basic Female模型再通过脚本在不同模型之间切换材质和身体部位。为了避免直接引用版权不明确的角色模型你也可以使用 Unity Asset Store 中允许研究使用的角色模型或者用 Blender 制作简单的低多边形角色。核心思路是外观变量必须独立保存在实验条件配置中。下面是一个简单的角色生成脚本思路放在Assets/Scripts/Avatar/RoleAvatar.csusing UnityEngine; public class RoleAvatar : MonoBehaviour { public GameObject malePrefab; public GameObject femalePrefab; public void BuildAvatar(bool isMale, int skinToneIndex) { // 先销毁已有角色 foreach (Transform child in transform) Destroy(child.gameObject); // 选择性别预设 GameObject instance Instantiate(isMale ? malePrefab : femalePrefab, transform); // 设置皮肤颜色 SkinnedMeshRenderer[] renderers instance.GetComponentsInChildrenSkinnedMeshRenderer(); Color skinColor skinToneIndex switch { 0 new Color(0.91f, 0.77f, 0.62f), // 浅色 1 new Color(0.65f, 0.48f, 0.35f), // 中间色 2 new Color(0.36f, 0.24f, 0.15f), // 深色 _ new Color(0.91f, 0.77f, 0.62f) }; foreach (var renderer in renderers) { // 角色模型材质球的 Shader 需要支持 _BaseColor 属性 Material mat renderer.material; if (mat.HasProperty(_BaseColor)) { mat.SetColor(_BaseColor, skinColor); } } } }这段代码的核心价值在于让实验变量可以程序化配置。正式实验时不需要手动去编辑器里换角色只需在日志中记录isMale和skinToneIndex即可。需要注意HasProperty(_BaseColor)是 URPPBR 材质的属性。如果项目使用内置渲染管线可能需要改成_Color。这个细节在切换渲染管线后很容易踩坑。4.3 随机分配实验条件组为了保证实验设计中的变量独立性我们不能让用户自己选择角色也不能每次都使用同一个角色。比较好的方式是有一个ExperimentCondition配置表由实验脚本随机抽取一组条件。下面定义一个条件数据类Assets/Scripts/Core/ExperimentCondition.cs[System.Serializable] public class ExperimentCondition { public int conditionId; public bool isMale; public int skinToneIndex; public string npcReplyType; }再写一个简单配置using UnityEngine; public class ExperimentConfig : MonoBehaviour { public ExperimentCondition[] conditions new ExperimentCondition[] { new ExperimentCondition { conditionId 1, isMale true, skinToneIndex 0, npcReplyType standard }, new ExperimentCondition { conditionId 2, isMale true, skinToneIndex 2, npcReplyType standard }, new ExperimentCondition { conditionId 3, isMale false, skinToneIndex 0, npcReplyType standard }, new ExperimentCondition { conditionId 4, isMale false, skinToneIndex 2, npcReplyType standard } }; }在实际项目中条件组数量、角色服装、对话内容都应依据研究假设进行设计。这里只是给出一套最小可运行示例。5. 语音交互与数据采集模块5.1 麦克风录音与保存Unity 的Microphone类可以非常方便地获取录音片段。核心流程是检查麦克风权限并选择输入设备。调用Microphone.Start开始录音。录音结束后从AudioClip中获取数据并保存为 WAV。保存文件同时写入日志记录文件名、时间戳、条件编号。下面是一段可复用的录音管理器代码放在Assets/Scripts/Audio/AudioRecorder.csusing System; using System.IO; using UnityEngine; public class AudioRecorder : MonoBehaviour { private AudioClip _clip; private string _microphoneDevice; private bool _isRecording; public string SaveFolder ExperimentData/audio; public void StartRecording() { if (_isRecording) return; if (Microphone.devices.Length 0) { Debug.LogError(未检测到麦克风设备); return; } _microphoneDevice Microphone.devices[0]; _clip Microphone.Start(_microphoneDevice, false, 60, 44100); _isRecording true; Debug.Log(录音开始); } public string StopRecording() { if (!_isRecording) return null; Microphone.End(_microphoneDevice); string fileName ${DateTime.Now:yyyyMMdd_HHmmss_fff}.wav; string fullPath Path.Combine(Application.dataPath, .., SaveFolder, fileName); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); SaveWav(fullPath, _clip); _isRecording false; Debug.Log($录音已保存: {fullPath}); return fullPath; } private void SaveWav(string path, AudioClip clip) { float[] samples new float[clip.samples * clip.channels]; clip.GetData(samples, 0); int sampleCount samples.Length; ushort channels (ushort)clip.channels; ushort bitsPerSample 16; int sampleRate clip.frequency; using (var stream new FileStream(path, FileMode.Create)) using (var writer new BinaryWriter(stream)) { // WAV 文件头 writer.Write(RIFF); writer.Write(36 sampleCount * 2); writer.Write(WAVE); writer.Write(fmt ); writer.Write(16); writer.Write((ushort)1); writer.Write(channels); writer.Write(sampleRate); writer.Write(sampleRate * channels * bitsPerSample / 8); writer.Write((ushort)(channels * bitsPerSample / 8)); writer.Write(bitsPerSample); writer.Write(data); writer.Write(sampleCount * 2); for (int i 0; i samples.Length; i) { short value (short)(Mathf.Clamp01((samples[i] 1f) / 2f) * ushort.MaxValue - short.MaxValue - 1); writer.Write(value); } } } }录音时长这里设置的是 60 秒。如果实验交互时间较长可以把这个参数调整到 180 秒或更长。需要注意Microphone类的录音数据保存在内存中录音时间越长占用内存越大。更稳健的做法是分段录制但本文示例保持简单。5.2 实验日志同步实验结束后我们需要将录音文件路径与实验条件对应起来。一个 CSV 日志是最直观的方式。下面这个脚本会在录音停止时追加一条记录using System; using System.IO; using UnityEngine; public class ExperimentLogger : MonoBehaviour { private string _logPath; private void Start() { _logPath Path.Combine(Application.dataPath, .., ExperimentData, logs, experiment_log.csv); Directory.CreateDirectory(Path.GetDirectoryName(_logPath)); if (!File.Exists(_logPath)) { File.AppendAllText(_logPath, timestamp,participantId,conditionId,audioFile,remarks\n); } } public void WriteRecord(string participantId, int conditionId, string audioFile, string remarks ) { string line ${DateTime.Now:yyyy-MM-dd HH:mm:ss},{participantId},{conditionId},{audioFile},{remarks}; File.AppendAllText(_logPath, line \n); } }在正式实验中participantId通常来自被试登记信息conditionId从ExperimentConfig中读取audioFile是StopRecording()返回的路径。5.3 完整交互流程控制把录音、日志、角色生成串起来的是实验控制脚本。流程建议分为三个阶段准备阶段显示实验说明等待用户确认。交互阶段生成本轮角色播放开场词自动开始录音。结束阶段停止录音写日志记录问卷结果进入下一轮或退出。这里给一个示意代码using UnityEngine; using UnityEngine.UI; public class ExperimentFlow : MonoBehaviour { public RoleAvatar roleAvatar; public AudioRecorder audioRecorder; public ExperimentLogger logger; public ExperimentConfig config; public Text instructionText; public Button nextButton; private ExperimentCondition currentCondition; private int conditionIndex 0; private void Start() { nextButton.onClick.AddListener(OnNextButton); LoadCondition(conditionIndex); } private void LoadCondition(int index) { if (index config.conditions.Length) { instructionText.text 实验完成感谢参与; return; } currentCondition config.conditions[index]; roleAvatar.BuildAvatar(currentCondition.isMale, currentCondition.skinToneIndex); instructionText.text $实验条件 {currentCondition.conditionId}准备完成后点击开始录音。; } public void OnNextButton() { string audioPath null; audioPath audioRecorder.StopRecording(); logger.WriteRecord(P001, currentCondition.conditionId, audioPath, VR场景交互); conditionIndex; LoadCondition(conditionIndex); } }实际项目里会使用手柄或键盘触发开始录音而不是依赖按钮。但流程控制的思路是一样的所有事件都要留出日志方便后续回溯。6. 基于 Python 的音频转文本与语言特征分析6.1 安装依赖VR 端采集到的是一批 WAV 文件和 CSV 日志。接下来使用 Python 对音频进行离线分析。首选本地 ASR 工具例如 OpenAI 开源的 Whisper 模型。安装依赖pip install openai-whisper pandas如果你需要使用 GPU 推理还需要确认 CUDA 环境。Whisper 的基础版本在 CPU 上也能运行只是速度较慢。实验数据量不大时CPU 完全够用。6.2 批量转写音频写一个 Python 脚本transcribe.py读取实验日志中的音频路径然后调用 Whisper 转写。import os import pandas as pd import whisper model whisper.load_model(base) def transcribe_batch(csv_path, audio_root): df pd.read_csv(csv_path) results [] for _, row in df.iterrows(): audio_path row[audioFile] if not os.path.isabs(audio_path): audio_path os.path.join(audio_root, audio_path) if not os.path.exists(audio_path): results.append({participantId: row[participantId], conditionId: row[conditionId], text: [缺失文件]}) continue result model.transcribe(audio_path, languagezh) results.append({ participantId: row[participantId], conditionId: row[conditionId], text: result[text].strip(), language: result.get(language, ) }) return pd.DataFrame(results) if __name__ __main__: out_df transcribe_batch( csv_path../ExperimentData/logs/experiment_log.csv, audio_root../ExperimentData/audio ) out_df.to_csv(transcripts.csv, indexFalse, encodingutf-8-sig) print(out_df.head())这里我假设语音主要是中文。如果实验使用英文language参数可以改为en或者直接不传让模型自动识别。6.3 提取语言特征得到转写文本后就可以抽取语言特征了。不同研究关心的指标差异很大常见的有总词数或总字数衡量表达量。平均语速总字数除以有效说话时长。礼貌用语数量例如“请”“谢谢”“麻烦”。疑问句数量通过标点或语气词判断。打断次数需要更细的话轮标注这里暂不讨论。下面是一个简单但可运行的特征提取示例import pandas as pd import re import string def extract_features(text): if not isinstance(text, str): return {char_count: 0, question_count: 0, polite_count: 0} char_count len(re.findall(r[\u4e00-\u9fff], text)) question_count text.count() text.count(?) polite_words [请, 谢谢, 麻烦, 您好, 对不起, 抱歉] polite_count sum(text.count(w) for w in polite_words) return { char_count: char_count, question_count: question_count, polite_count: polite_count, } if __name__ __main__: df pd.read_csv(transcripts.csv, encodingutf-8-sig) feature_df df[text].apply(extract_features) df pd.concat([df, feature_df], axis1) df.to_csv(analysis_result.csv, indexFalse, encodingutf-8-sig) print(df.groupby(conditionId)[char_count].mean())这个脚本已经能支撑初步的统计分析了。如果实验需要更深层的句法特征可以考虑引入 spaCy、HanLP 等自然语言处理工具。特征提取时一定要结合实际研究假设不要一次性堆几十个特征否则后续统计检验的校正成本会很高。6.4 与实验变量关联做统计分析分析阶段的最后一步是把语言特征和实验条件关联起来。用pandas可以轻松按条件分组import pandas as pd from scipy import stats df pd.read_csv(analysis_result.csv, encodingutf-8-sig) group1 df[df[conditionId] 1][polite_count] group2 df[df[conditionId] 2][polite_count] t_stat, p_value stats.ttest_ind(group1, group2) print(ft {t_stat:.3f}, p {p_value:.4f})需要注意正式研究中的统计分析远不止一个 t 检验。它通常要考虑多因素交互、重复测量、多重比较校正等问题。这里只是提供技术闭环具体统计方法应遵循研究方案和统计学规范。7. 完整实验流程与验证7.1 跑通一个最小实验现在我们把所有模块整合起来看一遍最小实验流程。打开 Unity 工程进入MainExperiment场景。将ExperimentFlow脚本挂到场景中的GameManager对象上。在 Inspector 中把RoleAvatar、AudioRecorder、ExperimentLogger、ExperimentConfig引用拖拽绑定。点击 Play进入场景。按N键或点击 UI 按钮进入下一轮观察日志与录音文件是否生成。Unity 编辑器模式下由于没有 VR 设备可以通过模拟输入触发按钮。接入头显后建议改用 XR 射线交互。7.2 验证数据完整性实验结束后检查ExperimentData/目录结构。一个完整的数据集应该包含audio/下每个受试者对应条件的 WAV 文件。logs/下包含时间戳、条件编号、音频文件路径的 CSV。问卷回收数据可能来自在线问卷系统这里不在 Unity 内实现。然后用 Python 脚本验证数据数量是否匹配python transcribe.py python extract_features.py如果transcripts.csv中每一行都有非空文本说明录音和转写正常。如果出现[缺失文件]优先检查audioFile的路径拼接逻辑特别是Application.dataPath在编辑器与打包后的差异。8. 常见问题与排查思路问题现象常见原因解决思路头显无画面或无法定位OpenXR 未启用或 Interaction Profile 不匹配检查 XR Plug-in Management 与 OpenXR 配置麦克风开始录音即报错系统麦克风权限未开启Windows 设置中允许应用访问麦克风重新运行WAV 文件播放无声通道数或采样位深不匹配确认 AudioClip 的 channels 与 WAV 头一致转写文本为空音频音量过低或 Whisper 模型语言不匹配放大音频检查录音设备音量手动指定语言日志中音频路径打不开Application.dataPath路径在打包后改变改用Application.persistentDataPath或绝对路径不同条件角色外观差异不明显材质颜色未正确应用到所有网格检查 SkinnedMeshRenderer 列表改用全模型材质替换录音内存占用过高单次录音时间过长减少录音时长或使用流式分段保存在项目初期强烈建议先使用 2 到 3 个受试者做小规模试运行把所有数据链路走通后再扩样本量。这样能节省大量后期排错时间。9. 最佳实践与工程建议9.1 实验伦理与数据安全涉及人类受试者的实验无论是否采集语音都必须先通过所在机构或企业的伦理审查。即使是技术演示也要明确声明数据用途获得受试者知情同意。语音数据属于个人信息在采集、传输、存储时都要做脱敏处理建议在保存 WAV 文件时使用随机编号代替受试者真实姓名。实验结束后应根据数据最小化原则删除不再需要的原始录音只保留分析结果与必要元数据。如果数据需要共享建议去除可直接识别的身份信息并对音频做变速、变调处理进一步保护隐私。9.2 变量控制与随机化VR 实验最怕的是“看似控制了变量实际没有”。比如角色外观不同但光照可能在角色脸上产生不同阴影或者 NPC 角色说话时的动画强弱不同导致被试产生了额外联想。建议在场景中固定光照方向、角色位置、动作序列尽量让不同条件之间只有自变量不同。另一个容易忽略的点是顺序效应。如果所有被试都先看到条件 A再看到条件 B那么实验结果可能受到疲劳、练习等因素影响。推荐对条件出现顺序做随机化或平衡设计这可以在ExperimentConfig中用随机排列实现。9.3 日志规范与可追溯性实验日志是整个研究可信度的基础。每条日志建议包含三个要素时间、对象、事件。时间使用统一格式精确到毫秒对象包含受试者编号、场景编号、条件编号事件描述使用规范缩写例如START_RECORDING、STOP_RECORDING、FINISH_TRIAL。日志文件本身不要放在Assets目录内避免 Unity 编译器反复导入造成卡顿。打包后的程序应使用Application.persistentDataPath作为数据根目录。正式分析时用 Python 脚本统一读取这样可以避免 CSV 编码不一致导致乱码。9.4 VR 体验舒适度长时间佩戴 VR 头显可能引起眩晕实验方案应控制单次实验时长建议每轮控制在 5 分钟以内。角色与玩家距离不要过近NPC 不能突然大幅度移动。如果实验需要持续说话可以在 VR 界面中加一个剩余时间提示减少受试者焦虑。遇到明显眩晕的受试者应及时中止实验并记录中止原因。10. 总结与后续方向通过这篇文章我们从零搭建了一套 VR 交互实验系统使用 Unity 配置 OpenXR实现角色外观的可编程切换完成麦克风录音与 CSV 日志同步再用 Python 对音频做转写和语言特征提取。整个过程覆盖了实验流程的设计、数据采集、离线分析和版本兼容问题。这套方案不只适用于开头提到的英文研究项目也可以迁移到客服培训、语言教学、服务场景评估等更多应用中。后续可以继续优化的方向包括用更精细的 NLP 模型分析话轮与情感在 Unity 中加入眼动追踪记录注视时长把统计分析模块接入 R 或 Jupyter Notebook形成一键式分析报告。如果实际项目中遇到问题建议先从日志和数据链路排查再逐步完善角色、交互与分析模块。希望这篇文章能帮你快速跑通自己的 VR 实验系统。