Creating a pseudo-three-dimensional racing game


As a child, I rarely went to arcade arcade halls, because I didn’t really need them, because at home I had awesome games for C64 ... but there are three arcade games for which I always had money - Donkey Kong, Dragons Lair and Outrun ...

... and I really loved Outrun - speed, hills, palm trees and music, even on the weak version for the C64.


So I decided to try writing an old-school pseudo-three-dimensional racing game in the style of Outrun, Pitstop or Pole position. I do not plan to put together a complete and complete game, but it seems to me it will be interesting to re-examine the mechanics with which these games realized their tricks. Curves, hills, sprites and a sense of speed ...

So, here is my “weekend project,” which ultimately took five or six weeks on the weekend



The playable version is more like a technical demo than a real game. In fact, if you want to create a real pseudo-three-dimensional race, then this will be the most minimal foundation that you need to gradually turn into a game.

It is not polished, a little ugly, but fully functional. I will show you how to implement it on your own in four simple steps.

You can also play


About performance


The performance of this game is very dependent on the machine / browser. In modern browsers, it works well, especially in those that have canvas GPU-acceleration, but a bad graphics driver can cause it to freeze. In the game, you can change the rendering resolution and rendering distance.

About the code structure


It so happened that the project was implemented in Javascript (due to the simplicity of prototyping), but it is not intended to demonstrate the techniques or recommended techniques of Javascript. In fact, for ease of understanding, the Javascript of each example is embedded directly in the HTML page (horror!); worse, it uses global variables and functions.

If I were creating a real game, the code would be much more structured and streamlined, but since this is a technical demo of a racing game, I decided to stick to KISS .

Part 1. Straight roads.


So, how do we get started on creating a pseudo-three-dimensional racing game?

Well, we need

  • Repeat trigonometry
  • Recall the basics of 3d projection
  • Create a game loop
  • Download sprite images
  • Build road geometry
  • Render background
  • Render the road
  • Render car
  • Implement keyboard support for machine control

But before we begin, let's read Lou's Pseudo 3d Page [ translation Habré] - the only source of information (that I could find) on how to create psevdotrohmernuyu racing game.

Finished reading Lou's article? Fine! We will be creating a variation of his Realistic Hills Using 3d-Projected Segments technique. We will do this gradually over the next four parts. But we will start now, with version v1, and create a very simple straight road geometry by projecting it onto an HTML5 canvas element.

The demo can be seen here .

A little trigonometry


Before we get into the implementation, let's use the basics of trigonometry to remember how to project a point in the 3D world onto a 2D screen.

In the simplest case, if you do not touch vectors and matrices, the law of similar triangles is used for 3D projection .

We use the following notation:

  • h = camera height
  • d = distance from camera to screen
  • z = distance from camera to car
  • y = screen y coordinate

Then we can use the law of similar triangles to calculate

y = h * d / z

as shown in the diagram:


You could also draw a similar diagram in a top view instead of a side view, and derive a similar equation to calculate the X coordinate of the screen:

x = w * d / z

Where w = half the width of the road (from the camera to the edge of the road).

As you can see, for x , and y we scale by a factor

d / z

Coordinate systems


In the form of a diagram, it looks beautiful and simple, but when you start coding, you can get a little confused, because we chose arbitrary names, and it is not clear with what we designated the coordinates of the 3D world, and what the coordinates of the 2D screen are. We also assume that the camera is in the center of the origin of the world, although in reality it will follow the machine.

If you approach more formally, then we need to perform:

  1. conversion from world coordinates to screen coordinates
  2. projecting camera coordinates onto a normalized projection plane
  3. scaling the projected coordinates to the coordinates of the physical screen (in our case, this is canvas)


Note: in the present 3d-system , the rotation stage is performed between stages 1 and 2 , but since we will simulate the curves, we do not need a rotation.

Projection


The formal projection equations can be represented as follows:


  • Conversion Equations ( translate ) point is calculated relative to the chamber
  • The projection equations ( project ) are variations of the “law of similar triangles” shown above.
  • Scaling Equations ( scale ) take into account the difference between:
    • math , where 0,0 is in the center and the y axis is up, and
    • , 0,0 , y :


: 3d- Vector Matrix 3d-, , WebGL ( )… . Outrun.



The last piece of the puzzle will be a way to calculate d - the distance from the camera to the projection plane.

