Raycasting renderer

Version:

13.0

Added on:

23 Jun 2024 00:19

Tags:

Description:
This is a simple raycasting renderer. It takes the mask of the level and uses it to construct a 3D-like environment the player can navigate. It uses the same principle as e.g. Wolfenstein 3D or the Windows '3D maze' screensaver. Wall colours are determined by the tiles in the level. This works well for some tilesets, less so for others.

This is a proof of concept and could both be optimised and expanded. There are many places where shortcuts or caching could be taken to speed up rendering, the movement code is very rudimentary and there is no texture mapping. This is left as an exercise for the reader.
//raycast rendering for jazz jackrabbit 2 + (https://jj2.plus)
//by stijn, 2023 (https://www.jazz2online.com)
#pragma name "Raycasting renderer"

// field of view
float fov = TWO_PI / 6;  // 60 degrees

// initial direction of the player
float angle = 0;

// speed at which the player can rotate
float turnSpeed = 0.01;

// speed at which the player can move
float moveSpeed = 2.0;

// ray precision (1.0 = pixel perfect, higher=worse)
float stepPrecision = 1.0;

// max distance for a ray (bigger is slower in large levels)
float maxDistance = 1250;
float maxWallHeight = 100.0;

// minimap scale, e.g. 16 = 1/16 scale
int minimapScale = 16;

// these are just constants and assorted things we'll use later, don't change
int wMap = 0;
int hMap = 0;
float planeDistance;
bool playerInit = false;
bool levelInit = false;
float turnX = 0.0;
float turnY = 0.0;
float xMap = 0.0;
float yMap = 0.0;

const int KEY_W = 87;
const int KEY_A = 65;
const int KEY_S = 83;
const int KEY_D = 68;
const int KEY_UP = 38;
const int KEY_DOWN = 40;
const int KEY_LEFT = 37;
const int KEY_RIGHT = 39;
const int KEY_M = 77;
const float PI = 3.14159;
const float HALF_PI = PI / 2;
const float TWO_PI = 6.28318;
bool showMinimap = true;
jjPIXELMAP@ minimap;
int keyDecay = 0;

float max(float one, float two) { return (one > two) ? one : two; }
float min(float one, float two) { return (one < two) ? one : two; }

// we're not controlling the rabbit this time, so keep it fixed in place
void onPlayer(jjPLAYER@ player) {
  if(!playerInit) {
    xMap = player.xPos;
    yMap = player.yPos;
    playerInit = true;
  }
  
  player.xSpeed = 0;
  player.ySpeed = 0;
  player.xPos = 0;
  player.yPos = 0;
  player.frozen = 1;
}

