Sunday, September 25, 2011

GLSL

Most people have already seen GLSL in action in several Minecraft mods, ranging from bump- and parallax mapping to bending the world into one giant acid trip. Other games, such as Mythruna, use it out of the box for effects like HDR bloom.

Personally, I prefer Hexahedra's current look, with the simple textures and the blocky lighting. But there are several other interesting things possible that have nothing to do with aesthetics.

One of them is the use of texture arrays. An old technique to speed up rendering is to use a texture atlas: a large texture that combines several smaller ones. The drawback is that such textures cannot be tiled, so every cube has to be drawn separately. Minecraft uses (used?) a texture atlas that is exactly one tile wide, so the textures can at least be tiled horizontally. Strips of cube faces can be merged together, which cuts back on the amount of work the GPU needs to do.


A texture array is a kind of 3-D texture. The individual textures are arranged in a stack. Each slice can now be tiled both horizontally and vertically, so we can merge even more faces. The gains are the most obvious for open water, where 256 cubes with the same texture and lighting can be merged into a single, large square.

And in one fell swoop, it also solves all problems with textures bleeding into each other at the edges because of mipmapping and rounding errors.

There's another advantage. Every corner used to be stored as three shorts (x, y, z) for the position, two floats (u, v) for the coordinates in the texture atlas, and three bytes (r, g, b) for the lighting. That's 68 bytes per face.

Such restrictions no longer exist in GLSL. We can put the coordinates in bytes. (I'd put them in nibbles, but the range is 0-16, not 0-15). Now that we're using texture arrays, we need a short to pick a slice, and two nibbles for the u,v coordinates. Three more nibbles for the lighting: ambient, sunlight, and artificial light. The actual light colors are grabbed from uniforms, global values we need to set only once. So we're down to 32 bytes per face. Nice!

Hmm... The u,v coordinates now move in lockstep with the vertex positions. It turns out that by storing a normal, we can go from world coordinates to texture coordinates by a simple multiplication. We only have six possible normals, so that's three bits. And we're down to 28 bytes per face. Even better, we can now use the normals for the ambient lighting as well. Dusk and dawn are going to look spectacular if we can give the west (c.q. east) faces a red or pink hue.



I look at the top of the screen where it says "20 FPS". Frown. Revert to previous shaders. 60 FPS. The fuck?

Turns out a simple lookup in a table with six 2x3 matrices is painfully slow in GLSL. It might not be worth the extra 4 bytes per face, but it would be neat to have anisotropic ambient light. Perhaps a lookup in a small 1D texture would solve this? I'll have to spend some more time on this.

9 comments:

  1. Your work is truly awesome!

    How have you been implementing your voxel/block system? I am working on a similar project, but I am stuck on trying to figure out how to make the voxels and blocks work efficiently.

    Are you simply using a 3D array? if so how are you extracting you r mesh surface(s)?

    Thanks!!!!!

    ReplyDelete
  2. Hey TronMC!

    I already responded to your PM, but I think I'll also write a blog post about it.

    ReplyDelete
  3. Thanks Nocte! I didn't know where would be the best place to contact you so I posted both places.

    Thanks again!

    ReplyDelete
  4. Hi.
    Good work and thanks for your blog with usefull insights.
    However, I don't understand how you can generate texture coordinates from world position. Two vertices may have the same world position but require different texture coordinates, because part of different faces.
    A drawing would help, but I don't know how to upload images so I'll do this:


    A---BE---F
    |___|___|
    D---CH---G
    Here, we have two faces ABCD and EFGH the points B and E (and C and H) are at the same position but B will have texture coordinate like (1,0) and E will have (0,0)
    I hope you understand what I'm trying to say.
    Thanks.

    ReplyDelete
  5. Hey NickD,

    Yes, I think I understand. :) If the two faces ABCD and EFGH are in the same plane, B and E would have the same texture coordinates. We're using a texture array so they wrap in both directions. So E has (1,0), F has (2,0), and it will tile perfectly.

    If they're not in the same plane, but B and E still share the same vertex, their mappings would depend on the normals of the faces, and the multiplication matrixes from the lookup table. So if the B/E vertex is at (1, 3, 5), B's uv could be (1, 5), and E's could be (3, 5). If A is at (0, 3, 5) and F at (1, 4, 5), their respective texture coordinates would be (0, 5) and (4, 5).

    Hope this explains it!

    ReplyDelete
  6. Okay I understand now, I was assuming that the texture coordinates should stay inside [0;1] but you're right, they can be higher and the texture will be repeated.
    About the normal face, you could probably avoid the lookup table by having a different vertex array for each normal direction (only 6 after all, but of course this needs more draw calls). This way all the faces of the same array share the same normal, so no need to store the normal on a per face basis, it can just be passed as a uniform. So you save bytes and you can specialize the texture coordinate computation based on the normal direction.
    But maybe you're already doing this and I've missed something?
    I have another question:
    I think the number of texture you can "slice" is limited, even on recent GPUs. Isn't that an issue if you want lots of different materials?
    Thanks again!
    Nick

    ReplyDelete
    Replies
    1. That's actually a very good idea! I was worried about the draw calls for a moment, but of course it is ridiculously easy to limit it to at the most 3 calls per chunk. I should definitely give that a try someday.

      I admit I haven't looked much into the limits of GL_MAX_ARRAY_TEXTURE_LAYERS. A quick google gives me varying values, all in the "couple o' thousand" range, but I wouldn't be surprised if older cards won't be able to keep up with material-heavy servers. The client already has an OpenGL 2.1 mode with a simple texture atlas, perhaps that could be a fallback for such cases. (At least until I've devised a better way of doing this with multiple array textures and a minimum of switching.)

      Delete
  7. I'm glad I could help! You can indeed get rid of half of the faces, but not for the chunks that are on the same axis as the player. You still need to display 5/6 faces for these chunks.
    You're right, GL_MAX_ARRAY_TEXTURE_LAYERS seems to be around a thousand for recent graphic cards, I was afraid it was closer to 8! But I was wrong, it seems fine. Now that I know that, I will probably try to implement it on my own program. (http://code.google.com/p/cube-wars/wiki/PictureStory)
    Thanks

    ReplyDelete
    Replies
    1. That's a cool project you got there! I guess everyone experimenting with this sort of thing ends up using binvox and broville somehow. ;) Good luck trying the trick with the texture array, let me know how it works for you.

      Delete