5

I'm having trouble with a movable canvas that adjusts as the 'player' moves around the map. As drawing 600 tiles, 60 times a second is very inefficient, I switched over to using translate3d and only draw once the player crossed a full tile -- but it keeps glitching and not moving around smooth. How would I achieve this properly?

const ctx = canvas.getContext('2d');
canvas.height = 200;
canvas.width = 600;
const tileSize = canvas.height/6;
const MAIN = {position:{x: 120, y: 120}};
const canvasRefresh = {x: 0, y: 20};
document.body.onmousemove = e => MAIN.position = {x: e.clientX, y: e.clientY};
const tiles = {x: 20, y: 20}

function update(){
    moveMap();
    requestAnimationFrame(update);
}
function drawMap(){
    for(var i = 0; i < tiles.x; i++){
        for(var j = 0; j < tiles.y; j++){
            ctx.fillStyle = ['black', 'green','orange'][Math.floor((i+j+canvasRefresh.x1+canvasRefresh.y1)%3)];
            ctx.fillRect(tileSize * i, tileSize * j, tileSize, tileSize);
        }
    }
}
function moveMap(){
    const sector = {
        x: Math.round(-MAIN.position.x % tileSize),
        y: Math.round(-MAIN.position.y % tileSize)
    };
    const x2 = Math.floor(MAIN.position.x/tileSize);
    const y2 = Math.floor(MAIN.position.y/tileSize);
    if(canvasRefresh.x1 != x2 || canvasRefresh.y1 != y2){
        canvasRefresh.x1 = x2;
        canvasRefresh.y1 = y2;
        requestAnimationFrame(drawMap);
    }
    $('#canvas').css({
        transform: "translate3d(" + sector.x + "px, " + sector.y + "px, 0)"
    });
}
update();
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<canvas id=canvas></canvas>
Darth
  • 1,592
  • 10
  • 19
Vardan Betikyan
  • 354
  • 5
  • 20
  • 1
    have you consider using a game engine? there a bunch of open source ones: https://github.com/collections/javascript-game-engines – Helder Sepulveda Feb 16 '21 at 23:38
  • 1
    I have, but I don't want the risk of limitations, I enjoy the freedom of raw code to make my games more unique :) – Vardan Betikyan Feb 17 '21 at 11:55
  • @Darth Thank you for the Edit, the code snippet perfectly describes my issue! – Vardan Betikyan Feb 17 '21 at 11:55
  • I don't think you will hit any limitations _(if you do those engines are open source, you can add new feature or fix the bugs)_ but what you do get is a quite refined engine, that has been optimized for years by many developers in the game industry – Helder Sepulveda Feb 17 '21 at 15:51
  • 1
    Using a nested `requestAnimationFrame(drawMap)` strikes me as very odd--you're already in a RAF callback so just `drawMap()` instead of firing off a separate one. – ggorlen Feb 18 '21 at 04:54
  • `drawMap()` executes my `drawImage()` for the tiles around `18000` times a second. Instead, calling `drawMap()` every time an entire is crossed brings that number down to `300` – Vardan Betikyan Feb 18 '21 at 17:21
  • @HelderSepulveda I very much appreciate the suggestion, and perhaps would use one for my next project, but the code is already made ready to go, just having trouble getting the canvas to move in a silky smooth way – Vardan Betikyan Feb 18 '21 at 17:23

2 Answers2

1

There are a few things going on:

Immediately invoking drawMap instead of using requestAnimationFrame

As ggorlen mentioned in the comments, using requestAnimationFrame multiple times in an update cycle is an unusual practice. When you use requestAnimationFrame, you're calling the function on the next frame update, meaning there will be a frame where the map isn't redrawn, causing a slight flicker. Instead, if you invoke it immediately, it'll redraw the map for that frame. Also, it's a good idea to consolidate all your painting and updating to one invocation of requestAnimationFrame, since it makes it clearer what order things are updated.

