maanantai 12. toukokuuta 2014

Making of Highway at Night

After finishing 6th in JS1k 2013, it was clear that I wanted to give this fun competition another go. First off, here is a list of things I took as "lessons learned" from my last year's entry:

  • Performance needs to be good with all devices
  • Controls take a lot of space and aren't always important
  • Many small visual details can be overlooked, so rather polish the important features
  • Let compressors help in making the code compact
What follows is an explanation of ideas and techniques behind my "Highway at Night" demo that was the runner-up in JS1k 2014.

1. Visual approach

 

 My initial idea was to do a very realistic looking demo of a car driving along a highway on a dark night. I watched some dashcam videos on YouTube and realized that what we see in real life is very limited - it's mainly just lights and silhouettes. I made notes on what I saw and how I could try and implement it. But first I needed a good yet small engine for road construction and rendering.

I wanted the demo to have true perpective 3D drawing combined with cheap tricks. I found out that I only needed to do the center line with real polygons and somehow that makes everything else around it look more realistic. Also using shadowBlur on the center line alone is enough to give the impression that all lights are glowing. I would have liked to do a lot of blurred lights but the performance cost was huge and I didn't want to fall into the same trap as last year. So the initial idea of photorealistic graphics was dropped in favor of vintage video game looks. Another thing that helps to fool the eye is high speed. This allowed the graphics to be very simple and raw as you can see from the "sprite sheet" below. Car lights are slightly offset to give a hint that the cars are at a small angle. Turn signal and emergency lights are animated. All these graphics are drawn with fillRect.



Imagine how much fun it would have been to add trucks, motorcycles, KITT and so on with this approach. The frustrating part about the 1kB limitation is that you have to leave a lot of stuff out. 

2. Track engine


The road is defined by an array of horizontal offsets from the center. The array itself is generated procedurally by a loop that runs 600 times. Every 30th time it creates a new target offset and the actual track points are calculated between these targets with a simple ease-in-out algorithm. Initially I had a much more complex track, but in the end used a cosine wave to save space. The hills are also defined with a cosine wave that depends on track position. It's not recorded to the array, but rather re-calculated each time. So now we have the track data as horizontal and vertical offsets from the center of the screen. To project we use the simplest possible 3d projection and just scale the offsets and road width based on distance. This works fine otherwise, but will eventually cause the road to move off the screen. So we need to have the camera follow the road and also make it look in the right direction. The most natural feeling is achieved when camera looks towards the point in the horizon where the road disappears. This is done by interpolating between that point and the camera position and adding that value to the corresponding horizontal offset. 

The camera position moves along the track and sees 100 steps ahead of the total 600. When it reaches the end it loops back to beginning. The visible distance is looped backwards while rendering so we are using the painter's algorithm for z-ordering. Objects are placed along the track in intervals defined by conditional statements that take remainders as input. For example if the remainder of track position divided by 8 is zero we draw a street light. For cars I use two offset variables that allow their placement to change constantly.


3. Rendering tricks


The theme of the competion was dragons, so instead of keeping the camera on the road I wanted it to seem like the viewer was flying. It turns out that translating and rotating the canvas in cosine waves is enough to pull this off. I found the right formulas by just experimenting. These things are never as complex as they might look.




For a 1kB demo it would be best to use one type of drawing method with everything. Using fillRect was the obvious choice and it almost worked great. But I couldn't live with the center line if it wasn't a real polygon and so I had to spend lots of bytes on path drawing. I think it was a good choice though. All other things on the screen are rects - road, cars, lights, tunnels, buildings... But the ground feels like it is drawn with some weird wireframe polygons. This is actually just another trick. What's really going on is that we draw vertical lines that are slightly rotated based on track position. Because the angle and line thickness follows the track position it gives the appearance of hills. I was very excited to come up with this technique and it really made the demo come alive.



4. Creating the atmosphere


I paid special attention to the sky as it creates the atmosphere for the whole demo. It consists of a changing background color, flickering stars and a moon. Even though the end result is very simple, and the code behind it seems obvious, it took a lot of effort to get there. At first I tried and failed with gradients, used expensive random numbers to place stars, and added the moon with a special character. When you ran out of space you start to rethink everything, make compromises and always notice how much simpler things can be. What you see in the final version is created like this...

    // Sky with some polar light effects
    c.fillStyle="hsl("+99+m%99+",50%,9%)";
    c.fillRect(-600,-600,1200,600);

    // Flickering stars
    for(c.fillStyle=c.shadowColor="#FFF",i=600;i--;)c.fillRect(400-i*i%800,-i%600,1,Z*i%.9);
   
    // Moon
    c.fillText("(",99,-99);


Variables m and Z change with track position, and they are used here to make the sky color and star size change. You can imagine how stupid I felt when I realized I could replace the charcode for a moon with a parenthese. :)





5. Conclusions and source code


Most important technical aspects of this demo:

  • Using modulus operator (remainder) on the track position index is the key to placing objects along the track, but also helps in adding all sorts of effects
  • Cosine waves are everywhere to achieve that analog feel
  • HTML5 canvas translate and rotate functions were used to do visually striking effects with very small cost

This time around I used Siorki's RegPack for compression. Last year I wasn't familiar with the use of compressors and I didn't get much help by using one. I still haven't learned to take full advantage of them but after some tests it was clear to me that repeating code patterns works better than creating special functions.

Finally here's the full source code...


