Jump to content

Ultra model file format


Josh
 Share

Recommended Posts

I am considering implementing our own file format and using a pipeline more like Leadwerks, where glTF and FBX models get automatically converted by the editor. You can still use glTF files for your final game files if you wish, and the converter can be disabled in the program settings if you wish.

The file format is designed to be very simple to read and write, while loading fast enough for game use.

Features:

  • LODs
  • Skeletal animation
  • External material files
  • Vertex morphs
  • User-defined entity properties
  • Embedded collider
  • Embedded picking structure

I think it is easiest to understand the file format just by looking at the loading code:

#include "UltraEngine.h"

using namespace UltraEngine;

namespace UltraEngine::Core
{
	String G3DModelLoader::ReadText(shared_ptr<Stream> stream)
	{
		int len = stream->ReadInt();
		auto pos = stream->GetPosition();
		String s;
		if (len)
		{
			s = stream->ReadString(len);
			stream->Seek(pos + len);
		}
		return s;
	}
	
	bool G3DModelLoader::Reload(shared_ptr<Stream> stream, shared_ptr<Object> o, const LoadFlags flags)
	{
		auto modelbase = o->As<ModelBase>();
		if (modelbase == NULL) return false;
		modelbase->model = CreateModel(NULL);
		auto model = modelbase->model->As<Model>();

		if (stream->ReadString(4) != "G3D") return false;
		int version = stream->ReadInt();
		if (version != 100)
		{
			Print("Error: G3D version " + String(version) + " not supported");
			return false;
		}

		return LoadNode(stream, model, flags);
	}

	bool G3DModelLoader::LoadNode(shared_ptr<Stream> stream, shared_ptr<Model> model, const LoadFlags flags)
	{
		Vec3 pos, scale;
		Quat rot;
		String s;
		if (stream->ReadString(4) != "NODE") return false;

		model->name = ReadText(stream);
		model->properties = ParseJson(ReadText(stream));
		ParseJson(ReadText(stream));

		pos.x = stream->ReadFloat();
		pos.y = stream->ReadFloat();
		pos.z = stream->ReadFloat();

		rot.x = stream->ReadFloat();
		rot.y = stream->ReadFloat();
		rot.z = stream->ReadFloat();
		rot.w = stream->ReadFloat();

		scale.x = stream->ReadFloat();
		scale.y = stream->ReadFloat();
		scale.z = stream->ReadFloat();

		model->SetPosition(pos);
		model->SetRotation(rot);
		model->SetScale(scale);

		int countlods = stream->ReadInt();
		for (int level = 0; level < countlods; ++level)
		{
			if (not LoadLod(stream, model, level, flags)) return false;
		}

		int countkids = stream->ReadInt();
		for (int n = 0; n < countkids; ++n)
		{
			auto child = CreateModel(NULL);
			child->SetParent(model);
			if (not LoadNode(stream, child, flags)) return false;
		}

		// Animations
		int animcount = stream->ReadInt();
		for (int n = 0; n < animcount; ++n)
		{
			stream->ReadInt();// length
			stream->ReadFloat();// speed
			auto name = ReadText(stream);
		}

		// Skeleton
		int bones = stream->ReadInt();
		if (bones)
		{
			if (model->GetParent())
			{
				Print("Error: Skeleton can only appear in the model root node");
				return false;
			}
			if (bones != 1)
			{
				Print("Error: Skeleton root must have one bone");
				return false;
			}
			auto skeleton = CreateSkeleton(nullptr);
			model->SetSkeleton(skeleton);
			for (int n = 0; n < bones; ++n)
			{
				auto bone = std::make_shared<Bone>(nullptr, skeleton);
				LoadBone(stream, skeleton, bone, animcount, flags);
			}
			skeleton->UpdateSkinning();
			model->SetSkeleton(skeleton);
		}

		// Collider
		int partscount = stream->ReadInt();

		model->UpdateBounds();
		return true;
	}

	bool G3DModelLoader::LoadLod(shared_ptr<Stream> stream, shared_ptr<Model> model, const int level, const LoadFlags flags)
	{
		if (stream->ReadString(4) != "LOD ") return false;
		if (level >= model->lods.size()) model->AddLod();
		float loddistance = stream->ReadFloat();
		int countmeshes = stream->ReadInt();
		for (int m = 0; m < countmeshes; ++m)
		{
			if (not LoadMesh(stream, model, level, flags)) return false;
		}
		return true;
	}

