javascriptcanvasputimagedata

putimagedata drawing pixel data 4 times / not to scale


I've recently been watching some of Notch's streams on twitch and was interested in one of his rendering techniques for the Ludum Dare challenge a few years ago. I tried converting his java code to javascript and am running into some problems, and it's because I'm still new to ctx.putimagedata from raw created pixels values.

Why is this app drawing the intended output 4 times, and not being scaled to the window? Is there something I'm missing where I should be iterating with a multiplicate or divisor of 4 due to the way the array is shaped? I'm confused so just going to post this here. The only fix I've found is if I adjust this.width and this.height to be multiplied by 4, but that is drawing outside the bounds of the canvas I believe and causes performance to go awful and is not really a valid solution to the problem.

class in question:

document.addEventListener('DOMContentLoaded', () => {
    //setup
    document.body.style.margin = 0;
    document.body.style.overflow = `hidden`;
    const canvas = document.createElement('canvas');
    document.body.appendChild(canvas);
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    const ctx = canvas.getContext("2d");
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    //global helpers
    const randomint = (lower, upper) => {
        return Math.floor((Math.random() * upper+1) + lower);
    }
    const genrandomcolor = () => {
        return [randomint(0, 255), randomint(0, 255), randomint(0, 255), 1/randomint(1, 2)];
    }

    class App {
        constructor(){
            this.scale = 15;
            this.width = window.innerWidth;
            this.height = window.innerHeight;
            this.pixels = [];
            this.fov = 10;
            this.ub = 0;
            this.lr = 0;
            this.keys = {
              up: false,
              down: false,
              left: false,
              right: false
            }
            this.speed = 4;
        }
        update(){
            this.keys.up ? this.ub++ : null;
            this.keys.down ? this.ub-- : null;
            this.keys.left ? this.lr-- : null;
            this.keys.right ? this.lr++ : null;
        }
        draw(){
            this.drawspace()
        }
        drawspace(){
            for(let y = 0; y < this.height; y++){
                let yd = (y - this.height / 2) / this.height;
                yd < 0 ? yd = -yd : null;
                const z = this.fov / yd;
                for (let x = 0; x < this.width; x++){
                    let xd = (x - this.width /2) / this.height * z;
                    const xx = (xd+this.lr*this.speed) & this.scale;
                    const zz = (z+this.ub*this.speed) & this.scale;
                    this.pixels[x+y*this.width] = xx * this.scale | zz * this.scale;
                }
            }
            const screen = ctx.createImageData(this.width, this.height);
            for (let i = 0; i<this.width*this.height*4; i++){
                screen.data[i] = this.pixels[i]
            }
            ctx.putImageData(screen, 0, 0);
        }
    }

    const app = new App;

    window.addEventListener('resize', e => {
        canvas.width = app.width = window.innerWidth;
        canvas.height = app.height = window.innerHeight;
    })
  
    //events
    document.addEventListener("keydown", e => {
        e.keyCode == 37 ? app.keys.left = true : null;
        e.keyCode == 38 ? app.keys.up = true : null;
        e.keyCode == 39 ? app.keys.right = true : null;
        e.keyCode == 40 ? app.keys.down = true : null;
    })
    document.addEventListener("keyup", e => {
        e.keyCode == 37 ? app.keys.left = false : null;
        e.keyCode == 38 ? app.keys.up = false : null;
        e.keyCode == 39 ? app.keys.right = false : null;
        e.keyCode == 40 ? app.keys.down = false : null;
    })

    //game loop
    const fps = 60;
    const interval = 1000 / fps;
    let then = Date.now();
    let now;
    let delta;
    const animate = time => {
        window.requestAnimationFrame(animate);
        now = Date.now();
        delta = now - then;
        if (delta > interval) {
            then = now - (delta % interval)
            ctx.fillStyle = 'black';
            ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
            app.update();
            app.draw();
        }
    }
    animate();
});