// in onMain, we handle set-up and movement controls
void onMain() {
  if(keyDecay > 0) {
    keyDecay -= 1;
  }
  
  if(!levelInit) {
    // determine map size in pixels
    hMap = jjLayers[4].height * 32;
    wMap = jjLayers[4].width * 32;
    
    // how far is the player from the 'projection screen'?
    planeDistance = float(jjResolutionWidth) / 2.0 / tan(fov / 2.0);
    
    // initialise minimap as top-down view of the map, scaled
    @minimap = jjPIXELMAP(wMap / minimapScale, hMap / minimapScale);
    int mmX = 0;
    int mmY = 0;
    for(int y = 0; y < hMap; y += minimapScale) {
      for(int x = 0; x < wMap; x += minimapScale) {
        if(jjMaskedPixel(x, y)) {
          minimap[mmX, mmY] = 16;
        } else {
          minimap[mmX, mmY] = 22;
        }    
        mmX += 1;
      }
      mmY += 1;
      mmX = 0;
    }
    
    minimap.save(jjAnimFrames[jjAnimations[jjAnimSets[ANIM::CUSTOM[0]].firstAnim].firstFrame]);
    
    jjConsole("Welcome to the third dimension");
    jjConsole("Control the camera with the WASD keys");
    jjConsole("Press M to toggle the mini-map (will give you some extra FPS)");
    levelInit = true;
  }
  
  // WASD controls; W and S move, A and D turn (no strafing)
  // mouselook and/or independent movement and rotation could be implemented
  // here
  if(!(jjKey[KEY_A] && jjKey[KEY_D])) {
    if(jjKey[KEY_A]) {
      if(angle > 0) {
        angle -= turnSpeed;
      } else {
        angle = TWO_PI;
      }
      
    }
    if(jjKey[KEY_D]) {
      if(angle < TWO_PI) {
        angle += turnSpeed;
      } else {
        angle = 0;
      }
    }
  }
  
  // move forward and backwards
  if(jjKey[KEY_W] || jjKey[KEY_S]) {
    float xNew = xMap;
    float yNew = yMap;
    float stepX;
    float stepY;
    if(angle > (TWO_PI * 0.875) || angle <= (TWO_PI * 0.125)) { // 'up'
      stepY = -moveSpeed;
      stepX = abs(stepY) * tan(angle);
    } else if(angle > (TWO_PI * 0.125) && angle <= (TWO_PI * 0.375)) { // 'right'
      stepX = moveSpeed;
      stepY = abs(stepX) * tan(angle - HALF_PI);
    } else if(angle > (TWO_PI * 0.375) && angle <= (TWO_PI * 0.625)) { // 'down'
      stepY = moveSpeed;
      stepX = abs(stepY) * tan(-angle - PI);
    } else if(angle > (TWO_PI * 0.625) && angle <= (TWO_PI * 0.875)) { // 'left'
      stepX = -moveSpeed;
      stepY = abs(stepX) * tan(-angle - HALF_PI - PI);
    }
    if(jjKey[KEY_W]) {
      // forward
      xNew += stepX;
      yNew += stepY;
    }
    if(jjKey[KEY_S]) {
      // backward
      xNew -= stepX;
      yNew -= stepY;
    }
    
    // only allow movement if it doesn't put the player inside a wall
    if(!jjMaskedPixel(int(xNew), int(yNew))) {
      xMap = xNew;
      yMap = yNew;
    }
  }

  
  // toggle minimap
  if(jjKey[KEY_M] && keyDecay == 0) {
    showMinimap = !showMinimap;
    keyDecay = 25;
  }
}

