AR 游戏:技术概述

AR 游戏2013 年 5 月 Dev Derby 的获奖作品。这是一个 增强现实 游戏,目标是将滚动游戏棋子从二维物理世界传输到三维空间。这款游戏可以在 GitHub 上玩,并在 YouTube 上演示。本文的目标是描述游戏设计和工程背后的方法。

从技术上讲,这款游戏是四种复杂的开源技术的简单结合:WebRTCJSARToolkitThreeJSBox2D.js。本文将介绍每一种技术,并解释我们如何将它们编织在一起。我们将逐步进行,从头开始构建游戏。本文讨论的代码可在 github 上获得,每个教程步骤都有一个 标签 和一个 实时 链接。本文档中将引用一些汇总的源代码片段,完整源代码可通过“diff”链接获得。在适当情况下,还提供演示应用程序行为的视频。

git clone https://github.com/abrie/devderby-may-2013-technical.git

本文将首先讨论 AR 面板 (真实空间),然后讨论二维面板 (平面空间),最后以对它们 耦合 的描述作为结尾。

真实空间面板

真实空间是相机所看到的内容——叠加了增强单元。

从骨架开始

git checkout example_0
实时diff标签

我们将使用 RequireJS 将代码组织成模块。起点是一个主模块,其中包含两个游戏通用的骨架方法。它们分别是 initialize() 用于调用启动,以及 tick() 用于渲染每一帧。请注意,游戏循环是由对 requestAnimationFrame 的重复调用驱动的。

requirejs([], function() {

    // Initializes components and starts the game loop
    function initialize() {
    }

    // Runs one iteration of the game loop
    function tick() {
        // Request another iteration of the gameloop
        window.requestAnimationFrame(tick);
    }

    // Start the application
    initialize();
    tick();
});

到目前为止,我们的代码提供了一个带有空循环的应用程序。我们将以此为基础进行构建。

给骨架安上一只眼睛

git checkout example_1
实时diff标签

AR 游戏需要实时视频流:HTML5WebRTC 通过访问 摄像头 来提供这种功能,因此,AR 游戏可以在现代浏览器(如 Firefox)中实现。有关 WebRTC 和 getUserMedia 的良好文档可在 developer.mozilla.org 上找到,因此我们在此不再介绍基础知识。

一个名为 webcam.jsRequireJS 模块以摄像头库的形式提供,我们将将其整合到我们的示例中。

首先,必须初始化并授权摄像头。webcam.js 模块 在用户同意时调用回调函数,然后,对于游戏循环的每个滴答,video 元素复制一帧到 canvas 上下文。这很重要,因为它使图像数据可访问。我们将在后面的部分中使用它,但现在,我们的应用程序只是一个在每个滴答时都用视频帧更新的 canvas

类似于视觉皮层的东西

git checkout example_2
实时diff标签

JSARToolkit 是一种增强现实引擎。它识别并描述图像中 基准标记 的方向。每个标记都与一个唯一的数字相关联。JSARToolkit 识别的标记可在 此处 获得,作为根据其 ID 编号命名的 PNG 图像(尽管截至撰写本文时,缺少 PNG 扩展名会让 Github 感到困惑)。对于此游戏,我们将使用 #16 和 #32,并将它们合并到一个页面上。

JSARToolkit 起源于 ARToolkit,它是在 华盛顿大学HITLab,位于 西雅图,用 C++ 编写的。从那时起,它被分叉并移植到多种语言,包括 Java,然后从 Java 移植到 Flash,最后从 Flash 移植到 JS。这种传承导致了一些特质和不一致的命名,正如我们即将看到的。

让我们看一下提炼后的功能

 // The raster object is the canvas to which we are copying video frames.
 var JSARRaster = NyARRgbRaster_Canvas2D(canvas);

 // The parameters object specifies the pixel dimensions of the input stream.
 var JSARParameters = new FLARParam(canvas.width, canvas.height);

 // The MultiMarkerDetector is the marker detection engine
 var JSARDetector = new FLARMultiIdMarkerDetector(FLARParameters, 120);
 JSARDetector.setContinueMode(true);

 // Run the detector on a frame, which returns the number of markers detected.
 var threshold = 64;
 var count = JSARDetector.detectMarkerLite(JSARRaster, threshold);

