博人眼球:用WebGL和WebRTC建造线上影院(下)

获取媒体设备

我们的app真的很好用!

尽管此应用出现在众多WebRTC入门教程的主题中,但它要解决的一大难题是首先获取用户的摄像头和麦克风许可。当然,大多数时候该操作都很简单——只需await navigator.mediaDevices.getUserMedia({ audio:true, video: true }),然后若用户认可你的应用程序,你会收到一个可在< video>元素中查看,或通过RTCPeerConnection发送的MediaStream。虽然该操作看起来可行,但如果你要构建一个实际的生产应用程序,则需要考虑许多其他问题。

首先,如果用户拒绝你摄像头、麦克风或同时开启两者的请求,你需要一种不减少过多功能还能正常回退的方法。就我们而言,我们礼貌地请用户允许我们使用他们的麦克风和摄像头,并让他们知道自己可以在演出中的任何时间开关麦克风和摄像头。但如果他们不信任我们,更愿意通过文字聊天的方式(同时他们还可以看到和听到其他玩家和演员)进行整个演出,那么我们也尊重他们的想法。但这使情况变得复杂了(有一部分原因是我们想借此检测用户是否允许使用摄像头,或只允许使用麦克风),我们还没能解决这个问题。

如果你的用户设备设置像我的一样混乱,你可以给他们工具,让他们自己解决问题。

用户可能没有用那些可用于getUserMedia的默认视频和音频源。比如用户用的可能是:

一个好的USB麦克风,一个麦克风坏了(但这是最近设置的浏览器默认麦克风)的蓝牙耳机;

显示器上安装的好的USB网络摄像头,以“翻盖模式”运行的笔记本电脑的内置网络摄像头(是浏览器默认的模式);

一个好的USB网络摄像头,但当你访问它时会出现“挂起”的情况,因为SplitCam等网络摄像头filter app(即代替用户而使用了自己伪视频的设备)正在占用它;

捕获卡或类似可以显示当网络摄像头用的设备,但实际只能展示用户显示屏上的内容。

这样的话你自然希望创建一个选择器,它能使用户选择要使用的设备(或完全不使用任何设备)。为此,你需要navigator.mediaDevices.enumerateDevices(),该函数会返回一个用户可用设备、其用户可读标签以及可用于收集反馈的deviceId的完整列表。所有这些都不会提示用户许可。

你可能要问为什么不提醒许可。如果未授予用户许可(或未询问用户),你会返回一个仅包含默认音频和视频设备,其组ID(不包括deviceId),且不包含标签的列表。这是为了不用进行指纹识别,这样操作的效果很好。因为你可以调用navigator.mediaDevices…uhoh there’s no way to do this来确定是否已提示用户进行麦克风/摄像机访问。

那么,启动我们的应用程序后,获取可用设备列表的逻辑如下所示:

  try {
    // After this we'll know for SURE whether we've asked permission
    let stream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
    // But these might not be the devices we actually want,
    // so dump this stream and wait for the user to select a device
    stream.getTracks().map(track => {
      stream.removeTrack(track);
      track.stop();
    });

    // If we've made it this far, we know we'll get "the good list"
    let devices = await navigator.mediaDevices.enumerateDevices();
  } catch(e) {
    console.log(`Error getting input devices: ${e.message}`);
  }