Instead of just writing a hard-set value of d , it would be more useful to calculate it from the desired vertical field of view. Thanks to this, we will be able to "zoom" the camera if necessary.

If we assume that we are projecting onto a normalized projection plane, the coordinates of which are in the range from -1 to +1, then d can be calculated as follows:

d = 1 / tan (fov / 2)

By defining fov as one (of many) variables, we can adjust the scope to fine-tune the rendering algorithm.

Javascript Code Structure


At the beginning of the article, I already said that the code does not quite comply with the guidelines for writing Javascript - it is a “quick and dirty” demo with simple global variables and functions. However, since I am going to create four separate versions (straight, curves, hills and sprites), I will store some reusable methods inside common.jswithin the following modules:

  • Dom is a few minor DOM helper functions.
  • Util - general utilities, mainly auxiliary mathematical functions.
  • Game - general gaming support functions, such as image downloader and game loop.
  • Render - helper rendering functions on canvas.

I will explain in detail methods from common.jsonly if they relate to the game itself, and are not just auxiliary mathematical or DOM functions. Hopefully from the name and context it will be clear what the methods should do.

As usual, the source code is in the final documentation.

Simple game loop


Before rendering something, we need a game loop. If you read any of my previous articles about games ( pong , breakout , tetris , snakes or boulderdash ), then you have already seen examples of my favorite game cycle with a fixed time step .

I will not go deep into the details, and simply reuse part of the code from previous games to create a game loop with a fixed time step using requestAnimationFrame .

The principle is that each of my four examples can call Game.run(...)and and use its own versions

  • update - Updating the game world with a fixed time step.
  • render - Updating the game world when the browser allows.

run: function(options) {

  Game.loadImages(options.images, function(images) {

    var update = options.update,    // method to update game logic is provided by caller
        render = options.render,    // method to render the game is provided by caller
        step   = options.step,      // fixed frame step (1/fps) is specified by caller
        now    = null,
        last   = Util.timestamp(),
        dt     = 0,
        gdt    = 0;

    function frame() {
      now = Util.timestamp();
      dt  = Math.min(1, (now - last) / 1000); // using requestAnimationFrame have to be able to handle large delta's caused when it 'hibernates' in a background or non-visible tab
      gdt = gdt + dt;
      while (gdt > step) {
        gdt = gdt - step;
        update(step);
      }
      render();
      last = now;
      requestAnimationFrame(frame);
    }
    frame(); // lets get this party started
  });
}

Again, this is a remake of ideas from my previous canvas games, so if you don’t understand how the game loop works, then return to one of the previous articles.

Images and sprites


Before the game cycle begins, we load two separate spritesheets (sprite sheets):

  • background - three parallax layers for sky, hills and trees
  • sprites - machine sprites (plus trees and billboards to be added to the final version)


The sprite sheet was generated using a small task Rake and Ruby Gem sprite-factory .

This task generates the combined sprite sheets, as well as the coordinates x, y, w, h, which will be stored in the constants BACKGROUNDand SPRITES.

Note: I created the backgrounds using Inkscape, and most sprites are graphics taken from the old Outrun version for Genesis and used as training examples.

Game variables


In addition to images of backgrounds and sprites, we will need several game variables, namely:

var fps           = 60;                      // how many 'update' frames per second
var step          = 1/fps;                   // how long is each frame (in seconds)
var width         = 1024;                    // logical canvas width
var height        = 768;                     // logical canvas height
var segments      = [];                      // array of road segments
var canvas        = Dom.get('canvas');       // our canvas...
var ctx           = canvas.getContext('2d'); // ...and its drawing context
var background    = null;                    // our background image (loaded below)
var sprites       = null;                    // our spritesheet (loaded below)
var resolution    = null;                    // scaling factor to provide resolution independence (computed)
var roadWidth     = 2000;                    // actually half the roads width, easier math if the road spans from -roadWidth to +roadWidth
var segmentLength = 200;                     // length of a single segment
var rumbleLength  = 3;                       // number of segments per red/white rumble strip
var trackLength   = null;                    // z length of entire track (computed)
var lanes         = 3;                       // number of lanes
var fieldOfView   = 100;                     // angle (degrees) for field of view
var cameraHeight  = 1000;                    // z height of camera
var cameraDepth   = null;                    // z distance camera is from screen (computed)
var drawDistance  = 300;                     // number of segments to draw
var playerX       = 0;                       // player x offset from center of road (-1 to 1 to stay independent of roadWidth)
var playerZ       = null;                    // player relative z distance from camera (computed)
var fogDensity    = 5;                       // exponential fog density
var position      = 0;                       // current camera Z position (add playerZ to get player's absolute Z position)
var speed         = 0;                       // current speed
var maxSpeed      = segmentLength/step;      // top speed (ensure we can't move more than 1 segment in a single frame to make collision detection easier)
var accel         =  maxSpeed/5;             // acceleration rate - tuned until it 'felt' right
var breaking      = -maxSpeed;               // deceleration rate when braking
var decel         = -maxSpeed/5;             // 'natural' deceleration rate when neither accelerating, nor braking
var offRoadDecel  = -maxSpeed/2;             // off road deceleration is somewhere in between
var offRoadLimit  =  maxSpeed/4;             // limit when off road deceleration no longer applies (e.g. you can always go at least this speed even when off road)

