VR 开发一瞥

732 阅读7分钟

VR 概念与设备

发展历程

计算机科学家、艺术家、哲学家、虚拟现实之父杰伦·拉尼尔在1987年创造了一个词组“virtual reality”,也就是我们所谓的VR,虚拟现实。从这一年起,这一研究领域终于有了一个正式的名字。

但早在这一概念出现以前,有关 VR 的探索就已经开始

理论萌芽

1838年,英国著名的物理学家查尔斯·惠斯通爵士,于1838年发现并确定立体图原理(Principle of stereo graph),为后续VR视觉模拟奠定了理论基础。

对这个理论的一个简单验证:

www.bilibili.com/video/BV164…

使用手机屏幕可以获得比较好的效果。全屏模式下手机距离眼睛大约一臂长, 调节双眼视焦直到左右画面重合。

核心在于 mock 人的左右眼的输入,来欺骗大脑形成空间感。


1935年,小说家斯坦利·G·温鲍姆发表短篇科幻小说《皮格马利翁的眼镜》。其中描述了一款VR眼镜,能够同时模拟视觉、触觉、嗅觉、味觉,并允许眼镜佩戴者和画面中的世界发生交互行为,并影响镜中世界的历史进程。这篇小说发表时,距离计算机的诞生还有 11 年。

image

这本小说被看作 VR 技术的预言书,直到今天,VR 设备仍不足以还原小说中的构想。

早期实验

1957年,摩登·海里戈(Morton Heilig) 的沉浸式电影体验设备。

image

image

摄影师摩登·海里戈分别于 1957、1960 年发明出两套 VR 设备,1960 年的这套,可以佩戴在头上,提供 3D 视觉与音频效果,外观上已经非常接近现在的 VR 头显。


1961年,美国飞歌公司两名工程师实现了一种动作追踪技术,基于这种技术,产生了许多军用设备和科研设备,但最初并没有用于 VR 领域。

image


1963年,雨果·根斯巴克(就是雨果奖的那个雨果)的头戴电视机,强调了画面的”实时性“。

image

蓝图

图灵奖获得者,计算机图形学之父伊凡·苏泽兰在1965年定义了所谓“终极显示器”的概念。此概念规定,某一种“终极显示器”所提供的内容可以让使用者无法区分与现实世界的差异:

  1. 通过头戴显示器可以展现3D的视觉声音效果,能够提供触觉反馈;

  2. 由电脑提供图像并保证实时性

  3. 用户能够通过与现实相同的方法与虚拟世界的物体进行互动

三年后,伊凡带领学生尝试制作出“终极显示器”的雏形:达摩克里斯之剑。

image

应用

关键技术

成像

zhuanlan.zhihu.com/p/486154341

实时渲染

位置追踪

支持位置追踪的 VR 设备可以带来更好的用户体验。然而若位置追踪速度跟不上渲染速度,就会造成眩晕感。

outside-in

这是一种依赖于外接定位/探测设备的位置追踪技术。如下图所示,

image

在这种模式下,需要有相对于空间静止不动的定位基站作为空间参考坐标,不断扫描所在场景。而用户穿戴的运动设备上有可被探测的标记点,用来确定其空间位置。

inside-out

不借助外接设备,仅靠环境图像的特征比对/陀螺仪信息,通过算法推算当前设备相比于上一帧的旋转和位移。

image

image

VR 开发

环境:PICO SDK + Unity

VR 设备上的软件是一种接收并处理 VR 设备复杂的输入数据,并将处理结果依照软件内置逻辑,以图形化方式渲染在 VR 成像装置上的系统。

image.png

平台 SDK

各个 VR 硬件厂商的产品各有特点,一般都会对主流 3D 引擎提供 SDK,来解决软件的跨平台移植问题。其中最重要的是处理系统输入,将系统输入映射为对应游戏引擎的输入。除此之外,PICO Unity SDK 还提供一些高级功能,如场景渐变、混合现实捕捉、应用内购买、平台公共能力接口等。

使用 PICO + Unity 的开发模式,按照文档操作,可快速建立好开发环境:

