JS1k 2014: Post-mortem

· Reading time: 10 mins

This year I discovered the JS1k code competition just in time for me to submit an entry. I'm not really new at doing ugly things with source code, but this time it was quite different. Perl is one of the quirkiest things I've ever put my hands on, and back in the day when I was scripting day in and day out with it I was happy like a child (well, I was practically a child after all); my Javascript coding skills came through a much more professional context though, so I'm better at being a nice guy and writing clean code than anything else.

Being given the task to put aside the code design principles I stand up for to make room for a greatest-result-with-least-code approach was one of the funniest and most addictive things that happened in my course of learning. I don't really think I did my very best, but I'm happy with it for the time being and I hope to submit something much juicier next year.

I participated in the WebGL compo, which has been introduced this year for the first time, along with only four entries other than mine, so five out of about a hundred. I chose to submit in WebGL because I'm learning the ropes of 3D *GL programming and wanted to do something nice with it; I began writing a GLSL raymarching shader but I though it would squeeze some fun out of the thing because of GLSL's inherently more rigid and verbose syntax, so I thought I'd do something simpler.

I wrote Einstein-Rosen, an "endless" run inside a wireframe tunnel. The name is inspired to wormholes, which have actually not much to share with my code but look like that in movies most of the times so I thought maybe that was okay. :D

I actually spent quite some time in the past couple of months trying to find a fitting algorithm to generate a pseudorandom curved path and extrude it to a tube geometry. The extrusion was the easy part, but still an interesting piece of code so I decided to do the same for JS1k.

Drawing an OpenGL vertex/index based geometry is quite a boilerplatish feat in WebGL, since it requires writing, compiling and linking the shaders, building the arrays, converting them to native, bla bla bla you know the drill, so the room for manoeuvre is somewhat smaller in 1024b, not counting the shim. Most of my action was thus focused towards compromising between code precision and graphical result.

A blatant example is what I think to be the ugliest perspective matrix ever calculated ever (line 28 in the code):

// Ugly hard coded and heavily rounded perspective matrix, good for 16:9
g.unM(g.geUL(p,'m'),0,[-.1, 0, 0, 0, 0, -.2, 0, 0, 0, 0, -1, -1, 0, 0, -.1, 0]);

I really hope the gods of linear algebra can forgive me and pray for my soul to be spared. The thought process that went on for it was something like: "you know what, -.100002 isn't that different from -.1 after all".

The path function was heavily simplified, too: it is now simply the function f : R -> R3 sending z into (2 sin z, 2 cos z, z) (line 7 in the code).

// x = 2 * sin(z), y = 2 * cos(z)
function R(z) {
  return [2*_(O*z),2*$(O*z)]
}

