Сильная нагрузка canvas js, проседают fps

введите сюда описание изображения

Накидал себе geometry dash на js, всё работало, но когда начал увеличивать карту, с стабильных 60 фпс всё начало скакать в районе 5-20: vsecoder.github.io/geometry/.

Я думаю что это связанно с частой перерисовкой большой карты, но как можно сделать чтобы прогружалась только нужная часть?

Код: https://github.com/vsecoder/geometry

P.S. не ругайте, с canvas только начал работать

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Geometry Dash prototype</title>
  <meta name="keywords" content="geometry dash">
  <meta name="description" content="Geometry Dash prototype">
  <link rel="shortcut icon" href="https://static.wikia.nocookie.net/geometry-dash/images/4/4a/Site-favicon.ico"/>
</head>
<body>
  <div style="display: none;">
    <img src="https://raw.githubusercontent.com/vsecoder/geometry/main/block.png">
    <img src="https://raw.githubusercontent.com/vsecoder/geometry/main/Cube.png">
    <img src="https://raw.githubusercontent.com/vsecoder/geometry/main/decor.png">
    <img src="https://raw.githubusercontent.com/vsecoder/geometry/main/fone.png">
    <img src="https://raw.githubusercontent.com/vsecoder/geometry/main/lose.gif">
    <img src="https://raw.githubusercontent.com/vsecoder/geometry/main/platform.png">
    <img src="https://raw.githubusercontent.com/vsecoder/geometry/main/spike.png">
    <img src="https://raw.githubusercontent.com/vsecoder/geometry/main/spikes.png">
    <img src="https://raw.githubusercontent.com/vsecoder/geometry/main/spikes2.png">
  </div>
  <video autoplay loop muted class="background" id="look">
    <source src="https://thumbs.gfycat.com/BlandGloomyGoldeneye-mobile.mp4" type="video/mp4">
  </video>
  <div class="start" id="look1" onclick="start()"><img src="https://raw.githubusercontent.com/vsecoder/geometry/main/1.png"></div>
  <div class="lose" id="look2" onclick="window.location.reload()" style="display: none;"><img src="https://raw.githubusercontent.com/vsecoder/geometry/main/2.png"></div>
  <div class="win" id="look3" onclick="window.location.reload()" style="display: none;"><img src="https://raw.githubusercontent.com/vsecoder/geometry/main/win.png"></div>
<style>
  * {
    box-sizing: border-box;
  }
  .start {
    position: fixed;
    top: calc(100%/3.2);
    left: 22.5%;
    bottom: 0;
    width: 50%;
    height: 200px;
    text-align: center;
    vertical-align: middle;
    padding: 44px;
    cursor: pointer;
    z-index: 1;
  }
  .lose {
    position: fixed;
    top: calc(100%/3.2);
    bottom: 0;
    width: 100%;
    height: 200px;
    text-align: center;
    vertical-align: middle;
    padding: 44px;
    cursor: pointer;
    z-index: 1;
  }
  .win {
    position: fixed;
    top: calc(100%/3.2);
    bottom: 0;
    width: 100%;
    height: 200px;
    text-align: center;
    vertical-align: middle;
    padding: 44px;
    cursor: pointer;
    z-index: 1;
  }
  .background {
    position: fixed;
    width: 100%;
    height: 100%;
    top: 0;
    left: 0;
    left: 0;
    bottom: 0;
    z-index: -1;

  }
  body {
    width: 99%;
    height: 100%;
    background-color: black;
  }
  canvas {
    position: fixed;
    top: calc(100%/3.2);
    left: 0;
    left: 0;
    bottom: 0;
    background-color: blue;
    background-image: url("https://raw.githubusercontent.com/vsecoder/geometry/main/fone.png");
    background-repeat: repeat;
    vertical-align: middle;
  }
</style>
  <script>
