OffscreenCanvas 是一个实验中的新特性(在最新版本的 Chrome 和 Firefox 上都可以通过实验室开关打开,Chrome 的开关是 chome://flags -> Experimental Web Platform features(离屏渲染chrome86已默认支持,不需要开启),本文的例程是在 Chrome 67 Canary 上进行验证),主要用于提升 Canvas 2D/3D 绘图的渲染性能和使用体验。
OffscreenCanvas和canvas都是渲染图形的对象。 不同的是canvas只能在window环境下使用,而OffscreenCanvas既可以在window环境下使用,也可以在web worker中使用,这让不影响浏览器主线程的离屏渲染成为可能。
跟 OffscreenCanvas 关系比较紧密的还有另外两个新的 API,ImageBitmap 和 ImageBitmapRenderingContext。
使用解析
OffscreenCanvas 目前主要用于两种不同的使用场景:
- Transfer 模式:一种是在 Worker 线程创建一个 OffscreenCanvas 做后台渲染,然后再把渲染好的缓冲区 Transfer 回主线程显示;
- Commit 模式:一种是主线程从当前 DOM 树中的 Canvas 元素产生一个 OffscreenCanvas,再把这个 OffscreenCanvas 发送给 Worker 线程进行渲染,渲染的结果直接 Commit 到浏览器的 Display Compositor 输出到当前窗口,相当于在 Worker 线程直接更新 Canvas 元素的内容;
一、Transfer模式
应用场景:Transfer 模式主要用于后台渲染,避免耗时的渲染任务会阻塞前台线程,导致应用无法及时响应用户的操作,比如一些 2D/3D 图表,图形可视化应用,地图应用等。
Transfer Demo 运行流程大致如下:
- 主线程启动 Worker 线程,并请求初始化;
- Worker 线程创建 OffscreenCanvas;
- Worker 线程获取 OffscreenCanvas 的 WebGL Context 并进行绘制;
- Worker 线程获取 OffscreenCanvas 的缓冲区(ImageBitmap),然后 Transfer 回主线程;
- 主线程将 Worker 线程回传的缓冲区分别绘制在两个不同的 Canvas 上,一个 Canvas 使用 CanvasRenderingContext2D,一个 Canvas 使用 ImageBitmapRenderingContext;
- 3 ~ 5 重复运行;
1、渲染到canvas上的两种方式:
- 方案一:ImageBitmap 可以被当做普通的 Image 绘制在一个 2D Canvas 上;
- 方案二:可以通过 ImageBitmapRenderingContext Transfer 到一个 Bitmap Canvas;
2、ImageBitmap介绍
- ImageBitmap 主要是用来封装一块 GPU 缓冲区,可以被 GPU 读写,并且实现了 Transferable 的接口(transferFromImageBitmap和transferToImageBitmap),可以在不同线程之间 Transfer。
- 跟 ImageData 不一样,ImageBitmap 并没有提供 JavaScipt API 供 CPU 进行读写,这是因为使用 CPU 读写 GPU 缓冲区的成本非常高,需要拷贝到临时缓冲区进行读写然后再写回。这也是为什么规范的制定者没有扩展 ImageData,而是提供了一个新的 ImageBitmap 的缘故。
3、渲染的具体流程:
- (1) 当我们使用 OffscreenCanvas,通过 2D/3D 进行绘制时==》我们有一块画板,上面有一些画纸,我们可以在画纸上作画;
- (2) 调用 OffscreenCanvas.transferToImageBitmap 获取 ImageBitmap 封装的缓冲区==》我们把当前绘画的画纸取下来;
function TransferBuffer() {
let image_bitmap = canvas.transferToImageBitmap();
postMessage({name:"TransferBuffer", buffer:image_bitmap},
[image_bitmap]);
}
- (3) 把 ImageBitmap 作为 Image 绘制在一个 2D Canvas 上(方案一)==》我们对已经绘制好的图画在新的画纸上进行临摹;
function init() {
g_bitmap_canvas = helper.GetCanvas("bitmap");
g_2d_canvas = helper.GetCanvas("2d");
g_render_worker = new Worker("./render_worker.js");
g_render_worker.onmessage = function(msg) {
if (msg.data.name === "TransferBuffer") {
GetTransferBuffer(msg.data.buffer);
}
}
}
function GetTransferBuffer(buffer) {
let context_2d = g_2d_canvas.getContext("2d");
context_2d.clearRect(0, 0, g_2d_canvas.width, g_2d_canvas.height);
context_2d.save();
context_2d.translate(g_bitmap_canvas.width / 2, g_bitmap_canvas.height / 2);
context_2d.rotate(g_angle * Math.PI / 180);
context_2d.scale(0.5, 0.5);
context_2d.translate(-g_bitmap_canvas.width / 2, -g_bitmap_canvas.height / 2);
context_2d.drawImage(buffer, 0, 0);
context_2d.restore();
g_angle += 15;
if (g_angle > 360)
g_angle = 0;
}
- (4)把 ImageBitmap 通过 ImageBitmapRenderingContext.transferFromImageBitmap Transfer 给 Bitmap Canvas(方案二)==》我们把画纸放入一个画框里挂在墙上显示;
function GetTransferBuffer(buffer) {
let bitmap_context = g_bitmap_canvas.getContext("bitmaprenderer");
bitmap_context.transferFromImageBitmap(buffer);
console.log("end");
}
4、Transfer 总结
- ImageBitmap Transfer 语义实现了零拷贝 的所有权转移,不需要对缓冲区进行拷贝,性能更高,但是也限制了显示的方式。
- 临摹意味着我们可以对临摹的副本进行旋转,缩放,位移等等,还可以在上面再绘制其它内容。
- ImageBitmap Transfer 之后所有权发生了转移,例如“渲染具体流程中”(3)和(4)交换顺序,即调换一下两个 Canvas 的绘制顺序就会报错,因为 Transfer 之后,原来的缓冲区引用已经被置空变成一个空引用。
===》具体使用哪种方式取决于应用的场景,如果只是简单的展现就可以考虑使用性能更高ImageBitmapRenderingContext,即方案二。
二、Commit模式
Commit 模式主要用于 H5 游戏,它允许应用/游戏在 Worker 线程直接对 DOM 树里面的 Canvas 元素进行更新,浏览器在这种模式下提供了一条最短路径和最佳性能的 Canvas 渲染流水线。
1、普通 Canvas 元素更新的渲染流水线
普通 Canvas 元素更新的渲染流水线,跟其它 DOM 元素一样,Canvas 元素的更新也是走非合成器动画的渲染流水线,主要的缺点是:
- 非合成器动画的渲染流水线比较复杂和冗长,有较多的 Overhead,页面的结构越复杂,Overhead 就越高;
- 如果同时有其它 DOM 元素一起更新,Canvas 的更新会被其它 DOM 元素的光栅化所阻塞,导致性能下降,性能下降的幅度取决于其它 DOM 元素光栅化的耗时;
如果我们调用 Commit,并且 Commit 的 OffscreenCanvas 是跟当前 DOM 树里面的某个 Canvas 元素相关联,浏览器就会直接将 OffscreenCanvas 的当前绘制缓冲区发送给 Display Compositor,然后 Display Compositor 就会合成新的一帧输出到当前窗口,对浏览器来说这就是最短的渲染路径。
Commit模式相对普通canvas的优点:
- 避免被主线程的其它任务所阻塞,Worker 线程可以完全专注在 Canvas 动画的运行上;
- 通过 OffscreenCanvas 更新 Canvas 元素,浏览器走的是最短的渲染路径,避免了非合成器动画的冗长流水线和 Overhead;
- 如果有其它 DOM 元素同时更新,不会阻塞 OffscreenCanvas 的更新,所以通过 OffscreenCanvas,的确实现了 Canvas 更新和其它 DOM 更新的并发运行;
- 如果 DOM 元素需要处理事件,这些事件处理不会被 Worker 线程所阻塞,只是处理的结果数据可能需要发送给 Worker 线程用于后续的绘制;
Commit模式的缺点:
- OffscreenCanvas 的更新和其它 DOM 元素的更新不再是强制同步的。即使它们是同时更新,甚至都在主线程而不使用 Worker 线程,因为两者已经分别走了不同的流水线,最后呈现在屏幕的时机也可能不会完全一致。如果一定要求同步,就只能参考 Transfer Demo 的做法,将绘制后的缓冲区 Transfer 给 Bitmap Canvas 来显示,但是这样就无法发挥 Commit 模式的性能优势了。
2、commit渲染的具体流程
- (1)主线程从当前 DOM 树中的 Canvas 元素生成 OffscreenCanvas;
- (2)主线程启动 Worker 线程并初始化,OffscreenCanvas 作为初始化的参数被 Transfer;
var g_offscreen_canvas = null;
var g_render_worker = null;
function main() {
g_offscreen_canvas = helper.GetCanvas("offscreen");
g_render_worker = new Worker("./render_worker.js");
let offscreen = g_offscreen_canvas.transferControlToOffscreen();
g_render_worker.postMessage(
{name:"Init", mode:"commit", canvas:offscreen}, [offscreen]);
}
- (3)Worker 线程接收 OffscreenCanvas 后完成初始化;
function Init(mode, data) {
if (mode === "transfer")
canvas = new OffscreenCanvas(data.width, data.height);
else if (mode === "commit")
canvas = data.canvas;
}
- (4)Worker 线程使用 WebGL 对 OffscreenCanvas 进行绘制;
- (5)Worker 线程绘制完成后 Commit,然后等待浏览器的回调;
function renderloop() {
render();
gl.commit().then(renderloop);
}
renderloop();
- (6)Worker 线程接收到到浏览器的回调后继续绘制下一帧,重复 4 ~ 6;
注意:
- canvas对象调用了函数transferControlToOffscreen移交控制权之后,不能再获取绘制上下文,调用canvas.getContext('2d')会报错。
- 同样,如果canvas已经获取的绘制上下文,调用transferControlToOffscreen会报错。
Demo1
1、html主线程
<!doctype html>
<html>
<head>
</head>
<body>
<style>
.items {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
</style>
<html>
<head>
<meta charset="utf-8">
<title>player</title>
</head>
<body>
<canvas id="player" width="480px" height="280px" style="border: 2px; background-color:black"></canvas>
</body>
</html>
<div class="items">
<button type="button" onClick="init()">start</button>
</div>
<script>
var worker2 = null;
var canvasBitmap=null;
var canvas2d=null;
var ctxBitmap=null;
function init() {
canvasBitmap = document.getElementById('player');
worker2 = new Worker('./bitmap_worker.js');
console.log("test.html create worker success");
方法一:transfer
//worker2.postMessage({name:"Init", mode:"transfer",width:canvasBitmap.width, height: canvasBitmap.height});
方法二:commit
let offscreen = canvasBitmap.transferControlToOffscreen();
worker2.postMessage({name:'Init',mode:"commit",canvas:offscreen},[offscreen]);
worker2.onmessage = function (e) {
if(e.data.name==="TransferBuffer")
{
//方法一:transfer 2d渲染
// ctxBitmap = canvasBitmap.getContext('2d');
// ctxBitmap.clearRect(0,0,canvasBitmap.width,canvasBitmap.height);
// ctxBitmap.save();
// ctxBitmap.drawImage(e.data.buffer,0,0);
// console.log("2d");
// ctxBitmap.restore();
//方法二:transfer bitmaprenderer渲染
let bitmap_context = canvasBitmap.getContext("bitmaprenderer");//bitmaprenderer
console.log("bitmap_context:"+bitmap_context);
if(bitmap_context)
{
bitmap_context.transferFromImageBitmap(e.data.buffer);
console.log("end");
}
}
}
}
</script>
</body>
</html>
worker:
'use strict';
importScripts("helper.js");
importScripts("Vector3D.js");
importScripts("Boid.js");
importScripts("cuon-utils.js");
importScripts("webgl-utils.js");
importScripts("webgl-debug.js");
var offscreen,ctx,gl;
onmessage = function (e) {
if(e.data.name == 'Init'){
console.log('Window post:' + e.data.name + ", mode:" + e.data.mode);
Init(e.data.mode,e.data);
//draw();
}else if(e.data.name == 'draw'){
draw();
}
}
function Init(mode,data) {
if (mode === "transfer")
{
console.log("bitmap_worker width:"+data.width+",height:"+data.height);
offscreen = new OffscreenCanvas(data.width, data.height);
}
else if (mode === "commit")
{
console.log("commit");
offscreen = data.canvas;
}
ctx = offscreen.getContext("2d");
console.log("worker new OffscreenCanvas");
draw(mode);
}
function draw(mode) {
if (mode === "transfer")
{
render();
let imageBitmap = offscreen.transferToImageBitmap();
postMessage({name:"TransferBuffer", buffer:imageBitmap},[imageBitmap]);
console.log("bitmap_worker.js transfer");
}
else if (mode === "commit")
{
render();
console.log("bitmap_worker.js commit");
}
}
function render()
{
var grd=ctx.createLinearGradient(20,20,150,150);//create grd object
grd.addColorStop(0,"yellow");//the start grd color;
grd.addColorStop(1,"red");//the end grd color; 1 means end
ctx.fillStyle=grd;//
ctx.fillRect(140,20,100,100);//draw a rect which is filled.
ctx.stroke();
}
Demo2:
test2.html
<!doctype html>
<html>
<head>
<script type="text/javascript" src="./helper.js"></script>
</head>
<body>
<style>
.items {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
</style>
<html>
<head>
<meta charset="utf-8">
<title>player</title>
</head>
<body>
<canvas id="bitmap" width="480px" height="280px" style="border: 2px; background-color:black"></canvas>
<canvas id="2d" width="480px" height="280px" ></canvas>
</body>
</html>
<div class="items">
<button type="button" onClick="init()">start</button>
</div>
<script>
var g_bitmap_canvas = null;
var g_2d_canvas = null;
var g_render_worker = null;
var g_angle = 30;
function init() {
g_bitmap_canvas = helper.GetCanvas("bitmap");
g_2d_canvas = helper.GetCanvas("2d");
let window_size = helper.GetWindowSizeInPx();
// g_bitmap_canvas.width = window_size.width;
// g_bitmap_canvas.height = window_size.height / 2;
//g_bitmap_canvas.width = 640;
//g_bitmap_canvas.height = 640;
//g_2d_canvas.width = window_size.width;
//g_2d_canvas.height = window_size.height / 2;
// g_2d_canvas.width = 512;
//g_2d_canvas.height = 512;
g_render_worker = new Worker("./renderWorker.js");
//g_render_worker = new Worker("./bitmap_worker.js");
g_render_worker.onmessage = function(msg) {
console.log('Woker post:' + msg.data.name);
if (msg.data.name === "TransferBuffer") {
GetTransferBuffer(msg.data.buffer);
}
}
g_render_worker.postMessage(
{name:"Init", mode:"transfer",
width:g_bitmap_canvas.width, height: g_bitmap_canvas.height});
}
function GetTransferBuffer(buffer) {
// let context_2d = g_2d_canvas.getContext("2d");
// context_2d.clearRect(0, 0, g_2d_canvas.width, g_2d_canvas.height);
// context_2d.save();
// context_2d.translate(g_bitmap_canvas.width / 2, g_bitmap_canvas.height / 2);
// context_2d.rotate(g_angle * Math.PI / 180);
// context_2d.scale(0.5, 0.5);
// context_2d.translate(-g_bitmap_canvas.width / 2, -g_bitmap_canvas.height / 2);
// context_2d.drawImage(buffer, 0, 0);
// context_2d.restore();
// g_angle += 15;
// if (g_angle > 360)
// g_angle = 0;
let bitmap_context = g_bitmap_canvas.getContext("bitmaprenderer");
bitmap_context.transferFromImageBitmap(buffer);
console.log("end");
}
</script>
</body>
</html>
renderWorker.js
'use strict';
importScripts("helper.js");
importScripts("Vector3D.js");
importScripts("Boid.js");
importScripts("cuon-utils.js");
importScripts("webgl-utils.js");
importScripts("webgl-debug.js");
// Basically RPC.
var STAGE = { width:1080, height:1920 };
var gl = null;
var canvas = null;
var meter = new helper.FPSMeter();
var drawCount = 0;
if (helper.IsWorkerEnv()) {
self.onmessage = function(msg) {
console.log('Window post:' + msg.data.name + ", mode:" + msg.data.mode);
if (msg.data.name === "Init") {
Init(msg.data.mode, msg.data);
}
}
}
function TransferBuffer() {
let image_bitmap = canvas.transferToImageBitmap();
postMessage({name:"TransferBuffer", buffer:image_bitmap},
[image_bitmap]);
}
function Init(mode, data) {
if (mode === "transfer")
canvas = new OffscreenCanvas(data.width, data.height);
else if (mode === "commit")
canvas = data.canvas;
console.log("Render init canvas size:" + canvas.width + "x" + canvas.height);
STAGE.width = canvas.width;
STAGE.height = canvas.height;
if (canvas && canvas.getContext) {
//setup page
// Get the rendering context for WebGL
gl = getWebGLContext(canvas);
if (!gl) {
console.log('Failed to get the rendering context for WebGL');
return;
}
// Initialize shaders
if (!initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)) {
console.log('Failed to intialize shaders.');
return;
}
// // Get the storage location of a_Position
a_Position = gl.getAttribLocation(gl.program, 'a_Position');
if (a_Position < 0) {
console.log('Failed to get the storage location of a_Position');
return;
}
// Get the storage location of a_Color
a_Color = gl.getAttribLocation(gl.program, 'a_Color');
if (a_Color < 0) {
console.log('Failed to get the storage location of a_Color');
return;
}
// Specify the color for clearing <canvas>
gl.clearColor(0.2, 0.2, 0.2, 1);
//initialize test variables
createBoids();
// Allocate buffer for position and color
verticesColors = new Float32Array(boids.length * 5);
// Create a buffer object
vertexColorBuffer = gl.createBuffer();
if (!vertexColorBuffer) {
console.log('Failed to create the buffer object');
return false;
}
if (mode === "transfer") {
//setInterval(() => {
// Render buffer first
render();
// Transfer render buffer back to browser context
TransferBuffer();
// }, 200);
} else if (mode === "commit") {
function renderloop() {
// Render buffer first
render();
// fps
let result = meter.update();
if (result.framerate > 0) {
console.log("WebGL Offscreen framerate:" + result.framerate);
}
// Wait next begin frame to loop
gl.commit().then(renderloop);
}
renderloop();
}
}
}
var config = {
minForce:3,
maxForce:6,
minSpeed:6,
maxSpeed:12,
minWanderDistance:10,
maxWanderDistance:100,
minWanderRadius:5,
maxWanderRadius:20,
minWanderStep:0.1,
maxWanderStep:0.9,
numBoids:500
};
var drawEnabled = true;
var firstDraw = true;
var boids = [];
// Vertex shader program
var VSHADER_SOURCE =
'attribute vec4 a_Position;\n' +
'attribute vec4 a_Color;\n' +
'varying vec4 v_Color;\n' +
'void main() {\n' +
' gl_Position = a_Position;\n' +
' gl_PointSize = 10.0;\n' +
' v_Color = a_Color;\n' +
'}\n';
// Fragment shader program
var FSHADER_SOURCE =
'#ifdef GL_ES\n' +
'precision mediump float;\n' +
'#endif\n' +
'varying vec4 v_Color;\n' +
'void main() {\n' +
' gl_FragColor = v_Color;\n' +
'}\n';
var a_Position;
var a_Color;
var verticesColors;
var vertexColorBuffer;
function initVertexBuffers(gl, first) {
// Bind the buffer object to target
if (first) {
gl.bindBuffer(gl.ARRAY_BUFFER, vertexColorBuffer);
}
gl.bufferData(gl.ARRAY_BUFFER, verticesColors, gl.DYNAMIC_DRAW);
if (first) {
let FSIZE = verticesColors.BYTES_PER_ELEMENT;
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 5, 0);
gl.enableVertexAttribArray(a_Position); // Enable the assignment of the buffer object
gl.vertexAttribPointer(a_Color, 3, gl.FLOAT, false, FSIZE * 5, FSIZE * 2);
gl.enableVertexAttribArray(a_Color); // Enable the assignment of the buffer object
}
// Unbind the buffer object
// gl.bindBuffer(gl.ARRAY_BUFFER, null);
}
function createBoids(){
for (var i = 0;i < config.numBoids; i++){
var boid = new Boid();
boid.color_r = Math.floor(random(100, 255)) / 255;
boid.color_g = Math.floor(random(100, 255)) / 255;
boid.color_b = Math.floor(random(100, 255)) / 255;
boid.edgeBehavior = Boid.EDGE_BOUNCE;
boid.maxForce = random(config.minForce, config.maxForce);
boid.maxForceSQ = boid.maxForce*boid.maxForce;
boid.maxSpeed = random(config.minSpeed, config.maxSpeed);
boid.maxSpeedSQ = boid.maxSpeed*boid.maxSpeed;
boid.wanderDistance = random(config.minWanderDistance, config.maxWanderDistance);
boid.wanderRadius = random(config.minWanderRadius, config.maxWanderRadius);
boid.wanderStep = random(config.minWanderStep, config.maxWanderStep);
boid.boundsRadius = STAGE.width/2;
boid.boundsCentre = new Vector3D(STAGE.width/2, STAGE.height/2, 0.0);
boid.radius = 16;
//add positoin and velocity
boid.position.x = boid.boundsCentre.x + random(-100, 100);
boid.position.y = boid.boundsCentre.y + random(-100, 100);
boid.position.z = random(-100, 100);
var vel = new Vector3D(random(-2, 2), random(-2, 2), random(-2, 2));
boid.velocity.incrementBy(vel);
boids.push(boid);
}
}
function render() {
for (var i = 0;i < boids.length; i++){
let boid = boids[i];
boid.wander(0.3);
// Add a mild attraction to the centre to keep them on screen
boid.seek(boid.boundsCentre, 0.1);
// Flock
boid.flock(boids);
boid.update();
verticesColors[i * 5] = (boid.position.x - canvas.width/2)/(canvas.width/2);
verticesColors[i * 5 + 1] =
(canvas.height/2 - boid.position.y)/(canvas.height/2);
verticesColors[i * 5 + 2] = boid.color_r;
verticesColors[i * 5 + 3] = boid.color_g;
verticesColors[i * 5 + 4] = boid.color_b;
}
if (drawEnabled){
// Clear <canvas>
gl.clear(gl.COLOR_BUFFER_BIT);
initVertexBuffers(gl, firstDraw);
gl.drawArrays(gl.POINTS, 0, boids.length);
firstDraw = false;
}
}
//helper classes
function random( min, max ) {
return Math.random() * ( max - min ) + min;
}