// in onDrawAmmo, we do the rendering
// this is pretty arbitrary, we just need a jjCANVAS handle
bool onDrawAmmo(jjPLAYER@ player, jjCANVAS@ screen) {
  if(!levelInit) {
    // wait for set-up in onMain to complete
    return false;
  }
  
  // ceiling
  screen.drawRectangle(0, 0, jjResolutionWidth, jjResolutionHeight / 2, 32);
  // floor
  screen.drawRectangle(0, jjResolutionHeight / 2, jjResolutionWidth, jjResolutionHeight / 2, 64);
  
  // get 'empty' minimap to draw current vision cone on
  jjPIXELMAP@ rayMap = jjPIXELMAP(jjAnimFrames[jjAnimations[jjAnimSets[ANIM::CUSTOM[0]].firstAnim].firstFrame]);

  
  // cast some rays
  // the ray will be at a different angle for each 'slice' of the wall - we
  // use the configured field of view to calculate by how much that angle
  // increases for each subsequent slice (i.e. vertical line of pixels)
  float fovStep = fov / float(jjResolutionWidth);
  float localAngle = angle - (fov / 2) - fovStep; // start on the left
  
  // for each pixel of horizontal resolution, determine if a wall slice 
  // should be drawn and if yes of what height
  for(int x = 0; x < jjResolutionWidth; x += 1) {
    localAngle += fovStep;
    
    // normalize angle (does AS have fmod?)
    if(localAngle < 0) {
      localAngle += TWO_PI;
    }
    
    if(localAngle > TWO_PI) {
      localAngle -= TWO_PI;
    }
    
    float stepX, stepY;
    float calcAngle = localAngle + (HALF_PI / 2);
    
    // we cast the ray by determining a horizontal and vertical delta/step, 
    // and then checking pixels along a path by starting at the player 
    // position and adjusting the position by the calculated stepX and
    // stepY. if performance becomes an issue, increasing the step sizes
    // is one way to speed things up - as it is we never increment by more
    // than a single pixel for pixel-perfect mapping, but that is probably 
    // overkill
    if(localAngle > (TWO_PI * 0.875) || localAngle <= (TWO_PI * 0.125)) {
      // facing 'up'
      stepY = -stepPrecision;
      stepX = abs(stepY) * tan(localAngle);
    } else if(localAngle > (TWO_PI * 0.125) && localAngle <= (TWO_PI * 0.375)) {
      // facing 'right'
      stepX = stepPrecision;
      stepY = abs(stepX) * tan(localAngle - HALF_PI);
    } else if(localAngle > (TWO_PI * 0.375) && localAngle <= (TWO_PI * 0.625)) {
      // facing 'down'
      stepY = stepPrecision;
      stepX = abs(stepY) * tan(-localAngle - PI);
    } else if(localAngle > (TWO_PI * 0.625) && localAngle <= (TWO_PI * 0.875)) {
      // facing 'left'
      stepX = -stepPrecision;
      stepY = abs(stepX) * tan(-localAngle - HALF_PI - PI);
    }
    
    // traverse the ray until we hit the edges of the map or a masked pixel
    // traditional raycasters often don't check every pixel but instead use
    // an underlying lower-resolution map of 'blocks' to check against - this
    // would work well with JJ2's tile system but we have the computing power
    // to just check every pixel and it allows using e.g. sloped tiles for
    // different wall shapes
    float xRay = xMap;
    float yRay = yMap;
    float distance = 0.0;
    
    // what we want to find out by casting the ray is the distance to the wall
    // the distance for each step can thus be determined through pythagoras'
    // theorem, since the x and y step sizes form two sides of a triangle and
    // the traversed distance is the third side
    float stepLength = sqrt(pow(abs(stepX), 2) + pow(abs(stepY), 2)); 
  
    // default colour
    int colour = 24;
    while(distance < maxDistance) {
      if(xRay < 0 || xRay > wMap || yRay < 0 || yRay > hMap) {
        // out of bounds
        break;
      }
      
      if(jjMaskedPixel(int(xRay), int(yRay))) {
        // masked pixel, draw wall
        // assume colors are from the first half of the palette - use 
        // the first color of the relevant gradient so we can use the 
        // others for hue later
        // texture mapping could be implemented here
        jjPIXELMAP tile(jjLayers[4].tileGet(int(xRay / 32), int(yRay / 32)));
        colour = int(tile[xRay % 32, yRay % 32] / 8) * 8;
        break;
      } else if(showMinimap) {
        // draw vision cone on minimap
        rayMap[int(xRay / minimapScale), int(yRay / minimapScale)] = 66;
      }
      
      distance += stepLength;
      xRay += stepX;
      yRay += stepY;
    }
    
    if(distance >= maxDistance) {
      // nothing within the scanning distance, assume no wall
      continue;
    }
        
    // correct fisheye effect and determine rendered wall height
    // https://www.permadi.com/tutorial/raycast/rayc8.html
    distance *= cos(localAngle - angle);
    distance = max(0.1, distance); // avoid divide by zero
    int wallHeight = int(maxWallHeight / distance * planeDistance);
    
    if(wallHeight > 0) {
      // draw the wall slice! actually just a rectangle of the calculated
      // height and colour - the 750 is arbitrary here and determines how
      // far away something needs to be to be the 'darkest' hue possible
      int hue = int(float(min(distance, 750.0) / 750) * 7.0);
      screen.drawRectangle(x, (jjResolutionHeight / 2) - (wallHeight / 2), 1, wallHeight, colour + hue);
    }
  }
  
  // minimap
  if(showMinimap) {
    // 3x3 rectangle to mark player position
    int mmX = int(xMap / minimapScale);
    int mmY = int(yMap / minimapScale);
    rayMap[mmX - 1, mmY - 1] = 64;
    rayMap[mmX - 1, mmY] = 64;
    rayMap[mmX - 1, mmY + 1] = 64;
    rayMap[mmX, mmY - 1] = 64;
    rayMap[mmX, mmY] = 64;
    rayMap[mmX, mmY + 1] = 64;
    rayMap[mmX + 1, mmY - 1] = 64;
    rayMap[mmX + 1, mmY] = 64;
    rayMap[mmX + 1, mmY + 1] = 64;
    
    // display as sprite
    rayMap.save(jjAnimFrames[jjAnimations[jjAnimSets[ANIM::CUSTOM[0]].firstAnim].firstFrame + 1]);
    screen.drawSprite(25, 25, ANIM::CUSTOM[0], 1, 0);
  }


  return false;
}