	bool G3DModelLoader::LoadMesh(shared_ptr<Stream> stream, shared_ptr<Model> model, const int level, const LoadFlags flags)
	{
		if (stream->ReadString(4) != "MESH") return false;
		
		MeshPrimitives type = MeshPrimitives(stream->ReadInt());
		if (type < 1 or type > 4)
		{
			Print("Error: Mesh type must be between one and four");
			return false;
		}

		auto mesh = model->AddMesh(type, level);

		mesh->name = ReadText(stream);

		WString mtlpath = ReadText(stream);
		if (not mtlpath.empty())
		{
			if (mtlpath.Left(2) == "./" and not stream->path.empty())
			{
				mtlpath = ExtractDir(stream->path) + "/" + mtlpath;
			}
			auto mtl = LoadMaterial(mtlpath);
			if (mtl) mesh->SetMaterial(mtl);
		}

		int vertexstride = stream->ReadInt();
		if (vertexstride != 84) return false;
		int vertexcount = stream->ReadInt();

		mesh->m_vertices.resize(vertexcount);
		for (int v = 0; v < vertexcount; ++v)
		{
			mesh->m_vertices[v].position.x = stream->ReadFloat();
			mesh->m_vertices[v].position.y = stream->ReadFloat();
			mesh->m_vertices[v].position.z = stream->ReadFloat();
			mesh->m_vertices[v].normal.x = stream->ReadFloat();
			mesh->m_vertices[v].normal.y = stream->ReadFloat();
			mesh->m_vertices[v].normal.z = stream->ReadFloat();
			mesh->m_vertices[v].texcoords.x = stream->ReadFloat();
			mesh->m_vertices[v].texcoords.y = stream->ReadFloat();
			mesh->m_vertices[v].texcoords.z = stream->ReadFloat();
			mesh->m_vertices[v].texcoords.w = stream->ReadFloat();
			mesh->m_vertices[v].color.r = float(stream->ReadByte()) / 255.0f;
			mesh->m_vertices[v].color.g = float(stream->ReadByte()) / 255.0f;
			mesh->m_vertices[v].color.b = float(stream->ReadByte()) / 255.0f;
			mesh->m_vertices[v].color.a = float(stream->ReadByte()) / 255.0f;
			mesh->m_vertices[v].displacement = stream->ReadFloat();
			mesh->m_vertices[v].tangent.x = stream->ReadFloat();
			mesh->m_vertices[v].tangent.y = stream->ReadFloat();
			mesh->m_vertices[v].tangent.z = stream->ReadFloat();
			mesh->m_vertices[v].bitangent.x = stream->ReadFloat();
			mesh->m_vertices[v].bitangent.y = stream->ReadFloat();
			mesh->m_vertices[v].bitangent.z = stream->ReadFloat();
			mesh->m_vertices[v].boneindices[0] = stream->ReadShort();
			mesh->m_vertices[v].boneindices[1] = stream->ReadShort();
			mesh->m_vertices[v].boneindices[2] = stream->ReadShort();
			mesh->m_vertices[v].boneindices[3] = stream->ReadShort();
			mesh->m_vertices[v].boneweights.x = float(stream->ReadByte()) / 255.0f;
			mesh->m_vertices[v].boneweights.y = float(stream->ReadByte()) / 255.0f;
			mesh->m_vertices[v].boneweights.z = float(stream->ReadByte()) / 255.0f;
			mesh->m_vertices[v].boneweights.w = float(stream->ReadByte()) / 255.0f;
		}

		int indicesize = stream->ReadInt();
		int indicecount = stream->ReadInt();

		uint32_t index;		
		switch (indicesize)
		{
		case 2:
			mesh->m_indices.reserve(indicecount);
			for (int i = 0; i < indicecount; ++i) mesh->AddIndice(stream->ReadShort());
			break;
		case 4:
			mesh->m_indices.resize(indicecount);
			stream->Read(mesh->m_indices.data(), indicecount * sizeof(mesh->indices[0]));
			break;
		default:
			return false;
		}

		// Pick structure cache
		int pickcachesize = stream->ReadInt();
		if (pickcachesize) stream->Seek(stream->GetPosition() + pickcachesize);

		//Vertex morphs
		int morphcount = stream->ReadInt();
		for (int m = 0; m < morphcount; ++m)
		{
			if (stream->ReadString(4) != "MORP") return false;
			if (stream->ReadInt() != 48) return false;

			for (int v = 0; v < vertexcount; ++v)
			{
				// Position
				stream->ReadFloat();
				stream->ReadFloat();
				stream->ReadFloat();

				// Normal
				stream->ReadFloat();
				stream->ReadFloat();
				stream->ReadFloat();

				// Tangent
				stream->ReadFloat();
				stream->ReadFloat();
				stream->ReadFloat();

				// Bitangent
				stream->ReadFloat();
				stream->ReadFloat();
				stream->ReadFloat();
			}
		}

		mesh->UpdateBounds();
		return true;
	}