Solution

  • The ImageData.data object is an Uint8ClampedArray representing the 4 Red, Green, Blue, and Alpha channels of every pixels, each channel represented as 8 bits (values in the range 0-255).

    This means that to set a pixel, you need to set its 4 channels independently:

    const r = data[0];
    const g = data[1];
    const b = data[2];
    const a = data[3];
    

    This represents the first pixel of our ImageData (the one at the top left corner).
    So to be able to loop through all the pixels, we need to create a loop that will allow us to go from one pixel to an other. This is done by iterating 4 indices at a time:

    for(
       let index = 0;
       index < data.length;
       index += 4 // increment by 4
    ) {
      const r = data[index + 0];
      const g = data[index + 1];
      const b = data[index + 2];
      const a = data[index + 3];
      ...
    }
    

    Now each pixels will get traversed as they need to be:

      //setup
      document.body.style.margin = 0;
      document.body.style.overflow = `hidden`;
      const canvas = document.createElement('canvas');
      document.body.appendChild(canvas);
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
      const ctx = canvas.getContext("2d");
      ctx.fillRect(0, 0, canvas.width, canvas.height);
    
      //global helpers
      const randomint = (lower, upper) => {
        return Math.floor((Math.random() * upper + 1) + lower);
      }
      const genrandomcolor = () => {
        return [randomint(0, 255), randomint(0, 255), randomint(0, 255), 1 / randomint(1, 2)];
      }
    
      class App {
        constructor() {
          this.scale = 15;
          this.width = window.innerWidth;
          this.height = window.innerHeight;
          this.pixels = [];
          this.fov = 10;
          this.ub = 0;
          this.lr = 0;
          this.keys = {
            up: false,
            down: false,
            left: false,
            right: false
          }
          this.speed = 4;
        }
        update() {
          this.keys.up ? this.ub++ : null;
          this.keys.down ? this.ub-- : null;
          this.keys.left ? this.lr-- : null;
          this.keys.right ? this.lr++ : null;
        }
        draw() {
          this.drawspace()
        }
        drawspace() {
          for (let y = 0; y < this.height; y++) {
            let yd = (y - this.height / 2) / this.height;
            yd < 0 ? yd = -yd : null;
            const z = this.fov / yd;
            for (let x = 0; x < this.width; x++) {
              let xd = (x - this.width / 2) / this.height * z;
              const xx = (xd + this.lr * this.speed) & this.scale;
              const zz = (z + this.ub * this.speed) & this.scale;
              this.pixels[x + y * this.width] = xx * this.scale | zz * this.scale;
            }
          }
          const screen = ctx.createImageData(this.width, this.height);
          for (let i = 0, j=0; i < screen.data.length; i += 4) {
            j++; // so we can iterate through this.pixels
            screen.data[i] = this.pixels[j]; // r
            screen.data[i + 1] = this.pixels[j], // g
            screen.data[i + 2] = this.pixels[j] // b
            screen.data[i + 3] = 255; // full opacity
          }
          ctx.putImageData(screen, 0, 0);
        }
      }
    
      const app = new App;
    
      window.addEventListener('resize', e => {
        canvas.width = app.width = window.innerWidth;
        canvas.height = app.height = window.innerHeight;
      })
    
      //events
      document.addEventListener("keydown", e => {
        e.keyCode == 37 ? app.keys.left = true : null;
        e.keyCode == 38 ? app.keys.up = true : null;
        e.keyCode == 39 ? app.keys.right = true : null;
        e.keyCode == 40 ? app.keys.down = true : null;
      })
      document.addEventListener("keyup", e => {
        e.keyCode == 37 ? app.keys.left = false : null;
        e.keyCode == 38 ? app.keys.up = false : null;
        e.keyCode == 39 ? app.keys.right = false : null;
        e.keyCode == 40 ? app.keys.down = false : null;
      })
    
      //game loop
      const fps = 60;
      const interval = 1000 / fps;
      let then = Date.now();
      let now;
      let delta;
      const animate = time => {
        window.requestAnimationFrame(animate);
        now = Date.now();
        delta = now - then;
        if (delta > interval) {
          then = now - (delta % interval)
          ctx.fillStyle = 'black';
          ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
          app.update();
          app.draw();
        }
      }
      animate();

    But note that you could also use an other view over the ArrayBuffer, and work on each pixels directly as 32bits values:

    //setup
      document.body.style.margin = 0;
      document.body.style.overflow = `hidden`;
      const canvas = document.createElement('canvas');
      document.body.appendChild(canvas);
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
      const ctx = canvas.getContext("2d");
      ctx.fillRect(0, 0, canvas.width, canvas.height);
    
      //global helpers
      const randomint = (lower, upper) => {
        return Math.floor((Math.random() * upper + 1) + lower);
      }
      const genrandomcolor = () => {
        return [randomint(0, 255), randomint(0, 255), randomint(0, 255), 1 / randomint(1, 2)];
      }
    
      class App {
        constructor() {
          this.scale = 15;
          this.width = window.innerWidth;
          this.height = window.innerHeight;
          this.pixels = [];
          this.fov = 10;
          this.ub = 0;
          this.lr = 0;
          this.keys = {
            up: false,
            down: false,
            left: false,
            right: false
          }
          this.speed = 4;
        }
        update() {
          this.keys.up ? this.ub++ : null;
          this.keys.down ? this.ub-- : null;
          this.keys.left ? this.lr-- : null;
          this.keys.right ? this.lr++ : null;
        }
        draw() {
          this.drawspace()
        }
        drawspace() {
          for (let y = 0; y < this.height; y++) {
            let yd = (y - this.height / 2) / this.height;
            yd < 0 ? yd = -yd : null;
            const z = this.fov / yd;
            for (let x = 0; x < this.width; x++) {
              let xd = (x - this.width / 2) / this.height * z;
              const xx = (xd + this.lr * this.speed) & this.scale;
              const zz = (z + this.ub * this.speed) & this.scale;
              this.pixels[x + y * this.width] = xx * this.scale | zz * this.scale;
            }
          }
          const screen = ctx.createImageData(this.width, this.height);
          // use a 32bits view
          const data = new Uint32Array(screen.data.buffer);
          for (let i = 0, j=0; i < this.width * this.height; i ++) {
            // values are 0-255 range, we convert this to 0xFFnnnnnn 32bits
            data[i] = (this.pixels[i] / 255 * 0xFFFFFF) + 0xFF000000;
          }
          ctx.putImageData(screen, 0, 0);
        }
      }
    
      const app = new App;
    
      window.addEventListener('resize', e => {
        canvas.width = app.width = window.innerWidth;
        canvas.height = app.height = window.innerHeight;
      })
    
      //events
      document.addEventListener("keydown", e => {
        e.keyCode == 37 ? app.keys.left = true : null;
        e.keyCode == 38 ? app.keys.up = true : null;
        e.keyCode == 39 ? app.keys.right = true : null;
        e.keyCode == 40 ? app.keys.down = true : null;
      })
      document.addEventListener("keyup", e => {
        e.keyCode == 37 ? app.keys.left = false : null;
        e.keyCode == 38 ? app.keys.up = false : null;
        e.keyCode == 39 ? app.keys.right = false : null;
        e.keyCode == 40 ? app.keys.down = false : null;
      })
    
      //game loop
      const fps = 60;
      const interval = 1000 / fps;
      let then = Date.now();
      let now;
      let delta;
      const animate = time => {
        window.requestAnimationFrame(animate);
        now = Date.now();
        delta = now - then;
        if (delta > interval) {
          then = now - (delta % interval)
          ctx.fillStyle = 'black';
          ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
          app.update();
          app.draw();
        }
      }
      animate();