developer-cn.pico-interactive.com/document/un…

Unity 引擎

Unity 引擎的学习是一个很大的话题~ 此处仅从 PICO VR 开发的角度切入,提一下务须了解的信息

Action

action 是 Unity 输入系统中的一个概念,介于物理设备输入信号 与 Unity 代码逻辑之间,是一个中间层。引入 action 的目的是使设备输入与代码逻辑解耦,所有的 action 统一在 Unity 提供的 action map 结构中定义触发条件,并且可以在运行时动态修改:

image.png 如上图,我们在 Action Map 中指定 action4 的触发条件为:

  1. 左手柄摇杆向前推,且

  2. 左手柄主按钮被按下

则当满足以上条件时,action4 将会被触发,并进入 action 的生命周期循环,触发相关的回调函数

image.png

图中 action 的每个生命周期都会触发对应的事件。

对于用户编写的脚本而言,action 是整个输入过程中能够获取到的最上游信息。

Component

一个 component = 一种能力

Unity 通过 gameObject + components 来构建整个场景。gameObject 是一个具有 transform(位置、旋转、缩放) 属性的对象,在这个对象上可以挂载其他各种不同种类的组件:

  • 渲染

  • 物理

  • 动画

  • 脚本

  • ...

Event

Event 的概念贯穿整个开发流程。前文提到的 action 的各个生命周期,就是 event。在 Unity 中,event 本质是一个回调函数队列。它可以作为某个组件的公共属性存在,任何拿到这个属性的其他组件都可以向队列中添加新的回调函数。

基于 C# 的 delegate 来实现:

using System;
class A {
  // delegate 是一种“类型安全”的函数指针(队列)。
  // 可以通过运算符 + 和 - 来操作这个队列。
  // 比如: 
  // public MyDelegate d;
  // d += (int a, int b) => {Console.WriteLine(a + b)};
  // d(1, 2);  ==> 输出 3
  // d += (int a, int b) => {Console.WriteLine(a + b + 1)};
  // d(1, 2); ==> 输出 3 4
  private delegate int MyDelegate(int a, int b);
  // event 是对 delegate 的一种封装。
  // 操作与 delegate 相统,但可以进一步添加 add / remove 方法来控制访问权限
  public event MyDelegate myEvent;
}

事件无处不在,大致可以分为外部事件(设备输入的 action 生命周期 event) 和 内部事件(脚本自己注册的事件)

image.png

实例

20230411-132942.gif

用 action / component / event 三个基本元素组合的视角来梳理这个 demo 中发生的事情:

剑:

image.png

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

public class WeaponAttractable : MonoBehaviour
{
    public InputActionProperty attractTriggerActionProperty;
    public InputActionProperty releaseTriggerActionProperty;
    public GameObject attractOriginObject;
    public float attractSpeed = 1;

    private bool _isInHand = false;
    private bool _isInAttract = false;
    private Vector3 _weaponOriginPosition;
    private Quaternion _weaponOriginRotation;
    // Start is called before the first frame update
    void Start()
    {
        _weaponOriginPosition = gameObject.transform.position;
        _weaponOriginRotation = gameObject.transform.rotation;
        attractTriggerActionProperty.action.performed += attract;
        attractTriggerActionProperty.action.canceled += attractCanceled;
        releaseTriggerActionProperty.action.started += release;
    }

    private void release(InputAction.CallbackContext obj)
    {
        _isInAttract = false;
        _isInHand = false;
    }

    private void attractCanceled(InputAction.CallbackContext obj)
    {
        _isInAttract = false;
        Debug.Log("action canceled");
    }

    private void attract(InputAction.CallbackContext obj)
    {
        _isInAttract = true;
        Debug.Log("action triggered");
    }