(R in the code does not mean the R set, is just a variable name ;)). This was really helpful as it let me do a dirty trick to simulate endless running. As some of you might know (not sure how many though, it's one of the deepest darkest secrets of mathematics in fact) sin and cos are periodic functions. The code responsible of moving the camera through the tunnel simply is a function of time. Sure, I could have simply replicated the geometry forever, but to be cheap memory-, cpu- and code-length-wise I simply computed a geometry long twice the necessary (lines 33 and following), that is 32 units, and computed the z value modulo 16.

// Build the tube geometry
s = [], i = [], W = 32, J=6.28/W;
for(I = 0; I <= W; I++) { // 32 skewed cylinders
  w = R(I); // get point
  for(j = 0; j < W; j++) { // each circle is built with 32 lines
    s.push($(J * j) + w[0], _(J * j) + w[1], - I/4) // a vertex on the circle
    K=W*I;
    i.push(
      K + j,
      K + (j + 1) % W, // current vertex to next vertex on the circle
      K + j,
      K + W + j // current vertex to vertex with the same position on the next circle
    )
  } 
}

This way, when I move past 16 units in the z direction, the code automatically jumps back to point 0, and being sin and cos periodic they realign perfectly. The geometry is twice as long to take into account for the geometry portion you can see fading in the distance ahead of you, which must be there when the camera crosses the 16 units point. Lines 58 and 61 to 68 in the code implement this behavior via a modelview matrix which is calculated by hand with no frills but the simple translation (I also shortened the z axis movement, effectively changing the function from (2 sin z, 2 cos z, z) to (.5 sin z, .5 cos z, z)):

// Calculate the point on the curve: divide time by 128 and reduce modulo 16 to loop
d=(D/128)%16;
requestAnimationFrame(X)
g.clear(16640);
E = R(d);
// Calculate view matrix
v = [
 1, 0, 0, 0,
 0, 1, 0, 0,
 0, 0, 1, 0,
 -E[0], -E[1], d/4, 1
];

The shaders are bare-bone, just projection matrix * modelview matrix * vertex position, GL_LINES rendering, and a "fog" shading - actually the color just fades towards (0.2, 0.2, 0.2) with the Z value of the vertex.

// vertex shader
uniform mat4 m, v;
attribute vec3 p;
varying lowp float z;

void main() { 
  vec4 P = gl_Position = m * v * vec4(p, 1.);
  z = 1. - clamp(P.z-.5, .0, 1.);
}
// fragment shader
varying lowp float z;

void main() { 
  gl_FragColor = vec4( vec3(.0, z*.5, z), .8);
}

The remainder of the code is pretty much clever tricks to reduce the code usage to the minimum possible, like the function aliasing on the GL context object borrowed from another demo, the usage of single name variables, the inlining of as much code as possible, the usage of numerical constants instead of GL's verbose stuff like GL_COLOR_BUFFER_BIT and so on. In retrospect, having seen and read other demos and post-mortems, I could have done much better to optimize it for JSCrush, but when I wrote the code I didn't know the way it compressed the code, I shout out a thank you to all the guys that explained it more or less in detail - particularly @greweb's awesome article for an awesome demo. I'm not going to go over it, in his article you can find so much information I couldn't explain in better terms, so I strongly recommend it.

So that's that. Hope you liked my work, and hope I'll be able to stay up to date with this beautiful world full of Javascript experts producing some of the most impressive code I've ever read, while I keep treading the Javascript graphics programming world.

Here is the full, uncompressed code. You can view the compressed version directly on JS1k.

// Shorten GL context function names. Avoided bind()ing since it didn't produce significant compression.
for(i in g) g[i.match(/^..|[A-Z]|\d\w$/g).join("")]=g[i]
// Alias sin, cos and pi/8 to single char variables since those come up in a couple of places
_=Math.sin;$=Math.cos;O=Math.PI/8;

// x = 2 * sin(z), y = 2 * cos(z)
function R(z) {
  return [2*_(O*z),2*$(O*z)]
}

// Compile the shader of source s and type t
function cS(s, t) {
  h = g.crS(t)
  g.shS(h, s)
  g.coS(h)
  return h;
}

var p = g.crP();
// Projection matrix * View matrix * point (model matrix is always identity); send the light intensity z to the fragment shader
g.atS(p,cS("uniform mat4 m,v;attribute vec3 p;varying lowp float z;void main(){vec4 P=gl_Position=m*v*vec4(p,1.);z=1.-clamp(P.z-.5,.0,1.);}",35633))
// Color is rgb(0.0, 0.5, 1.0) * z
g.atS(p,cS("varying lowp float z;void main(){gl_FragColor=vec4(vec3(.0,z*.5,z),.8);}",35632))
g.liP(p)
g.usP(p)

// Ugly hard coded and heavily rounded perspective matrix, good for 16:9
g.unM(g.geUL(p,'m'),0,[-.1, 0, 0, 0, 0, -.2, 0, 0, 0, 0, -1, -1, 0, 0, -.1, 0]);
g.clC(.2,.2,.2,1);
g.en(2929);

// Build the tube geometry
s = [], i = [], W = 32, J=6.28/W;
for(I = 0; I <= W; I++) { // 32 skewed cylinders
  w = R(I); // get point
  for(j = 0; j < W; j++) { // each circle is built with 32 lines
    s.push($(J * j) + w[0], _(J * j) + w[1], - I/4) // a vertex on the circle
    K=W*I;
    i.push(
      K + j,
      K + (j + 1) % W, // current vertex to next vertex on the circle
      K + j,
      K + W + j // current vertex to vertex with the same position on the next circle
    )
  } 
}

// build vertex and index buffers
m = g.crB()
g.biB(34962, m)
g.buD(34962, new Float32Array(s), 35044)
g.biB(34963, g.crB())
g.buD(34963, new Uint16Array(i), 35044);

(X = function(D) {

  // Calculate the point on the curve: divide time by 128 and reduce modulo 16 to loop
  d=(D/128)%16;
  requestAnimationFrame(X)
  g.clear(16640);
  E = R(d);
  // Calculate view matrix
  v = [
   1, 0, 0, 0,
   0, 1, 0, 0,
   0, 0, 1, 0,
   -E[0], -E[1], d/4, 1
  ];
  // vertexAttribPointer(getAttribLocation(), 3, FLOAT, uniformMatrix4fv(getUniformLocation(), 0, v), 0, 0)
  g.veAP(P=g.geAL(p,'p'), 3, 5126, g.unM(g.geUL(p,'v'),0,v), 0, 0);
  g.enVAA(P);
  g.drE(1, 4096, 5123, 0);

})()