diff --git a/src/level/doomdata.h b/src/level/doomdata.h
index 97dfd8f..86bab71 100644
--- a/src/level/doomdata.h
+++ b/src/level/doomdata.h
@@ -33,9 +33,9 @@ struct MapSideDef
 {
 	short	textureoffset;
 	short	rowoffset;
-	char	toptexture[8];
-	char	bottomtexture[8];
-	char	midtexture[8];
+	char	toptexture[64/*8*/];
+	char	bottomtexture[64/*8*/];
+	char	midtexture[64/*8*/];
 	uint16_t	sector;
 };
 
@@ -46,9 +46,9 @@ struct IntSideDef
 	// the first 5 values are only used for binary format maps
 	short	textureoffset;
 	short	rowoffset;
-	char	toptexture[8];
-	char	bottomtexture[8];
-	char	midtexture[8];
+	char	toptexture[64/*8*/];
+	char	bottomtexture[64/*8*/];
+	char	midtexture[64/*8*/];
 
 	int sector;
 	int lightdef;
@@ -95,8 +95,8 @@ struct MapSector
 {
 	short	floorheight;
 	short	ceilingheight;
-	char	floorpic[8];
-	char	ceilingpic[8];
+	char	floorpic[64/*8*/];
+	char	ceilingpic[64/*8*/];
 	short	lightlevel;
 	short	special;
 	short	tag;
diff --git a/src/level/level_light.cpp b/src/level/level_light.cpp
index 5326090..9ab5761 100644
--- a/src/level/level_light.cpp
+++ b/src/level/level_light.cpp
@@ -92,17 +92,17 @@ void FLevel::SetupLights()
 
 void FLevel::CheckSkySectors()
 {
-	char name[9];
+	char name[65];
 
 	for (int i = 0; i < (int)Sectors.Size(); ++i)
 	{
 		//if (mapDef && mapDef->sunIgnoreTag != 0 && Sectors[i].data.tag == mapDef->sunIgnoreTag)
 		//	continue;
 
-		strncpy(name, Sectors[i].data.ceilingpic, 8);
-		name[8] = 0;
+		strncpy(name, Sectors[i].data.ceilingpic, 64);
+		name[64] = 0;
 
-		if (!strncmp(name, "F_SKY001", 8) || !strncmp(name, "F_SKY1", 8) || !strncmp(name, "F_SKY", 8))
+		if (!strncmp(name, "F_SKY001", 64) || !strncmp(name, "F_SKY1", 64) || !strncmp(name, "F_SKY", 64))
 		{
 			Sectors[i].skySector = true;
 		}
diff --git a/src/level/level_udmf.cpp b/src/level/level_udmf.cpp
index 28d4013..d0ca20e 100644
--- a/src/level/level_udmf.cpp
+++ b/src/level/level_udmf.cpp
@@ -366,15 +366,15 @@ void FProcessor::ParseSidedef(IntSideDef *sd)
 
 		if (stricmp(key, "texturetop") == 0)
 		{
-			CopyUDMFString(sd->toptexture, 8, value);
+			CopyUDMFString(sd->toptexture, 64, value);
 		}
 		else if (stricmp(key, "texturemiddle") == 0)
 		{
-			CopyUDMFString(sd->midtexture, 8, value);
+			CopyUDMFString(sd->midtexture, 64, value);
 		}
 		else if (stricmp(key, "texturebottom") == 0)
 		{
-			CopyUDMFString(sd->bottomtexture, 8, value);
+			CopyUDMFString(sd->bottomtexture, 64, value);
 		}
 		else if (stricmp(key, "offsetx_mid") == 0)
 		{
@@ -413,11 +413,11 @@ void FProcessor::ParseSector(IntSector *sec)
 
 		if (stricmp(key, "textureceiling") == 0)
 		{
-			CopyUDMFString(sec->data.ceilingpic, 8, value);
+			CopyUDMFString(sec->data.ceilingpic, 64, value);
 		}
 		else if (stricmp(key, "texturefloor") == 0)
 		{
-			CopyUDMFString(sec->data.floorpic, 8, value);
+			CopyUDMFString(sec->data.floorpic, 64, value);
 		}
 		else if (stricmp(key, "heightceiling") == 0)
 		{
@@ -886,6 +886,7 @@ void FProcessor::WriteUDMF(FWadWriter &out)
 	if (LightmapsBuilt)
 	{
 		LMBuilder.AddLightmapLump(out);
+		//LMBuilder.ExportMesh("level.zmdl");
 	}
 
 	out.CreateLabel("ENDMAP");
diff --git a/src/lightmap/lightmap.cpp b/src/lightmap/lightmap.cpp
index 2ab262d..94511f3 100644
--- a/src/lightmap/lightmap.cpp
+++ b/src/lightmap/lightmap.cpp
@@ -37,6 +37,7 @@
 #include <map>
 #include <vector>
 #include <algorithm>
+#include <zlib.h>
 
 #ifdef _MSC_VER
 #pragma warning(disable: 4267) // warning C4267: 'argument': conversion from 'size_t' to 'int', possible loss of data
@@ -948,6 +949,311 @@ void LightmapBuilder::PrintTaskProcessed()
 	}
 }
 
+class PNGWriter
+{
+public:
+	static void save(const std::string &filename, int width, int height, int bytes_per_pixel, void *pixels)
+	{
+		PNGImage image;
+		image.width = width;
+		image.height = height;
+		image.bytes_per_pixel = bytes_per_pixel;
+		image.pixel_ratio = 1.0f;
+		image.data = pixels;
+
+		FILE *file = fopen(filename.c_str(), "wb");
+		if (file)
+		{
+			PNGWriter writer;
+			writer.file = file;
+			writer.image = &image;
+			writer.write_magic();
+			writer.write_headers();
+			writer.write_data();
+			writer.write_chunk("IEND", nullptr, 0);
+			fclose(file);
+		}
+	}
+
+private:
+	struct PNGImage
+	{
+		int width;
+		int height;
+		int bytes_per_pixel;
+		void *data;
+		float pixel_ratio;
+	};
+
+	struct DataBuffer
+	{
+		DataBuffer(int size) : size(size) { data = new uint8_t[size]; }
+		~DataBuffer() { delete[] data; }
+		int size;
+		void *data;
+	};
+
+	const PNGImage *image;
+	FILE *file;
+
+	class PNGCRC32
+	{
+	public:
+		static unsigned long crc(const char name[4], const void *data, int len)
+		{
+			static PNGCRC32 impl;
+
+			const unsigned char *buf = reinterpret_cast<const unsigned char*>(data);
+
+			unsigned int c = 0xffffffff;
+
+			for (int n = 0; n < 4; n++)
+				c = impl.crc_table[(c ^ name[n]) & 0xff] ^ (c >> 8);
+
+			for (int n = 0; n < len; n++)
+				c = impl.crc_table[(c ^ buf[n]) & 0xff] ^ (c >> 8);
+
+			return c ^ 0xffffffff;
+		}
+
+	private:
+		unsigned int crc_table[256];
+
+		PNGCRC32()
+		{
+			for (unsigned int n = 0; n < 256; n++)
+			{
+				unsigned int c = n;
+				for (unsigned int k = 0; k < 8; k++)
+				{
+					if ((c & 1) == 1)
+						c = 0xedb88320 ^ (c >> 1);
+					else
+						c = c >> 1;
+				}
+				crc_table[n] = c;
+			}
+		}
+	};
+
+	void write_magic()
+	{
+		unsigned char png_magic[8] = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
+		write(png_magic, 8);
+	}
+
+	void write_headers()
+	{
+		int ppm = (int)std::round(3800 * image->pixel_ratio);
+		int ppm_x = ppm;
+		int ppm_y = ppm;
+
+		int width = image->width;
+		int height = image->height;
+		int bit_depth = image->bytes_per_pixel == 8 ? 16 : 8;
+		int color_type = 6;
+		int compression_method = 0;
+		int filter_method = 0;
+		int interlace_method = 0;
+
+		unsigned char idhr[13];
+		idhr[0] = (width >> 24) & 0xff;
+		idhr[1] = (width >> 16) & 0xff;
+		idhr[2] = (width >> 8) & 0xff;
+		idhr[3] = width & 0xff;
+		idhr[4] = (height >> 24) & 0xff;
+		idhr[5] = (height >> 16) & 0xff;
+		idhr[6] = (height >> 8) & 0xff;
+		idhr[7] = height & 0xff;
+		idhr[8] = bit_depth;
+		idhr[9] = color_type;
+		idhr[10] = compression_method;
+		idhr[11] = filter_method;
+		idhr[12] = interlace_method;
+
+		//unsigned char srgb[1];
+		//srgb[0] = 0;
+
+		unsigned char phys[9];
+		phys[0] = (ppm_x >> 24) & 0xff;
+		phys[1] = (ppm_x >> 16) & 0xff;
+		phys[2] = (ppm_x >> 8) & 0xff;
+		phys[3] = ppm_x & 0xff;
+		phys[4] = (ppm_y >> 24) & 0xff;
+		phys[5] = (ppm_y >> 16) & 0xff;
+		phys[6] = (ppm_y >> 8) & 0xff;
+		phys[7] = ppm_y & 0xff;
+		phys[8] = 1; // pixels per meter
+
+		write_chunk("IHDR", idhr, 13);
+
+		if (ppm != 0)
+			write_chunk("pHYs", phys, 9);
+
+		//write_chunk("sRGB", srgb, 1);
+	}
+
+	void write_data()
+	{
+		//int width = image->width;
+		int height = image->height;
+		int bytes_per_pixel = image->bytes_per_pixel;
+		int pitch = image->width * bytes_per_pixel;
+
+		std::vector<unsigned char> scanline_orig;
+		std::vector<unsigned char> scanline_filtered;
+		scanline_orig.resize((image->width + 1) * bytes_per_pixel);
+		scanline_filtered.resize(image->width * bytes_per_pixel + 1);
+
+		auto idat_uncompressed = std::make_shared<DataBuffer>(height * scanline_filtered.size());
+
+		for (int y = 0; y < height; y++)
+		{
+			// Grab scanline
+			memcpy(scanline_orig.data() + bytes_per_pixel, (uint8_t*)image->data + y * pitch, scanline_orig.size() - bytes_per_pixel);
+
+			// Convert to big endian for 16 bit
+			if (bytes_per_pixel == 8)
+			{
+				for (size_t x = 0; x < scanline_orig.size(); x += 2)
+				{
+					std::swap(scanline_orig[x], scanline_orig[x + 1]);
+				}
+			}
+
+			// Filter scanline
+			/*
+			scanline_filtered[0] = 0; // None filter type
+			for (int i = bytes_per_pixel; i < scanline_orig.size(); i++)
+			{
+				scanline_filtered[i - bytes_per_pixel + 1] = scanline_orig[i];
+			}
+			*/
+			scanline_filtered[0] = 1; // Sub filter type
+			for (int i = bytes_per_pixel; i < scanline_orig.size(); i++)
+			{
+				unsigned char a = scanline_orig[i - bytes_per_pixel];
+				unsigned char x = scanline_orig[i];
+				scanline_filtered[i - bytes_per_pixel + 1] = x - a;
+			}
+
+			// Output scanline
+			memcpy((uint8_t*)idat_uncompressed->data + y * scanline_filtered.size(), scanline_filtered.data(), scanline_filtered.size());
+		}
+
+		auto idat = std::make_unique<DataBuffer>(idat_uncompressed->size * 125 / 100);
+		idat->size = compress(idat.get(), idat_uncompressed.get(), false);
+
+		write_chunk("IDAT", idat->data, (int)idat->size);
+	}
+
+	void write_chunk(const char name[4], const void *data, int size)
+	{
+		unsigned char size_data[4];
+		size_data[0] = (size >> 24) & 0xff;
+		size_data[1] = (size >> 16) & 0xff;
+		size_data[2] = (size >> 8) & 0xff;
+		size_data[3] = size & 0xff;
+		write(size_data, 4);
+
+		write(name, 4);
+
+		write(data, size);
+		unsigned int crc32 = PNGCRC32::crc(name, data, size);
+
+		unsigned char crc32_data[4];
+		crc32_data[0] = (crc32 >> 24) & 0xff;
+		crc32_data[1] = (crc32 >> 16) & 0xff;
+		crc32_data[2] = (crc32 >> 8) & 0xff;
+		crc32_data[3] = crc32 & 0xff;
+		write(crc32_data, 4);
+	}
+
+	void write(const void *data, int size)
+	{
+		fwrite(data, size, 1, file);
+	}
+
+	size_t compress(DataBuffer *out, const DataBuffer *data, bool raw)
+	{
+		if (data->size > (size_t)0xffffffff || out->size > (size_t)0xffffffff)
+			throw std::runtime_error("Data is too big");
+
+		const int window_bits = 15;
+
+		int compression_level = 6;
+		int strategy = Z_DEFAULT_STRATEGY;
+
+		z_stream zs;
+		memset(&zs, 0, sizeof(z_stream));
+		int result = deflateInit2(&zs, compression_level, Z_DEFLATED, raw ? -window_bits : window_bits, 8, strategy); // Undocumented: if wbits is negative, zlib skips header check
+		if (result != Z_OK)
+			throw std::runtime_error("Zlib deflateInit failed");
+
+		zs.next_in = (unsigned char *)data->data;
+		zs.avail_in = (unsigned int)data->size;
+		zs.next_out = (unsigned char *)out->data;
+		zs.avail_out = (unsigned int)out->size;
+
+		size_t outSize = 0;
+		try
+		{
+			int result = deflate(&zs, Z_FINISH);
+			if (result == Z_NEED_DICT) throw std::runtime_error("Zlib deflate wants a dictionary!");
+			if (result == Z_DATA_ERROR) throw std::runtime_error("Zip data stream is corrupted");
+			if (result == Z_STREAM_ERROR) throw std::runtime_error("Zip stream structure was inconsistent!");
+			if (result == Z_MEM_ERROR) throw std::runtime_error("Zlib did not have enough memory to compress file!");
+			if (result == Z_BUF_ERROR) throw std::runtime_error("Not enough data in buffer when Z_FINISH was used");
+			if (result != Z_STREAM_END) throw std::runtime_error("Zlib deflate failed while compressing zip file!");
+			outSize = zs.total_out;
+		}
+		catch (...)
+		{
+			deflateEnd(&zs);
+			throw;
+		}
+		deflateEnd(&zs);
+
+		return outSize;
+	}
+};
+
+void LightmapBuilder::ExportMesh(std::string filename)
+{
+	mesh->Export(filename);
+
+	int index = 0;
+	for (const auto &texture : textures)
+	{
+		int w = texture->Width();
+		int h = texture->Height();
+		uint16_t *p = texture->Pixels();
+#if 1
+		std::vector<uint8_t> buf(w * h * 4);
+		uint8_t *buffer = buf.data();
+		for (int i = 0; i < w * h; i++)
+		{
+			buffer[i * 4] = (uint8_t)(int)clamp(halfToFloat(p[i * 3]) * 255.0f, 0.0f, 255.0f);
+			buffer[i * 4 + 1] = (uint8_t)(int)clamp(halfToFloat(p[i * 3 + 1]) * 255.0f, 0.0f, 255.0f);
+			buffer[i * 4 + 2] = (uint8_t)(int)clamp(halfToFloat(p[i * 3 + 2]) * 255.0f, 0.0f, 255.0f);
+			buffer[i * 4 + 3] = 0xff;
+		}
+		PNGWriter::save("lightmap" + std::to_string(index++) + ".png", w, h, 4, buffer);
+#else
+		std::vector<uint16_t> buf(w * h * 4);
+		uint16_t *buffer = buf.data();
+		for (int i = 0; i < w * h; i++)
+		{
+			buffer[i * 4] = (uint16_t)(int)clamp(halfToFloat(p[i * 3]) * 65535.0f, 0.0f, 65535.0f);
+			buffer[i * 4 + 1] = (uint16_t)(int)clamp(halfToFloat(p[i * 3 + 1]) * 65535.0f, 0.0f, 65535.0f);
+			buffer[i * 4 + 2] = (uint16_t)(int)clamp(halfToFloat(p[i * 3 + 2]) * 65535.0f, 0.0f, 65535.0f);
+			buffer[i * 4 + 3] = 0xffff;
+		}
+		PNGWriter::save("lightmap" + std::to_string(index++) + ".png", w, h, 8, buffer);
+#endif
+	}
+}
+
 /////////////////////////////////////////////////////////////////////////////
 
 LightmapTexture::LightmapTexture(int width, int height) : textureWidth(width), textureHeight(height)
diff --git a/src/lightmap/lightmap.h b/src/lightmap/lightmap.h
index 5d1a4f9..fbd5dcf 100644
--- a/src/lightmap/lightmap.h
+++ b/src/lightmap/lightmap.h
@@ -60,6 +60,8 @@ public:
 
 	bool MakeRoomForBlock(const int width, const int height, int *x, int *y);
 
+	int Width() const { return textureWidth; }
+	int Height() const { return textureHeight; }
 	uint16_t *Pixels() { return mPixels.data(); }
 
 private:
@@ -89,6 +91,7 @@ public:
 
 	void CreateLightmaps(FLevel &doomMap, int sampleDistance, int textureSize);
 	void AddLightmapLump(FWadWriter &wadFile);
+	void ExportMesh(std::string filename);
 
 private:
 	BBox GetBoundsFromSurface(const Surface *surface);
diff --git a/src/lightmap/surfaces.cpp b/src/lightmap/surfaces.cpp
index be2ace8..36d02b2 100644
--- a/src/lightmap/surfaces.cpp
+++ b/src/lightmap/surfaces.cpp
@@ -89,9 +89,9 @@ LevelMesh::LevelMesh(FLevel &doomMap)
 			}
 			if (!IsDegenerate(s->verts[1], s->verts[2], s->verts[3]))
 			{
-				MeshElements.Push(pos + 1);
-				MeshElements.Push(pos + 2);
 				MeshElements.Push(pos + 3);
+				MeshElements.Push(pos + 2);
+				MeshElements.Push(pos + 1);
 				MeshSurfaces.Push(i);
 			}
 		}
@@ -126,6 +126,9 @@ void LevelMesh::CreateSideSurfaces(FLevel &doomMap, IntSideDef *side)
 
 	int typeIndex = side - &doomMap.Sides[0];
 
+	Vec2 dx(v2.x - v1.x, v2.y - v1.y);
+	float distance = std::sqrt(dx.Dot(dx));
+
 	if (back)
 	{
 		for (unsigned int j = 0; j < front->x3dfloors.Size(); j++)
@@ -145,7 +148,11 @@ void LevelMesh::CreateSideSurfaces(FLevel &doomMap, IntSideDef *side)
 			if (bothSides)
 				continue;
 
+			float texWidth = 128.0f;
+			float texHeight = 128.0f;
+
 			auto surf = std::make_unique<Surface>();
+			surf->material = "texture";
 			surf->type = ST_MIDDLESIDE;
 			surf->typeIndex = typeIndex;
 			surf->controlSector = xfloor;
@@ -162,6 +169,18 @@ void LevelMesh::CreateSideSurfaces(FLevel &doomMap, IntSideDef *side)
 			surf->plane.SetNormal(surf->verts[0], surf->verts[1], surf->verts[2]);
 			surf->plane.SetDistance(surf->verts[0]);
 
+			float texZ = surf->verts[0].z;
+
+			surf->uvs.resize(4);
+			surf->uvs[0].x = 0.0f;
+			surf->uvs[1].x = distance / texWidth;
+			surf->uvs[2].x = 0.0f;
+			surf->uvs[3].x = distance / texWidth;
+			surf->uvs[0].y = (surf->verts[0].z - texZ) / texHeight;
+			surf->uvs[1].y = (surf->verts[1].z - texZ) / texHeight;
+			surf->uvs[2].y = (surf->verts[2].z - texZ) / texHeight;
+			surf->uvs[3].y = (surf->verts[3].z - texZ) / texHeight;
+
 			surfaces.push_back(std::move(surf));
 		}
 
@@ -180,7 +199,11 @@ void LevelMesh::CreateSideSurfaces(FLevel &doomMap, IntSideDef *side)
 		{
 			if (side->bottomtexture[0] != '-')
 			{
+				float texWidth = 128.0f;
+				float texHeight = 128.0f;
+
 				auto surf = std::make_unique<Surface>();
+				surf->material = side->bottomtexture;
 				surf->numVerts = 4;
 				surf->verts.resize(4);
 
@@ -199,6 +222,18 @@ void LevelMesh::CreateSideSurfaces(FLevel &doomMap, IntSideDef *side)
 				surf->typeIndex = typeIndex;
 				surf->controlSector = nullptr;
 
+				float texZ = surf->verts[0].z;
+
+				surf->uvs.resize(4);
+				surf->uvs[0].x = 0.0f;
+				surf->uvs[1].x = distance / texWidth;
+				surf->uvs[2].x = 0.0f;
+				surf->uvs[3].x = distance / texWidth;
+				surf->uvs[0].y = (surf->verts[0].z - texZ) / texHeight;
+				surf->uvs[1].y = (surf->verts[1].z - texZ) / texHeight;
+				surf->uvs[2].y = (surf->verts[2].z - texZ) / texHeight;
+				surf->uvs[3].y = (surf->verts[3].z - texZ) / texHeight;
+
 				surfaces.push_back(std::move(surf));
 			}
 
@@ -221,7 +256,11 @@ void LevelMesh::CreateSideSurfaces(FLevel &doomMap, IntSideDef *side)
 
 			if (side->toptexture[0] != '-' || bSky)
 			{
+				float texWidth = 128.0f;
+				float texHeight = 128.0f;
+
 				auto surf = std::make_unique<Surface>();
+				surf->material = side->toptexture;
 				surf->numVerts = 4;
 				surf->verts.resize(4);
 
@@ -241,6 +280,18 @@ void LevelMesh::CreateSideSurfaces(FLevel &doomMap, IntSideDef *side)
 				surf->bSky = bSky;
 				surf->controlSector = nullptr;
 
+				float texZ = surf->verts[0].z;
+
+				surf->uvs.resize(4);
+				surf->uvs[0].x = 0.0f;
+				surf->uvs[1].x = distance / texWidth;
+				surf->uvs[2].x = 0.0f;
+				surf->uvs[3].x = distance / texWidth;
+				surf->uvs[0].y = (surf->verts[0].z - texZ) / texHeight;
+				surf->uvs[1].y = (surf->verts[1].z - texZ) / texHeight;
+				surf->uvs[2].y = (surf->verts[2].z - texZ) / texHeight;
+				surf->uvs[3].y = (surf->verts[3].z - texZ) / texHeight;
+
 				surfaces.push_back(std::move(surf));
 			}
 
@@ -252,7 +303,11 @@ void LevelMesh::CreateSideSurfaces(FLevel &doomMap, IntSideDef *side)
 	// middle seg
 	if (back == nullptr)
 	{
+		float texWidth = 128.0f;
+		float texHeight = 128.0f;
+
 		auto surf = std::make_unique<Surface>();
+		surf->material = side->midtexture;
 		surf->numVerts = 4;
 		surf->verts.resize(4);
 
@@ -271,6 +326,18 @@ void LevelMesh::CreateSideSurfaces(FLevel &doomMap, IntSideDef *side)
 		surf->typeIndex = typeIndex;
 		surf->controlSector = nullptr;
 
+		float texZ = surf->verts[0].z;
+
+		surf->uvs.resize(4);
+		surf->uvs[0].x = 0.0f;
+		surf->uvs[1].x = distance / texWidth;
+		surf->uvs[2].x = 0.0f;
+		surf->uvs[3].x = distance / texWidth;
+		surf->uvs[0].y = (surf->verts[0].z - texZ) / texHeight;
+		surf->uvs[1].y = (surf->verts[1].z - texZ) / texHeight;
+		surf->uvs[2].y = (surf->verts[2].z - texZ) / texHeight;
+		surf->uvs[3].y = (surf->verts[3].z - texZ) / texHeight;
+
 		surfaces.push_back(std::move(surf));
 	}
 }
@@ -278,8 +345,10 @@ void LevelMesh::CreateSideSurfaces(FLevel &doomMap, IntSideDef *side)
 void LevelMesh::CreateFloorSurface(FLevel &doomMap, MapSubsectorEx *sub, IntSector *sector, int typeIndex, bool is3DFloor)
 {
 	auto surf = std::make_unique<Surface>();
+	surf->material = sector->data.floorpic;
 	surf->numVerts = sub->numlines;
 	surf->verts.resize(surf->numVerts);
+	surf->uvs.resize(surf->numVerts);
 
 	if (!is3DFloor)
 	{
@@ -298,6 +367,9 @@ void LevelMesh::CreateFloorSurface(FLevel &doomMap, MapSubsectorEx *sub, IntSect
 		surf->verts[j].x = v1.x;
 		surf->verts[j].y = v1.y;
 		surf->verts[j].z = surf->plane.zAt(surf->verts[j].x, surf->verts[j].y);
+
+		surf->uvs[j].x = v1.x / 64.0f;
+		surf->uvs[j].y = v1.y / 64.0f;
 	}
 
 	surf->type = ST_FLOOR;
@@ -310,8 +382,10 @@ void LevelMesh::CreateFloorSurface(FLevel &doomMap, MapSubsectorEx *sub, IntSect
 void LevelMesh::CreateCeilingSurface(FLevel &doomMap, MapSubsectorEx *sub, IntSector *sector, int typeIndex, bool is3DFloor)
 {
 	auto surf = std::make_unique<Surface>();
+	surf->material = sector->data.ceilingpic;
 	surf->numVerts = sub->numlines;
 	surf->verts.resize(surf->numVerts);
+	surf->uvs.resize(surf->numVerts);
 	surf->bSky = sector->skySector;
 
 	if (!is3DFloor)
@@ -331,6 +405,9 @@ void LevelMesh::CreateCeilingSurface(FLevel &doomMap, MapSubsectorEx *sub, IntSe
 		surf->verts[j].x = v1.x;
 		surf->verts[j].y = v1.y;
 		surf->verts[j].z = surf->plane.zAt(surf->verts[j].x, surf->verts[j].y);
+
+		surf->uvs[j].x = v1.x / 64.0f;
+		surf->uvs[j].y = v1.y / 64.0f;
 	}
 
 	surf->type = ST_CEILING;
@@ -422,3 +499,183 @@ bool LevelMesh::IsDegenerate(const Vec3 &v0, const Vec3 &v1, const Vec3 &v2)
 	float crosslengthsqr = crossx * crossx + crossy * crossy + crossz * crossz;
 	return crosslengthsqr <= 1.e-6f;
 }
+
+void LevelMesh::Export(std::string filename)
+{
+	// Convert model mesh:
+
+	auto zmodel = std::make_unique<ZModel>();
+
+	zmodel->Vertices.resize(MeshVertices.Size());
+	for (unsigned int i = 0; i < MeshVertices.Size(); i++)
+	{
+		ZModelVertex &vertex = zmodel->Vertices[i];
+		vertex.Pos.X = MeshVertices[i].x;
+		vertex.Pos.Y = MeshVertices[i].z;
+		vertex.Pos.Z = MeshVertices[i].y;
+		vertex.BoneWeights.X = 0.0f;
+		vertex.BoneWeights.Y = 0.0f;
+		vertex.BoneWeights.Z = 0.0f;
+		vertex.BoneWeights.W = 0.0f;
+		vertex.BoneIndices.X = 0;
+		vertex.BoneIndices.Y = 0;
+		vertex.BoneIndices.Z = 0;
+		vertex.BoneIndices.W = 0;
+		vertex.Normal.X = 0.0f;
+		vertex.Normal.Y = 0.0f;
+		vertex.Normal.Z = 0.0f;
+		vertex.TexCoords.X = 0.0f;
+		vertex.TexCoords.Y = 0.0f;
+	}
+
+	std::map<std::string, std::vector<uint32_t>> materialRanges;
+
+	for (unsigned int surfidx = 0; surfidx < MeshElements.Size() / 3; surfidx++)
+	{
+		Surface *surface = surfaces[MeshSurfaces[surfidx]].get();
+		for (int i = 0; i < 3; i++)
+		{
+			int elementidx = surfidx * 3 + i;
+			int vertexidx = MeshElements[elementidx];
+			int uvindex = MeshUVIndex[vertexidx];
+
+			ZModelVertex &vertex = zmodel->Vertices[vertexidx];
+			vertex.Normal.X = surface->plane.Normal().x;
+			vertex.Normal.Y = surface->plane.Normal().z;
+			vertex.Normal.Z = surface->plane.Normal().y;
+			vertex.TexCoords.X = surface->uvs[uvindex].x;
+			vertex.TexCoords.Y = surface->uvs[uvindex].y;
+			vertex.TexCoords2.X = surface->lightmapCoords[uvindex * 2];
+			vertex.TexCoords2.Y = surface->lightmapCoords[uvindex * 2 + 1];
+			vertex.TexCoords2.Z = surface->lightmapNum;
+
+			std::string matname = surface->material;
+
+			size_t lastslash = matname.find_last_of('/');
+			if (lastslash != std::string::npos)
+				matname = matname.substr(lastslash + 1);
+
+			size_t lastdot = matname.find_last_of('.');
+			if (lastdot != 0 && lastdot != std::string::npos)
+				matname = matname.substr(0, lastdot);
+
+			for (auto &c : matname)
+			{
+				if (c >= 'A' && c <= 'Z') c = 'a' + (c - 'A');
+			}
+
+			matname = "materials/" + matname;
+
+			materialRanges[matname].push_back(vertexidx);
+		}
+	}
+
+	zmodel->Elements.reserve(MeshElements.Size());
+
+	for (const auto &it : materialRanges)
+	{
+		uint32_t startElement = (uint32_t)zmodel->Elements.size();
+		for (uint32_t vertexidx : it.second)
+			zmodel->Elements.push_back(vertexidx);
+		uint32_t vertexCount = (uint32_t)zmodel->Elements.size() - startElement;
+
+		ZModelMaterial mat;
+		mat.Name = it.first;
+		mat.Flags = 0;
+		mat.Renderstyle = 0;
+		mat.StartElement = startElement;
+		mat.VertexCount = vertexCount;
+		zmodel->Materials.push_back(mat);
+	}
+
+	// Save mesh
+
+	ZChunkStream zmdl, zdat;
+
+	// zmdl
+	{
+		ZChunkStream &s = zmdl;
+		s.Uint32(zmodel->Version);
+
+		s.Uint32(zmodel->Materials.size());
+		for (const ZModelMaterial &mat : zmodel->Materials)
+		{
+			s.String(mat.Name);
+			s.Uint32(mat.Flags);
+			s.Uint32(mat.Renderstyle);
+			s.Uint32(mat.StartElement);
+			s.Uint32(mat.VertexCount);
+		}
+
+		s.Uint32(zmodel->Bones.size());
+		for (const ZModelBone &bone : zmodel->Bones)
+		{
+			s.String(bone.Name);
+			s.Uint32((uint32_t)bone.Type);
+			s.Uint32(bone.ParentBone);
+			s.Vec3f(bone.Pivot);
+		}
+
+		s.Uint32(zmodel->Animations.size());
+		for (const ZModelAnimation &anim : zmodel->Animations)
+		{
+			s.String(anim.Name);
+			s.Float(anim.Duration);
+			s.Vec3f(anim.AabbMin);
+			s.Vec3f(anim.AabbMax);
+			s.Uint32(anim.Bones.size());
+			for (const ZModelBoneAnim &bone : anim.Bones)
+			{
+				s.FloatArray(bone.Translation.Timestamps);
+				s.Vec3fArray(bone.Translation.Values);
+				s.FloatArray(bone.Rotation.Timestamps);
+				s.QuaternionfArray(bone.Rotation.Values);
+				s.FloatArray(bone.Scale.Timestamps);
+				s.Vec3fArray(bone.Scale.Values);
+			}
+			s.Uint32(anim.Materials.size());
+			for (const ZModelMaterialAnim &mat : anim.Materials)
+			{
+				s.FloatArray(mat.Translation.Timestamps);
+				s.Vec3fArray(mat.Translation.Values);
+				s.FloatArray(mat.Rotation.Timestamps);
+				s.QuaternionfArray(mat.Rotation.Values);
+				s.FloatArray(mat.Scale.Timestamps);
+				s.Vec3fArray(mat.Scale.Values);
+			}
+		}
+
+		s.Uint32(zmodel->Attachments.size());
+		for (const ZModelAttachment &attach : zmodel->Attachments)
+		{
+			s.String(attach.Name);
+			s.Uint32(attach.Bone);
+			s.Vec3f(attach.Position);
+		}
+	}
+
+	// zdat
+	{
+		ZChunkStream &s = zdat;
+
+		s.VertexArray(zmodel->Vertices);
+		s.Uint32Array(zmodel->Elements);
+	}
+
+	FILE *file = fopen(filename.c_str(), "wb");
+	if (file)
+	{
+		uint32_t chunkhdr[2];
+		memcpy(chunkhdr, "ZMDL", 4);
+		chunkhdr[1] = zmdl.ChunkLength();
+		fwrite(chunkhdr, 8, 1, file);
+		fwrite(zmdl.ChunkData(), zmdl.ChunkLength(), 1, file);
+
+		memcpy(chunkhdr, "ZDAT", 4);
+		chunkhdr[1] = zdat.ChunkLength();
+		fwrite(chunkhdr, 8, 1, file);
+		fwrite(zdat.ChunkData(), zdat.ChunkLength(), 1, file);
+
+		fclose(file);
+	}
+}
diff --git a/src/lightmap/surfaces.h b/src/lightmap/surfaces.h
index 51578f6..4e3049f 100644
--- a/src/lightmap/surfaces.h
+++ b/src/lightmap/surfaces.h
@@ -29,6 +29,7 @@
 
 #include <vector>
 #include <memory>
+#include <string>
 
 #include "framework/tarray.h"
 #include "lightmap/collision.h"
@@ -67,6 +68,8 @@ struct Surface
 	int typeIndex;
 	IntSector *controlSector;
 	bool bSky;
+	std::vector<Vec2> uvs;
+	std::string material;
 };
 
 struct LevelTraceHit
@@ -85,6 +88,8 @@ class LevelMesh
 public:
 	LevelMesh(FLevel &doomMap);
 
+	void Export(std::string filename);
+
 	LevelTraceHit Trace(const Vec3 &startVec, const Vec3 &endVec);
 	bool TraceAnyHit(const Vec3 &startVec, const Vec3 &endVec);
 
@@ -105,3 +110,174 @@ private:
 
 	static bool IsDegenerate(const Vec3 &v0, const Vec3 &v1, const Vec3 &v2);
 };
+
+/////////////////////////////////////////////////////////////////////////////
+
+struct ZModelVec2f
+{
+	float X, Y;
+};
+
+struct ZModelVec3f
+{
+	float X, Y, Z;
+};
+
+struct ZModelVec4ub
+{
+	uint8_t X, Y, Z, W;
+};
+
+struct ZModelQuaternionf
+{
+	float X, Y, Z, W;
+};
+
+struct ZModelVertex
+{
+	ZModelVec3f Pos;
+	ZModelVec4ub BoneWeights;
+	ZModelVec4ub BoneIndices;
+	ZModelVec3f Normal;
+	ZModelVec2f TexCoords;
+	ZModelVec3f TexCoords2;
+};
+
+struct ZModelMaterial
+{
+	std::string Name;
+	uint32_t Flags = 0; // Two-sided, depth test/write, what else?
+	uint32_t Renderstyle;
+	uint32_t StartElement = 0;
+	uint32_t VertexCount = 0;
+};
+
+template<typename Value>
+struct ZModelTrack
+{
+	std::vector<float> Timestamps;
+	std::vector<Value> Values;
+};
+
+struct ZModelBoneAnim
+{
+	ZModelTrack<ZModelVec3f> Translation;
+	ZModelTrack<ZModelQuaternionf> Rotation;
+	ZModelTrack<ZModelVec3f> Scale;
+};
+
+struct ZModelMaterialAnim
+{
+	ZModelTrack<ZModelVec3f> Translation;
+	ZModelTrack<ZModelQuaternionf> Rotation; // Rotation center is texture center (0.5, 0.5)
+	ZModelTrack<ZModelVec3f> Scale;
+};
+
+struct ZModelAnimation
+{
+	std::string Name; // Name of animation
+	float Duration; // Length of this animation sequence in seconds
+
+	ZModelVec3f AabbMin; // Animation bounds (for culling purposes)
+	ZModelVec3f AabbMax;
+
+	std::vector<ZModelBoneAnim> Bones; // Animation tracks for each bone
+	std::vector<ZModelMaterialAnim> Materials; // Animation tracks for each material
+};
+
+enum class ZModelBoneType : uint32_t
+{
+	Normal,
+	BillboardSpherical,
+	BillboardCylindricalX,
+	BillboardCylindricalY,
+	BillboardCylindricalZ
+};
+
+struct ZModelBone
+{
+	std::string Name;
+	ZModelBoneType Type = ZModelBoneType::Normal;
+	int32_t ParentBone = -1;
+	ZModelVec3f Pivot;
+};
+
+struct ZModelAttachment
+{
+	std::string Name;
+	int32_t Bone = -1;
+	ZModelVec3f Position;
+};
+
+struct ZModel
+{
+	// ZMDL chunk
+	uint32_t Version = 1;
+	std::vector<ZModelMaterial> Materials;
+	std::vector<ZModelBone> Bones;
+	std::vector<ZModelAnimation> Animations;
+	std::vector<ZModelAttachment> Attachments;
+
+	// ZDAT chunk
+	std::vector<ZModelVertex> Vertices;
+	std::vector<uint32_t> Elements;
+};
+
+struct ZChunkStream
+{
+	void Uint32(uint32_t v) { Write<uint32_t>(v); }
+	void Float(float v) { Write<float>(v); }
+	void Vec2f(const ZModelVec2f &v) { Write<ZModelVec2f>(v); }
+	void Vec3f(const ZModelVec3f &v) { Write<ZModelVec3f>(v); }
+	void Vec4ub(const ZModelVec4ub &v) { Write<ZModelVec4ub>(v); }
+	void Quaternionf(const ZModelQuaternionf &v) { Write<ZModelQuaternionf>(v); }
+
+	void Uint32Array(const std::vector<uint32_t> &v) { WriteArray<uint32_t>(v); }
+	void FloatArray(const std::vector<float> &v) { WriteArray<float>(v); }
+	void Vec2fArray(const std::vector<ZModelVec2f> &v) { WriteArray<ZModelVec2f>(v); }
+	void Vec3fArray(const std::vector<ZModelVec3f> &v) { WriteArray<ZModelVec3f>(v); }
+	void Vec4ubArray(const std::vector<ZModelVec4ub> &v) { WriteArray<ZModelVec4ub>(v); }
+	void QuaternionfArray(const std::vector<ZModelQuaternionf> &v) { WriteArray<ZModelQuaternionf>(v); }
+	void VertexArray(const std::vector<ZModelVertex> &v) { WriteArray<ZModelVertex>(v); }
+
+	void String(const std::string &v)
+	{
+		Write(v.c_str(), v.length() + 1);
+	}
+
+	void StringArray(const std::vector<std::string> &v)
+	{
+		Uint32((uint32_t)v.size());
+		for (const std::string &s : v)
+			String(s);
+	}
+
+	const void *ChunkData() const { return buffer.data(); }
+	uint32_t ChunkLength() const { return (uint32_t)pos; }
+
+private:
+	template<typename Type>
+	void Write(const Type &v)
+	{
+		Write(&v, sizeof(Type));
+	}
+
+	template<typename Type>
+	void WriteArray(const std::vector<Type> &v)
+	{
+		Uint32((uint32_t)v.size());
+		Write(v.data(), v.size() * sizeof(Type));
+	}
+
+	void Write(const void *data, size_t size)
+	{
+		if (pos + size > buffer.size())
+			buffer.resize(buffer.size() * 2);
+
+		memcpy(buffer.data() + pos, data, size);
+		pos += size;
+	}
+
+	std::vector<uint8_t> buffer = std::vector<uint8_t>(16 * 1024 * 1024);
+	size_t pos = 0;
+};