在使用requestAnimationFrame
时,如何以指定的帧速绘制动画?
我能想到的是:假设检测到浏览器允许的requestAnimationFrame帧速为60fps,要实现30fps动画只需在每2个requestAnimationFrame回调中丢弃1个,要实现24fps则在每5个中丢弃3个,要实现59fps呢?当然是在每60个中丢弃1个。
关于帧丢弃的规则,我之前的设想是预设一些帧丢弃的范式例如 60fps转24fps的范式为[1,0,0,1,0,1,0,0,1,0,...]
(其中的0代表丢弃帧/放弃绘制)。考虑到范式预设得少了可能不够用,而预设得多了又会导致代码臃肿,因此只好写方法来生成帧丢弃的范式。
至此,帧速控制得实现思路算是通了:
- 检测浏览器requestAnimationFrame的帧速
animationFrameRate
(典型值是60fps) - 根据
animationFrameRate
和你期望的帧速desiredFrameRate
(比如说24fps)构建帧丢弃的范式bitSet
- 从帧丢弃范式
bitSet
中查找根据,决定当前帧是否该丢弃
接下来就是编写代码和测试功能的事。
经过几番调优,我总算有了份拿得出手的迷你代码库。这份代码库/用法/效果演示已经放在stackoverflow.com Controlling fps with requestAnimationFrame?。考虑到墙内墙外两个世界,本文中也嵌入一份,如下:
<div>
Animation Frame Rate: <span id="animationFrameRate">--</span>
</div>
<div>
Desired Frame Rate: <input id="frameRateInput" type="range" min="1" max="60" step="1" list="frameRates" />
<output id="frameRateOutput"></output>
<datalist id="frameRates">
<option>15</option>
<option>24</option>
<option>30</option>
<option>48</option>
<option>60</option>
</datalist>
</div>
<div>
Actual Frame Rate: <span id="actualFrameRate">--</span>
</div>
<canvas id="digitalClock" width="240" height="48"></canvas>
function detectAnimationFrameRate(numIntervals = 6){
if(typeof numIntervals !== 'number' || !isFinite(numIntervals) || numIntervals < 2){
throw new RangeError('Argument numIntervals should be a number not less than 2');
}
let intervals = Math.floor(numIntervals);
return new Promise((resolve) => {
let numFrames = intervals + 1;
let then;
let i = 0;
let tick = () => {
let now = performance.now();
i += 1;
if(i < numFrames){
requestAnimationFrame(tick);
}
if(i === 1){
then = now;
}else{
if(i === numFrames){
resolve(Math.round(1000 / ((now - then) / intervals)));
}
}
};
requestAnimationFrame(() => {
requestAnimationFrame(tick);
});
});
}
function buildFrameBitSet(animationFrameRate, desiredFrameRate){
let bitSet = new Uint8Array(animationFrameRate);
let ratio = desiredFrameRate / animationFrameRate;
if(ratio >= 1)
return bitSet.fill(1);
for(let i = 0, prev = -1, curr; i < animationFrameRate; i += 1, prev = curr){
curr = Math.floor(i * ratio);
bitSet[i] = (curr !== prev) ? 1 : 0;
}
return bitSet;
}
let $ = (s, c = document) => c.querySelector(s);
let $$ = (s, c = document) => Array.prototype.slice.call(c.querySelectorAll(s));
async function main(){
let canvas = $('#digitalClock');
let context2d = canvas.getContext('2d');
await new Promise((resolve) => {
if(window.requestIdleCallback){
requestIdleCallback(resolve, {timeout:3000});
}else{
setTimeout(resolve, 0, {didTimeout: false});
}
});
let animationFrameRate = await detectAnimationFrameRate(10); // 1. detect animation frame rate
let desiredFrameRate = 24;
let frameBits = buildFrameBitSet(animationFrameRate, desiredFrameRate); // 2. build a bit set
let handle;
let i = 0;
let count = 0, then, actualFrameRate = $('#actualFrameRate'); // debug-only
let draw = () => {
if(++i >= animationFrameRate){ // shoud use === if frameBits don't change dynamically
i = 0;
/* debug-only */
let now = performance.now();
let deltaT = now - then;
let fps = 1000 / (deltaT / count);
actualFrameRate.textContent = fps;
then = now;
count = 0;
}
if(frameBits[i] === 0){ // 3. lookup the bit set
handle = requestAnimationFrame(draw);
return;
}
count += 1; // debug-only
let d = new Date();
let text = d.getHours().toString().padStart(2, '0') + ':' +
d.getMinutes().toString().padStart(2, '0') + ':' +
d.getSeconds().toString().padStart(2, '0') + '.' +
(d.getMilliseconds() / 10).toFixed(0).padStart(2, '0');
context2d.fillStyle = '#000000';
context2d.fillRect(0, 0, canvas.width, canvas.height);
context2d.font = '36px monospace';
context2d.fillStyle = '#ffffff';
context2d.fillText(text, 0, 36);
handle = requestAnimationFrame(draw);
};
handle = requestAnimationFrame(() => {
then = performance.now();
handle = requestAnimationFrame(draw);
});
/* debug-only */
$('#animationFrameRate').textContent = animationFrameRate;
let frameRateInput = $('#frameRateInput');
let frameRateOutput = $('#frameRateOutput');
frameRateInput.addEventListener('input', (e) => {
frameRateOutput.value = e.target.value;
});
frameRateInput.max = animationFrameRate;
frameRateOutput.value = frameRateOutput.value = desiredFrameRate;
frameRateInput.addEventListener('change', (e) => {
desiredFrameRate = +e.target.value;
frameBits = buildFrameBitSet(animationFrameRate, desiredFrameRate);
});
}
document.addEventListener('DOMContentLoaded', main);