Статьи

HTML5 Canvas Optimization: практический пример

Если вы достаточно долго занимались разработкой JavaScript, скорее всего, вы несколько раз ломали браузер. Обычно проблема заключается в некоторой ошибке JavaScript, например, в бесконечном цикле while; если нет, то следующим подозрением являются преобразования страниц или анимации — такие, которые включают добавление и удаление элементов с веб-страницы или анимацию свойств стиля CSS. Этот урок посвящен оптимизации анимации, созданной с использованием JS и элемента HTML5 <canvas> .

Этот урок начинается и заканчивается тем, что виджет анимации HTML5 вы видите ниже:

Мы возьмем его с собой в путешествие, изучим различные новые советы и приемы по оптимизации холста и применим их к исходному коду JavaScript виджета. Цель состоит в том, чтобы повысить скорость выполнения виджета и получить более плавный, более плавный анимационный виджет, основанный на более тонком и эффективном JavaScript.

Исходная загрузка содержит HTML и JavaScript из каждого шага в учебнике, так что вы можете следовать с любой точки.

Давайте сделаем первый шаг.


Приведенный выше виджет основан на трейлере фильма для Sintel , 3D-анимации от Blender Foundation . Он построен с использованием двух самых популярных дополнений HTML5: элементов <canvas> и <video> .

<video> загружает и воспроизводит видеофайл Sintel, а <canvas> генерирует собственную последовательность анимации, делая снимки воспроизводимого видео и смешивая его с текстом и другой графикой. Когда вы нажимаете для воспроизведения видео, холст оживает на темном фоне, который является увеличенной черно-белой копией воспроизводимого видео. Меньшие цветные скриншоты видео копируются на сцену и скользят по ней как часть иллюстрации к фильму.

В верхнем левом углу у нас есть заголовок и несколько строк описательного текста, которые постепенно исчезают при воспроизведении анимации. Скорость выполнения сценария и связанные с ним показатели включены в анимацию в небольшой черный квадрат в левом нижнем углу с графиком и ярким текстом. Мы рассмотрим этот конкретный пункт более подробно позже.

Наконец, есть большой вращающийся клинок, который летит по сцене в начале анимации, чья графика загружается из внешнего файла изображения PNG.


Исходный код содержит обычную смесь HTML, CSS и Javascript. HTML-код разрежен: только теги <canvas> и <video> , заключенные в контейнер <div> :

1
2
3
4
5
6
7
<div id=»animationWidget» >
    <canvas width=»368″ height=»208″ id=»mainCanvas» ></canvas>
    <video width=»184″ height=»104″ id=»video» autobuffer=»autobuffer» controls=»controls» poster=»poster.jpg» >
        <source src=»sintel.mp4″ type=»video/mp4″ ></source>
        <source src=»sintel.webm» type=»video/webm» ></source>
    </video>
</div>

Контейнеру <div> присваивается идентификатор ( animationWidget ), который действует как ловушка для всех применяемых к нему правил CSS и его содержимого (ниже).

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
#animationWidget{
    border:1px #222 solid;
    position:relative;
    width: 570px;
    height: 220px;
}
#animationWidget canvas{
    border:1px #222 solid;
    position:absolute;
    top:5px;
    left:5px;
}
#animationWidget video{
    position:absolute;
    top:110px;
    left:380px;
}

В то время как HTML и CSS — это маринованные специи и приправы, именно JavaScript является основой виджета.

  • Вверху у нас есть основные объекты, которые будут часто использоваться в скрипте, включая ссылки на элемент canvas и его 2D-контекст.
  • Функция init() вызывается всякий раз, когда начинается воспроизведение видео, и устанавливает все объекты, используемые в скрипте.
  • Функция sampleVideo() захватывает текущий кадр воспроизводимого видео, а setBlade() загружает внешнее изображение, необходимое для анимации.
  • Темп и содержание анимации холста контролируются функцией main() , которая похожа на биение скрипта. Запускается с регулярными интервалами, как только начинается воспроизведение видео, оно рисует каждый кадр анимации, сначала очистив холст, а затем вызвав каждую из пяти функций рисования сценария:
    • drawBackground()
    • drawFilm()
    • drawTitle()
    • drawDescription()
    • drawStats()

Как следует из названий, каждая функция рисования отвечает за рисование элемента в анимационной сцене. Такое структурирование кода повышает гибкость и облегчает дальнейшее обслуживание.

Полный скрипт показан ниже. Найдите минутку, чтобы оценить его, и посмотрите, сможете ли вы заметить какие-либо изменения, которые вы бы сделали, чтобы ускорить его.

