Importing a model
The Assimp
library provides conversion tools for reading resource files, such as those for 3D mesh models, and provides a generic format on the application's side. For a 3D mesh file, Assimp
provides us with an aiScene
object that contains all the meshes and related data as described by the imported file.
After importing a model, we need to read the sets of data that we require for rendering. These are the types of data:
- Vertices (positions)
- Normals
- Texture mapping (UV)
- Indices
Vertices might be obvious; they are the positions of points between which lines of basic geometric shapes are drawn. Usually, three vertices are used to form a triangular face, which forms the basic shape unit for a model.
Normals indicate the orientation of the vertex. We have one normal per vertex.
Texture mapping is provided using so-called UV coordinates. Each vertex has a UV coordinate if texture mapping information is provided with the model.
Finally, indices are values provided per face, indicating which vertices should be used. This is essentially a compression technique, allowing the faces to define the vertices that they will use so that shared vertices have to be defined only once. During the drawing process, these indices are used by OpenGL to find the vertices to draw.
We start off our importer code by first creating a new file called assimpImporter.cpp
in the /jni
folder. We require the following include
:
#include "assimp/Importer.hpp" // C++ importer interface #include "assimp/scene.h" // output data structure #include "assimp/postprocess.h" // post processing flags // for native asset manager #include <sys/types.h> #include <android/asset_manager.h> #include <android/asset_manager_jni.h>
The Assimp include
give us access to the central Importer object, which we'll use for the actual import process, and the scene object for its output. The postprocess
include contains various flags and presets for post-processing information to be used with Importer, such as triangulation.
The remaining includes
are meant to give us access to the Android Asset Manager API. The model file is stored inside the /assets
folder, which once packaged as an APK is only accessible during runtime via this API, whether in Java or in native code.
Moving on, we will be using a single function in our native code to perform the importing and processing. As usual, we have to first declare a C-style interface so that when our native library gets compiled, our Java code can find the function in the library:
extern "C" { JNIEXPORT jboolean JNICALL Java_com_nyanko_andengineontour_MainActivity_getModelData(JNIEnv* env, jobject obj, jobject model, jobject assetManager, jstring filename); };
The JNIEnv*
parameter and the first jobject
parameter are standard in an NDK/JNI function, with the former being a handy pointer to the current JVM environment, offering a variety of utility functions. Our own parameters are as follows:
model
assetManager
filename
The model
is a basic Java class with getters/setters for the arrays of vertex, normal, UV and index data of which we create an instance and pass a reference via the JNI.
The next parameter is the Asset Manager instance that we created in the Java code.
Finally, we obtain the name of the file that we are supposed to load from the assets containing our mesh.
One possible gotcha
in the naming of the function we're exporting is that of underscores. Within the function name, no underscores are allowed, as underscores are used to indicate to the NDK what the package name and class names are. Our getModelData
function gets parsed is interpreted as being in the MainActivity
class of the package com.nyanko.andengineontour
.
If we had tried to use, for example, get_model_data
as the function name, it would have tried to find function data in the model class of the com.nyanko.andengineontour.get
package.
Next, we can begin the actual importing process. First, we define the aiScene
instance that will contain the imported scene, and the arrays for the imported data, as well as the Assimp Importer instance:
const aiScene* scene = 0; jfloat* vertexArray; jfloat* normalArray; jfloat* uvArray; jshort* indexArray; Assimp::Importer importer;
In order to use a Java string in native code, we have to use the provided method to obtain a reference via the env
parameter:
const char* utf8 = env->GetStringUTFChars(filename, 0); if (!utf8) { return JNI_FALSE; }
We then create a reference to the Asset Manager instance that we created in Java:
AAssetManager* mgr = AAssetManager_fromJava(env, assetManager); if (!mgr) { return JNI_FALSE; }
We use this to obtain a reference to the asset we're looking for, being the model
file:
AAsset* asset = AAssetManager_open(mgr, utf8, AASSET_MODE_UNKNOWN); if (!asset) { return JNI_FALSE; }
Finally, we release our reference to the filename
string before moving on to the next stage:
env->ReleaseStringUTFChars(filename, utf8);
With access to the asset, we can now read it from the memory. While it is, in theory, possible to directly read a file from the assets, you will have to write a new I/O manager to allow Assimp to do this. This is because asset files, unfortunately, cannot be passed as a standard file handle reference on Android. For smaller models, however, we can read the entire file from the memory and pass the file data which we just read from the asset file to the Assimp
importer.
First, we get the size of the asset
, create an array to store its contents, and read the file in it:
int count = (int) AAsset_getLength(asset); char buf[count + 1]; if (AAsset_read(asset, buf, count) != count) { return JNI_FALSE; }
Finally, we close the asset
reference:
AAsset_close(asset);
We are now done with the asset manager and can move on to the importing of this model data:
const aiScene* scene = importer.ReadFileFromMemory(buf, count, aiProcessPreset_TargetRealtime_Fast); if (!scene) { return JNI_FALSE; }
The importer has a number of possible ways to read in the file data, as mentioned earlier. Here, we read from a memory buffer (buf
) that the size of the buffer array was specified earlier with the count
value, indicating the size in bytes. The last parameter of the import function is the post-processing parameters. Here, we use the aiProcessPreset_TargetRealtime_Fast
preset, which performs triangulation (converting non-triangle faces to triangles), and other sensible presets.
The resulting aiScene
object can contain multiple meshes. In a complete importer, you'd want to import all of them into a loop. We'll just look at importing the first mesh into the scene here. First, we get the mesh
as aiMesh
instance:
aiMesh* mesh = scene->mMeshes[0];
This aiMesh
object contains all of the information on the data we're interested in. First, however, we need to create our arrays:
int vertexArraySize = mesh->mNumVertices * 3; int normalArraySize = mesh->mNumVertices * 3; int uvArraySize = mesh->mNumVertices * 2; int indexArraySize = mesh->mNumFaces * 3; vertexArray = new float[vertexArraySize]; normalArray = new float[normalArraySize]; uvArray = new float[uvArraySize]; indexArray = new jshort[indexArraySize];
For the vertex, normal, and texture mapping (UV) arrays, we use the number of vertices as defined in the aiMesh
object as normal, with the UVs are defined per vertex. The former two: vertex and normal arrays have three components (x, y, z) and the UVs as defined per vertex have two (x, y).
Finally, indices are defined per vertex of the face, so we use the face count from the mesh multiplied by the number of vertices.
All things but indices use floats for their components. The jshort
type is a short integer type defined by the NDK. It's generally a good idea to use the NDK types for values that are sent to and from the Java side.
Reading the data from the aiMesh
object to the arrays is fairly straightforward:
for (unsigned int i = 0; i < mesh->mNumVertices; i++) { aiVector3D pos = mesh->mVertices[i]; vertexArray[3 * i + 0] = pos.x; vertexArray[3 * i + 1] = pos.y; vertexArray[3 * i + 2] = pos.z; aiVector3D normal = mesh->mNormals[i]; normalArray[3 * i + 0] = normal.x; normalArray[3 * i + 1] = normal.y; normalArray[3 * i + 2] = normal.z; aiVector3D uv = mesh->mTextureCoords[0][i]; uvArray[2 * i * 0] = uv.x; uvArray[2 * i * 1] = uv.y; } for (unsigned int i = 0; i < mesh->mNumFaces; i++) { const aiFace& face = mesh->mFaces[i]; indexArray[3 * i * 0] = face.mIndices[0]; indexArray[3 * i * 1] = face.mIndices[1]; indexArray[3 * i * 2] = face.mIndices[2]; }
To access the correct part of the array to write to, we use an index that uses the number of elements (floats or shorts) times the current iteration plus an offset to ensure that we reach the next available index. Doing things this way, instead of pointer incrementation has the benefit that we do not have to reset the array pointer after we're done writing to the array.
We have now read in all of the data that we want from the model.
Next is arguably the hardest part of using the NDK—passing data via the JNI. This involves quite a lot of reference magic and type-matching, which can be rather annoying and lead to confusing errors. To make things as easy as possible, we used the generic Java class instance so that we already had an object to put our data into from the native side. We still have to find the methods in this class instance, however, using what is essentially a Java reflection:
jclass cls = env->GetObjectClass(model); if (!cls) { return JNI_FALSE; }
The first goal is to get a jclass
reference. For this, we use the jobject
model variable, as it already contains our instantiated class instance:
jmethodID setVA = env->GetMethodID(cls, "setVertexArray", "([F)V"); jmethodID setNA = env->GetMethodID(cls, "setNormalArray", "([F)V"); jmethodID setUA = env->GetMethodID(cls, "setUvArray", "([F)V"); jmethodID setIA = env->GetMethodID(cls, "setIndexArray", "([S)V");
We must then obtain the method references for the setters in the class as jmethodID
variables. The parameters in this class are the class reference we created, the name of the method, and its signature, being a float
array ([F
) parameter and a void
(V
) return type.
Finally, we must create our native Java arrays to pass back via the JNI:
jfloatArray jvertexArray = env->NewFloatArray(vertexArraySize); env->SetFloatArrayRegion(jvertexArray, 0, vertexArraySize, vertexArray); jfloatArray jnormalArray = env->NewFloatArray(normalArraySize); env->SetFloatArrayRegion(jnormalArray, 0, normalArraySize, normalArray); jfloatArray juvArray = env->NewFloatArray(uvArraySize); env->SetFloatArrayRegion(juvArray, 0, uvArraySize, uvArray); jshortArray jindexArray = env->NewShortArray(indexArraySize); env->SetShortArrayRegion(jindexArray, 0, indexArraySize, indexArray);
This code uses the env
JNIEnv*
reference to create the Java array and allocate memory for it in the JVM.
Finally, we call the setter functions in the class to set our data. These essentially calls the methods on the Java class inside the JVM, providing the parameter data as Java types:
env->CallVoidMethod(model, setVA, jvertexArray); env->CallVoidMethod(model, setNA, jnormalArray); env->CallVoidMethod(model, setUA, juvArray); env->CallVoidMethod(model, setIA, jindexArray);
We only have to return JNI_TRUE
now, and we're done.
Building our library
To build our code, we write the Android.mk
and Application.mk
files. Next, we go to the top level of the project in a terminal window and execute the ndk-build
command. This will compile the code and place a library in the /libs
folder of our project, inside a folder that indicates the CPU architecture it was compiled for.
For further details on the ndk-build
tool, you can refer to the official documentation at https://developer.android.com/ndk/guides/ndk-build.html.
Our Android.mk
file looks as follows:
LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE := libassimp LOCAL_SRC_FILES := libassimp.a include $(PREBUILT_STATIC_LIBRARY) include $(CLEAR_VARS) LOCAL_MODULE := assimpImporter #LOCAL_MODULE_FILENAME := assimpImporter LOCAL_SRC_FILES := assimpImporter.cpp LOCAL_LDLIBS := -landroid -lz -llog LOCAL_STATIC_LIBRARIES := libassimp libgnustl_static include $(BUILD_SHARED_LIBRARY)
The only things worthy of note here are the inclusion of the Assimp
library we compiled earlier and the use of the gnustl_static
library. Since we only have a single native library in the project, we don't have to share the STL
library. So, we link it with our library.
Finally, we have the Application.mk
file:
APP_PLATFORM := android-9 APP_STL := gnustl_static
There's not much to see here beyond the required specification of the STL runtime that we wish to use, and the Android revision we are aiming for.
After executing the build command, we are ready to build the actual application that performs the rendering of our model data.