Rendering Blocky Terrain Tiles

Started by
3 comments, last by Trienco 11 years, 2 months ago
I'm trying to render a MineCraft-esque heightmapped terrain in blocks. The specifics are as follows:

1. Each tile is a cube in 3-space.
2. Theoretically infinite expansion.
3. Each Tile having an independent texture. This is the tricky one for me.
4. Each texture covers all sides of a block. Eventually, I may need full skins like MC uses, but I'm not concerned with that at the moment.

I've got #2 covered with noise functions. #1 is trivial in isolation, but I encounter problems coupling it with 3.

My original idea/implementation was texture atlasing. However, atlasing a uniform terrain like this requires a lot of vertex duplication (36 vertices for every cube, because no vertices can be shared between anything beyond the quad level for atlasing to work), and batch limits mean I need an enormous number of draw calls for even a small sample. The other alternative I thought of was to create a new VBO for every texture, and make the textures separate. The problem with this is that it requires at least as many draw calls as there are tile textures, even on small geometry samples. This doesn't scale terribly well.

How do people actually do it? It doesn't seem like a terribly complicated problem, and everyone else seems to manage just fine.
Advertisement

Both of your naive solution will work very well.

I've started using your second solution: 1dynamic vb per texture.

- Then i've subdivided my terrain in chunk.

- Then i've added atlasing.

- Then i've added what you call "skinnin" at a later stage.

My best advice, do it then optimize! Dont optimize what you dont need to.

The only true thing about software design is: "The best solution is always the simplest solution".

In other words, the most naive solution is ALWAYS the best one.

When that first naive solution dont fit your needs/problems anymore; move forward.

If you're just using unit cubes anyway, you can limit yourself to per-face information and with a texture atlas get away with a few bytes per visible face. After all it just takes a few bits to encode the face, one byte to index the texture atlas and 3 bytes for the x,y,z position. So even if you blow this up to 4x32bit to make your hardware happy, you can save a lot of memory in return for a little work in the geometry shader.

My version basically passes two ivec4. One with position and texture info, one with lighting info. x and z are stored in position.x, y is stored in position.y (z and w are basically wasted). each coordinate in the light info has the sun and block light info for one face vertex.

So you have uniform info that never changes (face vertices for a unit cube), some that changes per frame (daylight), some that changes per chunk (chunk coordinate offset), and the actual geometry for each visible face.

Major downside if you aim at a MC clone: MC doesn't exclusively use unit cubes. Though there are enough of them to consider rendering those and the "special" blocks separately, especially if you just want to experiment and see how far you can push it.

f@dzhttp://festini.device-zero.de

- Then i've added atlasing.

What's the point of atlasing if you tie each VBO to a unique texture?blink.png

My best advice, do it then optimize! Dont optimize what you dont need to.
The only true thing about software design is: "The best solution is always the simplest solution".
In other words, the most naive solution is ALWAYS the best one.
When that first naive solution dont fit your needs/problems anymore; move forward.

The problem is that the first solution isn't good enough now. I haven't yet coded the second one, but it's a non-trivial task if I'm not even sure whether it will be sufficient.

If you're just using unit cubes anyway, you can limit yourself to per-face information and with a texture atlas get away with a few bytes per visible face. After all it just takes a few bits to encode the face, one byte to index the texture atlas and 3 bytes for the x,y,z position. So even if you blow this up to 4x32bit to make your hardware happy, you can save a lot of memory in return for a little work in the geometry shader.

My version basically passes two ivec4. One with position and texture info, one with lighting info. x and z are stored in position.x, y is stored in position.y (z and w are basically wasted). each coordinate in the light info has the sun and block light info for one face vertex.

So you have uniform info that never changes (face vertices for a unit cube), some that changes per frame (daylight), some that changes per chunk (chunk coordinate offset), and the actual geometry for each visible face.

Major downside if you aim at a MC clone: MC doesn't exclusively use unit cubes. Though there are enough of them to consider rendering those and the "special" blocks separately, especially if you just want to experiment and see how far you can push it.

This sounds interesting, but I really have no clue what you're describing. I'm not sure if my tiles are all going to be unit or not, but I do know that whatever dimensions they are, they will be uniform, so I won't have half-tiles or anything like that. I'm not really making a MC clone, it's just the easiest way to describe the kind of terrain I'm trying to make. My terrain won't even have overhangs or be destructible.

