贺贺呵呵

WebVR入主Servo引擎:架构及延迟优化

贺贺呵呵 · 2016-12-12翻译 · 911阅读 原文链接

关于译文,本译文的原文由于取材于网络,出现了代码缺失,及不正确文字的现象,导致翻译过程一度陷入困境。特在此更正原文地址,以便参考。查看原文


我们非常荣幸地宣布, 首款WebVR补丁 ,已经登陆到Servo引擎。

如果你已经迫不及待地想要开始尝试,你可以下载兼容HTC Vive试验版Servo binary 。打开你的VR设备,运行 servo.exe --resources-path resources webvr\room-scale.html

目前版本支持WebVR 1.2 规范,使得API可以像WebWorkers一样,不必运行在主线程,而是运行在一个上下文当中。

我们一直致力于优化VR的渲染方式,以获得更高的FPS以及少于20ms的延迟,以避免产生眩晕。如图所示是整体架构的介绍:WebVR Architecture in Servo


Rust-WebVR 库

Rust WebVR是一个弱耦合库,提供了WebVR标准实现,以及与供应商特定的SDK集成(OpenVR, Oculus …)。使其与自身组件解耦带来了以下好处:

  • 快速的开发-编译-测试周期。 在完整的浏览器中,编译时间快于开发及测试时间。

  • 贡献代码变得更加容易 。因为开发者无需纠结于复杂的浏览器代码。

  • 适用于各种第三方项目: 使用原生RustRoom scale demo

这套库的API既希望能够简单使用WebVR API,同时还要能够满足Rust的设计模式。VR服务特征提供了使用像OpenVR以及Oculus SDK等这样的原生SDK的方法。API提供了诸如初始化,关闭,事件轮询以及VR设备发现等操作:

pub trait VRService: Send {  
    fn initialize(&mut self) -> Result<(), String>;
    fn fetch_devices(&mut self) -> Result<Vec<VRDevicePtr>, String>;
    fn is_available(&self) -> bool;
    fn poll_events(&self) -> Vec<VRDisplayEvent>;
}

VR设备特征提供了一个与VR头盔交互的方式:

pub trait VRDevice: Send + Sync {

    // 返回一个唯一的设备标识符
    fn device_id(&self) -> u64;

    // 返回当前显示数据
    fn display_data(&self) -> VRDisplayData;

    // 返回头戴显示设备(HMD)的即时VRFrameData
    fn inmediate_frame_data(&self, near_z: f64, far_z: f64) -> VRFrameData;

    // 返回一个同步的VRFrameData渲染当前帧
    // 提交到设备时使用
    // sync_poses 必须在此之前调用
    fn synced_frame_data(&self, next: f64, far_z: f64) -> VRFrameData;

    // 为此次显示重置姿态
    fn reset_pose(&mut self);

    // 同步指针保持与HMD同步
    // 必须在渲染线程中调用,并且保证在做任何其他工作之前调用。
    fn sync_poses(&mut self);

    // 向此次显示提交帧数据
    // 必须在渲染线程中调用
    fn submit_frame(&mut self, layer: &VRLayer);
}

供应商特定的SDK集成方式(OpenVR, Oculus …)是建立于VR服务特征和VR设备特征之上的。OpenVR服务,实现了Valve公司的 OpenVR API接口。这些代码使用的是Rust语言,然而大多数的原生SDK使用的是C或C++语言。我们使用Rust FFI以及rust-bindgen来从C/C++头文件中生成Rust组件。

Mock服务,实现了一个可以脱离物理设备进行测试开发的虚拟VR设备。你将可以利用坐火车的时间,甚至是参加一个极为无聊的会议的时候完成这些代码。

VR服务管理器是rust-webvr库的主入口。它处理了VR服务实现的全部接口及生命周期。你可以使用cargo-features来注册一个默认的VR服务管理器或者通过手动注册的方式构建VR服务管理器。以下是一个使用原生Rust进行初始化操作的例子:

let mut vr = VRServiceManager::new();  
// 注册默认 VRService实现
// 默认VRServices使用Cargo
vr.register_defaults();  
// 如果没有物理设备,则使用虚拟设备
vr.register_mock();  
// 初始化所有注册的VR服务
vr.initialize_services();  
// 寻找VR设备
let devices = vr.get_devices();  
// 选择第一个发现的VR设备
let device = devices.get(0).unwrap();  
// 与设备交互
let device_data = device.borrow().display_data();  
println!("VR Device data: {:?}", device_data);