当一帧由 JSARDetector.detectMarkerLite() 处理后,JSARDetector 对象将包含一个已检测标记的索引。JSARDetector.getIdMarkerData(index) 返回 ID 编号,JSARDetector.getTransformMatrix(index) 返回空间方向。使用这些方法有点复杂,但我们将 将它们封装在 可用的辅助方法中,并从如下循环中调用它们。

var markerCount = JSARDetector.detectMarkerLite(JSARRaster, 90);

for( var index = 0; index < markerCount; index++ ) {
    // Get the ID number of the detected marker.
    var id = getMarkerNumber(index);

    // Get the transformation matrix of the detected marker.
    var matrix = getTransformMatrix(index);
}

由于检测器是在每帧的基础上运行的,因此我们有责任在帧之间维护标记状态。例如,在两个连续帧之间可能会发生以下情况之一。

  • 首次检测到标记。
  • 现有标记的位置发生变化。
  • 现有标记从流中消失。

状态跟踪 是使用 ardetector.js 实现的。要使用它,我们使用接收视频帧的 canvas 实例化一个副本。

// create an AR Marker detector using the canvas as the data source
var detector = ardetector.create( canvas );

并且,在每次滴答时,canvas 图像都会被检测器扫描根据需要触发回调函数

// Ask the detector to make a detection pass.
detector.detect( onMarkerCreated, onMarkerUpdated, onMarkerDestroyed );

从代码中可以推断出,我们的应用程序 现在可以检测标记将发现结果写入控制台

将现实作为平面

git checkout example_3
实时diff标签

增强现实显示由一个现实视图叠加在 3D 模型上组成。渲染这种显示通常需要两个步骤。第一步是渲染由相机捕获的现实视图。在前面的示例中,我们只是将该图像复制到一个 canvas。但是,我们希望用 3D 模型来增强显示,这需要一个 WebGL 画布。问题在于,WebGL 画布没有我们可以复制图像的上下文。相反,我们 渲染一个纹理平面 到 WebGL 场景中,使用来自摄像头的图像作为纹理。ThreeJS 可以使用 canvas 作为纹理源,因此,我们可以将接收视频帧的 canvas 馈送到其中

// Create a texture linked to the canvas.
var texture = new THREE.Texture(canvas);

ThreeJS 缓存 纹理,因此,每次将视频帧复制到 canvas 时,必须设置一个标志以指示应更新纹理缓存。

// We need to notify ThreeJS when the texture has changed.
function update() {
    texture.needsUpdate = true;
}

从用户的角度来看,这导致的应用程序与 示例 2 没有什么不同。但在幕后,一切都变成了 WebGL;下一步是增强它!

增强现实

git checkout example_4
实时diff标签影片

我们已经准备好将增强组件添加到组合中:它们将采用与由相机捕获的标记对齐的 3D 模型的形式。首先,我们必须允许 ardector 和 ThreeJS 进行通信,然后,我们就可以构建一些模型来增强基准标记。

步骤 1:转换翻译

熟悉 3D 图形的程序员会知道,渲染过程需要两个矩阵:模型矩阵 (变换) 和相机矩阵 (投影)。这些由我们之前实现的 ardetector 提供,但不能直接使用——ardetector 提供的矩阵数组与 ThreeJS 不兼容。例如,辅助方法 getTransformMatrix() 返回一个 Float32Array,而 ThreeJS 不接受。幸运的是,转换非常简单,可以通过原型扩展(也称为 猴子补丁)轻松完成。

// Allow Matrix4 to be set using a Float32Array
THREE.Matrix4.prototype.setFromArray = function(m) {
 return this.set(
  m[0], m[4], m[8],  m[12],
  m[1], m[5], m[9],  m[13],
  m[2], m[6], m[10], m[14],
  m[3], m[7], m[11], m[15]
 );
}

这允许我们设置变换矩阵,但在实践中,我们会发现更新不起作用。这是因为 ThreeJS 的 缓存。为了适应这种变化,我们构建了一个容器对象并 matrixAutoUpdate 标志设置为 false。然后,对于矩阵的每次更新,我们 matrixWorldNeedsUpdate 设置为 true