001
002
003
004
005
006
007
008
009
010
011
012
013
014
+015
016
+017
018
019
020
021
022
023
024
025
026
027
028
029
+030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
+055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
(function(){
    if( !document.createElement(«canvas»).getContext ){ return;
     
    var mainCanvas = document.getElementById(«mainCanvas»);
    var mainContext = mainCanvas.getContext(‘2d’);
    var video = document.getElementById(«video»);
    var frameDuration = 33;
    video.addEventListener( ‘play’, init );
    video.addEventListener( ‘ended’, function(){ drawStats(true); } );
     
    var videoSamples;
    var backgrounds;
    var blade;
    var bladeSrc = ‘blade.png’;
     
    var lastPaintCount = 0;
    var paintCountLog = [];
    var speedLog = [];
    var fpsLog = [];
    var frameCount = 0;
    var frameStartTime = 0;
     
    // Called when the video starts playing.
    function init(){
        if( video.currentTime > 1 ){ return;
 
        bladeSrc = new Image();
        bladeSrc.src = «blade.png»;
        bladeSrc.onload = setBlade;
         
        backgrounds = [];
        videoSamples = [];
        fpsLog = [];
        paintCountLog = [];
        if( window.mozPaintCount ){ lastPaintCount = window.mozPaintCount;
        speedLog = [];
        frameCount = 0;
        frameStartTime = 0;
        main();
        setTimeout( getStats, 1000 );
    }
     
    // As the scripts main function, it controls the pace of the animation
    function main(){
        setTimeout( main, frameDuration );
        if( video.paused || video.ended ){ return;
         
        var now = new Date().getTime();
        if( frameStartTime ){
            speedLog.push( now — frameStartTime );
        }
        frameStartTime = now;
        if( video.readyState < 2 ){ return;
         
        frameCount++;
        mainCanvas.width = mainCanvas.width;
        drawBackground();
        drawFilm();
        drawDescription();
        drawStats();
        drawBlade();
        drawTitle();
    }
     
    // This function is called every second, and it calculates and stores the current frame rate
    function getStats(){
        if( video.readyState >= 2 ){
            if( window.mozPaintCount ){ //this property is specific to firefox, and tracks how many times the browser has rendered the window since the document was loaded
                paintCountLog.push( window.mozPaintCount — lastPaintCount );
                lastPaintCount = window.mozPaintCount;
            }
             
            fpsLog.push(frameCount);
            frameCount = 0;
        }
        setTimeout( getStats, 1000 );
    }
     
    // create blade, the ofscreen canavs that will contain the spining animation of the image copied from blade.png
    function setBlade(){
        blade = document.createElement(«canvas»);
        blade.width = 400;
        blade.height = 400;
        blade.angle = 0;
        blade.x = -blade.height * 0.5;
        blade.y = mainCanvas.height/2 — blade.height/2;
    }
             
    // Creates and returns a new image that contains a snapshot of the currently playing video.
    function sampleVideo(){
        var newCanvas = document.createElement(«canvas»);
        newCanvas.width = video.width;
        newCanvas.height = video.height;
        newCanvas.getContext(«2d»).drawImage( video, 0, 0, video.width, video.height );
        return newCanvas;
    }
     
    // renders the dark background for the whole canvas element.
    function drawBackground(){
        var newCanvas = document.createElement(«canvas»);
        var newContext = newCanvas.getContext(«2d»);
        newCanvas.width = mainCanvas.width;
        newCanvas.height = mainCanvas.height;
        newContext.drawImage( video, 0, video.height * 0.1, video.width, video.height * 0.5, 0, 0, mainCanvas.width, mainCanvas.height );
             
        var imageData, data;
        try{
            imageData = newContext.getImageData( 0, 0, mainCanvas.width, mainCanvas.height );
            data = imageData.data;
        } catch(error){ // CORS error (eg when viewed from a local file).
            newContext.fillStyle = «yellow»;
            newContext.fillRect( 0, 0, mainCanvas.width, mainCanvas.height );
            imageData = mainContext.createImageData( mainCanvas.width, mainCanvas.height );
            data = imageData.data;
        }
         
        //loop through each pixel, turning its color into a shade of grey
        for( var i = 0; i < data.length; i += 4 ){
            var red = data[i];
            var green = data[i + 1];
            var blue = data[i + 2];
            var grey = Math.max( red, green, blue );
             
            data[i] = grey;
            data[i+1] = grey;
            data[i+2] = grey;
        }
        newContext.putImageData( imageData, 0, 0 );
         
        //add the gradient overlay
        var gradient = newContext.createLinearGradient( mainCanvas.width/2, 0, mainCanvas.width/2, mainCanvas.height );
        gradient.addColorStop( 0, ‘#000’ );
        gradient.addColorStop( 0.2, ‘#000’ );
        gradient.addColorStop( 1, «rgba(0,0,0,0.5)» );
        newContext.fillStyle = gradient;
        newContext.fillRect( 0, 0, mainCanvas.width, mainCanvas.height );
         
        mainContext.save();
        mainContext.drawImage( newCanvas, 0, 0, mainCanvas.width, mainCanvas.height );
         
        mainContext.restore();
    }
     
    // renders the ‘film reel’ animation that scrolls across the canvas
    function drawFilm(){
        var sampleWidth = 116;
        var sampleHeight = 80;
        var filmSpeed = 20;
        var filmTop = 120;
        var filmAngle = -10 * Math.PI / 180;
        var filmRight = ( videoSamples.length > 0 )?
         
        //here, we check if the first frame of the ‘film reel’ has scrolled out of view
        if( videoSamples.length > 0 ){
            var bottomLeftX = videoSamples[0].x + sampleWidth;
            var bottomLeftY = filmTop + sampleHeight;
            bottomLeftX = Math.floor( Math.cos(filmAngle) * bottomLeftX — Math.sin(filmAngle) * bottomLeftY );
             
            if( bottomLeftX < 0 ){ //the frame is offscreen, remove it’s refference from the film array
                videoSamples.shift();
            }
        }
         
        // add new frames to the reel as required
        while( filmRight <= mainCanvas.width ){
            var newFrame = {};
            newFrame.x = filmRight;
            newFrame.canvas = sampleVideo();
            videoSamples.push(newFrame);
            filmRight += sampleWidth;
        }
         
        // create the gradient fill for the reel
        var gradient = mainContext.createLinearGradient( 0, 0, mainCanvas.width, mainCanvas.height );
        gradient.addColorStop( 0, ‘#0D0D0D’ );
        gradient.addColorStop( 0.25, ‘#300A02’ );
        gradient.addColorStop( 0.5, ‘#AF5A00’ );
        gradient.addColorStop( 0.75, ‘#300A02’ );
        gradient.addColorStop( 1, ‘#0D0D0D’ );
             
        mainContext.save();
        mainContext.globalAlpha = 0.9;
        mainContext.fillStyle = gradient;
        mainContext.rotate(filmAngle);
         
        // loops through all items of film array, using the stored co-ordinate values of each to draw part of the ‘film reel’
        for( var i in videoSamples ){
            var sample = videoSamples[i];
            var punchX, punchY, punchWidth = 4, punchHeight = 6, punchInterval = 11.5;
             
            //draws the main rectangular box of the sample
            mainContext.beginPath();
            mainContext.moveTo( sample.x, filmTop );
            mainContext.lineTo( sample.x + sampleWidth, filmTop );
            mainContext.lineTo( sample.x + sampleWidth, filmTop + sampleHeight );
            mainContext.lineTo( sample.x, filmTop + sampleHeight );
            mainContext.closePath();
             
            //adds the small holes lining the top and bottom edges of the ‘fim reel’
            for( var j = 0; j < 10; j++ ){
                punchX = sample.x + ( j * punchInterval ) + 5;
                punchY = filmTop + 4;
                mainContext.moveTo( punchX, punchY + punchHeight );
                mainContext.lineTo( punchX + punchWidth, punchY + punchHeight );
                mainContext.lineTo( punchX + punchWidth, punchY );
                mainContext.lineTo( punchX, punchY );
                mainContext.closePath();
                punchX = sample.x + ( j * punchInterval ) + 5;
                punchY = filmTop + 70;
                mainContext.moveTo( punchX, punchY + punchHeight );
                mainContext.lineTo( punchX + punchWidth, punchY + punchHeight );
                mainContext.lineTo( punchX + punchWidth, punchY );
                mainContext.lineTo( punchX, punchY );
                mainContext.closePath();
            }
            mainContext.fill();
        }
         
        //loop through all items of videoSamples array, update the x co-ordinate values of each item, and draw its stored image onto the canvas
        mainContext.globalCompositeOperation = ‘lighter’;
        for( var i in videoSamples ){
            var sample = videoSamples[i];
            sample.x -= filmSpeed;
            mainContext.drawImage( sample.canvas, sample.x + 5, filmTop + 10, 110, 62 );
        }
         
        mainContext.restore();
    }
     
    // renders the canvas title
    function drawTitle(){
        mainContext.save();
        mainContext.fillStyle = ‘black’;
        mainContext.fillRect( 0, 0, 368, 25 );
        mainContext.fillStyle = ‘white’;
        mainContext.font = «bold 21px Georgia»;
        mainContext.fillText( «SINTEL», 10, 20 );
        mainContext.restore();
    }
     
    // renders all the text appearing at the top left corner of the canvas
    function drawDescription(){
        var text = [];
        text[0] = «Sintel is an independently produced short film, initiated by the Blender Foundation.»;
        text[1] = «For over a year an international team of 3D animators and artists worked in the studio of the Amsterdam Blender Institute on the computer-animated short ‘Sintel’.»;
        text[2] = «It is an epic short film that takes place in a fantasy world, where a girl befriends a baby dragon.»;
        text[3] = «After the little dragon is taken from her violently, she undertakes a long journey that leads her to a dramatic confrontation.»;
        text[4] = «The script was inspired by a number of story suggestions by Martin Lodewijk around a Cinderella character (Cinder in Dutch is ‘Sintel’). «;
        text[5] = «Screenwriter Esther Wouda then worked with director Colin Levy to create a script with multiple layers, with strong characterization and dramatic impact as central goals.»;
        text = text[Math.floor( video.currentTime / 10 )];
         
        mainContext.save();
        var alpha = 1 — ( video.currentTime % 10 ) / 10;
        mainContext.globalAlpha = ( alpha < 5 )?
        mainContext.fillStyle = ‘#fff’;
        mainContext.font = «normal 12px Georgia»;
         
        //break the text up into several lines as required, and write each line on the canvas
        text = text.split(‘ ‘);
        var colWidth = mainCanvas.width * .75;
        var line = »;
        var y = 40;
        for(var i in text ){
            line += text[i] + ‘ ‘;
            if( mainContext.measureText(line).width > colWidth ){
                mainContext.fillText( line, 10, y );
                line = »;
                y += 12;
            }
        }
        mainContext.fillText( line, 10, y );
         
        mainContext.restore();
    }
     
    //updates the bottom-right potion of the canvas with the latest perfomance statistics
    function drawStats( average ){
        var x = 245.5, y = 130.5, graphScale = 0.25;
         
        mainContext.save();
        mainContext.font = «normal 10px monospace»;
        mainContext.textAlign = ‘left’;
        mainContext.textBaseLine = ‘top’;
        mainContext.fillStyle = ‘black’;
        mainContext.fillRect( x, y, 120, 75 );
         
        //draw the x and y axis lines of the graph
        y += 30;
        x += 10;
        mainContext.beginPath();
        mainContext.strokeStyle = ‘#888’;
        mainContext.lineWidth = 1.5;
        mainContext.moveTo( x, y );
        mainContext.lineTo( x + 100, y );
        mainContext.stroke();
        mainContext.moveTo( x, y );
        mainContext.lineTo( x, y — 25 );
        mainContext.stroke();
         
        // draw the last 50 speedLog entries on the graph
        mainContext.strokeStyle = ‘#00ffff’;
        mainContext.fillStyle = ‘#00ffff’;
        mainContext.lineWidth = 0.3;
        var imax = speedLog.length;
        var i = ( speedLog.length > 50 )?
        mainContext.beginPath();
        for( var j = 0; i < imax; i++, j += 2 ){
            mainContext.moveTo( x + j, y );
            mainContext.lineTo( x + j, y — speedLog[i] * graphScale );
            mainContext.stroke();
        }
         
        // the red line, marking the desired maximum rendering time
        mainContext.beginPath();
        mainContext.strokeStyle = ‘#FF0000’;
        mainContext.lineWidth = 1;
        var target = y — frameDuration * graphScale;
        mainContext.moveTo( x, target );
        mainContext.lineTo( x + 100, target );
        mainContext.stroke();
         
        // current/average speedLog items
        y += 12;
        if( average ){
            var speed = 0;
            for( i in speedLog ){ speed += speedLog[i];
            speed = Math.floor( speed / speedLog.length * 10) / 10;
        }else {
            speed = speedLog[speedLog.length-1];
        }
        mainContext.fillText( ‘Render Time: ‘ + speed, x, y );
         
        // canvas fps
        mainContext.fillStyle = ‘#00ff00’;
        y += 12;
        if( average ){
            fps = 0;
            for( i in fpsLog ){ fps += fpsLog[i];
            fps = Math.floor( fps / fpsLog.length * 10) / 10;
        }else {
            fps = fpsLog[fpsLog.length-1];
        }
        mainContext.fillText( ‘ Canvas FPS: ‘ + fps, x, y );
         
        // browser frames per second (fps), using window.mozPaintCount (firefox only)
        if( window.mozPaintCount ){
            y += 12;
            if( average ){
                fps = 0;
                for( i in paintCountLog ){ fps += paintCountLog[i];
                fps = Math.floor( fps / paintCountLog.length * 10) / 10;
            }else {
                fps = paintCountLog[paintCountLog.length-1];
            }
            mainContext.fillText( ‘Browser FPS: ‘ + fps, x, y );
        }
         
        mainContext.restore();
    }
     
    //draw the spining blade that appears in the begining of the animation
    function drawBlade(){
        if( !blade || blade.x > mainCanvas.width ){ return;
        blade.x += 2.5;
        blade.angle = ( blade.angle — 45 ) % 360;
         
        //update blade, an ofscreen canvas containing the blade’s image
        var angle = blade.angle * Math.PI / 180;
        var bladeContext = blade.getContext(‘2d’);
        blade.width = blade.width;
        bladeContext.save();
        bladeContext.translate( 200, 200 );
        bladeContext.rotate(angle);
        bladeContext.drawImage( bladeSrc, -bladeSrc.width/2, -bladeSrc.height/2 );
        bladeContext.restore();
         
        mainContext.save();
        mainContext.globalAlpha = 0.95;
        mainContext.drawImage( blade, blade.x, blade.y + Math.sin(angle) * 50 );
        mainContext.restore();
    }
})();

Смысл этого правила состоит в том, чтобы препятствовать оптимизации ради оптимизации, поскольку этот процесс имеет свою цену.

Сильно оптимизированный скрипт будет легче анализировать и обрабатывать в браузере, но обычно с бременем для людей, которым будет сложнее следить и поддерживать. Всякий раз, когда вы решаете, что необходима некоторая оптимизация, заранее установите некоторые цели, чтобы вы не увлеклись процессом и не переусердствовали.

Целью оптимизации этого виджета будет запуск функции main() менее чем за 33 миллисекунды, как и положено, что будет соответствовать частоте кадров воспроизводимых видеофайлов ( sintel.mp4 и sintel.webm ). Эти файлы были закодированы со скоростью воспроизведения 30 кадров в секунду (тридцать кадров в секунду), что соответствует примерно 0,33 секундам или 33 миллисекундам на кадр (1 секунда ÷ 30 кадров).

Поскольку JavaScript рисует новый кадр анимации на холсте каждый раз, когда вызывается функция main() , целью нашего процесса оптимизации будет заставить эту функцию занимать 33 миллисекунды или меньше при каждом ее запуске. Эта функция повторно вызывает себя, используя таймер JavaScript setTimeout() как показано ниже.

1
2
3
4
var frameDuration = 33;
function main(){
    if( video.paused || video.ended ){ return false;
    setTimeout( main, frameDuration );

В этом правиле подчеркивается, что оптимизацию всегда следует выполнять в конце процесса разработки, когда вы уже выполнили какой-то законченный рабочий код. Полиция по оптимизации позволит нам перейти к этому, поскольку скрипт виджета является прекрасным примером законченной, работающей программы, готовой к процессу.

Это правило касается понимания вашей программы с точки зрения производительности во время выполнения. Профилирование помогает вам узнать, а не угадать, какие функции или области скрипта занимают больше всего времени или используются чаще всего, так что вы можете сосредоточиться на них в процессе оптимизации. Достаточно важно заставить ведущие браузеры поставлять встроенные профилировщики JavaScript или иметь расширения, предоставляющие эту услугу.

Я запустил виджет под профилировщиком в Firebug , и ниже скриншот результатов.


Когда вы запустили виджет, я уверен, что вы нашли все вещи Sintel в порядке, и были совершенно поражены тем, что находится в правом нижнем углу холста, с красивым графиком и блестящим текстом.

Это не просто красивое лицо; этот блок также предоставляет некоторую статистику производительности в режиме реального времени работающей программы. На самом деле это простой, простой Javascript профилировщик. Это верно! Йо, я слышал, что вы любите профилирование, поэтому я добавил профилировщик в ваш фильм, чтобы вы могли профилировать его во время просмотра.

График отслеживает время рендеринга , рассчитанное путем измерения того, сколько времени каждый прогон main() занимает в миллисекундах. Так как это функция, которая рисует каждый кадр анимации, это фактически частота кадров анимации. Каждая вертикальная синяя линия на графике показывает время, затраченное на один кадр. Красная горизонтальная линия — это целевая скорость, которую мы установили в 33 мс, чтобы соответствовать частоте кадров видеофайла. Чуть ниже графика скорость последнего вызова функции main() указывается в миллисекундах.

Профилировщик также является удобным тестом скорости рендеринга в браузере. На данный момент среднее время рендеринга в Firefox составляет 55 мс, 90 мс в IE 9, 41 мс в Chrome, 148 мс в Opera и 63 мс в Safari. Все браузеры работали в Windows XP, кроме IE 9, который был профилирован в Windows Vista.

Следующая ниже метрика — это Canvas FPS (холст кадров в секунду), полученный путем подсчета количества вызовов main() в секунду. Профилировщик отображает последнюю частоту кадров Canvas FPS, когда видео все еще воспроизводится, а когда он заканчивается, он показывает среднюю скорость всех вызовов main() .

Последний показатель — FPS браузера , который измеряет, сколько браузер перерисовывает текущее окно каждую секунду. Этот window.mozPaintCount. доступен только при просмотре виджета в Firefox, поскольку он зависит от функции, доступной в настоящее время только в этом браузере и называемой window.mozPaintCount. — свойство JavaScript, которое отслеживает, сколько раз окно браузера перекрашивалось с момента первой загрузки веб-страницы.

Перекрашивание обычно происходит, когда происходит событие или действие, которое изменяет внешний вид страницы, например, когда вы прокручиваете страницу вниз или наводите указатель мыши на ссылку. Фактически это реальная частота кадров браузера, которая определяется тем, насколько занята текущая веб-страница.

Чтобы оценить влияние неоптимизированной анимации холста на mozPaintCount, я удалил тег canvas и весь JavaScript, чтобы отслеживать частоту кадров браузера при воспроизведении только видео. Мои тесты были сделаны в консоли Firebug, используя функцию ниже:

1
2
3
4
5
var lastPaintCount = window.mozPaintCount;
   setInterval( function(){
       console.log( window.mozPaintCount — lastPaintCount );
       lastPaintCount = window.mozPaintCount;
   }, 1000);

Результаты: частота кадров браузера составляла от 30 до 32 кадров в секунду при воспроизведении видео и снижалась до 0-1 кадров в секунду по окончании видео. Это означает, что Firefox настраивал частоту перерисовки окна в соответствии с частотой воспроизводимого видео, закодированного со скоростью 30 кадров в секунду. Когда тест был запущен с неоптимизированной анимацией холста и воспроизведением видео вместе, он замедлился до 16 кадров в секунду, поскольку браузер теперь изо всех сил пытался запустить весь JavaScript и по-прежнему перерисовывать свое окно вовремя, делая и воспроизведение видео, и анимацию холста вялый.

Теперь мы приступим к настройке нашей программы, и при этом мы будем отслеживать время рендеринга, Canvas FPS и Browser FPS, чтобы измерить влияние наших изменений.


Последние два фрагмента JavaScript выше используют функции таймера setTimeout() и setInterval() . Чтобы использовать эти функции, вы указываете временной интервал в миллисекундах и функцию обратного вызова, которую вы хотите выполнить по истечении этого времени. Разница между ними заключается в том, что setTimeout() будет вызывать вашу функцию только один раз, а setInterval() неоднократно.

Хотя эти функции всегда были незаменимыми инструментами в JavaScript-аниматоре, у них есть несколько недостатков:

Во-первых, установленный временной интервал не всегда надежен. Если программа все еще находится в процессе выполнения чего-то еще, когда интервал истекает, функция обратного вызова будет выполнена позже, чем первоначально установленное, как только браузер больше не будет занят. В функции main() мы устанавливаем интервал в 33 миллисекунды — но, как показывает профилировщик, эта функция в Opera вызывается каждые 148 миллисекунд.

Во-вторых, проблема с перерисовкой браузера. Если бы у нас была функция обратного вызова, которая генерировала 20 кадров анимации в секунду, а браузер перерисовывал свое окно только 12 раз в секунду, 8 вызовов этой функции будут потрачены впустую, поскольку пользователь никогда не увидит результаты.

Наконец, браузер не может знать, что вызываемая функция анимирует элементы в документе. Это означает, что, если эти элементы прокручиваются вне поля зрения или пользователь нажимает на другую вкладку, обратный вызов все равно будет выполняться многократно, тратя циклы ЦП.

Использование requestAnimationFrame() решает большинство из этих проблем, и его можно использовать вместо функций таймера в анимации HTML5. Вместо указания временного интервала requestAnimationFrame() синхронизирует вызовы функций с перерисовками окна браузера. Это приводит к более плавной, согласованной анимации, поскольку кадры не удаляются, и браузер может выполнять дальнейшую внутреннюю оптимизацию, зная, что анимация выполняется.

Чтобы заменить setTimeout() на requestAnimationFrame в нашем виджете, мы сначала добавляем следующую строку вверху нашего скрипта:

1
2
3
4
5
requestAnimationFrame = window.requestAnimationFrame ||
                       window.mozRequestAnimationFrame ||
                       window.webkitRequestAnimationFrame ||
                       window.msRequestAnimationFrame ||
                       setTimeout;

Поскольку спецификация все еще довольно новая, некоторые браузеры или версии браузеров имеют свои собственные экспериментальные реализации, эта строка гарантирует, что имя функции указывает на правильный метод, если он доступен, и возвращается к setTimeout() если нет. Затем в функции main() мы меняем эту строку:

1
setTimeout( main, frameDuration );

… чтобы:

1
requestAnimationFrame( main, canvas );

Первый параметр принимает функцию обратного вызова, которая в данном случае является функцией main() . Второй параметр является необязательным и указывает элемент DOM, который содержит анимацию. Предполагается, что он будет использоваться для вычисления дополнительных оптимизаций.

Обратите внимание, что getStats() также использует setTimeout(), но мы оставляем ее на месте, поскольку эта конкретная функция не имеет ничего общего с анимацией сцены. requestAnimationFrame() был создан специально для анимации, поэтому, если ваша функция обратного вызова не выполняет анимацию, вы все равно можете использовать setTimeout() или setInterval().


На последнем шаге мы сделали requestAnimationFrame анимацию холста, и теперь у нас появилась новая проблема. Если мы запустим виджет, затем свернем окно браузера или переключимся на новую вкладку, скорость перерисовки окна виджета снизится для экономии энергии. Это также замедляет анимацию холста, поскольку теперь она синхронизируется со скоростью перерисовки — что было бы идеально, если бы видео не продолжалось до конца.

Нам нужен способ определить, когда страница не просматривается, чтобы мы могли приостановить воспроизведение видео; вот тут-то и приходит на помощь API видимости страницы.

API содержит набор свойств, функций и событий, которые мы можем использовать, чтобы определить, является ли веб-страница видимой или скрытой. Затем мы можем добавить код, который соответствующим образом корректирует поведение нашей программы. Мы будем использовать этот API для приостановки воспроизведения видео виджета, когда страница неактивна.

Мы начинаем с добавления нового слушателя событий в наш скрипт:

1
document.addEventListener( ‘visibilitychange’, onVisibilityChange, false);

Далее идет функция обработчика событий:

1
2
3
4
5
6
7
8
// Adjusts the program behavior, based on whether the webpage is active or hidden
function onVisibilityChange() {
    if( document.hidden && !video.paused ){
        video.pause();
    }else if( video.paused ){
        video.play();
    }
}

Контуры используются для создания и рисования пользовательских фигур и контуров в элементе <canvas> , который всегда будет иметь один активный путь.

Путь содержит список подпутей, и каждый подпуть состоит из координатных точек холста, связанных между собой линией или кривой. Все функции создания пути и рисования являются свойствами объекта context холста и могут быть разделены на две группы.

Существуют функции создания lineTo() , которые используются для определения lineTo() и включают в себя lineTo() , quadraticCurveTo() , bezierCurveTo() и arc() . Затем у нас есть функции рисования path stroke() и fill() , пути / подпути. Использование stroke() создаст контур, а fill() создаст форму, заполненную цветом, градиентом или узором.

При рисовании фигур и контуров на холсте более эффективно сначала создать весь путь, а затем просто stroke() или fill() один раз, а не определять и рисовать каждую ванну за раз. Взяв график профилировщика, описанный в шаге 4, в качестве примера, каждая отдельная вертикальная синяя линия является подпути, в то время как все они вместе составляют весь текущий путь.

Метод stroke() в настоящее время вызывается в цикле, который определяет каждый подпуть:

1
2
3
4
5
6
mainContext.beginPath();
for( var j = 0; i < imax; i++, j += 2 ){
    mainContext.moveTo( x + j, y );
    mainContext.lineTo( x + j, y — speedLog[i] * graphScale );
    mainContext.stroke();
}

Этот график можно нарисовать гораздо эффективнее, сначала определив все подпути, а затем просто рисуя весь текущий путь сразу, как показано ниже.

1
2
3
4
5
6
mainContext.beginPath();
for( var j = 0; i < imax; i++, j += 2 ){
    mainContext.moveTo( x + j, y );
    mainContext.lineTo( x + j, y — speedLog[i] * graphScale );
}
mainContext.stroke();

Этот метод оптимизации связан с тем, что использовался на предыдущем шаге, поскольку они оба основаны на одном и том же принципе минимизации перерисовок веб-страницы.

Всякий раз, когда происходит что-то, что изменяет внешний вид или содержание документа, браузер должен запланировать операцию перерисовки вскоре после этого, чтобы обновить интерфейс. Перекраска может быть дорогостоящей операцией с точки зрения циклов процессора и мощности, особенно для плотных страниц с большим количеством элементов и анимацией. Если вы создаете сложную анимационную сцену, добавляя множество элементов по одному в <canvas> , каждое новое добавление может просто вызвать полную перерисовку.

Лучше и намного быстрее построить сцену за пределами экрана (в памяти) <canvas> , а после этого закрасить всю сцену всего один раз на экране, видимом <canvas> .

Чуть ниже кода, который получает ссылку на виджет <canvas>и его контекст, мы добавим пять новых строк, которые создают внеэкранный объект DOM canvas и сопоставляют его размеры с исходным видимым <canvas>.

1
2
3
4
5
6
var mainCanvas = document.getElementById("mainCanvas"); // points to the on-screen, original HTML canvas element
var mainContext = mainCanvas.getContext('2d'); // the drawing context of the on-screen canvas element
var osCanvas = document.createElement("canvas"); // creates a new off-screen canvas element
var osContext = osCanvas.getContext('2d'); //the drawing context of the off-screen canvas element
osCanvas.width = mainCanvas.width; // match the off-screen canvas dimensions with that of #mainCanvas
osCanvas.height = mainCanvas.height;

Затем мы сделаем поиск и заменим во всех функциях рисования все ссылки на «mainCanvas» и изменим это на «osCanvas». Ссылки на «mainContext» будут заменены на «osContext». Теперь все будет нарисовано на новом закадровом холсте вместо оригинала <canvas>.

Наконец, мы добавляем еще одну строку, main()которая рисует то, что в данный момент находится за кадром, <canvas>в наш оригинал <canvas>.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
// As the scripts main function, it controls the pace of the animation
function main(){
    requestAnimationFrame( main, mainCanvas );
    }
     
    var now = new Date().getTime();
    if( frameStartTime ){
        speedLog.push( now - frameStartTime );
    }
    frameStartTime = now;
    }
     
    frameCount++;
    osCanvas.width = osCanvas.width; //clear the offscreen canvas
    drawBackground();
    drawFilm();
    drawDescription();
    drawStats();
    drawBlade();
    drawTitle();
    mainContext.drawImage( osCanvas, 0, 0 ); // copy the off-screen canvas graphics to the on-screen canvas
}

Для многих видов графики использование drawImage()будет намного быстрее, чем создание одного и того же изображения на холсте с использованием контуров. Если вы обнаружите, что большая часть вашего сценария многократно тратится на рисование одних и тех же фигур и контуров снова и снова, вы можете сэкономить браузеру некоторую работу, кэшируя полученную графику как растровое изображение, а затем рисовать ее один раз на холсте всякий раз, когда требуется использование drawImage().

Есть два способа сделать это.

Первый — создать внешний файл изображения в виде изображения в формате JPG, GIF или PNG, затем загрузить его динамически с помощью JavaScript и скопировать его на холст. Единственный недостаток этого метода — дополнительные файлы, которые ваша программа должна будет загружать из сети, но в зависимости от типа графики или того, что делает ваше приложение, это может быть хорошим решением. Анимационный виджет использует этот метод для загрузки изображения вращающегося лезвия, которое было бы невозможно воссоздать, используя только функции рисования траектории холста.

Второй метод заключается в том, чтобы просто нарисовать графику один раз за пределами экрана, а не загружать внешнее изображение. Мы будем использовать этот метод для кэширования заголовка анимационного виджета. Сначала мы создаем переменную, которая ссылается на новый элемент вне экрана, который будет создан. Его значение по умолчанию установлено на false, так что мы можем сказать, был ли создан кэш изображений и сохранен ли он после запуска скрипта:

1
var titleCache = false; // points to an off-screen canvas used to cache the animation scene's title

Затем мы редактируем drawTitle()функцию, чтобы сначала проверить, было ли создано titleCacheизображение холста. Если это не так, он создает изображение вне экрана и сохраняет ссылку на него в titleCache:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
// renders the canvas title
function drawTitle(){
    if( titleCache == false ){ // create and save the title image
        titleCache = document.createElement('canvas');
        titleCache.width = osCanvas.width;
        titleCache.height = 25;
         
        var context = titleCache.getContext('2d');         
        context.fillStyle = 'black';
        context.fillRect( 0, 0, 368, 25 );
        context.fillStyle = 'white';
        context.font = "bold 21px Georgia";
        context.fillText( "SINTEL", 10, 20 );      
    }
 
    osContext.drawImage( titleCache, 0, 0 );
}

Первый шаг в рисовании нового кадра анимации — очистить холст текущего. Это может быть сделано либо путем сброса ширины элемента canvas, либо с помощью clearRect()функции.

Сброс ширины имеет побочный эффект, также сбрасывая текущий контекст холста обратно в его состояние по умолчанию, что может замедлить процесс. Использование clearRect()всегда является более быстрым и лучшим способом очистки холста.

В main()функции мы изменим это:

1
osCanvas.width = osCanvas.width; //clear the off-screen canvas

…к этому:

1
osContext.clearRect( 0, 0, osCanvas.width, osCanvas.height ); //clear the offscreen canvas

Если вы раньше работали с программным обеспечением для редактирования изображений или видео, таким как Gimp или Photoshop, то вы уже знакомы с концепцией слоев, где изображение составляется путем наложения множества изображений друг на друга, и каждое из них можно выбрать и редактируется отдельно.

Применительно к сцене анимации холста каждый слой будет отдельным элементом холста, размещенным друг над другом с использованием CSS для создания иллюзии отдельного элемента. Как метод оптимизации, он работает лучше всего, когда есть четкое различие между передним и фоновым элементами сцены, при этом большинство действий происходит на переднем плане. Затем фон может быть нарисован на элементе canvas, который не сильно меняется между кадрами анимации, и на переднем плане на другом, более динамичном элементе canvas, над ним. Таким образом, не нужно заново перерисовывать всю сцену для каждого кадра анимации.

К сожалению, виджет анимации является хорошим примером сцены, в которой мы не можем с пользой применять эту технику, поскольку элементы переднего и заднего плана сильно анимированы.


Это еще один метод оптимизации, который сильно зависит от композиции сцены анимации. Его можно использовать, когда анимация сцены сосредоточена вокруг определенной прямоугольной области на холсте. Мы могли бы затем очистить и перерисовать просто перерисовать этот регион.

Например, заголовок Sintel остается неизменным на протяжении большей части анимации, поэтому мы можем оставить эту область нетронутой при очистке холста для следующего кадра анимации.

Чтобы реализовать эту технику, мы заменим строку, которая вызывает функцию рисования заголовка, main()следующим блоком:

1
2
3
4
5
if( titleCache == false ){ // If titleCache is false, the animation's title hasn't been drawn yet
    drawTitle(); // we draw the title. This function will now be called just once, when the program starts
    osContext.rect( 0, 25, osCanvas.width, osCanvas.height ); // this creates a path covering the area outside by the title
    osContext.clip(); // we use the path to create a clipping region, that ignores the title's region
}

Субпиксельный рендеринг или сглаживание происходит, когда браузер автоматически применяет графические эффекты для удаления неровных краев. Это приводит к более плавно выглядящим изображениям и анимации и автоматически активируется всякий раз, когда вы указываете дробные координаты, а не целое число при рисовании на холсте.

На данный момент не существует стандарта, как именно это должно быть сделано, поэтому субпиксельный рендеринг немного несовместим во всех браузерах с точки зрения отображаемого вывода. Это также замедляет скорость рендеринга, так как браузер должен выполнить некоторые вычисления, чтобы получить эффект. Поскольку сглаживание холста нельзя отключить напрямую, единственный способ обойти его — всегда использовать целые числа в координатах чертежа.

Мы будем использовать Math.floor()для обеспечения целых чисел в нашем сценарии, когда это применимо. Например, следующая строка в drawFilm():

1
punchX = sample.x + ( j * punchInterval ) + 5; // the x co-ordinate

… переписывается как:

1
punchX = sample.x + ( j * punchInterval ) + 5; // the x co-ordinate

Мы рассмотрели несколько методов оптимизации анимации холста, и теперь пришло время пересмотреть результаты.

В этой таблице показаны средние значения времени рендеринга до и после и FPS для Canvas. Мы можем видеть некоторые существенные улучшения во всех браузерах, хотя только Chrome действительно приближается к достижению нашей первоначальной цели — максимум времени рендеринга 33 мс. Это означает, что для достижения этой цели еще предстоит проделать большую работу.

Мы могли бы продолжить, применив более общие методы оптимизации JavaScript, и, если это все равно не помогло, возможно, стоит подумать об уменьшении анимации, удалив некоторые навороты. Но мы не будем рассматривать сегодня ни одну из этих техник, так как основное внимание здесь было уделено оптимизации <canvas>анимации.

Canvas API все еще довольно новый и растет с каждым днем, поэтому продолжайте экспериментировать, тестировать, исследовать и делиться. Спасибо за чтение учебника.