是的。我们获取了一个流(希望默认媒体设备可用并且不会挂起,如果确实获取了之后(意味着我们已经获得许可并可以获取真实设备列表),我们会立即转储该流,然后获取设备列表。当然,该操作会更复杂。如果我们无法获取流,我们会尝试单独获取摄像机和麦克风许可,再使用这些(转储流之后的)结果来找出用户已赋予我们的权限。像Saga / WebRTC集成一样,我们可能会再开一篇文章详述此操作。

但需要再强调一点:大家需要熟悉每个浏览器+操作系统组合中关于设备访问和突发状况的不成文规则。比如:

  • iOS上:一次只能有一个应用程序访问摄像头。所以如果用户刚结束Zoom通话但忘了退出程序,那么即使用户之前已经允许你的应用程序访问摄像头,你在拉取媒体流的时候也会出错。
  • iOS上: Safari应用程序、Safari独立PWA和SafariViewController都可以使用WebRTC, UIWebViews和WKWebViews不能。且navigator.mediaDevices实际上是未定义的,也就是说Chrome for iOS不支持WebRTC。但是如果你从Slack或现代电子邮件应用程序打开链接,WebView实际上是可以使用WebRTC的。这样做出于安全考虑,也是对过去“Safari应用一家独大”情况的一种改进。但我真的希望Apple和Google能够有办法让WebRTC在iOS版Chrome浏览器中工作。
  • 一般情况下: 你经常会碰到一些毫无价值的报错,比如“无法找到设备”或“视频源无法启动”。这些报错什么也说明不了,但我发现切换到“no input”几秒钟,然后再次返回设备;或拔出USB,再重新插入设备也可以解决问题。光靠远程诊断很难解决这个烫手山芋。
  • 一般情况下的网络摄像头:大家一定要记住,网络摄像头会以特定的分辨率和帧率进行录制,如果另一个应用程序或页面请求使用不同分辨率或帧率访问摄像头,因为摄像头已经在录制了,所以只能按当前规格进行操作。网络摄像头通常无法接收其传感器输入,也不能将其转换为不同应用程序的不同形式。建议大家从不同的制造商处买2台便宜的网络摄像头,以使测试更容易,覆盖范围更好。

最后,我想说一些用媒体设备的开发人员需要注意的其他事项:

  • 如果你要进行上述的设备切换(购物类视频应用必备功能),你就不能依赖Janus.js之类的库来为你处理此事。你不能仅仅向库下达“使用视频而不是音频”的指令,而是要自己获取媒体流,放到自己的WebRTC库里。
  • 切换设备并不像你以前媒体流中的MediaTrack与你新媒体流中的MediaTrack交换那样简单,因为这样做会导致旧的MediaTrack触发结束事件,从而终止你的RTCPeerConnection。因为没有(截止2020年6月跨平台协作的数据)MediaStream.replaceTrack()方法,所以你必须拆除所有有关WebRTC的内容,媒体设备一旦变化就要重新连接。因为要执行很多遍此操作,使其易于执行和防泄漏是很重要的。
  • 上述问题的一种解决方法是通过Web Audio API传输所有音频,通过canvas元素传输所有视频,并使用.captureStream()创建一个永不“结束”的视频轨道。但是你要有一台不在Safari中运行的,功能强大的计算机,不然也无法正常进行该操作。

three.js和OffscreenCanvas

https://chrisuehlinger.com/images/shattered/planet-selection.mp4

观看简短的介绍/教程视频后,玩家可以在导航屏幕上找到自己。在这里,他们可以选择要前往的行星、太空站或其他目的地。小组中的任何成员都可以选择一个目的地以供所有人查看,然后挑一个成员(每次都会随机选择成员)选择要去哪个星球。此操作需要两部分完成:

  • 用于进行选择的列表UI和用于确认选择的按钮。
  • 用于显示目的地的Three.js渲染视图的canvas。

像上文提到的那样,我以前用过three.js,但那时页面通常是由3D构成的。对于这次的项目,我准备把3D场景构建到有许多其他功能的应用中。假设用户的设备快没电了,但这时还在运行Chrome,页面正以1 FPS或更低的速度渲染场景。我们想要确保用户仍然可以单击UI中的按钮,及时做出回应。

那么,因为用了高级Web技术产生了问题要怎么办呢?当然是要用更高级的web技术解决它。

OffscreenCanvas是一款Web API,可让你在Web Worker中执行独立于主UI线程的大量渲染工作。但是它并不能完全解决问题(如果你的渲染tank达到25FPS,它会大大降低主线程的FPS),但是它可以避免两个大问题:

1. Web Worker中的错误得到控制,且不会影响主线程(停掉3D动画即可)。

2. 有时,Three.js场景会完全锁定其线程(在编译着色器或加载复杂模型时),且在那个时候,UI是保持响应状态的。

但这也带来了新的问题,比如:

1. Safari不支持OffscreenCanvas。所以你需要一个polyfill,以便在主线程上运行所有代码。建议大家用offscreen-canvas来练手,但我把它设置为仅自己可用,以更好地支持拥使用多个canvas的工作人员,她们可以根据需要发送新的canvas。

如果你使用的是webpack,你要给工作人员代码创建一个新的入口点,以及一个<link rel =“ preload”>标签,以便你的polyfill可以找到工作人员脚本的名称,并将其加载到合适的线程上。参考preload-webpack-plugin

如果你用three.js,需要确保它在WebWorker中运行时不会尝试创建/修改DOM元素。你要给renderer.setSize()设置一个false标志,这样它就不会尝试修改canvas的样式。而且你需要在后台使用ImageBitmapLoader,而不是ImageLoader来制作自己的加载器版本,例如TextureLoaderFBXLoader。这基本上是直接替代了,但你也要清楚(在.flipY等处中)ImageBitmapLoader与ImageLoader的不同之处,并进行相应的调整。

three.js和内存管理

另一个新问题是内存管理——仅仅让three.js对象落在范围之外还不算真正的回收清理。因为three.js在后台保留了数据注册表以提高性能(考虑到WebGL的工作原理,这点是可以理解的)。

需要清理的类别都有一.dispose()方法。我的代码中基本都有。我用一个加载了所有模型的构造函数,为场景中的每个“事物”创建了一个类、(在运行时把它们同时添加到this.disposables数组中)一个适用于任何动画的.update()方法,以及一个.dispose()方法,该方法可在所有一次性对象上调用.dispose(),处理任何其他必要的清除工作。

export default class TLW {
  constructor(globals, options) {
    this.globals = globals;
    this.disposables = [];

    (new FBXLoader(globals.loadingManager))
      .setPath(`${config.ASSET_PATH}/planets/TLW/`)
      .load( `fighter_low.fbx`, ( object ) => {
        object.traverse(( child ) => {
          if ( child.isMesh ) {
            // Add each loaded geometry to the disposables list
            this.disposables.push(child.geometry);
            // Don't forget their materials
            this.disposables.push(child.material);
            // Or their textures
            this.disposables.push(child.material.map);
          }
        } );

        this.group.add( object );
      });

    this.group = new THREE.Group();
    this.group.position.set(...options.position);
    this.options = options;
  }

  dispose() {
    this.globals = null;
    this.disposables.map(asset => {
      // Wrap this in a try/catch and announce in the console if we
      // try to dispose of something that doesn't need to be
      try {
        asset.dispose();
      } catch (e){
        console.error('DISPOSAL ERROR', asset);
        console.error(e);
      }
    });
  }
}

用来装载Ⱦẘ(一艘困在太空中的船)代码的简化版本

据我估计,此版本大概是用完整内存管理复杂场景所需工作量的60-70%,会有5-10%的漏洞(不包括我没有统计到的数据)。剩下的一个最大漏洞是,如果我实例化我的一个类,它会开始加载一些模型/纹理,加载没完成就会被销毁了。也就是加载-堵塞-泄漏。实践中,这种情况很少发生,并且由于内存占用而导致崩溃的情况很少见,因此,我很满意此项目方法。

之后我减免了大部分这样的需求。方法是坚持使用three.js场景(即使它不在屏幕上),此外,只要React装载/卸载了NavigationScreen组件,我就重建/销毁渲染器。这方法让场景加载(初始加载后)几乎能瞬间完成,这意味着内存泄漏的位置不多。

最后,我要坦白一个不足之处——因为列表用户界面占据着整个屏幕,我把three.js完全从移动设备(大致就是尺寸小于500px的设备)上剪切掉了。

远程检测和解决问题

整个表演过程中,我都会监测实时错误日志。

通常在预定开始表演前的5至10分钟,我们已经能看到与会者登录的情况了。也就是说如果他们遇到问题,我们有5-10分钟的时间来解决。

这就需要多管齐下,在演出中的许多点进行干预:

  • 接受宣传材料和登录的链接邮件,建议你使用Google Chrome(并不是硬性要求)。若你是iOS用户,就只能使用Safari了(其他iOS浏览器不兼容WebRTC)。
  • 我们的技术支持团队会监测我们的公司邮件,该电子邮件用于向用户发送其登录链接。用户遇到问题时基本都通过邮件与我们联系,部分原因是:
  • 应用程序中任何无法恢复的错误都会被React错误边界捕获,然后该边界会显示一个错误页面,其中包含错误消息以及导向我们公司电子邮件的mailto链接,以便他们可以直接与我们联系。
  • 演出时,如果有与会者未登录,我们的技术支持团队会通过电子邮件与其联系,看看他们是否遇到了登录问题。
  • 假设用户尚未关闭遥测功能,我们会立即收到演员和参与者应用产生的任何错误(无论是否可恢复)的通知。技术支持人员可以用这些通知,找出故障排除方法,向用户发送解决步骤的邮件(通常很简单,例如“刷新”、“更新浏览器”或“关闭麦克风然后再打开”)。这些错误日志不会一直保存在数据库中(它们会被直接发送到Admin应用,在刷新后消失)。

相关阅读:博人眼球:用WebGL和WebRTC建造线上剧院(上)

文章地址:https://chrisuehlinger.com/blog/2020/06/16/unshattering-the-audience-building-theatre-on-the-web-in-2020/

原文作者:Chris Uehlinger

填写常用邮箱,接收社区更新

WebRTC 中文社区由

运营