【问题标题】:Optimise javascript canvas for mass-drawing of tiny objects优化 javascript 画布以大量绘制微小对象
【发布时间】:2026-02-11 12:35:01
【问题描述】:

我一直在开发一款游戏,它需要每帧渲染和旋转数千个非常小的图像(20^20 像素)。提供了一个示例 sn-p。

我已经使用了我知道的所有技巧来加速它以提高帧速率,但我怀疑我还可以做其他事情来优化它。

目前的优化包括:

  • 用显式转换替换保存/恢复
  • 避免缩放/尺寸转换
  • 明确目标大小而不是让浏览器猜测
  • requestAnimationFrame 而不是 set-interval

已尝试但未出现在示例中:

  • 将对象批量渲染到其他屏幕外画布,然后再编译(降低性能)
  • 避免浮点位置(由于放置精度而需要)
  • 不在主画布上使用 alpha(由于 SO sn-p 渲染,未在 sn-p 中显示)

//initial canvas and context
var canvas = document.getElementById('canvas');
    canvas.width = 800; 
    canvas.height = 800;
var ctx = canvas.getContext('2d');

//create an image (I) to render
let myImage = new OffscreenCanvas(10,10);
let myImageCtx = myImage.getContext('2d');
myImageCtx.fillRect(0,2.5,10,5);
myImageCtx.fillRect(0,0,2.5,10);
myImageCtx.fillRect(7.5,0,2.5,10);


//animation 
let animation = requestAnimationFrame(frame);

//fill an initial array of [n] object positions and angles
let myObjects = [];
for (let i = 0; i <1500; i++){
  myObjects.push({
      x : Math.floor(Math.random() * 800),
      y : Math.floor(Math.random() * 800),
      angle : Math.floor(Math.random() * 360),
   });
}

//render a specific frame 
function frame(){
  ctx.clearRect(0,0,canvas.width, canvas.height);
  
  //draw each object and update its position
  for (let i = 0, l = myObjects.length; i<l;i++){
    drawImageNoReset(ctx, myImage, myObjects[i].x, myObjects[i].y, myObjects[i].angle);
    myObjects[i].x += 1; if (myObjects[i].x > 800) {myObjects[i].x = 0}
    myObjects[i].y += .5; if (myObjects[i].y > 800) {myObjects[i].y = 0}   
    myObjects[i].angle += .01; if (myObjects[i].angle > 360) {myObjects[i].angle = 0}   
    
  }
  //reset the transform and call next frame
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  requestAnimationFrame(frame);
}

//fastest transform draw method - no transform reset
function drawImageNoReset(myCtx, image, x, y, rotation) {
    myCtx.setTransform(1, 0, 0, 1, x, y);
    myCtx.rotate(rotation);
    myCtx.drawImage(image, 0,0,image.width, image.height,-image.width / 2, -image.height / 2, image.width, image.height);
}
&lt;canvas name = "canvas" id = "canvas"&gt;&lt;/canvas&gt;