    // Update is called once per frame
    void Update()
    {
        if (_isInHand)
        {
            gameObject.transform.position = attractOriginObject.transform.position;
            gameObject.transform.rotation = attractOriginObject.transform.rotation;
        }
        else if (_isInAttract)
        {
            gameObject.transform.position = Vector3.Lerp(gameObject.transform.position, attractOriginObject.transform.position, attractSpeed * Time.deltaTime);
            gameObject.transform.rotation = Quaternion.Lerp(gameObject.transform.rotation, attractOriginObject.transform.rotation, attractSpeed * Time.deltaTime);
        }
        else if (!_isInAttract && !_isInHand && gameObject.transform.position != _weaponOriginPosition)
        {
            // 需要把这个武器归位到 attract 之前的位置
            gameObject.transform.position = Vector3.Lerp(gameObject.transform.position, _weaponOriginPosition, attractSpeed * Time.deltaTime);
            gameObject.transform.rotation = Quaternion.Lerp(gameObject.transform.rotation, _weaponOriginRotation, attractSpeed * Time.deltaTime);
        }
    }

    private void OnCollisionEnter(Collision collision)
    {
        Debug.Log("weapon collision enter");
    }

    private void OnCollisionExit(Collision collision)
    {
        Debug.Log("weapon collision exit");
    }

    private void OnTriggerEnter(Collider other)
    {
        Debug.Log("weapon trigger enter");
        if (other.gameObject.tag == "Hand")
        {
            _isInHand = true;
            _isInAttract = false;
        }
        
    }

    private void OnTriggerExit(Collider other)
    {
        Debug.Log("weapon trigger exit");
    }
}

小细节:位移终点如何确定?

右手:

image.png

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

public class handMove : MonoBehaviour
{
    public InputActionProperty handAniActionProperty;
    public InputActionProperty handPartAniActionProperty;
    public float speed = 10;
    private Animator animator;
    private float currentAnimationProgress = 0;
    private float currentPartAnimationProgress = 0;
    private bool somethingInHand;
    // Start is called before the first frame update
    void Start()
    {
        animator = GetComponent<Animator>();
    }

    // Update is called once per frame
    void Update()
    {
        if (somethingInHand)
        {
            float targetProgress = 1;
            float targetPartProgress = 1;
            float newProgress = Mathf.MoveTowards(currentAnimationProgress, targetProgress, Time.deltaTime * speed);
            float newPartProgress = Mathf.MoveTowards(currentPartAnimationProgress, targetPartProgress, Time.deltaTime * speed);
            animator.SetFloat("handleAnimationProgress", newProgress);
            animator.SetFloat("partAnimationProgress", newPartProgress);
            currentAnimationProgress = newProgress;
            currentPartAnimationProgress = newPartProgress;

        }
        else
        {
            float targetProgress = 0;
            float targetPartProgress = 0;
            float newProgress = Mathf.MoveTowards(currentAnimationProgress, targetProgress, Time.deltaTime * speed);
            float newPartProgress = Mathf.MoveTowards(currentPartAnimationProgress, targetPartProgress, Time.deltaTime * speed);
            animator.SetFloat("handleAnimationProgress", newProgress);
            animator.SetFloat("partAnimationProgress", newPartProgress);
            currentAnimationProgress = newProgress;
            currentPartAnimationProgress = newPartProgress;
        }
    }

    private void OnCollisionEnter(Collision collision)
    {
        Debug.Log("hand collision enter");
    }

    private void OnCollisionExit(Collision collision)
    {
        Debug.Log("hand collision exit");
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.gameObject.tag == "Weapon")
        {
            somethingInHand = true;
        }
        Debug.Log("hand trigger enter");
    }

    private void OnTriggerExit(Collider other)
    {
        Debug.Log("hand trigger exit");
        if (other.gameObject.tag == "Weapon")
        {
            somethingInHand = false;
        }

    }
};

Web VR

image.png

想要实现一个运行在浏览器上的 VR 程序,需要解决输入渲染两个问题。

渲染方面,目前已经有较为成熟的 3d 图形库 与 物理引擎

输入方面,不同于 native vr 可以集成不同平台的 SDK 来分别发布,web 需要考虑跨平台

OpenXR 正是解决跨平台输入的方案。目前实验版本的 chrome 和 firefox 浏览器已经尝试接入了该规范,有理由相信,在不久的将来,将能够使用 js 语言在浏览器上实现简单的 VR 交互。