Raycasting renderer

Version:

1.0

Added on:

01 Jan 2023 23:46

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.
  1. //raycast rendering for jazz jackrabbit 2 + (https://jj2.plus)
  2. //by stijn, 2023 (https://www.jazz2online.com)
  3. #pragma name "Raycasting renderer"
  4.  
  5. // field of view
  6. float fov = TWO_PI / 6;  // 60 degrees
  7.  
  8. // initial direction of the player
  9. float angle = 0;
  10.  
  11. // speed at which the player can rotate
  12. float turnSpeed = 0.01;
  13.  
  14. // speed at which the player can move
  15. float moveSpeed = 2.0;
  16.  
  17. // ray precision (1.0 = pixel perfect, higher=worse)
  18. float stepPrecision = 1.0;
  19.  
  20. // max distance for a ray (bigger is slower in large levels)
  21. float maxDistance = 1250;
  22. float maxWallHeight = 100.0;
  23.  
  24. // minimap scale, e.g. 16 = 1/16 scale
  25. int minimapScale = 16;
  26.  
  27. // these are just constants and assorted things we'll use later, don't change
  28. int wMap = 0;
  29. int hMap = 0;
  30. float planeDistance;
  31. bool playerInit = false;
  32. bool levelInit = false;
  33. float turnX = 0.0;
  34. float turnY = 0.0;
  35. float xMap = 0.0;
  36. float yMap = 0.0;
  37.  
  38. const int KEY_W = 87;
  39. const int KEY_A = 65;
  40. const int KEY_S = 83;
  41. const int KEY_D = 68;
  42. const int KEY_UP = 38;
  43. const int KEY_DOWN = 40;
  44. const int KEY_LEFT = 37;
  45. const int KEY_RIGHT = 39;
  46. const int KEY_M = 77;
  47. const float PI = 3.14159;
  48. const float HALF_PI = PI / 2;
  49. const float TWO_PI = 6.28318;
  50. bool showMinimap = true;
  51. jjPIXELMAP@ minimap;
  52. int keyDecay = 0;
  53.  
  54. float max(float one, float two) { return (one > two) ? one : two; }
  55. float min(float one, float two) { return (one < two) ? one : two; }
  56.  
  57. // we're not controlling the rabbit this time, so keep it fixed in place
  58. void onPlayer(jjPLAYER@ player) {
  59.   if(!playerInit) {
  60.     xMap = player.xPos;
  61.     yMap = player.yPos;
  62.     playerInit = true;
  63.   }
  64.  
  65.   player.xSpeed = 0;
  66.   player.ySpeed = 0;
  67.   player.xPos = 0;
  68.   player.yPos = 0;
  69.   player.frozen = 1;
  70. }
  71.  
  72. // in onMain, we handle set-up and movement controls
  73. void onMain() {
  74.   if(keyDecay > 0) {
  75.     keyDecay -= 1;
  76.   }
  77.  
  78.   if(!levelInit) {
  79.     // determine map size in pixels
  80.     hMap = jjLayers[4].height * 32;
  81.     wMap = jjLayers[4].width * 32;
  82.    
  83.     // how far is the player from the 'projection screen'?
  84.     planeDistance = float(jjResolutionWidth) / 2.0 / tan(fov / 2.0);
  85.    
  86.     // initialise minimap as top-down view of the map, scaled
  87.     @minimap = jjPIXELMAP(wMap / minimapScale, hMap / minimapScale);
  88.     int mmX = 0;
  89.     int mmY = 0;
  90.     for(int y = 0; y < hMap; y += minimapScale) {
  91.       for(int x = 0; x < wMap; x += minimapScale) {
  92.         if(jjMaskedPixel(x, y)) {
  93.           minimap[mmX, mmY] = 16;
  94.         } else {
  95.           minimap[mmX, mmY] = 22;
  96.         }    
  97.         mmX += 1;
  98.       }
  99.       mmY += 1;
  100.       mmX = 0;
  101.     }
  102.    
  103.     minimap.save(jjAnimFrames[jjAnimations[jjAnimSets[ANIM::CUSTOM[0]].firstAnim].firstFrame]);
  104.    
  105.     jjConsole("Welcome to the third dimension");
  106.     jjConsole("Control the camera with the WASD keys");
  107.     jjConsole("Press M to toggle the mini-map (will give you some extra FPS)");
  108.     levelInit = true;
  109.   }
  110.  
  111.   // WASD controls; W and S move, A and D turn (no strafing)
  112.   // mouselook and/or independent movement and rotation could be implemented
  113.   // here
  114.   if(!(jjKey[KEY_A] && jjKey[KEY_D])) {
  115.     if(jjKey[KEY_A]) {
  116.       if(angle > 0) {
  117.         angle -= turnSpeed;
  118.       } else {
  119.         angle = TWO_PI;
  120.       }
  121.      
  122.     }
  123.     if(jjKey[KEY_D]) {
  124.       if(angle < TWO_PI) {
  125.         angle += turnSpeed;
  126.       } else {
  127.         angle = 0;
  128.       }
  129.     }
  130.   }
  131.  
  132.   // move forward and backwards
  133.   if(jjKey[KEY_W] || jjKey[KEY_S]) {
  134.     float xNew = xMap;
  135.     float yNew = yMap;
  136.     float stepX;
  137.     float stepY;
  138.     if(angle > (TWO_PI * 0.875) || angle <= (TWO_PI * 0.125)) { // 'up'
  139.       stepY = -moveSpeed;
  140.       stepX = abs(stepY) * tan(angle);
  141.     } else if(angle > (TWO_PI * 0.125) && angle <= (TWO_PI * 0.375)) { // 'right'
  142.       stepX = moveSpeed;
  143.       stepY = abs(stepX) * tan(angle - HALF_PI);
  144.     } else if(angle > (TWO_PI * 0.375) && angle <= (TWO_PI * 0.625)) { // 'down'
  145.       stepY = moveSpeed;
  146.       stepX = abs(stepY) * tan(-angle - PI);
  147.     } else if(angle > (TWO_PI * 0.625) && angle <= (TWO_PI * 0.875)) { // 'left'
  148.       stepX = -moveSpeed;
  149.       stepY = abs(stepX) * tan(-angle - HALF_PI - PI);
  150.     }
  151.     if(jjKey[KEY_W]) {
  152.       // forward
  153.       xNew += stepX;
  154.       yNew += stepY;
  155.     }
  156.     if(jjKey[KEY_S]) {
  157.       // backward
  158.       xNew -= stepX;
  159.       yNew -= stepY;
  160.     }
  161.    
  162.     // only allow movement if it doesn't put the player inside a wall
  163.     if(!jjMaskedPixel(int(xNew), int(yNew))) {
  164.       xMap = xNew;
  165.       yMap = yNew;
  166.     }
  167.   }
  168.  
  169.  
  170.   // toggle minimap
  171.   if(jjKey[KEY_M] && keyDecay == 0) {
  172.     showMinimap = !showMinimap;
  173.     keyDecay = 25;
  174.   }
  175. }
  176.  
  177. // in onDrawAmmo, we do the rendering
  178. // this is pretty arbitrary, we just need a jjCANVAS handle
  179. bool onDrawAmmo(jjPLAYER@ player, jjCANVAS@ screen) {
  180.   if(!levelInit) {
  181.     // wait for set-up in onMain to complete
  182.     return false;
  183.   }
  184.  
  185.   // ceiling
  186.   screen.drawRectangle(0, 0, jjResolutionWidth, jjResolutionHeight / 2, 32);
  187.   // floor
  188.   screen.drawRectangle(0, jjResolutionHeight / 2, jjResolutionWidth, jjResolutionHeight / 2, 64);
  189.  
  190.   // get 'empty' minimap to draw current vision cone on
  191.   jjPIXELMAP@ rayMap = jjPIXELMAP(jjAnimFrames[jjAnimations[jjAnimSets[ANIM::CUSTOM[0]].firstAnim].firstFrame]);
  192.  
  193.  
  194.   // cast some rays
  195.   // the ray will be at a different angle for each 'slice' of the wall - we
  196.   // use the configured field of view to calculate by how much that angle
  197.   // increases for each subsequent slice (i.e. vertical line of pixels)
  198.   float fovStep = fov / float(jjResolutionWidth);
  199.   float localAngle = angle - (fov / 2) - fovStep; // start on the left
  200.  
  201.   // for each pixel of horizontal resolution, determine if a wall slice
  202.   // should be drawn and if yes of what height
  203.   for(int x = 0; x < jjResolutionWidth; x += 1) {
  204.     localAngle += fovStep;
  205.    
  206.     // normalize angle (does AS have fmod?)
  207.     if(localAngle < 0) {
  208.       localAngle += TWO_PI;
  209.     }
  210.    
  211.     if(localAngle > TWO_PI) {
  212.       localAngle -= TWO_PI;
  213.     }
  214.    
  215.     float stepX, stepY;
  216.     float calcAngle = localAngle + (HALF_PI / 2);
  217.    
  218.     // we cast the ray by determining a horizontal and vertical delta/step,
  219.     // and then checking pixels along a path by starting at the player
  220.     // position and adjusting the position by the calculated stepX and
  221.     // stepY. if performance becomes an issue, increasing the step sizes
  222.     // is one way to speed things up - as it is we never increment by more
  223.     // than a single pixel for pixel-perfect mapping, but that is probably
  224.     // overkill
  225.     if(localAngle > (TWO_PI * 0.875) || localAngle <= (TWO_PI * 0.125)) {
  226.       // facing 'up'
  227.       stepY = -stepPrecision;
  228.       stepX = abs(stepY) * tan(localAngle);
  229.     } else if(localAngle > (TWO_PI * 0.125) && localAngle <= (TWO_PI * 0.375)) {
  230.       // facing 'right'
  231.       stepX = stepPrecision;
  232.       stepY = abs(stepX) * tan(localAngle - HALF_PI);
  233.     } else if(localAngle > (TWO_PI * 0.375) && localAngle <= (TWO_PI * 0.625)) {
  234.       // facing 'down'
  235.       stepY = stepPrecision;
  236.       stepX = abs(stepY) * tan(-localAngle - PI);
  237.     } else if(localAngle > (TWO_PI * 0.625) && localAngle <= (TWO_PI * 0.875)) {
  238.       // facing 'left'
  239.       stepX = -stepPrecision;
  240.       stepY = abs(stepX) * tan(-localAngle - HALF_PI - PI);
  241.     }
  242.    
  243.     // traverse the ray until we hit the edges of the map or a masked pixel
  244.     // traditional raycasters often don't check every pixel but instead use
  245.     // an underlying lower-resolution map of 'blocks' to check against - this
  246.     // would work well with JJ2's tile system but we have the computing power
  247.     // to just check every pixel and it allows using e.g. sloped tiles for
  248.     // different wall shapes
  249.     float xRay = xMap;
  250.     float yRay = yMap;
  251.     float distance = 0.0;
  252.    
  253.     // what we want to find out by casting the ray is the distance to the wall
  254.     // the distance for each step can thus be determined through pythagoras'
  255.     // theorem, since the x and y step sizes form two sides of a triangle and
  256.     // the traversed distance is the third side
  257.     float stepLength = sqrt(pow(abs(stepX), 2) + pow(abs(stepY), 2));
  258.  
  259.     // default colour
  260.     int colour = 24;
  261.     while(distance < maxDistance) {
  262.       if(xRay < 0 || xRay > wMap || yRay < 0 || yRay > hMap) {
  263.         // out of bounds
  264.         break;
  265.       }
  266.      
  267.       if(jjMaskedPixel(int(xRay), int(yRay))) {
  268.         // masked pixel, draw wall
  269.         // assume colors are from the first half of the palette - use
  270.         // the first color of the relevant gradient so we can use the
  271.         // others for hue later
  272.         // texture mapping could be implemented here
  273.         jjPIXELMAP tile(jjLayers[4].tileGet(int(xRay / 32), int(yRay / 32)));
  274.         colour = int(tile[xRay % 32, yRay % 32] / 8) * 8;
  275.         break;
  276.       } else if(showMinimap) {
  277.         // draw vision cone on minimap
  278.         rayMap[int(xRay / minimapScale), int(yRay / minimapScale)] = 66;
  279.       }
  280.      
  281.       distance += stepLength;
  282.       xRay += stepX;
  283.       yRay += stepY;
  284.     }
  285.    
  286.     if(distance >= maxDistance) {
  287.       // nothing within the scanning distance, assume no wall
  288.       continue;
  289.     }
  290.        
  291.     // correct fisheye effect and determine rendered wall height
  292.     // https://www.permadi.com/tutorial/raycast/rayc8.html
  293.     distance *= cos(localAngle - angle);
  294.     distance = max(0.1, distance); // avoid divide by zero
  295.     int wallHeight = int(maxWallHeight / distance * planeDistance);
  296.    
  297.     if(wallHeight > 0) {
  298.       // draw the wall slice! actually just a rectangle of the calculated
  299.       // height and colour - the 750 is arbitrary here and determines how
  300.       // far away something needs to be to be the 'darkest' hue possible
  301.       int hue = int(float(min(distance, 750.0) / 750) * 7.0);
  302.       screen.drawRectangle(x, (jjResolutionHeight / 2) - (wallHeight / 2), 1, wallHeight, colour + hue);
  303.     }
  304.   }
  305.  
  306.   // minimap
  307.   if(showMinimap) {
  308.     // 3x3 rectangle to mark player position
  309.     int mmX = int(xMap / minimapScale);
  310.     int mmY = int(yMap / minimapScale);
  311.     rayMap[mmX - 1, mmY - 1] = 64;
  312.     rayMap[mmX - 1, mmY] = 64;
  313.     rayMap[mmX - 1, mmY + 1] = 64;
  314.     rayMap[mmX, mmY - 1] = 64;
  315.     rayMap[mmX, mmY] = 64;
  316.     rayMap[mmX, mmY + 1] = 64;
  317.     rayMap[mmX + 1, mmY - 1] = 64;
  318.     rayMap[mmX + 1, mmY] = 64;
  319.     rayMap[mmX + 1, mmY + 1] = 64;
  320.    
  321.     // display as sprite
  322.     rayMap.save(jjAnimFrames[jjAnimations[jjAnimSets[ANIM::CUSTOM[0]].firstAnim].firstFrame + 1]);
  323.     screen.drawSprite(25, 25, ANIM::CUSTOM[0], 1, 0);
  324.   }
  325.  
  326.  
  327.   return false;
  328. }
  329.  
  330.  
  331.