Some of them can be customized using UI controls to change critical values ​​during program execution so that you can see how they affect the rendering of the road. Others are recalculated from custom UI values ​​in the method reset().

We manage Ferrari


We perform key bindings for Game.run, which provides simple keyboard input that sets or resets variables that report the player’s current actions:

Game.run({
  ...
  keys: [
    { keys: [KEY.LEFT,  KEY.A], mode: 'down', action: function() { keyLeft   = true;  } },
    { keys: [KEY.RIGHT, KEY.D], mode: 'down', action: function() { keyRight  = true;  } },
    { keys: [KEY.UP,    KEY.W], mode: 'down', action: function() { keyFaster = true;  } },
    { keys: [KEY.DOWN,  KEY.S], mode: 'down', action: function() { keySlower = true;  } },
    { keys: [KEY.LEFT,  KEY.A], mode: 'up',   action: function() { keyLeft   = false; } },
    { keys: [KEY.RIGHT, KEY.D], mode: 'up',   action: function() { keyRight  = false; } },
    { keys: [KEY.UP,    KEY.W], mode: 'up',   action: function() { keyFaster = false; } },
    { keys: [KEY.DOWN,  KEY.S], mode: 'up',   action: function() { keySlower = false; } }
  ],
  ...
}

The player’s state is controlled by the following variables:

  • speed - current speed.
  • position - the current Z position on the track. Note that this is a camera position, not a Ferrari.
  • playerX - player’s current position on X on the road. Normalized in the range from -1 to +1, so as not to depend on the actual value roadWidth.

These variables are set inside the method update, which performs the following actions:

  • updates positionbased on the current speed.
  • updates playerXwhen you press the left or right key.
  • increases speedif the up key is pressed.
  • decreases speedif the down key is pressed.
  • reduces speedif the up and down keys are not pressed.
  • reduces speedif playerXlocated off the edge of the road and on the grass.

In the case of direct roads, the method is updatequite clear and simple:

function update(dt) {

  position = Util.increase(position, dt * speed, trackLength);

  var dx = dt * 2 * (speed/maxSpeed); // at top speed, should be able to cross from left to right (-1 to 1) in 1 second

  if (keyLeft)
    playerX = playerX - dx;
  else if (keyRight)
    playerX = playerX + dx;

  if (keyFaster)
    speed = Util.accelerate(speed, accel, dt);
  else if (keySlower)
    speed = Util.accelerate(speed, breaking, dt);
  else
    speed = Util.accelerate(speed, decel, dt);

  if (((playerX < -1) || (playerX > 1)) && (speed > offRoadLimit))
    speed = Util.accelerate(speed, offRoadDecel, dt);

  playerX = Util.limit(playerX, -2, 2);     // dont ever let player go too far out of bounds
  speed   = Util.limit(speed, 0, maxSpeed); // or exceed maxSpeed

}

Do not worry, it will become much more difficult when in the finished version we add sprites and collision recognition.

Road geometry


Before we can render the game world, we need to build an array of segmentsin in the method resetRoad().

Each of these segments of the road will ultimately be projected from its world coordinates so that it turns into a 2d polygon in screen coordinates. For each segment, we store two points, p1 is the center of the edge closest to the camera, and p2 is the center of the edge farthest from the camera.


Strictly speaking, p2 of each segment is identical to p1 of the previous segment, but it seems to me that it is easier to store them as separate points and convert each segment separately.

We keep separate rumbleLengthbecause we can have beautiful detailed curves and hills, but at the same time horizontal stripes. If each subsequent segment has a different color, then this will create a bad strobe effect. Therefore, we want to have many small segments, but group them together to form separate horizontal stripes.

function resetRoad() {
  segments = [];
  for(var n = 0 ; n < 500 ; n++) { // arbitrary road length
    segments.push({
       index: n,
       p1: { world: { z:  n   *segmentLength }, camera: {}, screen: {} },
       p2: { world: { z: (n+1)*segmentLength }, camera: {}, screen: {} },
       color: Math.floor(n/rumbleLength)%2 ? COLORS.DARK : COLORS.LIGHT
    });
  }

  trackLength = segments.length * segmentLength;
}

We initialize p1 and p2 only with world coordinates z , because we only need straight roads. The y coordinates will always be 0, and the x coordinates will always depend on the scaled value +/- roadWidth. Later, when we add curves and hills, this part will change.

We will also set empty objects to store representations of these points in the camera and on the screen so as not to create a bunch of temporary objects in each render. To minimize garbage collection, we must avoid allocating objects within the game loop.

When the car reaches the end of the road, we simply return to the beginning of the cycle. To simplify this, we will create a method to find a segment for any value of Z, even if it goes beyond the length of the road:

function findSegment(z) {
  return segments[Math.floor(z/segmentLength) % segments.length];
}

Background rendering


The method render()begins by rendering the background image. In the following parts, where we will add curves and hills, we will need the background to perform parallax scrolling, so we will now begin to move in this direction, rendering the background as three separate layers:

function render() {

  ctx.clearRect(0, 0, width, height);

  Render.background(ctx, background, width, height, BACKGROUND.SKY);
  Render.background(ctx, background, width, height, BACKGROUND.HILLS);
  Render.background(ctx, background, width, height, BACKGROUND.TREES);

  ...

Road rendering


Then, the render function iterates through all the segments and projects p1 and p2 of each segment from world coordinates to screen coordinates, trimming the segment if necessary, and otherwise rendering it:

  var baseSegment = findSegment(position);
  var maxy        = height;
  var n, segment;
  for(n = 0 ; n < drawDistance ; n++) {

    segment = segments[(baseSegment.index + n) % segments.length];

    Util.project(segment.p1, (playerX * roadWidth), cameraHeight, position, cameraDepth, width, height, roadWidth);
    Util.project(segment.p2, (playerX * roadWidth), cameraHeight, position, cameraDepth, width, height, roadWidth);

    if ((segment.p1.camera.z <= cameraDepth) || // behind us
        (segment.p2.screen.y >= maxy))          // clip by (already rendered) segment
      continue;

    Render.segment(ctx, width, lanes,
                   segment.p1.screen.x,
                   segment.p1.screen.y,
                   segment.p1.screen.w,
                   segment.p2.screen.x,
                   segment.p2.screen.y,
                   segment.p2.screen.w,
                   segment.color);

    maxy = segment.p2.screen.y;
  }

Above, we have already seen the calculations necessary for projecting a point; The javascript version combines transformation, projection and scaling into one method:

project: function(p, cameraX, cameraY, cameraZ, cameraDepth, width, height, roadWidth) {
  p.camera.x     = (p.world.x || 0) - cameraX;
  p.camera.y     = (p.world.y || 0) - cameraY;
  p.camera.z     = (p.world.z || 0) - cameraZ;
  p.screen.scale = cameraDepth/p.camera.z;
  p.screen.x     = Math.round((width/2)  + (p.screen.scale * p.camera.x  * width/2));
  p.screen.y     = Math.round((height/2) - (p.screen.scale * p.camera.y  * height/2));
  p.screen.w     = Math.round(             (p.screen.scale * roadWidth   * width/2));
}

In addition to calculating the screen x and y for each point p1 and p2, we use the same projection calculations to calculate the projected width ( w ) of the segment.

Having the screen coordinates x and y of points p1 and p2 , as well as the projected width of the road w , we can quite easily calculate with the help of an auxiliary function Render.segmentall the polygons necessary for rendering grass, road, horizontal stripes and dividing lines, using the general auxiliary function Render.polygon (see . common.js) .

Car rendering


Finally, the last thing the method needs renderis a Ferrari rendering:

  Render.player(ctx, width, height, resolution, roadWidth, sprites, speed/maxSpeed,
                cameraDepth/playerZ,
                width/2,
                height);

This method is called player, and not car, because in the final version of the game there will be other cars on the road, and we want to separate the player's Ferrari from other cars.

The helper function Render.playeruses the canvas method called drawImageto render the sprite, having previously scaled it using the same projection scaling that was used before:

d / z

Where z in this case is the relative distance from the machine to the camera, stored in the variable playerZ .

In addition, the function “shakes” the car a little at high speeds, adding a bit of randomness to the scaling equation, depending on speed / maxSpeed .

And here is what we got:


Conclusion


We did a fairly large amount of work just to create a system with straight roads. We added

  • generic helper module dom
  • Util general math module
  • Render general canvas helper module ...
  • ... including Render.segment, Render.polygonandRender.sprite
  • fixed pitch game cycle
  • image downloader
  • keyboard handler
  • parallax background
  • sprite sheet with cars, trees and billboards
  • rudimentary geometry of the road
  • method update()for controlling the machine
  • method render()for rendering background, road and player’s car
  • HTML5 tag <audio>with racing music (hidden bonus!)

... which gave us a good foundation for further development.

Part 2. Curves.



In this part, we will explain in more detail how curves work.

In the previous part, we compiled the geometry of the road in the form of an array of segments, each of which has world coordinates that are transformed relative to the camera and then projected onto the screen.

We needed only the world coordinate z for each point, because on straight roads both x and y were equal to zero.


If we were to create a fully functional 3d system, we could implement the curves by calculating the x and z stripes of the polygons shown above. However, this type of geometry will be rather difficult to calculate, and for this it will be necessary to add the 3d-rotation stage to the projection equations ...

... if we went this way, it would be better to use WebGL or its analogues, but this project does not have other tasks for our project. We just want to use old-school pseudo-three-dimensional tricks to simulate curves.

Therefore, you will probably be surprised to learn that we will not calculate the x coordinates of the road segments at all ...

Instead, we will use Lu’s advice :

“To curve the road, simply change the position of the center line of the curve shape ... starting from the bottom of the screen, the amount of shift of the center of the road left or right gradually increases .

In our case, the center line is the value cameraXpassed to the projection calculations. This means that when we perform render()each segment of the road, you can simulate the curves by shifting the value cameraXby a gradually increasing value.


To know how much to shift, we need to store a value in each segment curve. This value indicates how much the segment should be shifted from the center line of the camera. She will be:

  • negative for left-turning curves
  • positive for curves turning right
  • less for smooth curves
  • more for sharp curves

The values ​​themselves are chosen quite arbitrarily; through trial and error, we can find good values ​​at which the curves seem to be “correct”:

var ROAD = {
  LENGTH: { NONE: 0, SHORT:  25, MEDIUM:  50, LONG:  100 }, // num segments
  CURVE:  { NONE: 0, EASY:    2, MEDIUM:   4, HARD:    6 }
};

In addition to choosing good values ​​for the curves, we need to avoid any gaps in the transitions when the line turns into a curve (or vice versa). This can be achieved by softening when entering and exiting the curves. We will do this by gradually increasing (or decreasing) the value curvefor each segment using traditional smoothing functions until it reaches the desired value:

easeIn:    function(a,b,percent) { return a + (b-a)*Math.pow(percent,2);                           },
easeOut:   function(a,b,percent) { return a + (b-a)*(1-Math.pow(1-percent,2));                     },
easeInOut: function(a,b,percent) { return a + (b-a)*((-Math.cos(percent*Math.PI)/2) + 0.5);        },

That is, now, taking into account the function of adding one segment to the geometry ...

function addSegment(curve) {
  var n = segments.length;
  segments.push({
     index: n,
        p1: { world: { z:  n   *segmentLength }, camera: {}, screen: {} },
        p2: { world: { z: (n+1)*segmentLength }, camera: {}, screen: {} },
     curve: curve,
     color: Math.floor(n/rumbleLength)%2 ? COLORS.DARK : COLORS.LIGHT
  });
}

we can create a method for smooth entry, finding and smooth exit from a curved road:

function addRoad(enter, hold, leave, curve) {
  var n;
  for(n = 0 ; n < enter ; n++)
    addSegment(Util.easeIn(0, curve, n/enter));
  for(n = 0 ; n < hold  ; n++)
    addSegment(curve);
  for(n = 0 ; n < leave ; n++)
    addSegment(Util.easeInOut(curve, 0, n/leave));
}

... and on top you can impose additional geometry, for example, S-shaped curves:

function addSCurves() {
  addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM,  -ROAD.CURVE.EASY);
  addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM,   ROAD.CURVE.MEDIUM);
  addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM,   ROAD.CURVE.EASY);
  addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM,  -ROAD.CURVE.EASY);
  addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM,  -ROAD.CURVE.MEDIUM);
}

