(正如上一节中讨论的那样),并生成了放置在
上的用单一颜色填充的画布元素。画布的大小被设定为 1600 x 900 像素,并从 Chrome1 的任务管理器实用程序收集数据。表 1 显示了一个示例。
在 Google Chrome 的 Task Manager 中,您可以看到某个页面所使用的内存量(也称为 RAM)。Chrome 也提供 GPU 内存,或者是 GPU 正在使用的内存。这是常见信息,如几何形状、纹理或计算机将您的画布数据推送到屏幕可能需要的任何形式的缓存数据。内存越低,放在计算机上的权重就会越少。虽然目前还没有任何确切的数字作为依据,但应始终对此进行测试,确保您的程序不会超出极限,并使用了过多的内存。如果使用了过多的内存,浏览器或页面就会因为缺乏内存资源而崩溃。GPU 处理是一个远大的编程追求,已超出本文的讨论范围。
表 1. 画布层的内存开销
在 表 1 中,随着在页面上引入和使用了更多的 HTML5 画布元素,使用的内存也越多。一般的内存也存在线性相关,但每增加一层,内存的增长就会明显减少。虽然这个测试并没有详细说明这些层对性能带来的影响,但它确实表明,画布会严重影响 GPU 内存。一定要记得在您的目标平台上执行压力测试,以确保平台的限制不会导致您的应用程序无法执行。
当选择更改某个分层解决方案的单一画布渲染周期时,需考虑有关内存开销的性能增益。尽管存在内存成本,但这项技术可以通过减小每一帧上修改的像素数量来完成其工作。
下一节将说明如何使用分层来组织一个场景。
对场景进行分层:游戏
在本节中,我们将通过重构一个滚动平台跑步风格的游戏上的视差效果的单画布实现,了解一个多层解决方案。图 2 显示了游戏视图的组成,其中包括云、小山、地面、背景和一些交互实体。
图 2. 合成游戏视图
在游戏中,云、小山、地面和背景都以不同的速度移动。本质上,背景中较远的元素移动得比在前面的元素慢,因此形成了视差效果。为了让情况变得更为复杂,背景的移动速度会足够慢,它每半秒钟才重新渲染一次。
通常情况下,好的解决方案会将所有帧都清除并重新渲染屏幕,因为背景是一个图像并且在不断变化。在本例中,由于背景每秒只需变化两次,所以您不需要重新渲染每一帧。
目前,您已经定义了工作区,所以可以决定场景的哪些部分应该在同一个层上。组织好各个层之后,我们将探讨用于分层的各种渲染策略。首先,需要考虑如何使用单个画布来实现该解决方案,如 清单 3 所示。
清单 3. 单画布渲染循环的伪代码
/**
* Render call
*
* @param {CanvasRenderingContext2D} context Canvas context
*/
function renderLoop(context)
{
context.clearRect(0, 0, width, height);
background.render(context);
ground.render(context);
hills.render(context);
cloud.render(context);
player.render(context);
}
像 清单 3 中的代码一样,该解决方案会有一个 render 函数,每个游戏循环调用或每个更新间隔都会调用它。在本例中,渲染是从主循环调用和更新每个元素的位置的更新调用中抽象出来。
遵循 “清除到渲染” 解决方案,render 会调用清除上下文,并通过调用屏幕上的实体各自的 render 函数来跟踪它。清单 3 遵循一个程序化的路径,将元素放置到画布上。虽然该解决方案对于渲染屏幕上的实体是有效的,但它既没有描述所使用的所有渲染方法,也不支持任何形式的渲染优化。
为了更好地详细说明实体的渲染方法,需要使用两种类型的实体对象。清单 4 显示了您将使用和细化的两个实体。
清单 4. 可渲染的 Entity 伪代码
var Entity = function() {
/**
Initialization and other methods
**/
/**
* Render call to draw the entity
*
* @param {CanvasRenderingContext2D} context
*/
this.render = function(context) {
context.drawImage(this.image, this.x, this.y);
}
};
var PanningEntity = function() {
/**
Initialization and other methods
**/
/**
* Render call to draw the panned entity
*
* @param {CanvasRenderingContext2D} context
*/
this.render = function(context) {
context.drawImage(
this.image,
this.x - this.width,
this.y - this.height);
context.drawImage(
this.image,
this.x,
this.y);
context.drawImage(
this.image,
this.x + this.width,
this.y + this.height);
}
};
清单 4 中的对象存储实体的图像、x、y、宽度和高度的实例变量。这些对象遵循 JavaScript 语法,但为了简洁起见,仅提供了目标对象的不完整的伪代码。目前,渲染算法非常贪婪地在画布上渲染出它们的图像,完全不考虑游戏循环的其他任何要求。
为了提高性能,需要重点注意的是,panning 渲染调用输出了一个比所需图像更大的图像。本文忽略这个特定的优化,但是,如果使用的空间比您的图像提供的空间小,那么请确保只渲染必要的补丁。
确定分层
现在您知道如何使用单一画布实现该示例,让我们看看有什么办法可以完善这种类型的场景,并加快渲染循环。要使用分层技术,则必须通过找出实体的渲染重叠,识别分层所需的 HTML5 画布元素。
重绘区域
为了确定是否存在重叠,要考虑一些被称为重绘区域的不可见区域。重绘区域是在绘制实体的图像时需要画布清除的区域。重绘区域对于渲染分析很重要,因为它们使您能够找到完善渲染场景的优化技术,如 图 3 所示。
图 3. 合成游戏视图与重绘区域
为了可视化 图 3 中的效果,在场景中的每个实体都有一个表示重绘区域的重叠,它跨越了视区宽度和实体的图像高度。场景可分为三组:背景、前景和交互。场景中的重绘区域有一个彩色的重叠,以区分不同的区域:
- 背景 - 黑色
- 云 - 红色
- 小山 - 绿色
- 地面 - 蓝色
- 红球 - 蓝色
- 黄色障碍物 - 蓝色
对于除了球和障碍物以外的所有重叠,重绘区域都会横跨视区宽度。这些实体的图像几乎填满整个屏幕。由于它们的平移要求,它们将渲染整个视区宽度,如 图 4 所示。预计球和障碍物会穿过该视区,并且可能拥有通过实体位置定义的各自的区域。如果您删除渲染到场景的图像,只留下重绘区域,就可以很容易地看到单独的图层。
图 4. 重绘区域
初始层是显而易见的,因为您可以注意到互相重叠的各个区域。由于球和障碍物区域覆盖了小山和地面,所以可将这些实体分组为一层,该层被称为交互层。根据游戏实体的渲染顺序,交互层是顶层。
找到附加层的另一种方法是收集没有重叠的所有区域。占据视区的红色、绿色和蓝色区域并没有重叠,并且它们组成了第二层——前景。云和交互实体的区域没有重叠,但因为球有可能跳跃到红色区域,所以您应该考虑将该实体作为一个单独的层。
对于黑色区域,可以很容易地推断出,背景实体将会组成最后一层。填充整个视区的任何区域(如背景实体)都应视为填充整个层中的该区域,虽然这对本场景并不适用。在定义了我们的三个层次之后,我们就可以开始将这层分配给画布,如 图 5 所示。
图 5. 分层的游戏视图
现在已经为每个分组的实体定义了层,现在就可以开始优化画布清除。此优化的目标是为了节省处理时间,可以通过减少每一步渲染的屏幕上的固定物数量来实现。需要重点注意的是,使用不同的策略可能会使图像获得更好的优化。下一节将探讨各种实体或层的优化方法。
渲染优化
优化实体是分层策略的核心。对实体进行分层,使得渲染策略可以被采用。通常,优化技术会试图消除开销。正如 表 1 所述,由于引入了层,您已经增加了内存开销。这里讨论的优化技术将减少处理器为了加快游戏而必须执行的大量工作。我们的目标是寻找一种减少要渲染的空间量的方法,并尽可能多地删除每一步中出现的渲染和清除调用。
单一实体清除
第一个优化方法针对的是清除空间,通过只清除组成该实体的屏幕子集来加快处理。首先减少与区域的各实体周围的透明像素重叠的重绘区域量。使用此技术的包括相对较小的实体,它们填充了视区的小区域。
第一个目标是球和障碍物实体。单一实体清除技术涉及到在将实体渲染到新位置之前清除前一帧渲染该实体的位置。我们会引入一个清除步骤到每个实体的渲染,并存储实体的图像的边界框。添加该步骤会修改实体对象,以包括清除步骤,如 清单 5 所示。
清单 5. 包含单框清除的实体
var Entity = function() {
/**
Initialization and other methods
**/
/**
* Render call to draw the entity
*
* @param {CanvasRenderingContext2D} context
*/
this.render = function(context) {
context.clearRect(
this.prevX,
this.prevY,
this.width,
this.height);
context.drawImage(this.image, this.x, this.y);
this.prevX = this.x;
this.prevY = this.y;
}
};
render 函数的更新引入了一个常规 drawImage 之前发生的 clearRect 调用。对于该步骤,对象需要存储前一个位置。图 6 显示了对象针对前一个位置所采取的步骤。
图 6. 清除矩形
您可以为每个实体创建一个在更新步骤前被调用的 clear 方法,实现此渲染解决方案(但本文将不会使用 clear 方法)。您还可以将这个清除策略引入到 PanningEntity,在地面和云实体上添加清除,如 清单 6 所示。
清单 6. 包含单框清除的 PanningEntity
ar PanningEntity = function() {
/**
Initialization and other methods
**/
/**
* Render call to draw the panned entity
*
* @param {CanvasRenderingContext2D} context
*/
this.render = function(context) {
context.clearRect(
this.x,
this.y,
context.canvas.width,
this.height);
context.drawImage(
this.image,
this.x - this.width,
this.y - this.height);
context.drawImage(
this.image,
this.x,
this.y);
context.drawImage(
this.image,
this.x + this.width,
this.y + this.height);
}
};
因为 PanningEntity 横跨了整个视区,所以您可以使用画布宽度作为清除矩形的大小。如果使用此清除策略,则会为您提供已为云、小山和地面实体定义的重绘区域。
为了进一步优化云实体,可以将云分离为单独的实体,使用它们自己的重绘区域。这样做会大幅减少在云重绘区域内要清除的屏幕空间量。图 7 显示了新的重绘区域。
图 7. 具有单独重绘区域的云
单一实体清除策略产生的解决方案可以解决像本例这样的分层画布游戏上的大多数问题,但仍然可以对它进行优化。为了寻找针对该渲染策略的极端情况,我们假设球会与三角形碰撞。如果两个实体碰撞,实体的重绘区域就有可能发生重叠,并创建一个不想要的渲染构件。另一个清除优化,更适合于可能会碰撞的实体,它也将有益于分层。
脏矩形清除
若没有单一清除策略,脏矩形清除策略可以是一个功能强大的替代品。您可以对有重绘区域的大量实体使用这种清除策略,这种实体包括密集的粒子系统,或有小行星的空间游戏。
从概念上讲,该算法会收集由算法管理的所有实体的重绘区域,并在一个清除调用中清除整个区域。为了增加优化,此清除策略还会删除每个独立实体产生的重复清除调用,如 清单 7 所示。
清单 7. DirtyRectManager
var DirtyRectManager = function() {
// Set the left and top edge to the max possible
// (the canvas width) amd right and bottom to least-most
// Left and top will shrink as more entities are added
this.left = canvas.width;
this.top = canvas.height;
// Right and bottom will grow as more entities are added
this.right = 0;
this.bottom = 0;
// Dirty check to avoid clearing if no entities were added
this.isDirty = false;
// Other Initialization Code
/**
* Other utility methods
*/
/**
* Adds the dirty rect parameters and marks the area as dirty
*
* @param {number} x
* @param {number} y
* @param {number} width
* @param {number} height
*/
this.addDirtyRect = function(x, y, width, height) {
// Calculate out the rectangle edges
var left = x;
var right = x + width;
var top = y;
var bottom = y + height;
// Min of left and entity left
this.left = left // Max of right and entity right
this.right = right > this.right ? right : this.right;
// Min of top and entity top
this.top = top // Max of bottom and entity bottom
this.bottom = bottom > this.bottom ? bottom : this.bottom;
this.isDirty = true;
};
/**
* Clears the rectangle area if the manager is dirty
*
* @param {CanvasRenderingContext2D} context
*/
this.clearRect = function(context) {
if (!this.isDirty) {
return;
}
// Clear the calculated rectangle
context.clearRect(
this.left,
this.top,
this.right - this.left,
this.bottom - this.top);
// Reset base values
this.left = canvas.width;
this.top = canvas.height;
this.right = 0;
this.bottom = 0;
this.isDirty = false;
}
};
将脏矩形算法集成到渲染循环,这要求在进行渲染调用之前调用 清单 7 中的管理器。将实体添加到管理器,使管理器可以在清除时计算清除矩形的维度。虽然管理器会产生预期的优化,但根据游戏循环,管理器能够针对游戏循环进行优化,如 图 8 所示。
图 8. 交互层的重绘区域
- 帧 1 - 实体在碰撞,几乎重叠。
- 帧 2 - 实体重绘区域是重叠的。
- 帧 3 - 重绘区域重叠,并被收集到一个脏矩形中。
- 帧 4 - 脏矩形被清除。
图 8 显示了由针对在交互层的实体的算法计算出的重绘区域。因为游戏在这一层上包含交互,所以脏矩形策略足以解决交互和重叠的重绘区域问题。
作为清除的重写
对于在恒定重绘区域中动画的完全不透明实体,可以使用重写作为一项优化技术。将不透明的位图渲染为一个区域(默认的合成操作),这会将像素放在该区域中,不需要考虑该区域中的原始渲染。这个优化消除了渲染调用之前所需的清除调用,因为渲染会覆盖原来的区域。
通过在之前的渲染的上方重新渲染图像,重写可以加快地面实体。也可以通过相同的方式加快最大的层,比如背景。
通过减少每一层的重绘区域,您已经有效地为层和它们所包含的实体找到优化策略。
结束语
对画布进行分层是一个可以应用于所有交互式实时场景的优化策略。如果想利用分层实现优化,您需要通过分析场景的重绘区域来考虑场景如何重叠这些区域。一些场景是具有重叠的重绘区域的集合,可以定义层,因此它们是渲染分层画布的良好候选。如果您需要粒子系统或大量物理对象碰撞在一起,对画布进行分层可能是一个很好的优化选择。