Загрузка файлов .3DS на Android

    Данная статья является эдаким приквелом к моему прошлому посту, посвящённому Glow-эффекту. Я обещал рассказать, как загружать файлы .3ds, чтобы отрисовывать их с применением использованных там шейдеров.

    Некоторую общую информацию о формате файла можно прочитать, например, в википедии или в demo.design 3D programming FAQ, однако это всё теория (причём, написанная не без ошибок), а здесь мы поговорим о практике, причём применительно к Java и Android.

    Что здесь будет:
    • довольно шустрое чтение всего файла (на телефоне двухмегабайтный файл грузится за пару секунд);
    • загрузка всей модели, вычисление нормалей, загрузка текстурных координат;
    • вся информация о материалах, источниках света;
    • загрузка анимации и иерархии объектов.

    Чего здесь не будет:
    • загрузки самих текстур (мне пока не понадобилось, хотя реализуется легко);
    • загрузки информации о камерах (опять же, легко добавить, но не пригодилось);
    • использования групп сглаживания для вычисления нормалей (не уверен, что это вообще нужно) (всё-таки будет);
    • использования сплайнов для анимации.

    Подробно разжёвывать весь исходник, как в прошлый раз, здесь я не стану (это более тысячи строк), лишь остановлюсь на основных моментах и приведу ссылки на полный исходный код (в конце статьи). Всё ещё интересно? Тогда продолжим.

    Чтение файла


    Как ни странно, именно самое простое чтение чисел из файла явилось одной из самых сложных задач, с которыми пришлось столкнуться в первую очередь. Здесь есть две грабли: скорость и корректность. Скорость мы обеспечим использованием 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/"), настоятельно рекомендую поменять это на что-нибудь более разумное.

    Update: Раз уж столько копий сломано о нормали — добавил в исходники код работы с группами сглаживания. Индексные буферы остались 2-байтными, так что остерегайтесь переполнения!
    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 18

      +1
      Когда писали игрушку для айфона, тоже сначала юзали 3DS, но потом перешли к PVR. 3DS-библиотека (lib3ds) оказалась немного глючной (не совсем корректно считались нормали), пришлось самим дописывать код, который корректировал нормали, чтобы на модельке не было артефактов. C PVR вроде таких проблем не было.
        0
        К счастью, я не использую lib3ds. :)
          +1
          Добавил вычисление нормалей по группам сглаживания.
          +1
          Вычисление нормали путем усреднения прилежащих нормалей является в корне не верным способом,
          для это есть чанк CHUNK_SMOOTHG и алгоритм восстановления нормалей для рендера там намного сложнее
            0
            Да, я знаю, я сразу так и написал в самом начале, что не использую это. А вот заглушка для этого чанка у меня таки есть, если поделитесь кодом для неё — буду благодарен.
              +1
              1) В документации 3smax сдк есть пример того как вычислять нормали docs.autodesk.com/3DSMAX/15/ENU/3ds-Max-SDK-Programmer-Guide/
              2) Также надо не забывать что некоторые рендер вертексы будут дублироваться из за того, что у одного и того же вертекса может быть больше одной нормали — соответственно надо будет еще и индекс буфер менять

                0
                И это еще не все — нормали складывать надо правильно docs.autodesk.com/3DSMAX/15/ENU/3ds-Max-SDK-Programmer-Guide/
                    0
                    По сути, у меня как раз получается складывание, взвешенное по площади (второй приём): суммирую-то я ненормализованные вектора, а векторное произведение двух рёбер как раз даст направление, умноженное на площадь треугольника.
                      0
                      Насколько я вижу по коду то вы просто складываете нормализованные вектора
                      и это не похоже на второй способ так как потом эта площадь используется для веса (или я не нашел у вас это в коде)

                      Второй вариант дает артефакты в случае «банального куба».
                        0
                        Там складываются НЕнормализованные вектора, а потом уже сумма нормализуется.
                        Про куб-то всё понятно, но всё равно мне не хотелось увеличивать количество вершин: телефону и так тяжко. :)
                          0
                          Точно, вижу.
                    0
                    Спасибо за ссылки.
                    Собственно, именно из-за пункта 2 я всё это и не делал.
                  0
                  Добавил код восстановления нормалей в соответствии с группами сглаживания.
                  +3
                  Статья хорошая, претензий нет. Хочется только добавить, что для промышленных целей лучше использовать формат, который можно целиком скопировать в видео-память (GL vertex buffer). К чему все эти парсинги выполнять каждый раз бедному ARM процессору, если можно подготовить данные заранее? :)
                    0
                    Конечно. Но меня просили выложить код загрузки .3ds, и я его выложил. А раз просили — значит, это кому-нибудь нужно.
                    0
                    Да уж, зачем засовывать в мобильное приложение файл с кучей лишней информации? :) Лучше написать плагин под используемый 3д пакет, который вытащет и запишет только то, что нужно.
                      0
                      Само собой. Просто, вот прямо сейчас у меня нет под рукой ни одного 3D-пакета, а файл — есть, и мне надо было его использовать в приложении. Да и лишних данных в том файле — совсем немного. Ну а сам загрузчик получился достаточно эффективным, чтобы не бояться недостатка производительности.

                    Only users with full accounts can post comments. Log in, please.