步骤 2:立方体标记标记

现在,我们将使用我们的猴子补丁和容器对象来显示彩色立方体作为增强标记。首先,我们制作一个立方体网格,大小适合在识别标记上

function createMarkerMesh(color) {
    var geometry = new THREE.CubeGeometry( 100,100,100 );
    var material = new THREE.MeshPhongMaterial( {color:color, side:THREE.DoubleSide } );

    var mesh = new THREE.Mesh( geometry, material );

    //Negative half the height makes the object appear "on top" of the AR Marker.
    mesh.position.z = -50;

    return mesh;
}

然后,我们将网格包含在 容器对象中

function createMarkerObject(params) {
    var modelContainer = createContainer();

    var modelMesh = createMarkerMesh(params.color);
    modelContainer.add( modelMesh );

    function transform(matrix) {
        modelContainer.transformFromArray( matrix );
    }
}

接下来,我们生成标记对象,每个对象对应一个标记 ID 号

// Create marker objects associated with the desired marker ID.
    var markerObjects = {
        16: arobject.createMarkerObject({color:0xAA0000}), // Marker #16, red.
        32: arobject.createMarkerObject({color:0x00BB00}), // Marker #32, green.
    };

ardetector.detect() 回调 将变换矩阵应用于关联的标记。例如,这里的 onCreate 处理程序将变换后的模型添加到 arview 中

// This function is called when a marker is initally detected on the stream
function onMarkerCreated(marker) {
    var object = markerObjects[marker.id];

    // Set the objects initial transformation matrix.
    object.transform( marker.matrix );

    // Add the object to the scene.
    view.add( object );
}
});

我们的应用程序现在是一个功能完善的增强现实示例!

制作洞

在 AR 游戏中,标记比彩色立方体更复杂。它们是“虫洞”,看起来像是进入标记页面。这种效果需要一些技巧,为了说明起见,我们将分三个步骤构建这种效果。

步骤 1:打开立方体

git checkout example_5
在线演示差异标签视频

首先,我们移除立方体的顶面以创建一个敞开的箱子。这是通过 将面的材质设置为 不可见来完成的。敞开的箱子被放置在标记页面的后面/下方 通过将 Z 坐标调整为箱子高度的一半

这种效果很有趣,但尚未完成 - 并且也许不清楚为什么。

步骤 2:用蓝色覆盖立方体

git checkout example_6
在线演示差异标签视频

那么缺什么呢?我们需要隐藏从标记页面的“后面”伸出来的箱子的部分。我们将通过首先将箱子包含在一个 稍微大一点的箱子中来实现这一点。这个箱子将被称为“遮挡器”,在 步骤 3 中,它将成为隐形斗篷。现在,我们将把它 设置为可见并将其颜色设置为蓝色,作为视觉辅助。

遮挡器对象增强对象 渲染到相同的上下文,但在不同的场景中

function render() {
    // Render the reality scene
    renderer.render(reality.scene, reality.camera);

    // Render the occluder scene
    renderer.render( occluder.scene, occluder.camera);

    // Render the augmented components on top of the reality scene.
    renderer.render(virtual.scene, virtual.camera);
}

这件蓝色外套目前还没有对“虫洞”错觉做出太多贡献。

步骤 3:用隐形覆盖立方体

git checkout example_7
在线演示差异标签视频

这种错觉需要蓝色外套在保持遮挡能力的同时保持不可见 - 它应该是不可见的遮挡器。诀窍是 禁用颜色缓冲区,从而只渲染到深度缓冲区。 render() 方法现在变为

function render() {
    // Render the reality scene
    renderer.render(reality.scene, reality.camera);

    // Deactivate color and alpha buffers, leaving only depth buffer active.
    renderer.context.colorMask(false,false,false,false);

    // Render the occluder scene
    renderer.render( occluder.scene, occluder.camera);

    // Reactivate color and alpha buffers.
    renderer.context.colorMask(true,true,true,true);

    // Render the augmented components on top of the reality scene.
    renderer.render(virtual.scene, virtual.camera);
}

这产生了更令人信服的错觉。

选择洞

git checkout example_8
在线演示差异标签

