javascripthtmlcanvastranslate3d

HTML5 Canvas Javascript - How to make a moveable canvas with tiles using 'translate3d'?


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>


Solution

  • 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>