function start() {
javascript:(function(){var script=document.createElement('script');script.onload=function(){var stats=new Stats();document.body.appendChild(stats.dom);requestAnimationFrame(function loop(){stats.update();requestAnimationFrame(loop)});};script.src='//mrdoob.github.io/stats.js/build/stats.min.js';document.head.appendChild(script);})()
document.getElementById('look').remove()
document.getElementById('look1').remove()
const kbd = {
  u: false,
  d: false,
  l: false
};
const keyCodes = {
  38: "u"
};
document.addEventListener("keydown", e => {
  if (e.keyCode in keyCodes) {
    e.preventDefault();
    kbd[keyCodes[e.keyCode]] = true;
  }
});
document.addEventListener("keyup", e => {
  if (e.keyCode in keyCodes) {
    e.preventDefault();
    kbd[keyCodes[e.keyCode]] = false;
  }
});

document.addEventListener("touchstart", e => {
  kbd.u = true;
});
document.addEventListener("touchend", e => {
  kbd.u = false;
});

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
canvas.width = window.screen.width;
document.body.width = window.screen.width;
canvas.height = 320;
document.body.height = window.screen.height;
const ctx = canvas.getContext("2d");
var camera = {
  leftTopPos: { x: 0, y: 0 },
  size: { x: 0, y: 0 },
  scale: 1,
};
let lose = false;
let win = false;
camera.size = {
  x: canvas.width / camera.scale,
  y: canvas.height / camera.scale,
};


const gridSize = 30;
const map = [
  "########################################################################################################################################",
  "#......................................................................................................................................#",
  "#......................................................................................................................................#",
  "#......................................................................................................................................#",
  "#......................................................................................................................................#",
  "#.....................................................................................................................................@#",
  "#...............................~.....~......~.....~..................................................................................@#",
  "#...........................~............~............~.....................................................#.........................@#",
  "#......................~~..................................~~~~~~~~~....~......................##~~#...#....~.........................@#",
  "#................###111.................#222222222222222222222222222222222222#...............###...#1..#...3333.......................@#",
  "########################################################################################################################################" 
];
const player = {
  x: 2,
  y: 5,
  vx: 0,
  vy: 0,
  jumping: false,
  
  xVelocity: 0.25,     
  jumpVelocity: 0.42,   
  gravity: 0.04,       
  maxGravity: 3.8,      
  collResolution: 0.2,  
  inset: 0.05,          
  
  updatePosition: function (map) {
    if (Math.abs(this.vy) < this.maxGravity) {
      this.vy += this.gravity;
    }
    
    this.y += this.vy;
    
    if (this.collides(map)) {
      if (this.vy > 0) { this.jumping = false; }
      
      while (this.collides(map)) {
        this.y -= this.vy * this.collResolution;
      }
      
      this.vy = 0;
    }
    if (this.lose(map)) {
      if (this.vy > 0) { this.jumping = false; }
    
      lose = true
      
      this.vy = 0;
    }
    if (this.win(map)) {
      if (this.vy > 0) { this.jumping = false; }
      
      win = true;
      this.vy = 0;
    }
    
    this.x += this.vx;
    
    if (this.collides(map)) {
      
      while (this.collides(map)) {
        this.x -= this.vx * this.collResolution;
      }
      
      this.vx = 0;
    }
  },

  collides: function (map) {
    const xL = (this.x + this.inset) | 0;       
    const xR = Math.ceil((this.x - this.inset)); 
    const yU = (this.y + this.inset) | 0;        
    const yD = Math.ceil(this.y - this.inset);   
    return map[yU] && map[yU][xL] === "#" ||
           map[yU] && map[yU][xR] === "#" ||
           map[yD] && map[yD][xL] === "#" ||
           map[yD] && map[yD][xR] === "#" ||

           map[yU] && map[yU][xL] === "~" ||
           map[yU] && map[yU][xR] === "~" ||
           map[yD] && map[yD][xL] === "~" ||
           map[yD] && map[yD][xR] === "~"
    ;
  },

  lose: function (map) {
    const xL = (this.x + this.inset) | 0;       
    const xR = Math.ceil((this.x - this.inset)); 
    const yU = (this.y + this.inset) | 0;        
    const yD = Math.ceil(this.y - this.inset);   
    return map[yU] && map[yU][xL] === "1" ||
           map[yU] && map[yU][xR] === "1" ||
           map[yD] && map[yD][xL] === "1" ||
           map[yD] && map[yD][xR] === "1" ||

           map[yU] && map[yU][xL] === "2" ||
           map[yU] && map[yU][xR] === "2" ||
           map[yD] && map[yD][xL] === "2" ||
           map[yD] && map[yD][xR] === "2" ||

           map[yU] && map[yU][xL] === "3" ||
           map[yU] && map[yU][xR] === "3" ||
           map[yD] && map[yD][xL] === "3" ||
           map[yD] && map[yD][xR] === "3"
    ;
  },
  win: function (map) {
    const xL = (this.x + this.inset) | 0;       
    const xR = Math.ceil((this.x - this.inset)); 
    const yU = (this.y + this.inset) | 0;        
    const yD = Math.ceil(this.y - this.inset);   
    return map[yU] && map[yU][xL] === "@" ||
           map[yU] && map[yU][xR] === "@" ||
           map[yD] && map[yD][xL] === "@" ||
           map[yD] && map[yD][xR] === "@"
    ;
  },
  
  jump: function () {
    if (!this.jumping) {
      this.vy = -this.jumpVelocity;
      this.jumping = true;  //на false не менять!!!
    }
  },
  
  move: function (dir) {
    this.vx = dir === "r" ? this.xVelocity : -this.xVelocity;
  },
  
  draw: function (ctx, size) {
    if (!lose) {
      var base_image = new Image();
      base_image.src = 'https://raw.githubusercontent.com/vsecoder/geometry/main/Cube.png';
      ctx.drawImage(base_image, this.x * size, this.y * size, size, size);
    } else {
      var base_image = new Image();
      base_image.src = 'https://raw.githubusercontent.com/vsecoder/geometry/main/lose.gif';
      ctx.drawImage(base_image, this.x * size, this.y * size, size, size);
      this.xVelocity = 0
    }
    //ctx.fillStyle = "#c11";
    //ctx.fillRect(this.x * size, this.y * size, size, size);
  }
}; 

const drawMap = (ctx, map, size) => {
  //ctx.fillStyle = "#445";
  //ctx.clearRect(0, 0, window.screen.width, window.screen.height);
  //ctx.translate(-camera.leftTopPos.x, -camera.leftTopPos.y);
  
  for (let i = 0; i < map.length; i++) {
    for (let j = 0; j < map[i].length; j++) {
      if (map[i][j] === "#") {
        var base_image = new Image();
        base_image.src = 'https://raw.githubusercontent.com/vsecoder/geometry/main/block.png';
        ctx.drawImage(base_image, j * size, i * size, size, size);
      } else if (map[i][j] === "/") {
        ctx.fillRect(j * size, i * size, size, size);
      } else if (map[i][j] === "@") {
        ctx.fillStyle = "blue";
        ctx.fillRect(j * size, i * size, size, size);
        ctx.fillStyle = "#445";
      } else if (map[i][j] === "1") {
        var base_image = new Image();
        base_image.src = 'https://raw.githubusercontent.com/vsecoder/geometry/main/spike.png';
        ctx.drawImage(base_image, j * size, i * size, size, size);
      } else if (map[i][j] === "2") {
        var base_image = new Image();
        base_image.src = 'https://raw.githubusercontent.com/vsecoder/geometry/main/spikes.png';
        ctx.drawImage(base_image, j * size, i * size, size, size);
      } else if (map[i][j] === "3") {
        var base_image = new Image();
        base_image.src = 'https://raw.githubusercontent.com/vsecoder/geometry/main/spikes2.png';
        ctx.drawImage(base_image, j * size, i * size, size, size);
      } else if (map[i][j] === "~") {
        var base_image = new Image();
        base_image.src = 'https://raw.githubusercontent.com/vsecoder/geometry/main/platform.png';
        ctx.drawImage(base_image, j * size, i * size, size, size / 2);
      } else if (map[i][j] === "+") {
        var base_image = new Image();
        base_image.src = 'https://raw.githubusercontent.com/vsecoder/geometry/main/decor.png';
        ctx.drawImage(base_image, j * size, i * size, size, size);
      }
      
      //ctx.strokeRect(j * size, i * size, size, size)
      
    }
  }
};

ctx.save()

let dr = 1.75;
(function update() {
  if (!lose && !win) {
    ctx.restore();
    ctx.save()

    ctx.clearRect(0, 0, canvas.width, canvas.height);

    ctx.translate(-player.x * 30 + 40, -camera.leftTopPos.y);
    //ctx.translate(-camera.leftTopPos.x, -camera.leftTopPos.y);
    ctx.scale(camera.scale, camera.scale);

    player.vx = 0;

    if (player.x - 0.25 != dr) {
      lose = true
    }
    dr = player.x
    player.move("r");
    if (kbd.u) { player.jump(); }

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.scale(camera.scale, camera.scale);
    player.updatePosition(map);

    drawMap(ctx, map, gridSize);
    player.draw(ctx, gridSize);
  
    requestAnimationFrame(update);
  } else if (lose) {
    document.getElementById('look2').style.display = 'block';
  } else {
    lose = false
    document.getElementById('look3').style.display = 'block';
  }
})();

}
  </script>