Servo集成的WebVR

性能和安全是Web浏览器的两大主要指标。DOM对象可以操作VR设备,但是DOM对象本身是不包含这些的,甚至连直接指向原生对象的指针都没有。之所以这样设计是因为:

  • JavaScript的执行是有风险的,输入的数据可能会进行恶意破坏。这也是为什么所有的JavaScript线程必须独立运行于自己的沙盒进程中。

  • 可能会有许多平行的JavaScript上下文,同时请求一个相同的VR设备实例,这将会导致数据竞争现象。

  • WebVR标准强调了隐私性及安全性的方面。例如,其他的网页页签不允许读取与VRDisplay有关的数据,或者是当用户在当前页签进行VR体验的过程中强制关闭VR展示。

WebVR线程是一个能够充分满足性能及安全需求的可靠组件。其拥有原生VR设备,在Servo引擎中处理他们的生命周期,并拦截来自DOM对象不受信任的VR请求。多亏了使用Rust语言实现了这套架构,使得线程相关的安全规则能够在编译期得到良好地检查。其他Servo组件特征被分成多个子组件以避免在Servo引擎中造成循环依赖。

简而言之,WebVR线程等待DOM对象传来的VR命令,并在其受信任的线程中处理这些命令。这确保在多条JavaScript脚本,产生并发请求时造成的数据竞争问题不会发生。使用IPC-Channels实现循环往复的消息传递。这里给出主循环是如何实现的:

while let Ok(msg) = self.receiver.recv() {
    match msg {
        WebVRMsg::RegisterContext(context) => {
            self.handle_register_context(context);
            self.schedule_poll_events();
        },
        WebVRMsg::UnregisterContext(context) => {
            self.handle_unregister_context(context);
        },
        WebVRMsg::PollEvents(sender) => {
            self.poll_events(sender);
        },
        WebVRMsg::GetVRDisplays(sender) => {
            self.handle_get_displays(sender);
            self.schedule_poll_events();
        },
        WebVRMsg::GetFrameData(pipeline_id, device_id, near, far, sender) => {
            self.handle_framedata(pipeline_id, device_id, near, far, sender);
        },
        WebVRMsg::ResetPose(pipeline_id, device_id, sender) => {
            self.handle_reset_pose(pipeline_id, device_id, sender);
        },
        WebVRMsg::RequestPresent(pipeline_id, device_id, sender) => {
            self.handle_request_present(pipeline_id, device_id, sender);
        },
        WebVRMsg::ExitPresent(pipeline_id, device_id, sender) => {
            self.handle_exit_present(pipeline_id, device_id, sender);
        },
        WebVRMsg::Exit => {
            break
        },
    }
}

在DOM中并不会去初始化全部的VR命令。 WebVR标准定义了一些在VR显示设备连接,断开连接,激活等情况下进行触发的事件。WebVR线程将周期性轮询向JavaScript提交这些事件。通过事件轮询线程发送一个轮询事件信息,来唤醒WebVR线程。

目前的实现目标是尽可能的减少资源消耗。事件轮询线程只有在至少有一个活动的、使用WebVR API的JavaScript上下文存在时创建,并在网页页签关闭时杀掉事件轮询进程。WebVR线程采用的是懒加载模式,而且仅在本地VR服务第一次有网页页签使用WebVR API时进行初始化操作。WebVR的实现不会在浏览器开启时或网页页签打开时,产生额外的开销。


VRCompositor 命令 (集成WebRender)

WebRender(Web渲染) 负责在Servo引擎中处理所有的 GPU 和 WebGL 渲染工作。一些本地VR SDK函数需要运行在同一个渲染线程中才能获得OpenGL的上下文内容:

  • 在使用OpenGL向VR头盔提交给每只眼睛的像素点时,只能通过来自WebGL的渲染线程的驱动程序进行读取。

  • 垂直以及水平同步姿势必须在发送像素的渲染线程中调用。

WebVR线程不能再WebGL渲染线程中调用函数。而WebRender是解决这一问题的唯一途径。VRCompositor特征通过一个使用了共享VR设备实例的WebVR线程来实现。它在初始化时,在WebRender中构建了一个VRCompositorHandler实例。