The first step to save a lot of memory and/or unnecessary rendering is to think in visible sides of a cube. If your cubes describe a plane, then drawing all 6 sides for each cube means 5/6th of that work is completely pointless. For example, since you don't want overhangs, none of your cubes will ever need a bottom. On average, I wouldn't expect more than 2-3 sides of a cube being visible. So whenever a neighboring cube exists (and isn't transparent), there is no point in drawing that side.
If all your cubes are uniform, then storing the actual vertices for each cube means a huge amount of redundant information. For example, each cube in relation to its position will have these vertices:

Vector4 faceCoords[6][4] =
{
     {   Vector4(0,1,0,0), Vector4(0,0,0,0), Vector4(0,1,1,0), Vector4(0,0,1,0), } //Left
     {   Vector4(1,1,1,0), Vector4(1,0,1,0), Vector4(1,1,0,0), Vector4(1,0,0,0), } //Right
     {   Vector4(0,1,1,0), Vector4(0,0,1,0), Vector4(1,1,1,0), Vector4(1,0,1,0), } //Front
     {   Vector4(1,1,0,0), Vector4(1,0,0,0), Vector4(0,1,0,0), Vector4(0,0,0,0), } //Back
     {   Vector4(0,1,0,0), Vector4(0,1,1,0), Vector4(1,1,0,0), Vector4(1,1,1,0), } //Top
     {   Vector4(1,0,0,0), Vector4(1,0,1,0), Vector4(0,0,0,0), Vector4(0,0,1,0), } //Bottom
};
Instead of dumping a million copies of this information in a vertex buffer, it is stored in a uniform array used by the geometry shader. Texture coordinates can be hard coded in the shader (just be sure to always start with the top left vertex of each face). I'm reducing the shader code to position and texture coordinates.

void EmitFaceVertex(vec4 pos, vec3 uvt)
{
    gl_Position = mvp * pos;
    texCoord = uvt;
    EmitVertex();
}
 
void main() 
{
    ivec4 pos = position[0];
    int textureID = pos.w;
    int face = pos.z;
 
    pos = ivec4(chunkPos, 0) + ivec4(pos.x >> 4, pos.y, pos.x & 0xF, 1);
    
    EmitFaceVertex( pos + faceCoords[face][0], vec3(0, 0, textureID) );
    EmitFaceVertex( pos + faceCoords[face][1], vec3(0, 1, textureID) );
    EmitFaceVertex( pos + faceCoords[face][2], vec3(1, 0, textureID) );
    EmitFaceVertex( pos + faceCoords[face][3], vec3(1, 1, textureID) );
 
    EndPrimitive();
}
The chunk position is a uniform shader variable set once before drawing a chunk.
Above code is sticking to the MC chunk size of 16x16 cubes, which means the cubes x and z position relative to the chunk position fits in 4bits each. The y coordinate gets a whole byte (for 256 possible positions).
The actual "vertex buffer" stores "vertices" as consisting of 4 bytes. "x" contains the x and z position (hence the bit fiddling), "y" is actually y, z contains the index of the face to draw (0 = left, 1 = right, ...) and w is the textures index in the atlas.
You can minimize this even more, if you don't allow different textures for different sides of the cube. Then you don't send z as the index of the side to draw, but instead use one bit for each side. The shader then just iterates from 0-6, checks if that bit is set and then outputs the appropriate quad.

for (i = 0; i < 6; ++i)
{
    if (pos.z & (1<<i))
    {
        EmitFaceVertex( pos + faceCoords[i][0], vec3(0, 0, textureID) );
        EmitFaceVertex( pos + faceCoords[i][1], vec3(0, 1, textureID) );
        EmitFaceVertex( pos + faceCoords[i][2], vec3(1, 0, textureID) );
        EmitFaceVertex( pos + faceCoords[i][3], vec3(1, 1, textureID) );
 
        EndPrimitive();
    }
}
That way you can squeeze an entire cube into 32bits.
However, if the cubes are the result of a simple height map, you will probably want to use bigger chunks.
f@dzhttp://festini.device-zero.de

This topic is closed to new replies.

Advertisement