Данная статья является эдаким приквелом к моему прошлому посту, посвящённому Glow-эффекту. Я обещал рассказать, как загружать файлы .3ds, чтобы отрисовывать их с применением использованных там шейдеров.
Некоторую общую информацию о формате файла можно прочитать, например, в википедии или в demo.design 3D programming FAQ, однако это всё теория (причём, написанная не без ошибок), а здесь мы поговорим о практике, причём применительно к Java и Android.
Что здесь будет:
- довольно шустрое чтение всего файла (на телефоне двухмегабайтный файл грузится за пару секунд);
- загрузка всей модели, вычисление нормалей, загрузка текстурных координат;
- вся информация о материалах, источниках света;
- загрузка анимации и иерархии объектов.
Чего здесь не будет:
- загрузки самих текстур (мне пока не понадобилось, хотя реализуется легко);
- загрузки информации о камерах (опять же, легко добавить, но не пригодилось);
- использования групп сглаживания для вычисления нормалей (не уверен, что это вообще нужно);
- использования сплайнов для анимации.
Подробно разжёвывать весь исходник, как в прошлый раз, здесь я не стану (это тысячи 1000 строк), лишь остановлюсь на основных моментах и приведу ссылки на полный исходный код (в конце статьи). Всё ещё интересно? Тогда продолжим.
Чтение файла
Как ни странно, именно самое простое чтение чисел из файла явилось одной из самых сложных задач, с которыми пришлось столкнуться в первую очередь. Здесь есть две грабли: скорость и корректность. Скорость мы обеспечим использованием BufferedInputStream и исключительно последовательным чтением, а вот с корректностью всё немного сложнее: Java считает, что все данные в файле должны быть big-endian, тогда как в .3ds используется little-endian. Что ж… Применяем простую обёртку:
private BufferedInputStream file;
private byte[] bytes = new byte[8];
private long filePos = 0;
...
private void Skip(long count) throws IOException
{
file.skip(count);
filePos += count;
}
private void Seek(long end) throws IOException
{
if (filePos < end) {
Skip(end - filePos);
filePos = end;
}
}
private byte ReadByte() throws IOException
{
file.read(bytes, 0, 1);
filePos++;
return bytes[0];
}
private int ReadUnsignedByte() throws IOException
{
file.read(bytes, 0, 1);
filePos++;
return (bytes[0]&0xff);
}
private int ReadUnsignedShort() throws IOException
{
file.read(bytes, 0, 2);
filePos += 2;
return ((bytes[1]&0xff) << 8 | (bytes[0]&0xff));
}
private int ReadInt() throws IOException
{
file.read(bytes, 0, 4);
filePos += 4;
return (bytes[3]) << 24 | (bytes[2]&0xff) << 16 | (bytes[1]&0xff) << 8 | (bytes[0]&0xff);
}
private float ReadFloat() throws IOException
{
return Float.intBitsToFloat(ReadInt());
}
По-хорошему, это должен был быть отдельный класс, унаследованный от BufferedInputStream, но в данном случае мне было удобнее делать именно так.
Вот теперь можно приступать к чтению чанков (chunks). Для начала — главный:
private Scene3D ProcessFile(long fileLen) throws IOException
{
Scene3D scene = null;
while (filePos < fileLen) {
int chunkID = ReadUnsignedShort();
int chunkLen = ReadInt() - 6;
switch (chunkID) {
case CHUNK_MAIN:
if (scene == null)
scene = ChunkMain(chunkLen);
else
Skip(chunkLen);
break;
default:
Skip(chunkLen);
}
}
return scene;
}
private Scene3D ChunkMain(int len) throws IOException
{
Scene3D scene = new Scene3D();
scene.materials = new ArrayList<Material3D>();
scene.objects = new ArrayList<Object3D>();
scene.lights = new ArrayList<Light3D>();
scene.animations = new ArrayList<Animation>();
long end = filePos + len;
while (filePos < end) {
int chunkID = ReadUnsignedShort();
int chunkLen = ReadInt() - 6;
switch (chunkID) {
case CHUNK_OBJMESH:
Chunk3DEditor(scene, chunkLen);
break;
case CHUNK_KEYFRAMER:
ChunkKeyframer(scene, chunkLen);
break;
case CHUNK_BACKCOL:
scene.background = new float[4];
ChunkColor(chunkLen, scene.background);
break;
case CHUNK_AMB:
scene.ambient = new float[4];
ChunkColor(chunkLen, scene.ambient);
break;
default:
Skip(chunkLen);
}
}
Seek(end);
scene.Compute(0);
return scene;
}
Структура загрузчика в целом достаточно однородна: для каждого чанка — своя функция, содержащая информацию о тех под-чанках, которые могут встретиться. Всю нужную нам информацию мы будем загружать, не нужную — перепрыгивать, перемещаясь сразу к следующему чанку. Защита от некорректных файлов тут минимальна.
Материалы
Блок материалов идёт обычно первым, потому как на него потом ссылается блок треугольников.
Материал состоит из нескольких цветов (ambient, diffuse, specular), имени материала, параметров блика, имени текстуры. Как уже отмечено выше, текстуры здесь не грузятся, но это легко добавить при необходимости.
3D-модели
Каждая 3D-модель (см. функцию ChunkTrimesh) задаётся следующими данными:
- список координат вершин;
- список треугольников;
- текстурные координаты;
- локальная система координат.
Если с первыми тремя пунктами всё ясно, то последний выглядит несколько загадочно. Забегая вперёд, скажу, что он для меня так и остался довольно-таки непонятной сущностью, хотя я всё же научился корректно применять эти данные.
Всю информацию о вершинах мы свалим в один массив float[], храня подряд восемь вещественных чисел для каждой вершины (по три на координаты и нормаль, плюс две текстурных координаты). Пару строчек из прошлой статьи надо будет изменить:
GLES20.glVertexAttribPointer(maPosition, 3, GLES20.GL_FLOAT, false, 32, 0);
GLES20.glVertexAttribPointer(maNormal, 3, GLES20.GL_FLOAT, false, 32, 12);
Здесь число 24 поменялось на 32, так как раньше текстурных координат не было, а теперь есть.
Все координаты грузятся функцией ChunkVector, которая заодно меняет местами оси Y и Z:
private void ChunkVector(float[] vec, int offset) throws IOException
{
vec[offset + 0] = ReadFloat();
vec[offset + 2] = ReadFloat();
vec[offset + 1] = ReadFloat();
}
Ну и вообще, для некоторых стандартных типов, таких как цвета или проценты, используются свои функции.
Список треугольников надо обрабатывать особым образом: во-первых, к граням (а не к вершинам) применяются материалы, а во-вторых, именно по граням можно определить нормали к вершинам. Для этого вычисляем нормаль к каждой грани, прибавляем её к каждой из трёх вершин, после чего (уже в конце, после загрузки всех треугольников) нормализуем. Пачка функций, немного математики — и готово.
Ещё одна особенность списка граней заключается в том, что после чанка с именами материалов могут остаться грани, к которым материал не применили. Для них надо при отрисовке использовать дефолтный материал, примерно так:
mAmbient[0] = 0.587f;
mAmbient[1] = 0.587f;
mAmbient[2] = 0.587f;
mAmbient[3] = 1.0f;
mDiffuse[0] = 0.587f;
mDiffuse[1] = 0.587f;
mDiffuse[2] = 0.587f;
mDiffuse[3] = 1.0f;
mSpecular[0] = 0.896f;
mSpecular[1] = 0.896f;
mSpecular[2] = 0.896f;
mSpecular[3] = 1.0f;
...
int mats = obj.faceMats.size();
for (j = 0; j < mats; j++) {
FaceMat mat = obj.faceMats.get(j);
if (mat.material != null) {
if (mat.material.ambient != null && scene.ambient != null) {
for (k = 0; k < 3; k++)
mAmbient[k] = mat.material.ambient[k] * scene.ambient[k];
GLES20.glUniform4fv(muAmbient, 1, mAmbient, 0);
}
else
GLES20.glUniform4f(muAmbient, 0, 0, 0, 1);
if (mat.material.diffuse != null)
GLES20.glUniform4fv(muDiffuse, 1, mat.material.diffuse, 0);
else
GLES20.glUniform4fv(muDiffuse, 1, mDiffuse, 0);
if (mat.material.specular != null)
GLES20.glUniform4fv(muSpecular, 1, mat.material.specular, 0);
else
GLES20.glUniform4fv(muSpecular, 1, mSpecular, 0);
GLES20.glUniform1f(muShininess, mat.material.shininess);
}
else {
GLES20.glUniform4f(muAmbient, 0, 0, 0, 1);
GLES20.glUniform4fv(muDiffuse, 1, mDiffuse, 0);
GLES20.glUniform4fv(muSpecular, 1, mSpecular, 0);
GLES20.glUniform1f(muShininess, 0);
}
GLES20.glDrawElements(GLES20.GL_TRIANGLES, mat.indexBuffer.length, GLES20.GL_UNSIGNED_SHORT, mat.bufOffset * 2);
}
Вуаля.
Источники света
Бывают всенаправленные и направленные. Про направленные источники света, опять же, пока не будем говорить (хотя совсем несложно написать шейдер, учитывающий направленность), а вот о бликах скажу пару слов. Рассмотрим шейдер для модели из прошлой статьи, и добавим к нему несколько строчек:
private final String vertexShaderCode =
"precision mediump float;n" +
"uniform mat4 uMVPMatrix;n" +
"uniform mat4 uMVMatrix;n" +
"uniform mat3 uNMatrix;n" +
"uniform vec4 uAmbient;n" +
"uniform vec4 uDiffuse;n" +
"uniform vec4 uSpecular;n" +
"uniform float uShininess;n" +
...
"vec4 light_point_view_local(vec3 epos, vec3 normal, int idx) {n" +
" vec3 vert2light = uLight[idx].position - epos;n" +
" vec3 ldir = normalize(vert2light);n" +
" vec3 vdir = vec3(0.0, 0.0, 1.0);n" +
" vec3 halfv = normalize(ldir + vdir);n" +
" float NdotL = dot(normal, ldir);n" +
" float NdotH = dot(normal, halfv);n" +
" vec4 outCol = vec4(0.0, 0.0, 0.0, 1.0);n" +
" if (NdotL > 0.0) {n" +
" outCol = uLight[idx].color * uDiffuse * NdotL;n" +
" if (NdotH > 0.0 && uShininess > 0) {n" +
" outCol += uSpecular * pow(NdotH, uShininess);n" +
" }n" +
" }n" +
" return outCol;n" +
"}n";
Собственно, добавилось вычисление и применение NdotH. uShininess здесь и shininess в Material3D имеют разные размерности, точное соответствие между ними я не подбирал (опять же, если кому-нибудь понадобится — это легко сделать).
Анимация
Одна из самых сложных и интересных тем в формате .3ds. Дело в том, что без применения анимационных треков некоторые объекты могут вообще отображаться некорректно. А уж если объекты являются клонами друг друга, то и подавно не отобразятся.
Все объекты в файле .3ds объединены в иерархическое дерево, и трансформации каждого «предка» должны применяться к «потомку». Вершины дерева записаны в порядке «сверху вниз», поэтому применение преобразований можно осуществлять в том же порядке. Любопытно, что с точки зрения .3ds-файла 3D-модели, источники света и камеры являются равноправными объектами, которые можно связывать друг с другом иерархией и одинаково применять анимацию. Однако, нас пока что интересуют только 3D-модели, а в частности — треки перемещения, поворотов и масштабирования в них.
Для каждого объекта хранится:
- имя объекта;
- идентификатор самого объекта и его предка;
- точка поворота (pivot, условный центр объекта, вокруг которого осуществляется поворот);
- списки ключевых кадров сдвига, поворота, масштабирования, а также параметров камеры (их я игнорирую, как и сами камеры).
Загрузка треков — дело скучное, поэтому поговорим лучше о том, как их применять. Итак, у нас есть:
- матрица преобразования предка (индуктивное предположение);
- сдвиг;
- пачка поворотов;
- масштабирование;
- центр объекта;
- локальная система координат (см. чанк 3D-модели).
Осталось собрать всё это в одну готовую матрицу преобразования. Со сдвигом и масштабированием всё относительно просто: между двумя кадрами просто применяется линейная интерполяция, значения заданы в абсолютном виде. А вот повороты надо применять все последовательно! И между ключевыми кадрами мы всего лишь применяем поворот следующего кадра на соответствующее число градусов, линейно интерполируя именно его.
Ещё один любопытный момент состоит в том, что надо держать в голове две матрицы: преобразование для потомка (result) и преобразование для модели (world). Первая применяется в цепочке иерархии, вторая — при отрисовке модели. В каком же порядке всё это собирается?
result = parent.result * move * rotate * scale;
world = result * Move(-pivot) * trmatrix;
Подразумевается, что преобразования применяются к вершине в порядке «справа налево» (как принято в OpenGL). Здесь trmatrix — это матрица, обратная той, что была в чанке 3D-модели. Итого, код вычисления преобразования для заданного момента времени (при загрузке все номера кадров преобразовывались в вещественные числа от 0 до 1):
private void lerp3(float[] out, float[] from, float[] to, float t)
{
for (int i = 0; i < 3; i++)
out[i] = from[i] + (to[i] - from[i]) * t;
}
private AnimKey findVec(AnimKey[] keys, float time)
{
AnimKey key = keys[keys.length - 1];
// We'll use either first, or last, or interpolated key
for (int j = 0; j < keys.length; j++) {
if (keys[j].time >= time) {
if (j > 0) {
float local = (time - keys[j - 1].time) /
(keys[j].time - keys[j - 1].time);
key = new AnimKey();
key.time = time;
key.data = new float[3];
lerp3(key.data, keys[j - 1].data, keys[j].data, local);
}
else
key = keys[j];
break;
}
}
return key;
}
private void applyRot(float[] result, float[] data, float t)
{
if (Math.abs(data[3]) > 1.0e-7 && Math.hypot(Math.hypot(data[0], data[1]), data[2]) > 1.0e-7)
Matrix.rotateM(result, 0, (float) (data[3] * t * 180 / Math.PI), data[0], data[1], data[2]);
}
public void Compute(float time)
{
int i, n = animations.size();
for (i = 0; i < n; i++) {
Animation anim = animations.get(i);
Object3D obj = anim.object;
float[] result = new float[16];
Matrix.setIdentityM(result, 0);
if (anim.position != null && anim.position.length > 0) {
AnimKey key = findVec(anim.position, time);
float[] pos = key.data;
Matrix.translateM(result, 0, pos[0], pos[1], pos[2]);
}
if (anim.rotation != null && anim.rotation.length > 0) {
// All rotations that are prior to the target time should be applied sequentially
for (int j = anim.rotation.length - 1; j > 0; j--) {
if (time >= anim.rotation[j].time) // rotation in the past, apply as is
applyRot(result, anim.rotation[j].data, 1);
else if (time > anim.rotation[j - 1].time) {
// rotation between key frames, apply part of it
float local = (time - anim.rotation[j - 1].time) /
(anim.rotation[j].time - anim.rotation[j - 1].time);
applyRot(result, anim.rotation[j].data, local);
}
// otherwise, it's a rotation in the future, skip it
}
// Always apply the first rotation
applyRot(result, anim.rotation[0].data, 1);
}
if (anim.scaling != null && anim.scaling.length > 0) {
AnimKey key = findVec(anim.scaling, time);
float[] scale = key.data;
Matrix.scaleM(result, 0, scale[0], scale[1], scale[2]);
}
if (anim.parent != null)
Matrix.multiplyMM(anim.result, 0, anim.parent.result, 0, result, 0);
else
Matrix.translateM(anim.result, 0, result, 0, 0, 0, 0);
if (obj != null && obj.trMatrix != null) {
float[] pivot = new float[16];
Matrix.setIdentityM(pivot, 0);
Matrix.translateM(pivot, 0, -anim.pivot[0], -anim.pivot[1], -anim.pivot[2]);
Matrix.multiplyMM(result, 0, pivot, 0, obj.trMatrix, 0);
}
else {
Matrix.setIdentityM(result, 0);
Matrix.translateM(result, 0, -anim.pivot[0], -anim.pivot[1], -anim.pivot[2]);
}
Matrix.multiplyMM(anim.world, 0, anim.result, 0, result, 0);
}
}
Всё это было получено путём проб и ошибок на особо изощрённых примерах, но за абсолютную точность и корректность я поручиться всё равно боюсь, слишком уж мощно это всё. И это ещё без использования сплайнов!
Кроме того, цикл по моделям из прошлой статьи теперь выглядит немного иначе:
num = scene.animations.size();
for (i = 0; i < num; i++) {
Animation anim = scene.animations.get(i);
Object3D obj = anim.object;
if (obj == null) continue;
Matrix.multiplyMM(mMVMatrix, 0, mVMatrix, 0, anim.world, 0);
Matrix.multiplyMM(mMVPMatrix, 0, mProjMatrix, 0, mMVMatrix, 0);
// Apply a ModelView Projection transformation
GLES20.glUniformMatrix4fv(muMVPMatrix, 1, false, mMVPMatrix, 0);
GLES20.glUniformMatrix4fv(muMVMatrix, 1, false, mMVMatrix, 0);
for (j = 0; j < 3; j++)
for (k = 0; k < 3; k++)
mNMatrix[k*3 + j] = mMVMatrix[k*4 + j];
GLES20.glUniformMatrix3fv(muNMatrix, 1, false, mNMatrix, 0);
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, obj.glVertices);
GLES20.glVertexAttribPointer(maPosition, 3, GLES20.GL_FLOAT, false, 32, 0);
GLES20.glVertexAttribPointer(maNormal, 3, GLES20.GL_FLOAT, false, 32, 12);
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0);
...
Далее всё так же, как раньше.
Заключение
Вот и всё, для подавляющего большинства случаев этих знаний вполне достаточно, ну а самые больные грабли я здесь упомянул и обошёл, как мог. Если добавить загрузку текстур, то всё станет совсем хорошо, но это уж я оставлю в качестве домашнего задания.
Ну и, собственно, обещанные готовые исходники: Scene3D(структуры данных) и Load3DS (загрузчик). Обратите внимание, что файлы грузятся из корня карточки памяти ("/sdcard/"), настоятельно рекомендую поменять это на что-нибудь более разумное.
Автор: ginkage