</body>
</html>


Ответы (1 шт):

Автор решения: Leonid

Оптимизация canvas - в этой статье описаны большинство проблем кода и даны решения.

Ключевая проблема: покадровая перерисовка участка игровой карты. При этом для ячеек с изображениями каждый раз для каждой ячейки создаетcя новое изображение!

var base_image = new Image();
base_image.src = 'https://raw.githubusercontent.com/vsecoder/geometry/main/block.png';
ctx.drawImage(base_image, j * size, i * size, size, size);

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

Как следует сделать. Для масштаба игрового поля создать динамический спрайт на offscreen canvas. Каждое изображение подгрузить один раз. Например:

const textures = [{src: 'images/brick.jpg'}, {}, {}, {}];
const cell = {height: 20, width: 20}; // Вычисленный размер ячейки

const sprite_canvas= document.createElement('canvas');
sprite_canvas.width = cell.width * textures.length;
sprite_canvas.height = cell.height;
const sprite_ctx = texture_sprite.getContext('2d');

for(let i=0; i < textures.length; i++){
    const texture_image = new Image(cell.width, cell.height);
    texture_image.onload = (img) => drawOnSprite(img,i);
    texture_image.src = textures[i].src;
}

function drawOnSprite(image, position){
    sprite_ctx.drawImage(image, cell.width * position, 0);
}

