Skip to content
Advertisement

Ray Casting floor with HTML canvas

I am coding a small Javascript/ HTML-canvas Wolfenstein style game. I am following Permadi tutorial.

For now I did suceed to implement the textured wall raycasting. What I want to do now is to do the floor raycasting.

As far as I understand, when I finish to draw a slice of wall, I have to check if it reaches the bottom of the canvas. If not, that means there is a floor to be rendered underneath it. So I grab every pixel from the bottom of the wall to the bottom of the canvas, calculate their coordinates in “real-world”, grab their texture and draw them on the screen.

I am using these two schemas for my calculations.

enter image description here enter image description here

These is my code:

//we check if the wall reaches the bottom of the canvas
    // this.wallToBorder = (400 - wallHeight) / 2;
    if (this.wallToBorder > 0) {
      // we calculate how many pixels we have from bottom of wall to border of canvas
      var pixelsToBottom = Math.floor(this.wallToBorder);

      //we calculate the distance between the first pixel at the bottom of the wall and the player eyes (canvas.height / 2)
      var pixelRowHeight = 200 - pixelsToBottom;

      // then we loop through every pixels until we reach the border of the canvas
      for (let i = pixelRowHeight; i < 200; i++) {

        // we calculate the straight distance between the player and the pixel
        var directDistFloor = this.screenDist * (canvas.height/2) / Math.floor(i);
        
        // we calculate it's real world distance with the angle relative to the player
        var realDistance = directDistFloor / Math.cos(this.angleR);

        // we calculate it's real world coordinates with the player angle
        this.floorPointx = this.player.x + Math.cos(this.angle) * realDistance;
        this.floorPointy = this.player.y - Math.sin(this.angle) * realDistance;

        // we map the texture
        var textY = Math.floor(this.floorPointx % 64);
        var textX = Math.floor(this.floorPointy % 64);

        var pixWidthHeight = (1 / realDistance) * this.screenDist;
        if (pixWidthHeight < 1) pixWidthHeight = 1;
        // we draw it on the canvas
        this.ctx.drawImage(wallsSprite, textX, textY + 64, 1, 1, this.index, i + 200, pixWidthHeight, pixWidthHeight);
      }
    }

But The result is not I am expecting:

enter image description here

Here is my project in StackBlitz. What I am doing wrong?

Advertisement

Answer

There are two small mistakes in your code.

var directDistFloor = this.screenDist * (canvas.height/2) / Math.floor(i);

This should calculate the distance between the player and a floor tile. If we look up the actual value of this.screenDist we can see:

this.screenDist = (canvas.width / 2) / Math.tan((30 * Math.PI) / 180);

So there is no relation to a floor tile. screenDist should mirror the ‘height’ of a floor tile – 64 pixels in your case – which is determined as property mapS of the Map class. Change the above line to this:

var directDistFloor = ((this.map.mapS / Math.tan((30 * Math.PI) / 180)) * (canvas.height/2)) / i;

The second bug is lurking here:

this.floorPointy = this.player.y - Math.sin(this.angle) * realDistance;

as you need to add the sine to the player’s vertical position.

Just as a side note: In your floor drawing algorithm, you’re literally abusing the drawImage() method to draw individual pixels. This is a huge bottleneck. A quick fix would be reading the pixel’s color from your texture map and use fillRect() instead. There’s still room for improvement though.

Here’s your modified code:

var wallsSprite = new Image();
wallsSprite.crossOrigin = "anonymous";
let tempCanvas = document.createElement("canvas");
let context = tempCanvas.getContext("2d");
let pixelData;
wallsSprite.onload = function(e) {
  context.drawImage(e.target, 0, 0, e.target.naturalWidth, e.target.naturalHeight);
  pixelData = context.getImageData(0, 0, tempCanvas.width, tempCanvas.height).data;
  animate();
}
wallsSprite.src = "https://api.codetabs.com/v1/proxy?quest=https://i.ibb.co/rbJJw2N/walls-2.png";
class Map {
  constructor(ctx) {
    this.ctx = ctx;
    this.mapX = 26;
    this.mapY = 20;
    this.mapS = 64;
    this.grid = [
      [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
      [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
      [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 0, 1],
      [1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
      [1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
      [1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
      [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
      [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
    ]
  }
  draw() {
    for (let y = 0; y < this.mapY; y++) {
      for (let x = 0; x < this.mapX; x++) {
        var color;
        this.grid[y][x] != 0 ? color = "black" : color = "white";
        var Xo = x * this.mapS / 10;
        var Yo = y * this.mapS / 10;
        this.ctx.fillStyle = color;
        this.ctx.fillRect(Xo + 10, Yo + 10, this.mapS / 10, this.mapS / 10)
      }
    }
  }
  checkCollision(y, x) {
    var collision = false;
    if (this.grid[y][x] != 0) {
      collision = true;
    }
    return collision;
  }
  getTile(x, y) {
    var X = Math.floor(x / this.mapS);
    var Y = Math.floor(y / this.mapS);
    return (this.grid[Y][X]);
  }
}

class Player {
  constructor(x, y, map, ctx) {
    this.color = "yellow";
    this.x = x;
    this.y = y;
    this.width = 4;
    this.height = 4;
    this.map = map;
    this.ctx = ctx;
    this.angle = 0;
    this.speed = 4;
    this.moveForward = 0;
    this.rotate = 0;
    this.rotationSpeed = 3 * (Math.PI / 180);
    this.isColliding = false;
    this.FOV = 60;
  }
  up() {
    this.moveForward = 1;
  }
  down() {
    this.moveForward = -1;
  }
  right() {
    this.rotate = 1;
  }
  left() {
    this.rotate = -1;
  }
  stopMove() {
    this.moveForward = 0;
  }
  stopTurn() {
    this.rotate = 0;
  }
  checkForCollision(x, y) {
    var collision = false;
    var xGridNb = Math.floor(x / this.map.mapS);
    var yGridNb = Math.floor(y / this.map.mapS);
    if (this.map.checkCollision(yGridNb, xGridNb)) {
      collision = true;
    };
    return collision;
  }
  update() {

    var newX = this.x + this.moveForward * Math.cos(this.angle) * this.speed;
    var newY = this.y + this.moveForward * Math.sin(this.angle) * this.speed;

    this.angle += this.rotate * this.rotationSpeed;
    this.angle = normalizeAngle(this.angle);

    if (!this.checkForCollision(newX, newY)) {
      this.x = newX;
      this.y = newY;
    }
  }
  draw() {
    this.update();
    this.ctx.fillStyle = this.color;
    this.ctx.fillRect(this.x / 10 + 10, this.y / 10 + 10, this.width, this.height);
  }
}



class Ray {
  constructor(player, map, ctx, angleR, i) {
    this.x;
    this.y;
    this.player = player;
    this.dist = 0;
    this.map = map;
    this.ctx = ctx;
    this.yIntercept;
    this.xIntercept;
    this.xStep;
    this.yStep;
    this.angleR = angleR;
    this.isHittingX;
    this.isHittingY;
    this.wallHitHX;
    this.wallHitHY;
    this.wallHitVX;
    this.wallHitVY;
    this.wallHitX;
    this.wallHitY;
    this.angle = this.player.angle + this.angleR;
    this.lookUp;
    this.lookRight;
    this.index = i;
    this.distHit = 0;
    this.texturePix;
    this.texture;
    this.wallBottom;
    this.playerHeight = canvas.height / 2;
    this.screenDist;
    this.floorPointx;
    this.floorPointy;
    this.screenDist = (canvas.width / 2) / Math.tan((30 * Math.PI) / 180);
  }
  update() {
    this.angle = this.player.angle + this.angleR;
    this.angle = normalizeAngle(this.angle)
    this.angle > Math.PI ? this.lookUp = true : this.lookUp = false;
    this.angle > Math.PI / 2 && this.angle < (3 * Math.PI) / 2 ? this.lookRight = false : this.lookRight = true;

    this.x = this.player.x;
    this.y = this.player.y;
  }
  cast() {
    this.update();
    this.xCollision();
    this.yCollision();
    this.checkTile();
    this.wallRendering();
  }
  yCollision() {

    this.isHittingY = false;

    this.yIntercept = Math.floor(this.y / this.map.mapS) * this.map.mapS;

    if (!this.lookUp) this.yIntercept += this.map.mapS;

    var xOffset = (this.yIntercept - this.y) / Math.tan(this.angle);

    this.xIntercept = this.x + xOffset;

    this.xStep = this.map.mapS / Math.tan(this.angle);

    this.yStep = this.map.mapS;

    if (this.lookUp) this.yStep *= -1;

    if ((!this.lookRight && this.xStep > 0) || (this.lookRight && this.xStep < 0)) {
      this.xStep *= -1;
    }

    var nextHorizX = this.xIntercept;
    var nextHorizY = this.yIntercept;

    if (this.lookUp) {
      nextHorizY--;
    }
    while (!this.isHittingY) {
      var xTile = Math.floor(nextHorizX / this.map.mapS);
      var yTile = Math.floor(nextHorizY / this.map.mapS);
      if (this.map.checkCollision(yTile, xTile)) {
        this.isHittingY = true;
        this.wallHitHX = nextHorizX;
        this.wallHitHY = nextHorizY;
      } else {
        nextHorizX += this.xStep;
        nextHorizY += this.yStep;
      }
    }
  }
  xCollision() {
    this.isHittingX = false;

    this.xIntercept = Math.floor(this.x / this.map.mapS) * this.map.mapS;

    if (this.lookRight) this.xIntercept += this.map.mapS;
    var yOffset = (this.xIntercept - this.x) * Math.tan(this.angle);

    this.yIntercept = this.y + yOffset;
    this.xStep = this.map.mapS;
    this.yStep = this.map.mapS * Math.tan(this.angle);

    if (!this.lookRight) this.xStep *= -1;

    if ((this.lookUp && this.yStep > 0) || (!this.lookUp && this.yStep < 0)) {
      this.yStep *= -1;
    }

    var nextHorizX = this.xIntercept;
    var nextHorizY = this.yIntercept;
    if (!this.lookRight) {
      nextHorizX--;
    }
    var mapWidth = this.map.mapX * this.map.mapS;
    var mapHeight = this.map.mapY * this.map.mapS;
    while (!this.isHittingX && (nextHorizX > 1 && nextHorizY > 1 && nextHorizX < mapWidth - 1 && nextHorizY < mapHeight - 1)) {
      var xTile = Math.floor(nextHorizX / this.map.mapS);
      var yTile = Math.floor(nextHorizY / this.map.mapS);
      if (this.map.checkCollision(yTile, xTile)) {
        this.isHittingX = true;
        this.wallHitVX = nextHorizX;
        this.wallHitVY = nextHorizY;
      } else {
        nextHorizX += this.xStep;
        nextHorizY += this.yStep;
      }
    }
  }
  checkTile() {
    var horizDst = 999999;
    var vertiDst = 999999;
    var square;

    if (this.isHittingY) {
      vertiDst = distance(this.x, this.y, this.wallHitHX, this.wallHitHY);
    }
    if (this.isHittingX) {
      horizDst = distance(this.x, this.y, this.wallHitVX, this.wallHitVY);
    }
    if (horizDst < vertiDst) {
      this.wallHitX = this.wallHitVX;
      this.wallHitY = this.wallHitVY;
      this.distHit = horizDst;

      square = Math.floor(this.wallHitY / this.map.mapS);
      this.texturePix = this.wallHitY - (square * this.map.mapS);

      this.texture = this.map.getTile(this.wallHitX, this.wallHitY);
    } else {
      this.wallHitX = this.wallHitHX;
      this.wallHitY = this.wallHitHY;
      this.distHit = vertiDst;

      square = Math.floor(this.wallHitX / this.map.mapS) * this.map.mapS;
      this.texturePix = this.wallHitX - square;

      this.texture = this.map.getTile(this.wallHitX, this.wallHitY);
    }

    this.distHit = this.distHit * Math.cos(this.player.angle - this.angle);
  }
  draw() {
    this.ctx.beginPath();
    this.ctx.strokeStyle = "blue";
    this.ctx.moveTo(this.x, this.y);
    this.ctx.lineTo(this.wallHitX, this.wallHitY);
    this.ctx.stroke();
  }
  wallRendering() {
    var realWallHeight = 64;

    var wallHeight = (realWallHeight / this.distHit) * this.screenDist;

    var y0 = canvas.height / 2 - Math.floor(wallHeight / 2);
    var y1 = y0 + wallHeight;

    this.wallToBorder = Math.floor((400 - wallHeight) / 2);

    var spriteHeight = 64;
    var screenSpriteHeight = y0 - y1;

    this.ctx.imageSmoothingEnabled = false;

    this.ctx.drawImage(wallsSprite, this.texturePix, this.texture * spriteHeight, 1, 63, this.index, y1, 1, screenSpriteHeight);

    //we check if the wall reaches the bottom of the canvas
    // this.wallToBorder = (400 - wallHeight) / 2;
    if (this.wallToBorder > 0) {
      // we calculate how many pixels we have from bottom of wall to border of canvas
      var pixelsToBottom = Math.floor(this.wallToBorder);

      //we calculate the distance between the first pixel at the bottom of the wall and the player eyes (canvas.height / 2) 
      var pixelRowHeight = 200 - pixelsToBottom;
      let color;
      // then we loop through every pixels until we reach the border of the canvas  
      for (let i = pixelRowHeight; i < 200; i++) {

        // we calculate the straight distance between the player and the pixel

        let temp = this.map.mapS / Math.tan((30 * Math.PI) / 180)
        var directDistFloor = ((this.map.mapS / Math.tan((30 * Math.PI) / 180)) * (canvas.height / 2)) / i;
        //var directDistFloor = (this.screenDist * (canvas.height / 2)) / i;
        //if (this.index === 399 ) console.log(this.screenDist, i, directDistFloor);
        // we calculate it's real world distance with the angle relative to the player
        var realDistance = directDistFloor / Math.cos(this.angleR);

        // we calculate it's real world coordinates with the player angle
        this.floorPointx = this.player.x + Math.cos(this.angle) * realDistance;
        this.floorPointy = this.player.y + Math.sin(this.angle) * realDistance;

        var cellX = Math.floor(this.floorPointx / 64);
        var cellY = Math.floor(this.floorPointy / 64);
        if ((cellX < map.mapX) &&
          (cellY < map.mapY) &&
          cellX >= 0 && cellY >= 0) {
          // we map the texture
          var textY = Math.floor(this.floorPointx % 64);
          var textX = Math.floor(this.floorPointy % 64);

          var pixWidthHeight = (1 / realDistance) * this.map.mapS;
          if (pixWidthHeight < 1) pixWidthHeight = 1;

          // we draw it on the canvas
          //    this.ctx.drawImage(wallsSprite, textX, textY + 64, 1, 1, this.index, i + 200, pixWidthHeight, pixWidthHeight);
          color = ((textY + 64) * tempCanvas.width + textX) * 4;
          this.ctx.fillStyle = `rgba(${pixelData[color]},${pixelData[color+1]},${pixelData[color+2]},${pixelData[color+3]})`;
          this.ctx.fillRect(this.index, i + 200, pixWidthHeight, pixWidthHeight);
        }
      }
    }
  }
}

class RayCaster {
  constructor(player, map, ctx) {
    this.player = player;
    this.map = map;
    this.ctx = ctx;
    this.rayNb = canvas.width;
    this.rays = [];
    this.incAngle = toRadians(this.player.FOV / this.rayNb);
    this.startAngle = toRadians(this.player.angle - this.player.FOV / 2);
    this.rayAngle = this.startAngle;
    this.init();
  }
  init() {
    for (let i = 0; i < this.rayNb; i++) {
      this.rays[i] = new Ray(this.player, this.map, this.ctx, this.rayAngle, i);
      this.rayAngle += this.incAngle;
    }
  }
  draw() {

    for (let i = 0; i < this.rays.length; i++) {
      this.rays[i].cast();
    }
    this.map.draw();
  }
}

class Controls {
  constructor(player) {
    document.addEventListener('keydown', function(e) {
      switch (e.keyCode) {
        case 38:
          player.up();
          break;
        case 40:
          player.down();
          break;
        case 39:
          player.right();
          break;
        case 37:
          player.left();
          break;
      }
    });
    document.addEventListener('keyup', function(e) {
      switch (e.keyCode) {
        case 38:
        case 40:
          player.stopMove();
          break;
        case 39:
        case 37:
          player.stopTurn();
          break;
      }
    });
  }
}

function normalizeAngle(angle) {
  angle = angle % (2 * Math.PI)
  if (angle < 0) {
    angle = angle + (2 * Math.PI)
  }
  return angle;
}

function distance(x1, y1, x2, y2) {
  return Math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1))
}

function toRadians(angle) {
  return angle * (Math.PI / 180);
}


var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');
canvas.height = 400;
canvas.width = 800;

var map = new Map(ctx);
var player = new Player(400, 65, map, ctx);
//var player = new Player(128, 65, map, ctx);
var controls = new Controls(player);
var rayCaster = new RayCaster(player, map, ctx);

Player.prototype.rays = rayCaster.rays;

function animate() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  rayCaster.draw();

  player.draw();

  requestAnimationFrame(animate);
}
#canvas {
  width: 800px;
  height: 400px;
  border: 1px solid black;
}

body {
  display: flex;
  justify-content: center;
  padding-top: 50px;
}
<canvas id="canvas"></canvas>
User contributions licensed under: CC BY-SA
5 People found this is helpful
Advertisement