So you should change requestAnimationFrame(drawMap); to drawMap();

Finding remainders using non integers

Modulo arithmetic (i.e. the % operator) generally works with integers. In the case where you have MAIN.position.x % tileSize, it glitches out every so often because tileSize isn't an integer (200 / 6). To find remainders using non-integer numbers, we can use a custom function:

function remainder(a, b) {
  return a - Math.floor(a / b) * b;
}

and replace instances of modulo arithmetic with our new function (e.g. changing MAIN.position.x % tileSize to remainder(MAIN.position.x, tileSize))

Math.round vs Math.floor

Finally, you probably want to use Math.floor instead of Math.round, because Math.round returns 0, both for ranges between (-1, 0) and (0, 1), while Math.floor returns -1, and 0.

Using a container and css to hide shifting parts of the canvas

You may want to using a containing div and corresponding css to hide the edges of the canvas that are being redrawn:

In the HTML:

<div class="container">
<canvas id=canvas></canvas>
</div>

In the CSS:

.container {
  width: 560px;
  height: 160px;
  overflow: hidden;
}

All together

All together it looks like this:

const ctx = canvas.getContext('2d');
canvas.height = 200;
canvas.width = 600;
const tileSize = canvas.height/6;
const MAIN = {position:{x: 120, y: 120}};
const canvasRefresh = {x: 0, y: 20};
document.body.onmousemove = e => MAIN.position = {x: e.clientX, y: e.clientY};
const tiles = {x: 20, y: 20}

