maanantai 14. toukokuuta 2018

Making of Wireframe World




For some time I've been trying to come up with a way to do a 3D wireframe landscape within the 140 byte size limit of Dwitter. This article details how I finally managed to pull it off.

1. The grid


Wireframe landscape is very tricky to do in limited space. The reason is the need to use the HTML5 Canvas API path drawing methods. The calls to x.beginPath(), x.lineTo() and x.stroke() already take up 33 bytes and that's not counting the statement separation. Also you need to call lineTo at least twice with different arguments to actually draw a line segment so you have to be really creative with the way you arrange the code. As usual lets start in 2D to keep things simple. Wireframe landscape can be thought of as a perfectly symmetrical grid but with height information stored for each vertex. So looking directly from above, it looks like a regular grid...




I know this might look boring unless you are really into Xs and Os. To draw a square we need four lines. With a grid we can cheat and just do two for each square...


With this simplification we've saved two calls to lineTo but our grid has two ugly unfinished edges. This is not a problem as we will make sure those are never visible in the demo.

So we are drawing only the left and top edges and the neighbors will take care of the rest. To do this efficiently the best solution I could come up with is to start from bottom left corner and then move to top left and top right corners. I've made a very important illustration to show this...


First we need to move to the right position. Normally this would be done with moveTo-method but lineTo works the same way when there is no previous points in the path. The movement sequence could be written X1,Y2 -> X1,Y1 -> X2,Y1. I'll just show you the full source code and then we'll break it into parts...

for(c.width=i=-9;++i<9;R=a=>x.lineTo(150+a*99/(z=j-t%1),z+90+(99+T(j+t&a+9|8)+a*a)/z))for(j=12;x.beginPath(x.stroke()),R(i),--j;R(i+1))R(i)

The grid points are done as expected with two nested loops. Outer loop with the variable i defines the x-coordinates and j defines the y-coordinates. The horizontal loop is centered around zero for simpler perspective projection and goes from left to right. The vertical loop goes backwards from 11 to 1. It's important to stop at one to avoid getting negative values for our perspective divider z. We create a function R that encapsulates the projection and path drawing. Arrow function syntax is naturally very suitable for code golfing. We'll use an argument a to pass an x-coordinate to the function and we can use j directly from global scope. Now we have our loops ready and a function that draws lines for us. Next we'll take a deeper look into the magic of the for-loop.

2. Understanding the for-loop


It is really important for any programmer to fully understand how a for-loop works. It is not necessarily obvious in which order statements and code are run so here is a quick reminder.

for(A;B;D){C}

  • Statement A is executed first and only once before the looping starts. It is used to initialize the loop
  • Statement B is executed before each repetition of the loop. It is the condition for running the loop. If it evaluates to false the loop stops, otherwise the loop will continue running
  • Code block C is executed if condition B was met
  • Statement D is executed after each repetition of code block C. Loop variable is often incremented here, but that is optional
Our inner loop looks like this. Lets go through what happens

for(j=12;x.beginPath(x.stroke()),R(i),--j;R(i+1))R(i)

In initialization we declare a variable j and set it to 12. For our conditional statement we are grouping statements in a way that some functions are called before the actual condition. This is something that is not normally done but allows us to run code before we change the value of j. The first function we call is x.stroke() which draws the current path. Since there is no path at first nothing is drawn, but we save one byte by placing it here as a useless argument. Then x.beginPath() gets called and starts a new path for us. Then we move to "X1,Y2" by doing our first call to R(). We pass our current x-coordinate i as an argument and the function uses our y-coordinate j directly. Then we decrement j and call R() again to draw our first segment to "X1,Y1". Notice here that the loop is now running the code block after evaluating the condition. Once --j evaluates to zero, the loop is stopped. Because we decrement first and then evaluate the condition, our code block will not run when j is zero. Noticing this optimization trick saved me 2 bytes and allowed me to fit the demo in 140 bytes after being stuck at 141 for several days. So why is this imporant. Well if we went to zero, we'd need to add 1 to our z calculation like this z=j-t%1+1After this the last statement is executed. Now we need to draw a line to "X2,Y1" so we pass i+1 as the argument. Now our path is complete and during the next repetition it gets drawn to canvas by a call to x.stroke().

The lesson here is that there are many ways you can use the for-loop. We are so used to sticking to the most common structures that it can be hard to notice when we benefit from a different approach.

3. Going 3D


Lets assume our grid lies flat on the floor. From where we look at it the horizontal axis is X and vertical axis is Y. Since it's flat, Y is constant. Lets have an axis perpendicular to X on the same plane and call it Z. This can approximately be thought of as our distance from any of the grid points. To achieve perspective projection we just need to divide both X and Y by Z. In our demo we could do it like this...

R=a=>x.lineTo(150+a*40/(z=j),90+90/z) 




Now we could use some movement. We want to go forward which means our distance should decrease. We do this by subtracting time from z. Our grid is limited in size so we will do a loop by using modulus operator...

R=a=>x.lineTo(150+a*40/((z=j-t%1)),90+90/z)





At this point my small 3D wireframe engine is working, but it's not very interesting. I tried to do various random terrains but wasn't happy with them. Then I remembered a cool unfinished JS1K demo by Bálint Csala and decided to do a small planet that spins around and has little spiky mountains. I like spruce trees so the end results also reminds me of Taiga landscapes. Anyway to make it seem like we are on a small planet we need to make the grid round both horizontally and vertically. Here's how I did it...

R=a=>x.lineTo(150+a*80/((z=j-t%1)),z+90+90/z+a*a/z)




This also effectively hides the ugly right edge of our grid. Now all we need is those spikes. It really is not easy to add any type of recognicable architecture to a 3D model this small. Often the best solution is to use trigonometric functions. This time I experimented with tangent planes until I found something I liked...

R=a=>x.lineTo(150+a*80/((z=j-t%1)),z+90+90/z+a*a/z+T(j+t&a+9|8)/z)


A bit more code golfing trickery and that's all of Wireframe World. I just wish there was room to use some 80s colors on black background.

Follow me on Twitter if you want to hear of new demos and blog updates - @jylikangas

Ei kommentteja:

Lähetä kommentti