AR 游戏允许用户通过将标记放置在目标十字线下方来选择要打开的哪个虫洞。这是游戏的核心方面,在技术上被称为对象拾取。ThreeJS 使这成为一件相当简单的事情。关键类是 THREE.Projector()THREE.Raycaster(),但有一个 警告:尽管关键方法的名称为 Raycaster.intersect<i>Object</i>(),但它实际上以 THREE.Mesh 作为参数。因此,我们 添加一个名为“hitbox”的网格createMarkerObject()。在我们的例子中 它是一个不可见的几何平面。请注意,我们没有明确为这个网格设置位置,而是让它保持默认值(0,0,0),相对于 markerContainer 对象。这将其放置在虫洞对象的入口处,在标记页面的平面上,即如果我们没有移除它,我们移除的面将位于的地方。

现在我们有了可测试的碰撞体,我们创建了一个名为 Reticle 的类来处理交叉检测和状态跟踪。当我们使用 arivew.add() 添加对象时,将回调合并到 arview 中。每次选择对象时,此回调将被调用,例如

view.add( object, function(isSelected) {
    onMarkerSelectionChanged(marker.id, isSelected);
});

玩家现在可以通过将它们放置在屏幕中央来选择增强标记。

重构

git checkout example_9
在线演示差异标签

我们的增强现实功能实际上已经完成。我们能够检测网络摄像头帧中的标记并将 3D 对象与它们对齐。我们还可以检测到何时选择了标记。我们已经准备好继续进行 An AR 游戏的第二个关键组件:玩家从其中传输棋子的平面 2D 空间。这将需要相当多的代码,一些初步的重构将有助于保持一切整洁。注意,许多 AR 功能目前都在 main application.js 文件中。让我们将其删除并将其放入一个名为 realspace.js 的专用模块中,使我们的 application.js 文件更简洁。

平面空间面板

git checkout example_10
在线演示差异标签

在 An AR 游戏中,玩家的任务是将棋子从 2D 平面转移到 3D 空间。前面实现的 realspace 模块用作 3D 空间。我们的 2D 平面将由一个名为 flatspace.js 的模块管理,它以与 application.jsrealspace.js 相似的骨架模式开始。

物理

git checkout example_11
在线演示差异标签

realspace 视图的 物理自然 提供。但是,flatspace 面板使用模拟的 2D 物理,这需要物理中间件。我们将使用一个名为 Box2D.js 的著名 Box2D 引擎的 JavaScript 转译。JavaScript 版本诞生于最初的 C++ 通过 LLVM,由 emscripten 处理。

Box2D 是一个相当复杂的软件,但文档齐全,并且 描述良好。因此,本文在大多数情况下将避免重复已经在其他地方有良好文档的内容。相反,我们将描述使用 Box2D 时遇到的常见问题,以模块的形式介绍解决方案,并描述其与 flatspace.js 的集成。

首先,我们构建 原始 Box2D.js 世界引擎的包装器,并将其命名为 boxworld.js。然后,它 集成到 flatspace 中

这不会产生任何明显的外部影响,但实际上我们现在正在模拟一个空的空间。

可视化

能够看到正在发生的事情将非常有帮助。Box2D 周到地提供了调试渲染,而 Box2D.js 通过类似于 虚拟函数 的东西来促进它。这些函数将绘制到 canvas 上下文中,因此我们需要创建一个画布,然后为 VTable 提供绘图方法。

步骤 1:制作一个度量画布

git checkout example_12
在线演示差异标签

canvas 将映射一个 Box2D 世界。画布使用像素作为其测量单位,而 Box2D 使用米来描述其空间。我们需要使用 像素到米的比率 来进行两种单位之间的转换。转换方法使用此常量来将 像素转换为米,以及将 米转换为像素。我们还 对齐坐标原点。这些方法与画布相关联,并且都封装在 boxview.js 模块 中。这使得将 其整合到扁平空间 中变得容易。

它在初始化期间被实例化,然后将它的画布添加到 DOM。

view = boxview.create({
    width:640,
    height:480,
    pixelsPerMeter:13,
});

document.getElementById("flatspace").appendChild( view.canvas );