	bool G3DModelLoader::LoadBone(shared_ptr<Stream> stream, shared_ptr<Skeleton> skeleton, shared_ptr<Bone> bone, const int animcount, const LoadFlags flags)
	{
		bone->name = ReadText(stream);
		bone->position.x = stream->ReadFloat();
		bone->position.y = stream->ReadFloat();
		bone->position.z = stream->ReadFloat();
		bone->quaternion.x = stream->ReadFloat();
		bone->quaternion.y = stream->ReadFloat();
		bone->quaternion.z = stream->ReadFloat();
		bone->quaternion.w = stream->ReadFloat();
		bone->scale = stream->ReadFloat();
		stream->ReadFloat();// scale y
		stream->ReadFloat();// scale z

		int count = stream->ReadInt();
		if (count != animcount)
		{
			Print("Error: Bone animation count must match that of the root node");
			return false;
		}

		for (int anim = 0; anim < count; ++anim)
		{
			if (stream->ReadString(4) != "ANIM") return false;
			int keyflags = stream->ReadInt();
			int keyframes = stream->ReadInt();
			if (keyflags)
			{
				for (int k = 0; k < keyframes; ++k)
				{
					if ((1 & keyflags) != 0)
					{
						stream->ReadFloat();
						stream->ReadFloat();
						stream->ReadFloat();
					}
					if ((2 & keyflags) != 0)
					{
						stream->ReadFloat();
						stream->ReadFloat();
						stream->ReadFloat();
						stream->ReadFloat();
					}
					if ((4 & keyflags) != 0)
					{
						stream->ReadFloat();
						stream->ReadFloat();
						stream->ReadFloat();
					}
				}
			}
		}

		return true;
	}
}

 

  • Upvote 2

My job is to make tools you love, with the features you want, and performance you can't live without.

Link to comment
Share on other sites

I dare say it's not hard, its just time consuming.  The main issue I had was finding out the right stuff to access in blender from python, but google was the answer there.  There's also the Leadwerks mdl exporter for blender.  Maybe that could be converted to suit this rather than starting again?

Link to comment
Share on other sites

15 hours ago, SpiderPig said:

I dare say it's not hard, its just time consuming.  The main issue I had was finding out the right stuff to access in blender from python, but google was the answer there.  There's also the Leadwerks mdl exporter for blender.  Maybe that could be converted to suit this rather than starting again?

I thought you had a working exporter your wrote yourself?

My job is to make tools you love, with the features you want, and performance you can't live without.

Link to comment
Share on other sites

That's right.  I've pretty much got that then.  All I'd have to do is change the extension and add the missing data structures.  Seeing as Blender doesn't support BC7 I was thinking of auto converting any png textures to the correct dds format on export with a small program made with ultra.

Link to comment
Share on other sites

I guess just go without whatever format is in use. The fact that nobody understand or even knows what BC7 is probably means it isn't that important in the grand scheme of things. If someone wants BC7, we can do an auto-converter in the editor from an image file.

It would be better to have something simple and reliable that works now than to have something that produces perfect results but breaks easily.

  • Upvote 1

My job is to make tools you love, with the features you want, and performance you can't live without.

Link to comment
Share on other sites

Changes:

  • Animations block will be preceded with the tag "ASET"
  • "COLL" tag renamed to "PHYS"
  • Vertex stride must be 88
  • Like 1

My job is to make tools you love, with the features you want, and performance you can't live without.

Link to comment
Share on other sites

@Josh I'm giving animation a go and just need to confirm a few things to help me debug;

In LoadBone(), is the bone position;

  • Global coords
  • Local to skeleton
  • Or, Local to the parent bone

Is bone->quaternion;

  • The rotation of the bone toward the position of it's child?
  • For example, blender only stores the tail and head positions of bones when they are created. No matter the direction the bones are facing when they are created, they have a rotation of 0 on all axis.

E.g. All the bones in this shot have a rotation of 0 an all axis;  Do I need to calculate what these rotations would be for Ultra?

1247237492_BoneRotation.png.181fcac95358b74b495dc9c00f0d78b3.png

You are correct.  Animation is hard.

Link to comment
Share on other sites

Here is how colliders are stored. For tolerance and optimization, if you don't know just write 0.0 / 0:
 

