
最近在推进一个基于 Pico 4 的 VR 项目《王国保卫战》从设备选型、交互方案到塔防核心战斗逻辑踩了不少坑也积累了一整套可以复用的流程。本文是这个系列的第一季重点解决如何从零搭建一个可运行的 VR 塔防原型这件事包括 Pico 4 开发环境准备、Unity 工程初始化、手柄射线交互、敌人寻路、防御塔建造与波次管理以及最终如何打包到真机调试。如果你是刚接触 VR 开发的 Unity 开发者或者想了解 Pico 4 上做交互游戏的完整思路这篇文章可以当作一份起步手册。文章里所有代码都会给出完整示例配置项也会逐个解释你可以直接照着创建一个属于自己的 VR 塔防项目。1. VR 塔防项目设计与核心概念1.1 为什么选择 Pico 4 做 VR 开发Pico 4 是一体式 VR 头显不需要连接电脑就能运行基础应用同时也可以串流 PC 内容。对 Unity 开发者来说它有几点很实际的优势设备价格相比同级别产品更友好适合个人开发者和学生团队试错。支持 OpenXR 标准接口Unity 可以直接通过 OpenXR 插件层访问头显追踪、手柄输入和震动反馈。提供独立的 Unity SDK包含设备检测、手势识别、手柄模型绑定、Passthrough 等能力。6DoF 追踪能在房间尺度内自由移动适合做塔防这种站在战场旁边观察全局的玩法。在《王国保卫战》这个项目里玩家会站在一片城堡外的草地上用双手选择防御塔位置、升级塔、观察敌人从固定路径涌向城堡。这种玩法天然适合 VR 的 6DoF 空间玩家可以俯视战场也可以凑近观察某座塔的射击细节。1.2 VR 塔防与传统塔防的核心差异传统 PC 塔防使用鼠标点击建造位置摄像机俯视全图。VR 塔防需要考虑三个问题交互方式不同不能依赖鼠标要用手柄射线或手势进行瞄准、选择和确认。视角自由玩家可以走动、转头、蹲下场景要考虑从各个角度看都不穿帮。晕动症控制镜头不能随意平移和旋转推荐用传送或固定区域移动。因此VR 塔防项目的技术重点不在算法有多复杂而在交互闭环是否顺滑。规划 Path 路径、波次管理、伤害计算这些部分和普通塔防一致但玩家用手柄选中空地并放置塔这个操作才是 VR 端最值得打磨的地方。1.3 第一季要实现的内容范围作为系列第一季目标不是做完整商业游戏而是完成一个可运行、可演示的技术原型。本期覆盖Pico 4 开发环境搭建与 Unity 工程配置。场景构建地面、路径、城堡、防御塔占位点。手柄射线扳机交互实现指向 确认。敌人沿路径行走到达终点后城堡扣血。玩家选择防御塔类型在指定位置建造。波次管理器定时生成敌人。后续季次可以继续做塔升级、粒子特效、音频、手势交互、UI 面板、存档系统、性能优化等。2. 开发环境准备与 Unity 工程初始化2.1 硬件与软件清单本项目的开发环境如下你可以根据自己的实际版本调整项目推荐配置头显设备Pico 4电脑操作系统Windows 10/11Unity 版本Unity 2021.3 LTS 或 Unity 2022.3 LTS渲染管线URP通用渲染管线SDKPICO Unity Integration SDK基于 OpenXR开发工具Visual Studio 2022 / VS Code .NET 脚本支持测试方式Pico 4 真机 USB 连接调试 / PICO 开发者工具之所以推荐 LTS 版本是因为 VR 插件和 Android 构建链路对长期支持版本适配更稳定部分预览版 Unity 在打包 Android 时容易出现 Gradle 或 IL2CPP 的兼容问题。2.2 安装 PICO SDK 并创建工程在 Unity Hub 中创建新项目时模板选择 3D 项目名称可以叫 KingdomDefenseVR。创建完成后通过 Package Manager 安装 PICO 提供的 Unity SDK 包。如果你从 PICO 开发者平台下载的是 .unitypackage直接执行Assets Import Package Custom Package选择下载好的 SDK 包等待导入完成。如果使用 OpenXR 方式需要确认 Project Settings 中启用了 OpenXR 插件并在 Interaction Profiles 中加入 PICO Controller 配置。这样 Unity 才能识别 Pico 4 的手柄输入和头显追踪。2.3 Android 构建环境配置Pico 4 运行的是 Android 系统所以 Unity 需要支持 Android 构建。主要步骤如下打开 Build Settings切换到 Android 平台。安装 Android Build Support 模块包括 SDK、NDK 和 OpenJDK。在 Player Settings 中设置包名例如com.yourcompany.kingdomdefensevr。最低 API Level 设置为 Android 10 或更高目标 API Level 根据 PICO SDK 要求设置。脚本后端选择 IL2CPP目标架构勾选 ARM64。确认启用 XR Plug-in Management并添加 OpenXR 或 PICO XR 插件。这些配置在后续打包章节还会详细说明。3. VR 场景搭建与基础交互3.1 场景布局与占位设计塔防场景不需要非常复杂的模型先用基本几何体搭建即可。本文示例场景包含以下物体地面 Plane一片绿色草地代表战场。路径 Cube 阵列多个长条形 Cube 拼出敌人的行走路线。终点 Castle一个较大的 Cube敌人到达后减少城堡血量。防御塔占位点在路径两侧放置多个空物体表示可以建造的位置。搭建路径时建议在场景中创建一条折线使用多个 Cube 拼接。为了让寻路更简单后续代码中敌人会沿着一个名为waypoints的节点列表依次移动而不是使用 NavMesh。这样做的好处是路径完全可控方便调整波次节奏和陷阱位置。3.2 手柄射线与扳机点击在 Pico 4 上玩家通过手柄发出一条射线用来选中场景中的物体。Unity 中实现这一交互有两种常见方式使用 PICO SDK 自带的光线交互组件。自己获取手柄 Pose通过 Physics.Raycast 实现射线检测。本文为了便于理解核心逻辑采用手动射线方案左手或右手手柄发出射线按扳机键触发点击事件。核心思路是获取手柄在世界空间中的位置和旋转。每一帧朝手柄前方发射 Ray。检测命中的物体高亮显示。按下扳机时触发对应物体的交互方法。这样做的好处是代码完全透明方便后续接入自定义交互逻辑。3.3 玩家移动方式VR 塔防中玩家主要站在原地观察战场不需要大范围移动。如果允许自由行走建议只使用真实房间尺度移动即玩家物理移动不做瞬移和摇杆平滑移动避免晕动症。如果场景范围较大可以加入传送移动玩家指向地面按下按钮后瞬移到目标位置。传送点在后续波次防守玩法中可以设置成瞭望台、城堡前、路径中段等。4. 核心玩法逻辑实现4.1 防御塔建造机制在《王国保卫战》原型中防御塔由玩家手动选择类型并放置在指定位置。为了简化本文实现两种塔弓箭塔射速快攻击力低适合清理小怪。炮塔射速慢攻击力高适合对付高血量敌人。建造流程玩家用手柄射线指向一个空建造点。按下扳机弹出建造菜单第一季用按键切换代替。再次按下扳机确认建造。在第一季中为了让核心逻辑更直观我们使用左手扳机切换塔类型右手扳机确认建造。4.2 敌人寻路实现敌人沿路径行走使用最简单的顺序寻路。场景中的路径由多个点组成敌人依次向下一个点移动。public class Enemy : MonoBehaviour { public Transform[] waypoints; public float moveSpeed 3f; public int health 100; public int damageToCastle 10; public int reward 20; private int currentWaypointIndex 0; void Update() { if (waypoints null || waypoints.Length 0) return; Transform target waypoints[currentWaypointIndex]; Vector3 direction (target.position - transform.position).normalized; transform.position direction * moveSpeed * Time.deltaTime; if (Vector3.Distance(transform.position, target.position) 0.2f) { currentWaypointIndex; if (currentWaypointIndex waypoints.Length) { GameManager.Instance.CastleTakeDamage(damageToCastle); Destroy(gameObject); } } } }这种实现虽然简单但非常好调试。路径可以由策划直接摆放空物体调整不需要额外烘焙导航网格非常适合 VR 塔防原型。4.3 防御塔攻击逻辑防御塔通过球形范围检测敌人找到射程内最近的敌人然后按冷却时间发射子弹或炮弹。public class Tower : MonoBehaviour { public float range 8f; public float fireRate 1f; public int damage 20; public GameObject bulletPrefab; public Transform firePoint; private float nextFireTime 0f; void Update() { if (nextFireTime Time.time) return; Collider[] hits Physics.OverlapSphere(transform.position, range); Transform target FindNearestEnemy(hits); if (target ! null) { ShootAt(target); nextFireTime Time.time 1f / fireRate; } } Transform FindNearestEnemy(Collider[] hits) { float minDist Mathf.Infinity; Transform nearest null; foreach (Collider hit in hits) { if (!hit.CompareTag(Enemy)) continue; float dist Vector3.Distance(transform.position, hit.transform.position); if (dist minDist) { minDist dist; nearest hit.transform; } } return nearest; } void ShootAt(Transform target) { if (bulletPrefab null) return; GameObject bullet Instantiate(bulletPrefab, firePoint.position, Quaternion.identity); bullet.GetComponentBullet().SetTarget(target, damage); } }这里使用 Physics.OverlapSphere 做范围检测性能在小规模场景中足够。如果要支撑几十个敌人同屏建议使用球体非分配检测 OverlapSphereNonAlloc 或者把敌人列表缓存到 GameManager 中减少物理查询开销。4.4 波次管理器波次管理器负责定时生成敌人并把敌人分配到敌人生成点。先看最简单的实现public class WaveManager : MonoBehaviour { public GameObject enemyPrefab; public Transform spawnPoint; public Transform[] waypoints; public int enemiesPerWave 5; public float spawnInterval 2f; private int waveNumber 1; private int spawnedCount 0; void Start() { StartNextWave(); } void StartNextWave() { spawnedCount 0; InvokeRepeating(SpawnEnemy, 1f, spawnInterval); } void SpawnEnemy() { if (spawnedCount enemiesPerWave) { CancelInvoke(SpawnEnemy); return; } GameObject enemyObj Instantiate(enemyPrefab, spawnPoint.position, spawnPoint.rotation); Enemy enemy enemyObj.GetComponentEnemy(); enemy.waypoints waypoints; spawnedCount; } }波次之间的间隔和数量可以配置成 ScriptableObject 或者 ScriptableObject 数组方便后续扩展关卡配置。第一季先用直接挂在场景中的方式演示。5. 完整工程代码示例5.1 脚本结构规划本节给出一个可以直接运行的最小 VR 塔防原型脚本结构Assets/ Scenes/ Main.unity Scripts/ GameManager.cs Enemy.cs Tower.cs Bullet.cs TowerPlacement.cs HandRayInteractor.cs WaveManager.cs所有脚本先不依赖复杂框架保证最小可运行。5.2 GameManagerGameManager 负责管理全局状态、金币、城堡血量以及 UI 提示。在 VR 项目中GameManager 建议使用单例模式。using UnityEngine; using UnityEngine.UI; public class GameManager : MonoBehaviour { public static GameManager Instance; public int gold 200; public int castleHealth 100; public Text goldText; public Text healthText; void Awake() { if (Instance null) { Instance this; } else { Destroy(gameObject); } } public bool TrySpendGold(int amount) { if (gold amount) return false; gold - amount; UpdateUI(); return true; } public void AddGold(int amount) { gold amount; UpdateUI(); } public void CastleTakeDamage(int damage) { castleHealth - damage; UpdateUI(); if (castleHealth 0) { Debug.Log(城堡被摧毁游戏结束); } } void UpdateUI() { if (goldText ! null) goldText.text 金币: gold; if (healthText ! null) healthText.text 城堡: castleHealth; } }5.3 Bullet 子弹脚本子弹飞行到目标位置后造成伤害。简单做法是朝目标移动到达后直接销毁并对目标调用 TakeDamage 方法。using UnityEngine; public class Bullet : MonoBehaviour { private Transform target; private int damage; private float speed 15f; public void SetTarget(Transform target, int damage) { this.target target; this.damage damage; } void Update() { if (target null) { Destroy(gameObject); return; } Vector3 dir target.position - transform.position; float step speed * Time.deltaTime; if (dir.magnitude step) { target.GetComponentEnemy()?.TakeDamage(damage); Destroy(gameObject); } else { transform.position dir.normalized * step; transform.rotation Quaternion.LookRotation(dir); } } }5.4 Enemy 补充伤害方法在 Enemy 类中补充 TakeDamage 和死亡逻辑public void TakeDamage(int damage) { health - damage; if (health 0) { GameManager.Instance.AddGold(reward); Destroy(gameObject); } }当敌人被子弹击中后根据伤害扣除生命值生命值归零时给予玩家金币奖励。5.5 TowerPlacement 建造交互脚本TowerPlacement 脚本挂在玩家控制器上负责读取左右手柄的扳机输入并在射线命中地点创建防御塔。为了简化脚本直接放在玩家 GameObject 下。using UnityEngine; using UnityEngine.XR; using UnityEngine.XR.Interaction.Toolkit; public class TowerPlacement : MonoBehaviour { public Transform leftHand; public Transform rightHand; public GameObject arrowTowerPrefab; public GameObject cannonTowerPrefab; public LayerMask buildLayer; private bool isArrowTower true; void Update() { // 左手扳机切换攻击塔类型 if (LeftTriggerPressed()) { isArrowTower !isArrowTower; Debug.Log(当前选择 (isArrowTower ? 弓箭塔 : 炮塔)); } // 右手扳机确认建造 if (RightTriggerPressed()) { TryBuild(); } } bool LeftTriggerPressed() { // 根据 PICO 输入设备或 XR InputDevice 读取 trigger 按键 // 示例中使用 XR 通用接口 return InputHelpers.GetButtonDown(leftHand, InputHelpers.Button.Trigger); } bool RightTriggerPressed() { return InputHelpers.GetButtonDown(rightHand, InputHelpers.Button.Trigger); } void TryBuild() { Ray ray new Ray(rightHand.position, rightHand.forward); RaycastHit hit; if (Physics.Raycast(ray, out hit, 20f, buildLayer)) { if (hit.collider.CompareTag(BuildSite)) { GameObject tower isArrowTower ? arrowTowerPrefab : cannonTowerPrefab; if (GameManager.Instance.TrySpendGold(50)) { Instantiate(tower, hit.collider.transform.position, Quaternion.identity); // 标记该位置已占用 hit.collider.enabled false; } else { Debug.Log(金币不足); } } } } }上面代码中InputHelpers是封装 XR 输入的工具类实际项目中你可以用UnityEngine.XR.InputDevice手动读取也可以使用 XR Interaction Toolkit 的XRController事件回调。这里为保持示例简洁使用工具类封装你按自己的 SDK 版本替换即可。5.6 InputHelpers 示例封装如果使用 OpenXR XR Interaction Toolkit可以直接绑定按钮事件但如果想手动读取可以这样写using UnityEngine; using UnityEngine.XR; public static class InputHelpers { public enum Button { Trigger, Grip, PrimaryButton, SecondaryButton } public static bool GetButtonDown(Transform handTransform, Button button) { InputDevice device GetDevice(handTransform); if (!device.isValid) return false; if (button Button.Trigger) { if (device.TryGetFeatureValue(CommonUsages.triggerButton, out bool value)) return value; } else if (button Button.Grip) { if (device.TryGetFeatureValue(CommonUsages.gripButton, out bool value)) return value; } else if (button Button.PrimaryButton) { if (device.TryGetFeatureValue(CommonUsages.primaryButton, out bool value)) return value; } else if (button Button.SecondaryButton) { if (device.TryGetFeatureValue(CommonUsages.secondaryButton, out bool value)) return value; } return false; } static InputDevice GetDevice(Transform handTransform) { InputDeviceCharacteristics characteristics InputDeviceCharacteristics.HeldInHand; if (handTransform.name.Contains(Left)) characteristics | InputDeviceCharacteristics.Left; else characteristics | InputDeviceCharacteristics.Right; InputDevices.GetDevicesWithCharacteristics(characteristics, out System.Collections.Generic.ListInputDevice devices); foreach (InputDevice device in devices) { if (device.isValid) return device; } return new InputDevice(); } }这段代码的核心是根据手柄 Transform 名称判断左右手然后从 XR InputDevice 读取扳机按钮。在真机上运行时需要确保 PICO 手柄能被 Unity 识别为左/右手柄。6. 运行与真机打包验证6.1 编辑器内模拟测试在 Unity Editor 中即使没有 VR 设备也可以先运行场景验证塔防逻辑。此时射线方向来自右手柄对象的 forward 方向。为了方便测试可以在右手柄位置放一个空物体并用一个调试射线组件可视化右手柄的指向。运行场景后你应该能看到敌人生成点开始生成敌人。敌人沿着路径移动到终点。终点城堡血量减少。金币初始值为 200。通过手柄输入尝试建造防御塔。如果没有手柄输入可以临时用鼠标模拟在 TowerPlacement 中增加调试分支当按下鼠标左键时使用 Camera.main 的射线代替手柄射线。6.2 连接 Pico 4 真机调试要真机调试推荐使用 PICO 开发者工具或 ADB 连接。连接步骤在 Pico 4 上开启开发者模式具体入口在 设置 通用 关于本机连续点击版本号打开开发者选项。使用 USB-C 数据线连接电脑Pico 4 会弹出 USB 调试授权请求选择允许。Unity 中打开 Build And Run。首次连接时Unity 会自动安装 APK 到设备。如果 APK 安装失败检查 Player Settings 中的包名是否设置、目标架构是否为 ARM64、Scripting Backend 是否为 IL2CPP。6.3 打包配置检查清单在打包前快速检查以下配置配置项推荐值Scripting BackendIL2CPPTarget ArchitecturesARM64Minimum API LevelAndroid 10Active Input HandlingInput System PackageXR Plug-in ManagementOpenXR 已启用渲染管线URP关闭多余后处理注意如果项目使用了 Input Manager 的旧输入系统需要把 Active Input Handling 设为 Both否则 OpenXR 的手柄输入可能无法被识别。7. 常见问题与排查思路7.1 手柄射线不显示问题现象常见原因解决思路场景中看不到手柄射线未安装 XR Interaction Toolkit 或没有创建 Ray Interactor检查是否添加 XR Ray Interactor 组件并确认手柄的 Tracked Pose Driver 正常绑定射线方向不对手柄 Transform 没有跟随设备使用 XR Origin 下的 Camera Offset并检查 Tracked Pose Driver 的 Tracking Type在 Pico 4 上射线推荐使用 XR Interaction Toolkit 的 Interactor配合 Line Renderer 可视化射线。如果使用 PICO SDK也可以直接用自带的射线组件更简单。7.2 敌人不沿路径移动可能原因场景中路径点数组没有按顺序排列或者 Enemies 的 waypoints 没有赋值。排查步骤在场景中创建空物体按顺序命名为 Waypoint0、Waypoint1...选中 Enemy 预制体把 waypoints 数组拖入所有路径点。运行后观察敌人是否逐个点移动。如果使用 WaveManager 动态生成敌人一定要在生成时把路径数组赋值给新生成的 Enemy 实例上面例子已经做了。7.3 建造时金币被扣但塔没生成多发生在预制体没有正确配置或者建造点位置与预制体尺寸不匹配。常用解决方案在 TryBuild 方法中打印日志确认进入了哪一个分支。检查生成塔的预制体是否带有 Collider避免生成后与地面碰撞弹开。检查塔预制体是否在指定层或使用 Quaternion.identity 生成时是否被约束。7.4 APK 安装失败或打开黑屏最典型的原因是 SDK 版本不匹配或渲染管线设备不支持。Pico 4 基于 Android 系统部分后处理特效可能导致兼容问题建议先关闭全局后处理确认基础画面正常后再逐步打开。如果打开应用后黑屏但手柄震动说明 Unity 渲染管线与 OpenXR 初始化顺序有问题尝试在 Player Settings 中关闭 Vulkan 或把 Graphics API 设置为 OpenGLES3测试是否正常。8. 最佳实践与优化建议8.1 输入层抽象VR 项目最忌讳把输入逻辑直接写死在业务脚本中。建议把输入封装成一个 InputManager对外提供事件监听方法比如OnBuildConfirm()、OnSwitchTowerType()。这样如果后续换手柄型号或换设备只改一层不需要改塔防业务代码。第一季为了可读性直接在 TowerPlacement 中读取按钮。正式项目一定要抽象出来。8.2 敌人生成与对象池原型中用 Instantiate 和 Destroy 管理敌人和子弹在数量少时没问题。当波次后期同时出现 30 个敌人、每座塔每秒发射 2 颗子弹时频繁生成和销毁会产生 GC 压力导致卡顿。建议使用对象池技术在波次开始前预创建一个敌人池敌人死亡后不销毁而是隐藏并回收到池中。子弹同样处理。8.3 URP 性能与 VR 渲染Pico 4 的屏幕分辨率较高全屏后处理开销较大。建议使用 URP 的 Forward Renderer关闭不必要的 Effect。不要使用 MSAA 和 HDR 同时开启的高档配置可先用中等画质跑通。场景中的光源数量控制在 2 到 3 个。纹理压缩格式使用 ASTC适合移动端 GPU。8.4 碰撞层分配VR 塔防中手柄射线要区分三类物体地面、建造点、敌人。建议为它们分配不同 Layer物体Layer地面Ground建造点BuildSite敌人Enemy这样射线检测时可以通过 LayerMask 快速过滤避免在建造逻辑中误选地面也避免敌人挡住建造点。8.5 玩家舒适度与交互反馈VR 中每次扳机点击都应该有反馈音效、手柄震动、UI 变化。在 Pico 4 的 SDK 中可以通过手柄震动接口实现。建造成功时在高亮位置显示一个全息线框预览也是提升手感的方式。如果场景需要移动一定使用传送而非平滑移动。塔防观察位建议固定减少眩晕风险。9. 后续季次规划第一季完成了最基础的可玩循环波次生成、敌人寻路、防御塔放置、攻击、金币与血量管理。第二季可以考虑以下方向防御塔升级体系点击已建造的塔花费金币升级到 2 级、3 级增加射程和攻击力。技能系统玩家挥动手柄释放火球或冰冻技能。手势识别Pico 4 支持手势追踪尝试用食指点击替代手柄扳机。美术资源替换将 Cube 占位替换为 Low Polygon 风格模型配合 URP 做出更精致的观感。音频系统动态混音、敌人死亡音效、防御塔射击音效。存档与关卡选择使用 ScriptableObject 配置关卡波次存储通关进度。本文所有代码都以可运行为目标建议你新建一个 3D 工程把脚本贴进去搭建简单场景跑通一遍再逐步替换成自己的美术资源。VR 开发和普通 Unity 开发最大的不同在于交互反馈眼动、手部抖动、头显延迟都会影响体验所以每改一个参数就要上真机感受一次不要只在编辑器里看着合理就够了。《王国保卫战》第一季的核心目标是建立一个稳定、可调试、可扩展的 VR 塔防原形。从这个基础出发后续加玩法、加表现、加 SDK 能力都会顺畅很多。如果按这篇文章的思路在 Pico 4 上跑通了一次完整对局恭喜你你已经跨过了 VR 开发最麻烦的从无到有阶段。接下来要做的就是不断迭代互动细节把一个能玩的原型变成真正好玩的 VR 游戏。