页面上现在有两个画布——扁平空间和真实空间。在 application.css 中的一点 CSS 将它们并排放置。

#realspace {
    overflow:hidden;
}

#flatspace {
    float:left;
}

步骤 2:组装一个绘图工具包

git checkout example_13
livedifftag

如前所述,Box2D.js 提供了绘制世界调试草图的钩子。它们通过 VTable 通过 customizeVTable() 方法访问,并随后由 b2World.DrawDebugData() 调用。我们将采用来自 kripken 描述 的绘制方法,并将它们封装在一个名为 boxdebugdraw.js 的模块中。

现在我们可以绘制了,但没有东西可以绘制。我们首先需要克服一些障碍!

官僚机构

一个 Box2D 世界由称为“刚体”的实体组成。将刚体添加到箱形世界中使其服从物理定律,但它还必须遵守游戏规则。为此,我们创建了一套管理人口的管理结构和方法。它们的应用简化了刚体创建、碰撞检测和刚体销毁。一旦这些结构到位,我们就可以开始实现游戏逻辑,构建要玩的系统。

创建

git checkout example_14
livedifftag

让我们通过一些创建来使模拟更加生动。Box2D 刚体构造有点冗长,涉及固定装置、形状和物理参数。因此,我们将把我们的刚体创建方法存储在一个名为 boxbody.js 的模块中。要创建一个刚体,我们将 boxbody 方法 传递给 boxworld.add()例如:

function populate() {
    var ball = world.add(
        boxbody.ball,
        {
            x:0,
            y:8,
            radius:10
        }
    );
}

这将在空中产生一个未装饰的球,它受到重力的影响。仔细思考它可能会让人想起 一只特定的鲸鱼

注册

git checkout example_15
livedifftag

我们必须能够跟踪填充扁平世界的刚体。Box2D 提供了对刚体列表的访问,但它对于我们的目的来说有点太低级了。相反,我们将使用一个名为 userDatab2Body 字段。我们为其分配一个唯一的 ID 号,该 ID 号随后用作我们自己设计的注册表的索引。它在 boxregistry.js 中实现,是扁平空间实现的关键方面。它使刚体与装饰性实体(如精灵)的关联成为可能,简化了碰撞回调,并便于从模拟中删除刚体。这里不会描述实现细节,但有兴趣的读者可以参考仓库来查看 注册表如何在 boxworld.js 中实例化,以及 add() 方法 如何 返回包装并注册的刚体

碰撞

git checkout example_16
livedifftag

Box2D 碰撞检测很复杂,因为本机回调只是提供了两个固定装置,原始且无序,并且报告了世界上发生的每一次碰撞,这导致了很多条件检查。boxregistry.js 模块可以管理数据过载。通过它,我们为注册的对象分配了 一个 onContact 回调。当 Box2D 碰撞处理程序被触发时,我们查询注册表以获取关联的对象 并检查 回调是否存在。如果对象具有定义的回调,那么我们知道它的碰撞活动是令人感兴趣的。要在 flatspace.js 中使用此功能,我们只需要 为注册的对象分配一个碰撞回调

function populate() {
    var ground = world.add(
        boxbody.edge,
        {
            x:0,
            y:-15,
            width:20,
            height:0,
        }
    );

    var ball = world.add(
        boxbody.ball,
        {
            x:0,
            y:8,
            radius:10
        }
    );

    ball.onContact = function(object) {
        console.log("The ball has contacted:", object);
    };
}

删除

git checkout example_17
livedifftag

删除刚体很复杂,因为 Box2D 不允许从 b2World.Step() 中调用 b2World.DestroyBody()。这一点很重要,因为通常您会因为碰撞而想要删除刚体,而碰撞回调发生在模拟步骤期间:这是一个难题!一种解决方案是将刚体排队删除,然后在模拟步骤之外处理该队列。boxregistry 通过为每个对象提供一个标志,isMarkedForDeletion 来解决这个问题。注册对象的集合 被迭代,并且监听器会收到删除请求的通知。迭代 发生在模拟步骤之后,因此 删除回调干净地销毁了刚体。敏锐的读者可能会注意到,我们现在 在调用碰撞回调之前检查 isMarkedForDeletion 标志