Changes to the update () Method


The only changes that need to be made to the method update()are the application of a kind of centrifugal force when the machine moves along a curve.

We set an arbitrary factor that can be adjusted according to our preferences.

var centrifugal = 0.3;   // centrifugal force multiplier when going around curves

And then we will just update the position playerXbased on its current speed, curve value and centrifugal force multiplier:

playerX = playerX - (dx * speedPercent * playerSegment.curve * centrifugal);

Curve rendering


We said above that you can render simulated curves by shifting the value cameraXused in projection calculations during the execution of render()each road segment.


To do this, we will store the drive variable dx , increasing for each segment by a value curve, as well as the variable x , which will be used as the offset of the value cameraXused in projection calculations.

To implement the curves, we need the following:

  • shift the projection p1 of each segment by x
  • shift the projection p2 of each segment by x + dx
  • increase x for the next segment by dx

Finally, in order to avoid torn transitions when crossing the boundaries of segments, we must make dx initialized with the interpolated value of the curve of the current base segments.

Change the method render()as follows:

var baseSegment = findSegment(position);
var basePercent = Util.percentRemaining(position, segmentLength);
var dx = - (baseSegment.curve * basePercent);
var x  = 0;
for(n = 0 ; n < drawDistance ; n++) {

  ...

  Util.project(segment.p1, (playerX * roadWidth) - x,      cameraHeight, position - (segment.looped ? trackLength : 0), cameraDepth, width, height, roadWidth);
  Util.project(segment.p2, (playerX * roadWidth) - x - dx, cameraHeight, position - (segment.looped ? trackLength : 0), cameraDepth, width, height, roadWidth);

  x  = x + dx;
  dx = dx + segment.curve;

  ...
}