Затем этот спрайт использовать для отрисовки на игровом поле. Если использовать фрагмент вашего кода, то что-то вроде этого:

if (map[i][j] === "#") {
        drawCell(j, i, BRICK_SPRITE_POS, size);
}

function drawCell(x, y, sprite_pos, size){
    ctx.drawImage(sprite_canvas, sprite_pos * size, 0, size, size, x * size, y * size, size, size);
}

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

Уже на каждом кадре переносить на рабочий холст (game_map) участок с offscreen_map исходя из смещения в пикселях. Например:

game_map_ctx.drawImage(offscreen_map, deltaX, 0, map_width, map_height, 0, 0, map_width, map_height);

То есть, offscreen_map рисуется однажды (как и sprite_canvas) на холсте, который не будет добавляться в DOM, после загрузки всех изображений спрайта (это тоже надо правильно отследить). И уже из готового холста по смещению карты (deltaX) рисовать игровую карту.

Героя рисовать на отдельном холсте, который будет наложен сверху с бOльшим z-index. Это холст будет прозрачным. Хотя если герой находится всегда в одной позиции x, только подпрыгивает, то этот холст можно значительно сократить по размеру. А холст с игровой картой сделать без прозрачных пикселей, как и все текстуры в спрайте. Для этого укажите при создании контекстов холстов:

const ctx = canvas.getContext('2d', { alpha: false });

Хотя, по-моему возможность отключить алфа-канал правильно работает только для WebGL контекста (3Д). Но MDN рекомендует, значит смысла не лишено.

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

→ Ссылка