就 flatspace.js 而言,这会透明地发生,因此我们只需要 为注册的对象设置删除标志

ball.onContact = function(object) {
    console.log("The ball has contacted:", object);
    ball.isMarkedForDeletion = true;
};

现在,刚体在与地面接触时被删除。

辨别

git checkout example_18
livedifftag

当检测到碰撞时,AR 游戏需要知道该对象与什么发生了碰撞。为此,我们在注册对象中添加了一个 is() 方法,用于比较对象。我们现在将添加 条件删除 到我们的游戏中

ball.onContact = function(object) {
    console.log("The ball has contacted:", object);
    if( object.is( ground ) ) {
        ball.isMarkedForDeletion = true;
    }
};

二维虫洞

git checkout example_19
livedifftag

我们已经讨论了真实空间虫洞,现在我们将实现它们在扁平空间的对应部分。扁平空间虫洞只是一个由 Box2D 传感器组成的刚体。球应该从封闭的虫洞中通过,但从打开的虫洞中通过。现在想象一下一个极端情况,即一个球在关闭的虫洞上方,然后该虫洞被打开。问题在于 Box2D 的 onBeginContact 处理程序的行为与其名称相符,这意味着我们检测到虫洞接触是在封闭状态下,但现在已经打开了虫洞。因此,球不会被扭曲,我们遇到了一个错误。我们的修复方法是使用传感器集群。使用集群,当球穿过虫洞时,将有一系列的 BeginContact 事件。因此,我们可以确信,当球在虫洞上方时打开它,会导致扭曲。传感器集群生成器名为 hole 并在 boxbody.js 中实现。生成的集群看起来像这样

导管

此时,我们已经将 JSARToolkit 和 Box2D.js 制作成了可用的模块。我们已经使用它们在真实空间和扁平空间中创建了虫洞。AR 游戏的目标是将扁平空间中的物体传输到真实空间,因此虫洞之间必须进行通信。我们的方法如下

  1. git checkout example_20
    livedifftag

    当真实空间虫洞的状态发生变化时,通知应用程序。

  2. git checkout example_21
    livedifftag

    根据真实空间虫洞的状态设置扁平空间虫洞状态。

  3. git checkout example_22
    livedifftag

    当球体穿过开放的平坦空间虫洞时,通知应用程序。

  4. git checkout example_23
    实时差异标签

    当应用程序收到传输通知时,将球体添加到真实空间。

结论

本文展示了 AR 游戏的技术基础。我们构建了两个不同现实的窗格,并用虫洞将它们连接起来。玩家现在可以通过将球体从平坦空间传输到真实空间来娱乐自己。从技术上讲,这很有趣,但总的来说并不有趣!

要让这个应用程序成为游戏,还有很多工作要做,但这些工作超出了本文的范围。剩下的任务包括

  • 精灵和动画。
  • 引入多个球体和虫洞。
  • 提供一种交互式设计关卡的方法。

感谢您的阅读!我们希望这能激发您对该主题的进一步探索!

关于 Abrie

Abrie 是一位四处游走的笔记本电脑用户。他很少被人看到没有他的机器,当被问及生计时,他也会指向它。

更多来自 Abrie 的文章…

关于 罗伯特·奈曼 [荣誉编辑]

Mozilla Hacks 的技术布道者和编辑。发表关于 HTML5、JavaScript 和开放网络的演讲和博客文章。罗伯特是 HTML5 和开放网络的坚定支持者,自 1999 年以来一直在瑞典和纽约市从事 Web 前端开发。他还会定期在 http://robertnyman.com 上发布博客文章,喜欢旅行和结识新朋友。

更多来自罗伯特·奈曼 [荣誉编辑] 的文章…


3 条评论

  1. abs

    我见过的最好的演示之一。谢谢。

    2013 年 10 月 9 日 下午 11:43

  2. Aras Balali Moghaddam

    将复杂演示分解成小块做得很好。AR 是 Web 应用程序的一个相当新的领域,但您的实验表明,我们已经可以使用现有的工具做一些非常酷的事情。

    2013 年 10 月 10 日 下午 4:49

  3. James

    很棒的演示。
    谢谢!

    2013 年 10 月 12 日 上午 11:34

本文的评论已关闭。