Parallax Scrolling Background


Finally, we need to scroll the parallax background layers, storing the offset for each layer ...

var skySpeed    = 0.001; // background sky layer scroll speed when going around curve (or up hill)
var hillSpeed   = 0.002; // background hill layer scroll speed when going around curve (or up hill)
var treeSpeed   = 0.003; // background tree layer scroll speed when going around curve (or up hill)
var skyOffset   = 0;     // current sky scroll offset
var hillOffset  = 0;     // current hill scroll offset
var treeOffset  = 0;     // current tree scroll offset

... and increasing it during time update()depending on the curve value of the current player segment and its speed ...

skyOffset  = Util.increase(skyOffset,  skySpeed  * playerSegment.curve * speedPercent, 1);
hillOffset = Util.increase(hillOffset, hillSpeed * playerSegment.curve * speedPercent, 1);
treeOffset = Util.increase(treeOffset, treeSpeed * playerSegment.curve * speedPercent, 1);

... and then use to use this offset when doing render()background layers.

Render.background(ctx, background, width, height, BACKGROUND.SKY,   skyOffset);
Render.background(ctx, background, width, height, BACKGROUND.HILLS, hillOffset);
Render.background(ctx, background, width, height, BACKGROUND.TREES, treeOffset);

Conclusion


So, here we get the fake pseudo-three-dimensional curves:


The main part of the code that we added is to build the geometry of the road with the corresponding value curve. Realizing it, adding centrifugal force during the time is update()much easier.

Curve rendering is performed in just a few lines of code, but it can be difficult to understand (and describe) what exactly is happening here. There are many ways to simulate curves and it is very easy to wander when they are implemented into a dead end. It is even easier to get carried away with an outside task and try to do everything “correctly”; before you realize this, you will begin to create a fully functional 3d-system with matrices, rotations and real 3d-geometry ... which, as I said, is not our task.

When I wrote this article, I was sure that there definitely were problems in my implementation of the curves. Trying to visualize the algorithm, I did not understand why I needed two values ​​of the dx and x drives instead of one ... and if I can’t fully explain something, then something went wrong somewhere ...

... but the project’s time “on the weekend ”has almost expired, and, frankly, the curves seem to me quite beautiful, and in the end, this is the most important.

All Articles