// Create track
for(T=[B=F=Z=D=i=0];i<600;i++){
 if(i%30<1)m=D,D=1800-(i%4-2)*600;
 T[i]=m+(D-m)*(.5-Math.cos(i%30/9)/2)
}
// Timer loop
setInterval(function(){
 m=Z/5|0;
 
 // Reset canvas and fill with black
 c.fillRect(0,0,a.width=a.height=600,600);
 
 // Dragon flight waving and tilting
 c.translate(300,300+Math.cos(Z/99)*99);
 c.rotate(i>540?Z/50%6:Math.cos(i/9)/9);
 
 // Sky with some polar light effects
 c.fillStyle="hsl("+99+m%99+",50%,9%)";
 c.fillRect(-600,-600,1200,600);

 // Flickering stars
 for(c.fillStyle=c.shadowColor="#FFF",i=600;i--;)c.fillRect(400-i*i%800,-i%600,1,Z*i%.9);
 
 // Moon
 c.fillText("(",99,-99);

 // Loop through visible distance of the track
 for (i=99+m;i>m;i--){
  D=i-Z/5;
  
  // Hills
  y=Math.cos(Math.cos(i/9+i/50)+8)*300;
  
  // Calculate road and camera direction from track data
  O=T[m]+(T[m+1]-T[m])*(Z%5)/5-T[i];
  
  if(i%3<1){
  
   // Draw ground and hills
   c.rotate(Math.cos(i)/9); // Do we need to rotate at all?
   c.fillRect(-600,300/D+y/D+9,1200,Math.cos(i));
   c.rotate(Math.cos(i)/-9);
      
   // Draw white lines
   c.shadowBlur=D<9?30:0;
   if(i%2){
    c.beginPath();
    c.moveTo(-30/D+O/D,300/D+y/D);
    c.lineTo(30/D+O/D,300/D+y/D)
   }
   if(i%2<1){
    c.lineTo(30/D+O/D,300/D+y/D);
    c.lineTo(-30/D+O/D,300/D+y/D);
    c.fill()
   }
   c.shadowBlur=0
  } 
  
  // Road
  c.fillStyle="#000";
  c.fillRect(-600/D+O/D,300/D+y/D,1200/D,60/D);
  
  if(i%8<1){ // Draw street or tunnel lights
   c.fillRect(-800/D+O/D,300/D+y/D-800/D,30/D,800/D);
   c.fillStyle="#FFA";
   i%400<80?c.fillRect(-100/D+O/D,300/D+y/D-600/D,200/D,50/D):c.fillRect(-600/D+O/D,300/D+y/D-800/D,80/D,20/D)
  }
  if(i%400<80){ // Tunnel walls
   c.fillStyle=i%2?0:"#222";
   c.fillRect(600/D+O/D,300/D+y/D-900/D,1400/D,900/D);
   c.fillRect(-2e3/D+O/D,300/D+y/D-900/D,1400/D,900/D);
   c.fillRect(-2e3/D+O/D,300/D+y/D-900/D,4e3/D,300/D)
  }
  
  // Draw cars
  c.fillStyle="#000";
  if((i+~~B)%30==0){
   c.fillRect(150/D+O/D,300/D+y/D-200/D,260/D,160/D);
   c.fillStyle="red";
   c.fillRect(180/D+O/D,300/D+y/D-90/D,60/D,30/D);
   c.fillRect(340/D+O/D,300/D+y/D-90/D,60/D,30/D);
   // Turn signal
   c.fillStyle="#FFA";
   i%300<99&&i%9<5?c.fillRect(140/D+O/D,300/D+y/D-90/D,60/D,30/D):0
  }
  if((i+F)%40<1){
   c.fillStyle="#000";
   c.fillRect(-400/D+O/D,300/D+y/D-200/D,260/D,1/D*160);
   c.fillStyle="#FFA";
   c.fillRect(-240/D+O/D,300/D+y/D-90/D,60/D,30/D);
   c.fillRect(-400/D+O/D,300/D+y/D-90/D,60/D,30/D);
   // Police car
   if(i%200>99)
    c.fillStyle=i%2?"red":"#00F",
    c.fillRect(i%3-(i%2*80+260)/D+O/D,300/D+y/D-220/D,60/D,30/D)
  }
   
  // City
  if(i>500&&i%4<1)for(j=i%9*8;j--;)
    c.fillStyle=j%5?"#FFA":"#999",
    c.fillRect((i%12?-2e3:2e3)/D-j%5*200/D+O/D,300/D+y/D-(j-j%5)*60/D,200/D,200/D);
  
  c.fillStyle="#999"
 }
 // Integrate car movement
 B-=.3;F+=.5;
 // Move forward and loop back to start if track ends
 Z++;Z%=3e3
},20)

11 kommenttia:

  1. Didn't understand the flying part. I thought it was just some reckless driver, moving on the center of the road and occasionally losing contact with it when he comes over the hill.

    VastaaPoista
  2. Nice Job! Crazy constraint, shame there's no controls - I love making games more than anything. Still pretty awesome challenge and looks visually amazing.

    VastaaPoista
  3. It was very nice article and it is very useful to Javacript learners.We also provide Cub training software online training.

    VastaaPoista
  4. Which is the best AWS training course in Noida in terms?
    The Best Training Institute in Chennai Login For Excellence at Velachery. If you want to more details to contact us:#LoginForExcellence, #EmbeddedSystemsTrainingInstituteinChennai,#EmbeddedSystemsTraininginChennai,#EmbeddedSystemsTraininginVelachery,#TraininginVelachery,
    Embedded Systems Training in Chennai

    VastaaPoista
  5. We also offer job placement assistance for freshers and job seekers. Call +91 82209 79379 for course content and more details.
    Web Design Courses in Coimbatore

    VastaaPoista