// Trait 对象处理WebVR命令
// 接收与WebGLContext有关的texture_id 
pub trait VRCompositorHandler: Send {
    fn handle(&mut self, command: VRCompositorCommand, texture_id: Option<u32>);
}

WebRender同样也是一个受信任的组件。Rust的borrow checker机制迫使你使用多线程安全规则写出安全可靠的代码。Rust还有一个很好的特性是它非常灵活,当你觉得性能比较重要的时候,Rust可以允许你避开这些安全检查机制编写代码。我们在这里使用了传统的指针代替Arc以及Mutex来在线程中共享VR设备,这样做的目的是优化渲染路径,减少因为锁以及各种间接的调用关系带来的性能损失。而多线程在这里并不是什么问题:

  • VR设备的实现,允许你通过使用Rust语言实现的发送和同步特征来在其他的线程中调用compositor函数。

  • 多亏了WebVR线程实现了线程安全规则,VRDisplay运行在独立进程中,不会有多线程资源竞争的现象发生。

为了降低延迟我们必须减少ipc-channel信息的数量。通过使用一个共享内存实现Webrender可以实现VRCompositor函数的直接调用。VR渲染调用了像SubmitFrame之类的原生JavaScript,同样有助于优化延迟问题。当一个JavaScript线程可以向VR头盔发出请求时,它将获得一个,可以向不使用WebVR线程作为媒介的Webrender channel传递信息的,受信任的IPCSender实例。这将避免潜在的,通过使用其它JavaScript脚本产生大量的WebVR线程向VR头盔进行提交造成的"JavaScript DDoS"攻击。

以下是一个活动的VRDispaly DOM对象可以通过IPC-Channel发送WebRender的VR Compositor命令

pub enum VRCompositorCommand {  
    Create(VRCompositorId),
    SyncPoses(VRCompositorId, f64, f64, IpcSender<Result<Vec<u8>,()>>),
    SubmitFrame(VRCompositorId, [f32; 4], [f32; 4]),
    Release(VRCompositorId)
}

你可能会提出疑问,为什么使用字节数组发送VR帧数据。这是因为,为了使得WebRender以及WebVR解耦而设计的。这将实现更快的请求周期,并且可以避免版本依赖冲突。Rust-WebVR以及WebVR线程可以被更新,甚至可以在不改变Webrender的情况下像VR帧数据结构中添加新的域。Servo引擎中的IPC-Channel信息需要通过serde-serialization进行序列化,所以字节数组成为了进一步序列化的解决方案。Rust-webvr库通过使用传统的内存操作memcpy实现了从VR帧数据到字节数组的序列化过程。


DOM 对象 (针对于Servo引擎)

DOM对象连接了JavaScript代码以及原生Rust代码。他们依赖于之前所提到所有组件。

  • 在rust-webvr中定义的结构被用来映射DOM数据对象:VRFrameData, VRStageParameters, VRDisplayData等等。其中涉及到一些原生Rust float数组和JavaScript类型数组之间的转换。

  • WebVR线程通过使用ipc-channel进行诸如发现设备,获取帧数据,请求或停止VR头盔的显示等操作。

  • 当WebVR线程有权限获取到VRDisplay的时候,通过ipc-channels 使用优化的WebRender路径

第一步需要做的是添加WebIDL files文件,以便于自动生成一些依赖文件。Servo引擎需要为WebVR中定义的每一个对象创建一个独立的文件。自动生成的代码会产生一些模板代码,我们只需要关心那些想要暴露给JavaScript的那些API。

每一个在WebIDL files中定义的DOM对象,都需要定义一个sturct,以及实现一些特征方法(trait methods)。以下是关于VRDisplay DOM对象结构的一个例子:

#[dom_struct]
pub struct VRDisplay {
    eventtarget: EventTarget,
    #[ignore_heap_size_of = "Defined in rust-webvr"]
    display: DOMRefCell<WebVRDisplayData>,
    depth_near: Cell<f64>,
    depth_far: Cell<f64>,
    presenting: Cell<bool>,
    left_eye_params: MutHeap<JS<VREyeParameters>>,
    right_eye_params: MutHeap<JS<VREyeParameters>>,
    capabilities: MutHeap<JS<VRDisplayCapabilities>>,
    stage_params: MutNullableHeap<JS<VRStageParameters>>,
    #[ignore_heap_size_of = "Defined in rust-webvr"]
    frame_data: DOMRefCell<WebVRFrameData>,
    #[ignore_heap_size_of = "Defined in rust-webvr"]
    layer: DOMRefCell<WebVRLayer>,
    layer_ctx: MutNullableHeap<JS<WebGLRenderingContext>>,
    next_raf_id: Cell<u32>,
    /// 动画帧请求回调列表
    #[ignore_heap_size_of = "closures are hard"]
    raf_callback_list: DOMRefCell<Vec<(u32, Option<Box<FnBox(f64)>>)>>,
    // Compositor VRFrameData 同步
    frame_data_status: Cell<VRFrameDataStatus>,
    #[ignore_heap_size_of = "channels are hard"]
    frame_data_receiver: DOMRefCell<Option<IpcReceiver<Result<Vec<u8>,()>>>>
}

关于DOM对象中sturct的实现需要遵循一定的规则,确保垃圾回收机制GC能够正确的工作。这需要确保内部可变性(interior mutability)。JS容器被用来存储在sturct中的GC管理值。另一方面,容器必须在处理GC管理值栈的时候使用。这些容器可以结合其他的数据类型使用,例如 Heap, MutHeap, MutNullableHeap, DOMRefCell, Mutex等等,取决于你的可空性,可变性以及多线程等方面的需求。

类型数组(Typed arrays)的使用,以便于高效地使用JavaScript共享所有的VR帧数据矩阵及四元组。为了最大化性能表现,避免垃圾回收带来的问题,我们添加了新的Rust模板,来创建、更新实例化的类型数组。这些模板自动调用正确的基于Rust切片(Rust slice)的SpiderMonkey C 的函数。这里是API:

/// 从Rust切片中创建JS类型数组
pub fn slice_to_array_buffer_view<T>(cx: *mut JSContext, data: &[T]) -> *mut JSObject 
    where T: ArrayBufferViewContents
{
    unsafe {
        let js_object = T::new(cx, data.len() as u32);
        assert!(!js_object.is_null());
        update_array_buffer_view(js_object, data);
        js_object
    }
}

/// 从Rust切片中更新JS类型数组
pub unsafe fn update_array_buffer_view<T>(obj: *mut JSObject, data: &[T])
    where T: ArrayBufferViewContents
{
    let mut buffer = array_buffer_view_data(obj);
    if let Some(ref mut buffer) = buffer {
        ptr::copy_nonoverlapping(&data[0], &mut buffer[0], data.len())
    }
}

对于本地VR头盔帧率的渲染循环(render loop),是通过一个专用的线程实现的。每一次循环迭代都将姿态数据同步到VR头盔中,使用共享的OpenGL材质提交像素到显示器上,并等待垂直同步。这使得其优化的延迟更小,帧率更加稳定。在JavaScript线程中的VRDisplay调用requestAnimationFrame,以及在WebRender线程中的VRSyncPoses调用并行执行的。这便允许一些JavaScript代码可以继续执行,同时渲染线程也在不断地为当前帧同步VR帧数据。

目前的实现,可以处理在渲染线程结束姿态数据同步之前,调用JavaScript线程的场景。在这种情况下JavaScript线程需要一直等待,直到准备好数据为止。目前还可以处理JavaScript不调用GetFrameData的情况。当这种情况发生时,将读取待处理的VR帧数据,以避免发生IPC-Channel缓冲区溢出的情况。

为了获得最优的WebVR渲染路径,在你的JavaScript代码中必须尽可能晚地调用GetFrameData,并要在调用GetFrameData之后,尽快地调用SubmitFrame操作。


结论

我们非常荣幸看到Servo WebVR从刚开始没有使用WebGL作为后台的雏形,进化到现在能够以90fps及低延迟运行WebVR例子程序的变化。我们发现Rust语言可以简化复杂并行架构系统以及具有高性能,高隐私,内存安全需求的WebVR系统的开发。此外Cargo包管理器对于处理灵活的包需求,以及复杂的依赖关系有着非常出色的表现。

对于我们而言,接下来的任务是为轨迹控制器实现 GamePad API扩展,并支持更多的VR设备,同时继续提高WebVR API的稳定性及性能。敬请期待!

相关文章