【问题讨论】:

    标签: javascript canvas optimization drawimage


    【解决方案1】:

    您已经非常接近使用 2D API 和单线程的最大吞吐量,但是有一些小点可以提高性能。

    WebGL2

    不过,首先,如果您希望使用 javascript 获得最佳性能,则必须使用 WebGL

    使用 WebGL2,您可以绘制比使用 2D API 多 8 倍或更多的 2D 精灵,并且具有更大范围的 FX(例如颜色、阴影、凹凸、单调用智能平铺贴图...)

    WebGL 非常值得努力

    性能相关点

    • globalAlpha 应用于每个drawImage 调用,1 以外的值不会影响性能。

    • 避免调用rotate 这两个数学调用(包括一个刻度)比rotate 快​​一点。例如ax = Math..cos(rot) * scale; ay = Math.sin(rot) * scale; ctx.setTransform(ax,ay,-ay,ax,x,y)

    • 不要使用许多图像,而是将所有图像放在一个图像(精灵表)中。在这种情况下不适用

    • 不要乱扔全局范围。使对象尽可能靠近函数范围并通过引用传递对象。访问全局作用域变量比局部作用域变量慢得多。

      最好使用模块,因为它们有自己的本地范围

    • 使用弧度。将角度转换为度数并返回是浪费处理时间。学习使用弧度Math.PI * 2 === 360Math.PI === 180等等

    • 对于正整数,不要使用 Math.floor 使用按位运算符,因为它们会自动将 Doubles 转换为 Int32,例如 Math.floor(Math.random() * 800)Math.random() * 800 | 0 更快(| 是 OR)

      注意使用的数字类型。如果每次使用它都将其转换回双精度,则转换为整数将花费周期。

    • 尽可能地预先计算。例如,每次渲染图像时,您都会否定并划分宽度和高度。这些值可以预先计算。

    • 避免数组查找(索引)。在数组中索引对象比直接引用要慢。例如主循环索引myObject 11 次。使用for of 循环,因此每次迭代只有一个数组查找,并且计数器是性能更高的内部计数器。 (见示例)

    • 虽然这样做会降低性能,但如果您在较慢的渲染设备上将更新和渲染循环分开,您将通过为每个渲染帧更新两次游戏状态来获得性能。例如,如果您检测到此更新状态两次并渲染一次,则慢速渲染设备下降到 30FPS 并且游戏速度减半。游戏仍会以 30FPS 的速度呈现,但仍可正常运行(甚至可以在您将渲染负载减半时保存偶尔出现的帧下降)

      不要试图使用增量时间,这会带来一些负面的性能开销(对于许多可以是 Int 的值,强制加倍)并且实际上会降低动画质量。

    • 尽可能避免条件分支,或使用性能更高的替代方案。 EG 在您的示例中,您使用 if 语句跨越边界循环对象。这可以使用余数运算符% 来完成(参见示例)

      你检查rotation &gt; 360。这不是必需的,因为旋转是循环的 360 的值与 44444160 相同。(Math.PI * 2Math.PI * 246912 旋转相同)

    非性能点。

    每个动画调用都在为下一次(即将到来的)显示刷新准备一帧。在您的代码中,您正在显示游戏状态然后更新。这意味着您的游戏状态比客户看到的要早一帧。始终更新状态,然后显示。

    示例

    这个例子给对象增加了一些额外的负载

    • 可以向任何方向前进
    • 具有单独的速度和旋转
    • 不要在边缘闪烁。

    该示例包含一个实用程序,该实用程序尝试通过改变对象数量来平衡帧速率。

    每 15 帧更新一次(工作)负载。最终会达到一个稳定的速率。

    不要通过运行这个 sn-p 来衡量性能,所以 sn-ps 位于运行页面的所有代码下,代码也会被修改和监控(以防止无限循环)。你看到的代码不是sn-p中运行的代码。光是移动鼠标就可以在 SO sn-p 中造成几十个丢帧

    为了获得准确的结果,请复制代码并在页面上单独运行(在测试时删除浏览器上可能存在的任何扩展)

    使用此工具或类似工具定期测试您的代码,并帮助您获得了解性能好坏的经验。

    利率文本的含义。

    • 1 +/- Number 为下一个周期添加或删除的对象
    • 2上一周期每帧渲染的对象总数
    • 3 Number 渲染时间的运行平均值,以毫秒为单位(这不是帧速率)
    • 4 数字 FPS 是最佳平均帧速率。
    • 5 Number 期间丢弃的帧数。丢帧是报告帧速率的长度。 IE。 "30fps 5dropped" 5个丢帧为30fps,总丢帧时间为5 * (1000 / 30)

    const IMAGE_SIZE = 10;
    const IMAGE_DIAGONAL = (IMAGE_SIZE ** 2 * 2) ** 0.5 / 2;
    const DISPLAY_WIDTH = 800;
    const DISPLAY_HEIGHT = 800;
    const DISPLAY_OFFSET_WIDTH = DISPLAY_WIDTH + IMAGE_DIAGONAL * 2;
    const DISPLAY_OFFSET_HEIGHT = DISPLAY_HEIGHT + IMAGE_DIAGONAL * 2;
    const PERFORMANCE_SAMPLE_INTERVAL = 15;  // rendered frames
    const INIT_OBJ_COUNT = 500;
    const MAX_CPU_COST = 8; // in ms
    const MAX_ADD_OBJ = 10;
    const MAX_REMOVE_OBJ = 5;
    
    canvas.width = DISPLAY_WIDTH; 
    canvas.height = DISPLAY_HEIGHT;
    requestAnimationFrame(start);
    
    function createImage() {
        const image = new OffscreenCanvas(IMAGE_SIZE,IMAGE_SIZE);
        const ctx = image.getContext('2d');
        ctx.fillRect(0, IMAGE_SIZE / 4, IMAGE_SIZE, IMAGE_SIZE / 2);
        ctx.fillRect(0, 0, IMAGE_SIZE / 4, IMAGE_SIZE);
        ctx.fillRect(IMAGE_SIZE * (3/4), 0, IMAGE_SIZE / 4, IMAGE_SIZE);
        image.neg_half_width = -IMAGE_SIZE / 2;  // snake case to ensure future proof (no name *)
        image.neg_half_height = -IMAGE_SIZE / 2; // use of Image API
        return image;
    }
    function createObject() {
        return {
             x : Math.random() * DISPLAY_WIDTH,
             y : Math.random() * DISPLAY_HEIGHT,
             r : Math.random() * Math.PI * 2,
             dx: (Math.random() - 0.5) * 2,
             dy: (Math.random() - 0.5) * 2,
             dr: (Math.random() - 0.5) * 0.1,
        };
    }
    function createObjects() {
        const objects = [];
        var i = INIT_OBJ_COUNT;
        while (i--) { objects.push(createObject()) }
        return objects;
    }
    function update(objects){
        for (const obj of objects) {
            obj.x = ((obj.x + DISPLAY_OFFSET_WIDTH + obj.dx) % DISPLAY_OFFSET_WIDTH);
            obj.y = ((obj.y + DISPLAY_OFFSET_HEIGHT + obj.dy) % DISPLAY_OFFSET_HEIGHT);
            obj.r += obj.dr;       
        }
    }
    function render(ctx, img, objects){
        for (const obj of objects) { drawImage(ctx, img, obj) }
    }
    function drawImage(ctx, image, {x, y, r}) {
        const ax = Math.cos(r), ay = Math.sin(r);
        ctx.setTransform(ax, ay, -ay, ax, x  - IMAGE_DIAGONAL, y  - IMAGE_DIAGONAL);    
        ctx.drawImage(image, image.neg_half_width, image.neg_half_height);
    }
    function timing(framesPerTick) {  // creates a running mean frame time
        const samples = [0,0,0,0,0,0,0,0,0,0];
        const sCount = samples.length;
        var samplePos = 0;
        var now = performance.now();
        const maxRate = framesPerTick * (1000 / 60);
        const API = {
            get FPS() {
                var time = performance.now();
                const FPS =  1000 / ((time - now) / framesPerTick);
                const dropped = ((time - now) - maxRate) / (1000 / 60) | 0;
                now = time;
                if (FPS > 30) { return "60fps " + dropped + "dropped" };
                if (FPS > 20) { return "30fps " + (dropped / 2 | 0) + "dropped" };
                if (FPS > 15) { return "20fps " + (dropped / 3 | 0) + "dropped" };
                if (FPS > 10) { return "15fps " + (dropped / 4 | 0) + "dropped" };
                return "Too slow";
            },
            time(time) { samples[(samplePos++) % sCount] = time },
            get mean() { return samples.reduce((total, val) => total += val, 0) / sCount },
        };
        return API;
    }
    function updateStats(CPUCost, objects) {
        const fps = CPUCost.FPS;
        const mean = CPUCost.mean;            
        const cost = mean / objects.length; // estimate per object CPU cost
        const count =  MAX_CPU_COST / cost | 0;
        const objCount = objects.length;
        var str = "0";
        if (count < objects.length) {
            var remove = Math.min(MAX_REMOVE_OBJ, objects.length - count);
            str = "-" + remove;
            objects.length -= remove;
        } else if (count > objects.length + MAX_ADD_OBJ) {
            let i = MAX_ADD_OBJ;
            while (i--) {
                objects.push(createObject());
            }
            str = "+" + MAX_ADD_OBJ;
        }
        info.textContent = str + ": "  + objCount + " sprites " + mean.toFixed(3) + "ms " + fps;
    }
    
    function start() {
        var frameCount = 0;
        const CPUCost = timing(PERFORMANCE_SAMPLE_INTERVAL);
        const ctx = canvas.getContext('2d');
        const image = createImage();
        const objects = createObjects();
        function frame(time) {
            frameCount ++;
            const start = performance.now();
            ctx.setTransform(1, 0, 0, 1, 0, 0);
            ctx.clearRect(0, 0, DISPLAY_WIDTH, DISPLAY_WIDTH);
            update(objects);
            render(ctx, image, objects);
            requestAnimationFrame(frame);
            CPUCost.time(performance.now() - start);
            if (frameCount % PERFORMANCE_SAMPLE_INTERVAL === 0) {
                updateStats(CPUCost, objects);
            }
        }
        requestAnimationFrame(frame);
    }
    #info {
       position: absolute;
       top: 10px;
       left: 10px;
       background: #DDD;
       font-family: arial;
       font-size: 18px;
    }
    <canvas name = "canvas" id = "canvas"></canvas>
    <div id="info"></div>

    【讨论】:

    • 感谢您提供非常详细的帖子。有很多工作要做——我今天会好好看看,可能会带着几个问题回来。只是删除此评论表示感谢,我一定会查看您的所有观点!
    • 还没有看所有的点(第一个可能是无论如何应该接受的),但是关于“与其使用许多图像,不如将所有图像放在一个图像中(精灵表)。”您确定将其作为性能提升吗? (真诚的问题)。我可以看到加载的好处——单个请求、单个解码等——但是在绘图时,我会认为每次都进行裁剪、每次移动整个图像等实际上会比从单个精灵准备多个 ImageBitmap 消耗更多的性能-工作表。
    • 啊,“不要试图使用增量时间”是什么意思?你的意思是他们应该在 rAF 回调中增量移动而不检查自上一帧以来经过了多少时间?我猜你的意思是“对从增量时间计算的值进行四舍五入”。
    • @Kaiido Re 第二条评论。增量时间在 +/- 之上添加一个乘法。为一个人做,你必须为所有人做,最重要的是你自己说“......自上一帧以来经过的时间”最后一帧如何定义下一帧何时出现?如果最后一帧由于 GC 负载而被丢弃,那么它已经很晚了,并且将那个时间用于下一帧更可能是准时(下一个 Vsync)意味着它具有前一帧的状态。因此,一个丢帧会导致两个异相呈现。增量时间不能用于预测当前帧何时出现。
    • @Kaiido 不涉及“裁剪”,图像是通过纹理坐标渲染的。必须为整个图像或部分设置坐标 切换正在使用的纹理需要设置新的纹理绑定(在 GPU 中),如果多次执行,这是一个昂贵的 GPU 状态更改。在低端机器上,这可能是一个杀手(并非所有 GPU 都以相同的方式访问纹理内存)