■ List of all posts■ Part 3:Building a sceneSo, so far we've figured out what we have to model (sticking to the limited set of 55 configurations arising from 2x2x2 volumes), and we've figured out how to bring those models to Unity in an organized and fast fashion (using custom asset processing scripts). Great, now we have a huge pile of meshes on our hands, with attached metadata about configurations they represent. How do we build a level out of them?
Well, we can do it the old-fashioned way - by placing the blocks into a scene, moving them, rotating them, copying them, using grid snapping to line them up, and so on. Just like most people do with modular assets.
Except come on, that's really draining and unfun. A small area, like one used by the scene in the images on top of this thread, can use more than ten thousands blocks. Do we really want to look for a right one in a list of 55 blocks, hundreds and hundreds of times over? Do we really want to handle their rotations, their precise alignment? Do we really want to hunt the scenes for holes in geometry, for misplaced block types not meeting correctly? Nope. Let's focus on fun work like authoring the tileset assets, and let robots do the boring placement work. We need to automate this.
Let's take a look back at part 1 of our devblog. We have decided to use a full/empty volume point grid as a base for our blocks. Every block type represents a certain combination of 8 full/empty points, corners of 2x2x2 cube:
So, let's expand this idea and make the grid bigger. Similarly to voxel-driven games like Minecraft, we can have space represented by an evenly spaced set of points, each either full or empty.
For the sake of convenience, we ended up using simple 1d bool arrays, and 3 integers describing dimensions, not 3d arrays, but that's details. More importantly, instead of using the full/empty
volume points directly, we will look at the
spots between them.
■
1920x1080Each spot, instead of being a full/empty boolean, is a description of a
configuration the surrounding eight volume points make up. The most obvious way of defining a spot is, of course, a boolean array. We can have foo[0] as a top south-east corner of a cube, foo[1] as a top north-east corner of a a cube, and so on. But once you start looking at those arrays, you see a better alternative. Eight bools can be represented as eight numbers, with each being either 0 or 1 - it's a convenient way to print those eight entries long arrays for debugging, e.g. 00101110, 01000111 and so on.
In other words, our bool arrays can be sets of eight bits. Let's also remember that a 2x2x2 set of variables with two possible values gives you 2^8 = 256 permutations. What value type can store exactly 256 possible values and is represented by exactly 8 bits? That's right, it's a byte!
If we use bitwise operations, we can treat any byte as a set of 8 bools. So, to get a performance friendly, easily readable array with spot information, all we need to do is to fetch the states of 7 neighbors for every volume point (with the remaining true/false value being the currently iterated volume point), treat each point as a bit, and generate a byte from that. Doing that for every point gives us an array of bytes describing the shapes of grid step sized chunks of our scene.
Obviously, since our processed tileset assets already pack data about associated volume configurations, we can immediately use that byte array to spawn blocks in our scenes:
- Since, as I've mentioned in part 1, we only author 55 shapes or less, the scene manager creates a lookup table mapping the 55 block assets to the full set of 256 byte values (possible volume configurations). It contains block reference, block instance rotation ID (there are 4 possible rotations) and a flipping flag (since we've also trimmed configurations through X-mirroring at asset authoring stage)
- Then we set up a pooled set of game objects equal in size to the spot array
- Then we iterate through all spots, and for each spot, we convert it's index into a world space position, check the block lookup list for a match to spot configuration, and instantiate the looked up block reference, using the world space position, looked up rotation ID and a looked up flipping flag.
So, now we have a way of building a scene from blocks: it's an automated process, so we never have to touch rotation/move/copy tools, search the project for a certain block every time it's needed, and so on. We expect to get results like those:
■
1920x1080We need a data set to feed to our script, though. How do we make it? We can fill arrays with random noise, but it's not a very good test of our tileset. It would be best to have a way to edit the scenes directly. There is a way to do that available out of the box - Unity can present an inspector for any array of almost any type. Seriously though, do we really want to use this for even a 64-point long 4x4x4 volume?
Nope. Instead, I wrote a simple presentation method that generated grid step sized boxes with colliders at positions of all full volume points, and generated smaller boxes at positions of all empty volume points neighbouring a full volume point. Then I just do raycasts from mouse pointer of a user against that geometry, and if they hit anything, check the pressed buttons. Voila, we have a way to edit volumes visually, just like in many voxel games. After some polish, like adding color coding, mouseover hinting and so on, editing looks like this:
Now we can test the block placement! Here are some examples:
■
1920x1080 ■
1920x1080 ■
1920x1080 ■
1920x1080Speaking of which: since every single configuration is, roughly speaking, shaped by grid-sized fills protruding from full volume points, simply placing cube colliders on every single full volume point creates great collisions for the whole level! So you can already do visibility checks, bounce particles, land projectile hits onto walls and so on, from just this dead simple generator.
That's all well and good, but at this point, the scenes that the system can produce don't really inspire that much. Yes, they are instantly assembled and you save hours of work through use of the scene system, but in the end, having just one block representation per each 2x2x2 volume configuration limits the variety of results. There is only one vertical wall, there is only one roof corner, there is only one floor.
■
1920x1080Yes, you can play with some interesting shapes, like checkerboard-pattern balconies and such, but in the end, you have no variety. There is no cobblestone, asphalt or grass. Buildings have neither doors nor windows. You can't add pipes, you can't add fire exits, you can't add HVAC systems to the roofs, and so on.
So, uh, let's fix this. We look at the tileset and decided to add more subtypes to each frequently grouped configuration that couild use more variety. We made versions of the 00110011 wall block with windows, doors, ladders and balconies, some versions of 00001111 flat floor block with roof exits, grass, asphalt, vents and hatches, and many others. The assets look like this:
■
1920x1080 ■
1920x1080Question is, how do we push them through into the engine and how do we teach the scene system to use them?
Well, for the pipeline, the approach is the same as before - we parse what we find in the 3d scene. Keeping your 3d scene clean and maintaining an orderly naming of objects in it has it's benefits. We embed more information into the block holder names - instead of just "00001111" being a floor block, we name many versions of the floor "00001111_0", "00001111_1" and so on. The asset pipeline then recognizes those suffixes as subtype indexes and exports all the processed variants (
subtypes) of a block as an ordered set of assets. Here they are, automatically cleaned up, merged and material-swapped in a way described in part 2 of the blog:
■
1920x1080Now to get it to work with our scene system. Well, that's relatively simple to implement: in addition to a configuration byte, each spot should store an index of a block subtype (another byte variable). A block lookup table created on tileset loading should contain not a single block asset reference, but an array of subtype assets, with indexes matching 3d scene subtype indexes. The scene system can then easily instantiate a subtyped block for a spot requiring it.
Here are some gifs of a simple block subtype selection implementation. Left click redraws a block using the next available subtype, right click redraws it using a previous available subtype:
The only problem remaining is the fact that everything has a fixed orientation. A roof exit can only be pointing north, a road corner can only cover a south-west edge of a road turn, and so on. That's an obvious consequence of blocks visualizing a fixed, non-rotatable shape of the 2x2x2 volume point set. But what if we did something on top of the base configuration-mandated flipping and rotations?
What if we generate 4 rotated and 4 flipped/rotated copies of each of the 256 configurations, then check whether configuration stays the same after some or both of these operations. For example, a floor configuration 00001111 stays exactly the same no matter how you flip it or rotate it. There are also some complex cases where flipping yields an identical configuration if an additional rotation was performed (e.g. a letter L can be flipped horizontally, then rotated once by 90 degrees clockwise to produce an L again) - we have to detect those cases too, with some additional checks.
Then, if we encounter a configuration that has those rotation symmetry or mirror symmetry properties, we mark it as cleared for cosmetic rotation or flipping in the configuration lookup list. So, now the system can know that a certain configuration can arbitrarily rotate and/or arbitrarily flip the instantiated block objects.
To facilitate that, we add more data to the spots - this time, we add an index telling the system which rotation and flipping should be used on instantiation of an associated block objects. And voila, we can now orient our roads, roof exits, balconies, fire exit catwalks and a host of other stuff visualized through block subtypes in any way we want. Let's take a look at this in action:
So, to recap: now we have a functional asset pipeline and a scene editor which can be used to reproduce almost everything you see in the opening post concept art. You don't spend a single minute wasting time on convoluted asset export, on manually placing the blocks, on hunting for holes in geometry. You just spend all your time drawing more and more assets and playing with volumes to shape your scenes.
We have some ideas for improving this further, of course.
- Making subtypes layered, so that adding e.g. another road corner subtype 6 after a previous road subtype 5 in a set of 10 subtypes won't break drawing of all the subtypes further up in the numeration. Instead, subtypes can be declared with a category first, and with an index second, e.g. 00001111_roads_0, 00001111_roads_1, 00001111_exits_0, 00001111_exits_1, and so on.
- To make placement of complex subtype groups easier (e.g. roads and pipes with turns, corners and such), it might be worth making subtype selection automatic, based on higher level controls. For example, I have an idea for something called contexts, which are basically tags you paint over spots - that chain of 10 spots is a road, that group of spots is a pipe, and so on. Then, a system, using simple code similar to edge tile placement implementations in 2d games, will select an appropriate subtype (central fill, straight edge, outward edge corner, inward edge corner) and appropriate flipping rotations of those subtyped blocks. So, instead of 10 clicks to place road subtypes and 20 clicks to rotate pieces like corners, you'll just do a few brush clicks on the road area and the system will place proper edges by itself.
- Blocks should have a prop system with points allowing for random spawning of randomly selected randomly rotated props. Parking lots should have cars, windows should have blinds, grass should have vegetation and so on, and not everything makes sense as a manually defined block subtype.
We still have more already implemented material on the scene system to cover too.
- Rendering tens of thousands of block meshes separately is a pretty bad idea with discouraging metrics like drawcalls, so we avoid that
- Levels need color variety, preferably without incurring any additional drawcalls, as a naive material swapping approach would do, so we've got a trick to drive color and many more parameters like emission without branching materials
- Block volume is limiting, you can't fit all the window/door/roof/equipment designs into that small space. We need a way to integrate bigger objects into levels - and so we solve that, with a multi-block system we'll take a look at in another post.
- It's a tactical game about giant robots, so the most important question should be "can we blow up everything"? The answer is a resounding yes, and I'm quite proud of our destruction system. Again, we'll talk about it too.
As usual, next post will be up in about a week. I welcome any questions, by the way - those posts aren't really a static promo material I just throw out, I'm open to answering questions, checking suggestions and so on.