How to use Blender as a Box2D Platformer Level Editor [Tutorial]
Hey guys so you want a level editor but don't want to build one yourself right? Who does? You basically need to create a entire tangential project, with full GUI editor and undo tree etc. Unless that's a goal of yours (selling etc?) you probably want an easier solution.
Well folks, look no further than your good friend Blender 3D. The same tool you use for modeling crazy cartoon knights swinging swords and whatnot, can be your level editor for your 2D game.
A Plan
So how do we do this? What do we need?- We need a output format that's easy to parse.
- We need to decide on attributes that we can assign meaning to (for box2D and our game)
- The texturing and vertex placement etc, comes for free since we're using a 3D modeling format :)
The OBJ file format.
Why OBJ? This is super easy to parse. You could use another format, if you're already using a parser (I recommend ASSIMP :) ), but for this tutorial, I'm going to use OBJ so you can see all the gory details of parsing it. Don't run yet, it's easier than it sounds :)
Ok so we know OBJ can specify vertex types, color, texture etc, but how does this relate to Box2D and how will we attribute context so we know what object type to create?
Aha, that's pretty simple too. We can just use parts of the Blender material name to assign meaning. For example, for solid static objects we could have the substring "solid" to designate this is a Box2D object which will be collidable with other physics objects.
For example
material name = "solid_wood"
Notice material name "solid_wood"
For maybe a dynamic rock we could use:
material name = "ball_dynamic"
Or even easier, if the code knows about this object type, we could just name the type of game object outright:
material name = "rabid_squirrel"
Then we just do a string compare on the material name of each object read to see if we should make a "rabid_squirrel" at that position.
rabid_squirrel is the material name, but it's also the name of the GameObject (RabidSquirrel) :) easy peezy
Note: For Hoarder Monkey, I just assume anything without a special keyword "solid" etc, or an outright object type we know, about is just a decoration object that has no collision associated with it (pass through).
You could also do nutty things in blender, such as assign meaning to different objects that should be joined with a joint. E.g.
One object in Blender could be:
material name = "wood_object1_jointA"
The other part of the joint could be:
material name = "wood_object2_jointA"
Then you could assign a pivot point by another object(s) and just use the center of this object as the anchor point(s), etc. But I won't get into that in this tutorial. It's a bit complex, for just getting started.
We can also assign other meaning to the z value of the objects. For example, far away objects could use a special shader for parallax etc (see other post)
Anyway, with these little tricks we can repurpose Blender as a pretty solid Box2D editor.
First let's build a OBJ parser. For brevity I'm not going to explain OBJ format or the parser, but I am open to any questions so I can clarify anything.
Just to note the current known limitations are:
Then you could assign a pivot point by another object(s) and just use the center of this object as the anchor point(s), etc. But I won't get into that in this tutorial. It's a bit complex, for just getting started.
We can also assign other meaning to the z value of the objects. For example, far away objects could use a special shader for parallax etc (see other post)
Anyway, with these little tricks we can repurpose Blender as a pretty solid Box2D editor.
Lets get started.
First let's build a OBJ parser. For brevity I'm not going to explain OBJ format or the parser, but I am open to any questions so I can clarify anything.
Just to note the current known limitations are:
- Joined objects will crash the parser
- No colored vertices, just textures
- Don't export normals (it might do weird things?)
- Important: Make sure the winding order of your objects is correct (the normal shown in blender should point out of the screen). This is subtle, so if you're unsure, turn them on in the viewer:
- First push 1 on your Number pad so your view has the blender Z axis up and blender X axis to the right.
- All objects must be triangulated before exporting to OBJ (attach the "triangulate" modifier to every object. No need to apply it though, if you set the "apply modifiers" option on export).
- If you don't, things will not render correctly and or, Box2D will crash due to sending vertices in the wrong order (see issue above) . Let's just avoid all these issues and triangulate :)
- When exporting to obj: make sure that your
- Forward axis is set to "-Z Forward" (for opengl )
- Up Axis to "Y Up"
- I also chose relative path mode so textures paths are referenced correctly):
So great, now how do we model this in blender? For dynamic objects I simply use the "Import as planes" menu option.
If this option is not available in your menu (it isn't by default usually) enable it by going to:
File->User Preferences; then search for "plane" and check the box on "Import-Export: Import Images as Planes", addon.
When you import your plane, Be sure to rotate the object by 90 degrees on the x axis.
For repeating texture type objects, ground, etc, you can simply model your 2D object however you want, taking care that your normals are pointing out of the screen. Then you can UV map the object to your liking.
Parsing the ".obj"
Feel free to skip over this section!Remember, this is just to be a clear example. Please feel free to use assimp for a free out of the box, excellent parser. But if you are having a hard time porting to your platform, the below obj parser, while limited, should do just fine for most situations.
Here's our class definitions for storing the obj data. ObjFile is your main data source, that will contain all your ObjModels and a list of vertices and texture coordinates that they will index from (that's the structure of Obj).
Ok, ok I'll give you a little example on the file output, but this is a bit out of scope of this tutorial:
# Blender v2.70 (sub 0) OBJ File: 'test_blender_level.blend
# www.blender.org
o palm_tree_Plane.046
v 5.825770 2.393540 -25.426136
v 36.587597 2.393540 -25.426136
v 5.825770 33.155369 -25.426109
v 36.587597 33.155369 -25.426109
vt 0.999900 0.000100
vt 0.999900 0.999900
vt 0.000100 0.999900
vt 0.000100 0.000100
usemtl palm_tree
s off
f 18/9 20/10 19/11
f 17/12 18/9 19/11
- In this case the line starting with:
- 'o' is the object name;
- 'v' a vertex,
- 'usemtl' is the material name of the object (important!),
- 's', ignore it it's about shading (if you're doing just textured sprites, you won't care)
- 'f', the "faces" of the object. (e.g "f 18/9 20/10 19/11" The vertex at index 18, is paired with texture coord at index 9, etc).
Anyway, that's not terribly pertinent to this tutorial. Please look up wavefront format to get a better grasp on the file if you're interested.
[ ObjLoader.h ]
#ifndef OBJLOADER_H #define OBJLOADER_H #include <vector> #include <string> #include <map> #include "Vector2D.h" #include "Vector3D.h" //*************************************************************************** /** */ class ObjMaterial { public: std::string name; std::string kd_image_path; std::string ka_image_path; std::string ke_image_path; std::string toString() const; void loadTextures(); }; /// <material_name, ObjMaterial> typedef std::map<std::string, ObjMaterial> ObjMaterialMap; //*************************************************************************** /** * Set of faces associated with a material */ class Faces { public: std::string material_name; /// All face indices of model std::vector<int> vertex_index; /// All texture indices of model (if they exist) std::vector<int> texture_index; }; class ObjFile; //*************************************************************************** /** */ class ObjModel { public: /// Name of model std::string name; /// All faces as a list of vertex/texture indexes std::vector<Faces> faces; ObjFile* obj_file; ObjModel(ObjFile* file): obj_file(file) {} }; //*************************************************************************** class ObjFile { public: std::vector<ObjModel> obj_models; ObjMaterialMap material_map; /// All 3D vertices of model std::vector<Vector3Df> vertex; /// All texture coords of model (if they exit) std::vector<Vector2Df> texture_coord; ObjFile(): obj_models(), material_map(), vertex(), texture_coord() {} static void createVerts (const ObjModel& obj, std::vector<Vector2Df>& tex_coords, std::vector<Vector3Df>& verts, std::vector<uint16_t>& indexes, const Faces& faces); }; //*************************************************************************** /** * All OBJ model loading functions */ namespace ObjLoader { void load_obj_file(const std::string file, ObjFile& obj_file); } #endif /* OBJLOADER_H */
[ ObjLoader.cpp ]
#include <fstream> #include <vector> #include <string> #include <sstream> #include <string.h> #ifdef ANDROID_NDK #include <android/asset_manager.h> #include <android/asset_manager_jni.h> #include "AndroidJNIHelper.h" #endif #include "Vector2D.h" #include "Vector3D.h" #include "Logging.h" #include "Game.h" #include "FileIO.h" #include "ObjLoader.h" using namespace std; const string MATERIAL_HEADER = "usemtl"; const string MATERIAL_FILE_HEADER = "mtllib"; const string NEW_MATERIAL_HEADER = "newmtl"; const string MAP_KD = "map_Kd"; const string MAP_KA = "map_Ka"; const string MAP_KE = "map_Ke"; const string MAP_D = "map_d"; //*********************************************************************** inline int StrToInt(const string &str) { int i; if (sscanf(str.c_str(), "%i", &i) == 1) return i; else return 0; } //*********************************************************************** inline std::vector<string> split_string(const string& str, const string& split_str) { //printf("split_string: %s\n",str.c_str()); vector<string> stlv_string; string part_string(""); string::size_type i; i=0; while(i < str.size()) { // If this char in string is a split char if(split_str.find(str[i]) != string::npos) { // Save the built string so far stlv_string.push_back(part_string); // Reset to capture further string pieces part_string=""; // Ignore multiple delimiters in a row. while(split_str.find(str[i]) != string::npos) { ++i; } } else { part_string += str[i]; ++i; } } if (!part_string.empty()) stlv_string.push_back(part_string); //printf("stlv_string: %s\n",part_string.c_str()); //exit(1); return stlv_string; } //*********************************************************************** /** * Clean up path of ObjMaterial */ void cleanPath(ObjMaterial& material) { while(replace(material.ka_image_path, "../", "./")); while(replace(material.kd_image_path, "../", "./")); while(replace(material.ke_image_path, "../", "./")); replace(material.ka_image_path, "assets/", "/"); replace(material.kd_image_path, "assets/", "/"); replace(material.ke_image_path, "assets/", "/"); LOGD("Cleaned paths Material: %s\n", material.toString().c_str()); } //*********************************************************************** void load_mtl_file( const std::string file, ObjFile& obj_file) { ObjMaterialMap& material_map = obj_file.material_map; //--------------------------------------------------- // Open the file //--------------------------------------------------- FILEIO_OPEN(infile,file); printf("About to load material file: %s\n", file.c_str()); bool parsing_material = false; ObjMaterial material; // Iterate through each line in the file string current_line; while(file_io.getLineAsset(infile, current_line) > 0) { stringstream line_stream(current_line); LOGD("MTL line: %s\n", current_line.c_str()); string header; line_stream >> header ; LOGD("Found header: %s\n", header.c_str()); if(current_line.find(NEW_MATERIAL_HEADER) != string::npos) { string name; line_stream >> name; LOGD("Found new material name: %s\n", name.c_str()); if(parsing_material) { cleanPath(material); material_map[material.name] = material; LOGD("Saving material: %s\n", material.name.c_str()); } material = ObjMaterial(); material.name = name; parsing_material = true; } else if( current_line.find(MAP_KD) != string::npos) { string path; line_stream >> path; material.kd_image_path = path; LOGD("Saving kd_map: %s\n", path.c_str()); } else if( current_line.find(MAP_KA) != string::npos) { string path; line_stream >> path; material.ka_image_path = path; LOGD("Saving ka_map: %s\n", path.c_str()); } else if( current_line.find(MAP_KE) != string::npos) { string path; line_stream >> path; material.ke_image_path = path; LOGD("Saving ke_map: %s\n", path.c_str()); } } if(parsing_material) { cleanPath(material); material_map[material.name] = material; LOGD("Saving material: %s\n", material.name.c_str()); } } //*********************************************************************** /** * Extract path of a file minus the file name * Will work with backslash and forward slash as path delimiters */ std::string getDirectoryOfFile(std::string file) { unsigned found = file.find_last_of("/\\"); return file.substr(0, found); } //*********************************************************************** /** * Load a obj file * * @param file file name to load */ void ObjLoader::load_obj_file(const std::string file, ObjFile& obj_file) { std::vector<ObjModel>& obj_models = obj_file.obj_models; printf("Entered ObjLoader::load(char*)...\n"); printf("File:%s\n",file.c_str()); printf("About to load file...\n"); // Count the number of faces placed in structure int face_count = 0; // The current model we are parsing for bool parsing_faces = false; Faces faces; bool parsing_model = false; ObjModel model(&obj_file); // Save dir name of this file string dirname = getDirectoryOfFile(file); // The materials file for this OBJ if it exists string material_file = ""; //--------------------------------------------------- // Open the OBJ file //--------------------------------------------------- FILEIO_OPEN(infile,file); // Iterate through each line in the file string current_line; while(file_io.getLineAsset(infile, current_line) > 0) { stringstream line_stream(current_line); LOGD("OBJ line: %s\n", current_line.c_str()); switch (current_line[0]) { //--------------------------------------------------- // Object name //--------------------------------------------------- case 'o': { string o; string object_name; line_stream >> o >> object_name; LOGD("Found new object name: %s\n", object_name.c_str()); // Save old model if we were parsing one if(parsing_model) { if(faces.vertex_index.size()) { model.faces.push_back(faces); LOGD("Saved old faces: %s\n", faces.material_name.c_str()); faces = Faces(); parsing_faces = false; } obj_models.push_back(model); LOGD("Saving model: %s\n", model.name.c_str()); } model = ObjModel(&obj_file); model.name = object_name; parsing_model = true; } break; //--------------------------------------------------- // Take care of vertex types //--------------------------------------------------- case 'v': { // check the next character on the line switch(current_line[1]) { // Parse normals case 'n': { // Vertex cordinates Vector3Df v; if(sscanf(current_line.c_str(), "vn %f %f %f", &v.x,&v.y,&v.z) != 3) { LOGE("Error reading line: %s", current_line.c_str()); continue; } LOGE("Normals ignored!: %s\n", current_line.c_str()); } break; // Parse texture coords case 't': { Vector2Df v; if(sscanf(current_line.c_str(), "vt %f %f", &v.x,&v.y) != 2) { LOGE("Error reading line: %s", current_line.c_str()); continue; } obj_file.texture_coord.push_back(v); } break; // Parse a normal vertex default: { // Vertex cordinates Vector3Df v; if(sscanf(current_line.c_str(), "v %f %f %f", &v.x,&v.y,&v.z) != 3) { LOGE("Error reading line: %s\n", current_line.c_str()); continue; } LOGD("Saving default vertex: %s\n", v.toString().c_str()); obj_file.vertex.push_back(v); } break; } } break; //--------------------------------------------------- // Parse faces from file //--------------------------------------------------- case 'f': { Vector3Di vert_index; Vector3Di text_index; if(sscanf(current_line.c_str(), "f %d %d %d\n", &vert_index.x, &vert_index.y, &vert_index.z) == 3) { LOGD("Parsed face line: %s\n", current_line.c_str()); faces.vertex_index.push_back(vert_index.x-1); faces.vertex_index.push_back(vert_index.y-1); faces.vertex_index.push_back(vert_index.z-1); LOGD("Vertex indexes saved (subtract 1): %s\n", vert_index.toString().c_str()); } else if(sscanf(current_line.c_str(), "f %d/%d %d/%d %d/%d\n", &vert_index.x, &text_index.x, &vert_index.y, &text_index.y, &vert_index.z, &text_index.z ) == 6) { LOGD("Parsed face line: %s\n", current_line.c_str()); faces.vertex_index.push_back(vert_index.x-1); faces.vertex_index.push_back(vert_index.y-1); faces.vertex_index.push_back(vert_index.z-1); faces.texture_index.push_back(text_index.x-1); faces.texture_index.push_back(text_index.y-1); faces.texture_index.push_back(text_index.z-1); LOGD("Vertex indexes saved: %s\n", vert_index.toString().c_str()); LOGD("Texture indexes saved: %s\n", text_index.toString().c_str()); } else { LOGE("Error reading face line: %s\n", current_line.c_str()); LOGD("Could be that there are normals in this file?\n"); continue; } ++face_count; } break; // Skip comments case '#': break; //--------------------------------------------------- // ObjMaterials lib //--------------------------------------------------- case 'm': { if(current_line.find(MATERIAL_FILE_HEADER) == string::npos) { LOGE("Could not find material header:%s in line: %s\n", MATERIAL_FILE_HEADER.c_str(), current_line.c_str()); continue; } string header; line_stream >> header >> material_file; LOGD("Found material file: %s\n", material_file.c_str()); } break; //--------------------------------------------------- // ObjMaterial type found //--------------------------------------------------- case 'u': { if(current_line.find(MATERIAL_HEADER) == string::npos) { LOGE("Could not find material header:%s in line: %s\n", MATERIAL_HEADER.c_str(), current_line.c_str()); continue; } string header; string material_type; line_stream >> header >> material_type; LOGD("Found material type: %s\n", material_type.c_str()); if(parsing_faces) { model.faces.push_back(faces); LOGD("Saved old faces: %s\n", faces.material_name.c_str()); } faces = Faces(); faces.material_name = material_type; parsing_faces = true; } break; // Skip unknowns default: LOGD("Skipped unknown line: %s\n", current_line.c_str()); break; } } // Save old model if we were parsing one if(parsing_model) { if(faces.vertex_index.size()) { model.faces.push_back(faces); LOGD("Saved old faces: %s\n", faces.material_name.c_str()); faces = Faces(); parsing_faces = false; } obj_models.push_back(model); LOGD("Saving model: %s\n", model.name.c_str()); } // Parse materials file if(material_file.size() > 0) { load_mtl_file(dirname + "/" + material_file, obj_file); } } //*********************************************************************** void ObjMaterial::loadTextures() { Game& game = Game::getInstance(); if(kd_image_path.size() ) { game.addTextureToLevel(kd_image_path); } if(ka_image_path.size()) { game.addTextureToLevel(ka_image_path); } if(ke_image_path.size()) { game.addTextureToLevel(ka_image_path); } } //***************************************************************************** /** */ void ObjFile::createVerts(const ObjModel& obj, std::vector<Vector2Df>& tex_coords, std::vector<Vector3Df>& verts, std::vector<uint16_t>& indexes, const Faces& faces) { int stored_index = 0; for(size_t vi = 0; vi < faces.vertex_index.size(); vi++) { int index = faces.vertex_index[vi]; const Vector3Df& vert = obj.obj_file->vertex[index]; bool found = false; for(size_t i = 0; i < verts.size() && !found; i++) { if(verts[i] == vert) { found = true; // Found vert in list so store it's index indexes.push_back(i); } } if(!found) { verts.push_back(vert); // Add texture coordinates in the same order as the verts in the // face list e.g.: // f 4/40 7/20 // vet[4] will be same index as tex[40] when stored if(faces.texture_index.size() == faces.vertex_index.size()) { const Vector2Df& tex = obj.obj_file->texture_coord[ faces.texture_index[vi]]; tex_coords.push_back(tex); } indexes.push_back(stored_index); stored_index++; } } } //*********************************************************************** std::string ObjMaterial::toString() const { string to_ret; to_ret = "Name: "+ name + "\n" "kd path: " + kd_image_path +"\n" "ka path: " + ka_image_path + "\n" "ke path: " + ke_image_path + "\n"; return to_ret; }
Ok sorry for the dump there. Like I said, you could skip that if you're not interested in parsers. The key thing to take away here is not the low level details, but the overall idea on how to tie together Blender 3D file exports with creating a level in your game.
How to give this Obj data Context
Now let's take the data that we parsed and create our game objects. First we take in the big hunk of data (ObjFile) and create every game object from the ObjModel objects it contains.//***************************************************************************** /** * Load level based on OBJ parsed info */ void Game::loadLevel( ObjFile& obj_file) { Vector3Df location; Vector3Df size; for(ObjModel obj : obj_file.obj_models) { if(obj.faces.size() == 0) { LOGE("Obj to load has no faces!: %s\n", obj.name.c_str()); continue; } loadModel(obj); } }
Here's an example of using a material property to set a GameObject property. In other words using Blender as your Game level editor! Extend as you'd like.
This just uses the string comparison to see if the object is a METAL_BOX and creates it based on that specific material name.
//***************************************************************************** /** * Load model based on OBJ parsed info (from #ObjFile) */ void Game::loadModel( ObjModel& obj_model) { string material = obj_model.faces[0].material_name.c_str(); LOGD("Loading model: %s, material: %s\n", obj_model.name.c_str(), material.c_str()); if(obj_model.faces[0].material_name == METAL_BOX_MATERIAL) { LOGD("Create a METAL BOX from OBJ: %s\n", obj_model.name.c_str()); MetalBoxSprite* b = new MetalBoxSprite(); b->createFromObj(obj_model); } ....
Here's another example of using a material property to set a GameObject property. Extend as you'd like.
This one is a little different than the last, it will take the partial match of the material name to decide if the object should be semi transparent.
//***************************************************************************** /** * Create a sprite from an OBJ model */ void Sprite::createFromObj(const ObjModel& obj) { FrameCoord tex_coords; Vector3Df position = createVertsFromObjModel(obj, tex_coords); string material = obj.faces[0].material_name.c_str(); LOGD("Loading Sprite model: %s, material: %s\n", obj.name.c_str(), material.c_str()); // Example of using material name for object property if(material.find(SEMI_TRANSPARENT_PARTIAL_NAME) != string::npos || obj.name.find(SEMI_TRANSPARENT_PARTIAL_NAME) != string::npos) { LOGD("Found semi transparent sprite for material: %s\n", material.c_str()); setSemiTransparent(true); } else { setSemiTransparent(false); } // Override this in subclass to load a texture other than the OBJ one loadTextureBasedOnObjModel(obj, tex_coords); // Must be overridden in a subclass to do anything! createAnimationFrames(tex_coords); createBox2DBody(Vector2Df::convertTo2D(position)); } .... //***************************************************************************** /** * * @param obj to parse data from * @param tex_coords texture coords from ObjFile for this ObjModel * stored here after parsing @p obj. * * @return position to use for creating a Box2D obj */ Vector2Df Sprite::createVertsFromObjModel(const ObjModel& obj, std::vector<Vector2Df>& tex_coords) { assert(obj.faces.size()); std::vector<Vector3Df> temp_verts; ObjFile::createVerts(obj, tex_coords, temp_verts, index_list, obj.faces[0]); Vector2Df min = Vector2Df::convertTo2D(setVertexesAndCalcCenter(temp_verts)); calculateCenter(); return min; } ... //***************************************************************************** /** * Create the box2D body for this Sprite */ b2Body* Sprite::createBox2DBody(const Vector2Df& position, float density, float friction, b2BodyType type) { box2d_body = Box2DContext::createBox2DBodyPolygon( position, density, friction, vertex, index_list, this, type); return box2d_body; }
Physics Magic
Here's where the physics magic happens! We will not create the box2D object from the ObjModel parsed. I just use a Box2D, b2PolygonShape object so we can create any editor object (so long as they are counter-clockwise winding vertices)
//***************************************************************************** /** * Create the box2D body for this Sprite */ b2Body* Box2DContext::createBox2DBodyPolygon(const Vector2Df& position, float density, float friction, const std::vector<Vector3Df>& vertex, const std::vector<uint16_t>& index_list, GameObject* obj, b2BodyType type) { if(vertex.size() == 0) { LOGE("vertex.size() == 0, not creating a b2Body!\n"); return NULL; } if(obj == NULL) { LOGE("User data was NULL in createBox2DBodyPolygon!\n"); return NULL; } if(obj->getGameObjectType() == INVALID_GAME_OBJECT) { LOGE("INVALID_GAME_OBJECT was passed to " "createBox2DBodyPolygon() " "position: %s, density: %f, friction: %f" "\n", position.toString().c_str(), density, friction); } // Get current Box2D context Box2DContext& box2d_context = Box2DContext::getInstance(); b2BodyDef bd; bd.type = type; bd.userData = obj; bd.position.x = position.x; bd.position.y = position.y; b2Body *box = box2d_context.getb2World()->CreateBody(&bd); vector<Vector2Df> list_2d; convertTo2D(list_2d, vertex); LOGD("list_2d.size(): %zu\n", list_2d.size()); // Number of fixtures created int i = 0; // Code to render when not using a vertex index list // (for generated sprites not from blender etc) if(index_list.size() == 0) { b2FixtureDef fixture_def; vector<Vector2Df> face; for(auto v: list_2d) { face.push_back(v); if(face.size() == 3) { LOGD("Creating fixture: %d\n", i); b2PolygonShape shape; shape.Set((b2Vec2*)&face[0], (int32)face.size()); fixture_def.shape = &shape; fixture_def.density = 1.0f; fixture_def.friction = 0.3f; box->CreateFixture(&fixture_def); face.clear(); i++; } } } else { LOGD("Creating Box2D fixtures from vertex index!\n"); b2FixtureDef fixture_def; vector<Vector2Df> face; for(auto index: index_list) { face.push_back(list_2d[index]); if(face.size() == 3) { LOGD("Creating fixture: %d\n", i); b2PolygonShape shape; shape.Set((b2Vec2*)&face[0], (int32)face.size()); fixture_def.shape = &shape; fixture_def.density = 1.0f; fixture_def.friction = 0.3f; box->CreateFixture(&fixture_def); face.clear(); i++; } } } if(i < 2) { LOGE("Only created %d fixtures!\n", i); } // Save body reference return box; }
Ultimately, you decide how you want to hack the material name string to do your evil bidding.
Hacking Blender for your game level editor is so.... evil
Comments
Post a Comment