Сильная нагрузка 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 шт):
Оптимизация 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 рекомендует, значит смысла не лишено.
Код везде только приблизительный и не соответствует вашему стилю, но это для демонстрации основных концепций. Думаю, в вашем случае, даже создание спрайта при динамической отрисовке карты на каждом кадре будет достаточно для достаточного увеличения скорости рендера.