function update(){
    moveMap();
    requestAnimationFrame(update);
}
function drawMap(){
    for(var i = 0; i < tiles.x; i++){
        for(var j = 0; j < tiles.y; j++){
            ctx.fillStyle = ['black', 'green','orange'][Math.floor((i+j+canvasRefresh.x1+canvasRefresh.y1)%3)];
            ctx.fillRect(tileSize * i, tileSize * j, tileSize, tileSize);
        }
    }
}
function remainder(a, b) {
  return a - Math.floor(a / b) * b;
}
function moveMap(){
    const sector = {
        x: Math.floor(-remainder(MAIN.position.x, tileSize)),
        y: Math.floor(-remainder(MAIN.position.y, tileSize))
    };
    const x2 = Math.floor(MAIN.position.x/tileSize);
    const y2 = Math.floor(MAIN.position.y/tileSize);
    if(canvasRefresh.x1 != x2 || canvasRefresh.y1 != y2){
        canvasRefresh.x1 = x2;
        canvasRefresh.y1 = y2;
        drawMap();
    }
    $('#canvas').css({
        transform: "translate3d(" + sector.x + "px, " + sector.y + "px, 0)"
    });
}
update();
.container {
  width: 560px;
  height: 160px;
  overflow: hidden;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div class="container">
<canvas id=canvas></canvas>
</div>
Steve
  • 10,435
  • 15
  • 21
  • You got it man, thank you! Using `requestAnimationFrame()` was the main culprit! Before I accept your answer, I would like your opinion.. I actually went ahead and just made a very large canvas, and drew all the tiles (100x100), now I just move the canvas without any redraws. Would you think this is more efficient, or would your method be better? – Vardan Betikyan Feb 21 '21 at 17:08
  • 1
    I think it should depend on the size of the tiles (and ultimately the canvas)-- if they're 32px x 32px (a 3200px x 3200px canvas) it should be fine, but if the tiles are bigger, say 256px x 256px (a 25600px x 25600px canvas), then it might be too big for the browser or take up a lot of ram. – Steve Feb 21 '21 at 21:26
  • For browser the size comes to `7380x7380`, and mobile it comes to `6010x6010`. May reach up to `10000x10000` if I increase `tileSize` – Vardan Betikyan Feb 22 '21 at 15:42
  • Also, would you say this method is truly more efficient than using `drawImage()` for the tiles `18000` (20x15, @60fps) a second, instead of using `translate3D`? – Vardan Betikyan Feb 22 '21 at 16:13
  • 1
    For the map size, it depends on the platforms you're targeting -- I'd suggest testing out various implementations on your targeted platforms. I'd think that those dimensions should be okay with modern hardware though. See also https://stackoverflow.com/questions/6081483/maximum-size-of-a-canvas-element for maximum constraints. – Steve Feb 22 '21 at 18:21
  • 1
    For efficiency, it's a bit of a tricky question. Generally speaking, translating will be much more efficient than redrawing each frame by canvas on average, but usually you care more about per frame lag. If you draw the entire map beforehand, it could potentially cause some initialization time. If you redraw the map only occasionally, you may introduce a stutter when the map is being redrawn. In the particular case of the answer, when the map you're drawing is about the same size as the one you'd be drawing anyway on a canvas, I'd think the stutter would be nonexistent or unnoticeable. – Steve Feb 22 '21 at 18:33
  • 1
    Perfect! Thank you very much – Vardan Betikyan Feb 22 '21 at 20:07
0

Based on the comments above that it shows that you just want to move the canvas smoothly on the existing code and have no plans to modify, have you tried adding easing transitions to your canvas element?

canvas { transition: all 1500ms cubic-bezier(0.250, 0.100, 0.250, 1.000); transition-timing-function: cubic-bezier(0.250, 0.100, 0.250, 1.000); /* ease (default) */ }

const ctx = canvas.getContext('2d');
canvas.height = 200;
canvas.width = 600;
const tileSize = canvas.height/6;
const MAIN = {position:{x: 120, y: 120}};
const canvasRefresh = {x: 0, y: 20};
document.body.onmousemove = e => MAIN.position = {x: e.clientX, y: e.clientY};
const tiles = {x: 20, y: 20}

function update(){
    moveMap();
    requestAnimationFrame(update);
}
function drawMap(){
    for(var i = 0; i < tiles.x; i++){
        for(var j = 0; j < tiles.y; j++){
            ctx.fillStyle = ['black', 'green','orange'][Math.floor((i+j+canvasRefresh.x1+canvasRefresh.y1)%3)];
            ctx.fillRect(tileSize * i, tileSize * j, tileSize, tileSize);
        }
    }
}
function moveMap(){
    const sector = {
        x: Math.round(-MAIN.position.x % tileSize),
        y: Math.round(-MAIN.position.y % tileSize)
    };
    const x2 = Math.floor(MAIN.position.x/tileSize);
    const y2 = Math.floor(MAIN.position.y/tileSize);
    if(canvasRefresh.x1 != x2 || canvasRefresh.y1 != y2){
        canvasRefresh.x1 = x2;
        canvasRefresh.y1 = y2;
        requestAnimationFrame(drawMap);
    }
    $('#canvas').css({
        transform: "translate3d(" + sector.x + "px, " + sector.y + "px, 0)"
    });
}
update();
canvas {
  transition: all 1500ms cubic-bezier(0.250, 0.100, 0.250, 1.000); /* ease (default) */
  transition-timing-function: cubic-bezier(0.250, 0.100, 0.250, 1.000); /* ease (default) */
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<canvas id=canvas></canvas>

I personally wouldn't move the canvas itself but the elements inside, by adding a row/column to the direction is going and removing the squares in the opposite direction. However, this should solve your problem raised by the question

gugateider
  • 1,983
  • 2
  • 13
  • 17
  • Why wouldn't you move the canvas itself? My objective is to move the canvas (hardware acceleration) and the elements in the canvas, minimizing CPU use and giving it a smooth scroll. In your example code snippet, the tiles move in a blocky/choppy way. Needs to be smooth. Imagine a 'player' on a 2D map moving around the city – Vardan Betikyan Feb 20 '21 at 02:11
  • My original example code was edited. Every tile is a separate image from a map[x][y] (based on the player's coordinates) in a 2D matrix – Vardan Betikyan Feb 20 '21 at 02:21