// Collider
if (stream->ReadString(4) != "PHYS")
{
	auto pos = stream->GetPosition() - 4;
	Print("Error: Expected PHYS tag at position " + String(pos));
	return false;
}
int colliderdatasize = stream->ReadInt();
auto colliderstartposition = stream->GetPosition();
if (colliderdatasize)
{
	Vec3 position, scale, euler;
	Quat rotation;
	std::vector<shared_ptr<Collider> > parts;
	int partcount = stream->ReadInt();
	for (int n = 0; n < partcount; ++n)
	{
		if (stream->ReadString(4) != "PART")
		{
			auto pos = stream->GetPosition() - 4;
			Print("Error: Expected PART tag at position " + String(pos));
			return false;
		}
		shared_ptr<Collider> part;
		auto tag = stream->ReadString(4);
		if (tag == "HULL")
		{
			float tol = stream->ReadFloat();// tolerance
			std::vector<Vec3> points;
			Vec3 p;
			int count = stream->ReadInt();
			for (int n = 0; n < count; ++n)
			{
				p.x = stream->ReadFloat();
				p.y = stream->ReadFloat();
				p.z = stream->ReadFloat();
				points.push_back(p);
			}
			part = CreateConvexHullCollider(points);
			part->tolerance = tol;
		}
		else if (tag == "MESH")
		{
			int opt = stream->ReadInt();// optimize flag
			int count = stream->ReadInt();
			int vcount;
			std::vector<Vec3> face;
			Vec3 p;
			std::vector<std::vector<Vec3> > meshfaces;
			for (int n = 0; n < count; ++n)
			{
				vcount = stream->ReadInt();
				face.clear();
				for (int v = 0; v < vcount; ++v)
				{
					p.x = stream->ReadFloat();
					p.y = stream->ReadFloat();
					p.z = stream->ReadFloat();
					face.push_back(p);
				}
				meshfaces.push_back(face);
			}
			part = CreateMeshCollider(meshfaces);
			part->optimizemesh = opt;
		}
		else
		{
			position.x = stream->ReadFloat(); position.y = stream->ReadFloat(); position.z = stream->ReadFloat();
			rotation.x = stream->ReadFloat(); rotation.y = stream->ReadFloat(); rotation.z = stream->ReadFloat(); rotation.w = stream->ReadFloat();
			scale.x = stream->ReadFloat(); scale.y = stream->ReadFloat(); scale.z = stream->ReadFloat();
			if (tag == "BOX_")
			{
				part = CreateBoxCollider(scale, position, rotation.Euler());						
			}
			else if (tag == "CYLI")
			{
				part = CreateCylinderCollider(scale.x, scale.y, position, rotation.Euler());
			}
			else if (tag == "CONE")
			{
				part = CreateConeCollider(scale.x, scale.y, position, rotation.Euler());
			}
			else if (tag == "CCYL")
			{
				part = CreateChamferCylinderCollider(scale.x, scale.y, position, rotation.Euler());
			}
			else if (tag == "CAPS")
			{
				part = CreateCapsuleCollider(scale.x, scale.y, position, rotation.Euler());
			}
			else if (tag == "SPHE")
			{
				part = CreateSphereCollider(scale.x, position);
			}
			else
			{
				Print("Error: Unknown collider type \"" + tag + "\"");
				return false;
			}
		}
		if (part) parts.push_back(part);
	}
	if (parts.size())
	{
		if (parts.size() == 1)
		{
			model->SetCollider(parts[0]);
		}
		else
		{
			auto c = CreateCompoundCollider(parts);
			model->SetCollider(c);
		}
	}
	stream->Seek(colliderstartposition + colliderdatasize);
}

 

My job is to make tools you love, with the features you want, and performance you can't live without.

Link to comment
Share on other sites

I don't quite understand the exactly what's needed in the bone data.  I think that's the only issue I have left.

This is going to be hard to explain, so bare with me.

I understand that both bone position and rotation should be local to their parent.

What I was doing is getting the local bone position like this.

bone_local_pos = bone.global_position - bone.parent.global_postition

However that only works if the quaternions are left as this.  I knew from the start this probably wasn't correct.

quat.x = 0.0
quat.y = 0.0
quat.z = 0.0
quat.w = 1.0

 

I think what I need to is rather than setting the bone position to the "yellow dot" relative to the "red dot."  I need to calculate the "blue dot", and then the "quaternion will be the "orange angle".

The "blue dot" will basically be the normal the parent bone is pointing * the length of the child bone.

This is the only way I see Ultra being able to give the correct bone position with bone rotation.  Am I on the right track or have I utterly confused you? :D

BoneStuff.png.0667e7caba068506678683c44e17dbff.png

Link to comment
Share on other sites

So blender only gives you a bone's orientation in global space? You have to multiply that by the inverse of the parent's 4x4 matrix to get the local orientation.

My job is to make tools you love, with the features you want, and performance you can't live without.

Link to comment
Share on other sites

Can you get the local rotation as a quaternion?

You might look at the old Leadwerks exporter and see what the author did there.

My job is to make tools you love, with the features you want, and performance you can't live without.

Link to comment
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

 Share

×
×
  • Create New...