diff --git a/albam/engines/mtframework/blender_export.py b/albam/engines/mtframework/blender_export.py index ac11e96..b5fd841 100644 --- a/albam/engines/mtframework/blender_export.py +++ b/albam/engines/mtframework/blender_export.py @@ -270,6 +270,31 @@ def _process_weights(weights_per_vertex, max_bones_per_vertex=4): return new_weights_per_vertex +def _get_normals_per_vertex(blender_mesh): + normals = {} + + if blender_mesh.has_custom_normals: + blender_mesh.calc_normals_split() + for loop in blender_mesh.loops: + normals.setdefault(loop.vertex_index, loop.normal) + else: + for vertex in blender_mesh.vertices: + normals[vertex.index] = vertex.normal + return normals + + +def _get_tangents_per_vertex(blender_mesh): + tangents = {} + try: + uv_name = blender_mesh.uv_layers[0].name + except IndexError: + uv_name = '' + blender_mesh.calc_tangents(uv_name) + for loop in blender_mesh.loops: + tangents.setdefault(loop.vertex_index, loop.tangent) + return tangents + + def _export_vertices(blender_mesh_object, bounding_box, mesh_index, bone_palette): blender_mesh = blender_mesh_object.data vertex_count = len(blender_mesh.vertices) @@ -277,6 +302,8 @@ def _export_vertices(blender_mesh_object, bounding_box, mesh_index, bone_palette weights_per_vertex = get_bone_indices_and_weights_per_vertex(blender_mesh_object) weights_per_vertex = _process_weights(weights_per_vertex) max_bones_per_vertex = max({len(data) for data in weights_per_vertex.values()}, default=0) + normals = _get_normals_per_vertex(blender_mesh) + tangents = _get_tangents_per_vertex(blender_mesh) VF = VERTEX_FORMATS_TO_CLASSES[max_bones_per_vertex] @@ -293,7 +320,7 @@ def _export_vertices(blender_mesh_object, bounding_box, mesh_index, bone_palette vertices_array = (VF * vertex_count)() has_bones = hasattr(VF, 'bone_indices') - has_second_uv_layer = hasattr(VF, 'uv2_x') + for vertex_index, vertex in enumerate(blender_mesh.vertices): vertex_struct = vertices_array[vertex_index] @@ -302,23 +329,30 @@ def _export_vertices(blender_mesh_object, bounding_box, mesh_index, bone_palette if has_bones: # applying bounding box constraints xyz = vertices_export_locations(xyz, box_width, box_length, box_height) - vertex_struct.position_x = xyz[0] - vertex_struct.position_y = xyz[1] - vertex_struct.position_z = xyz[2] - vertex_struct.position_w = 32767 - - if has_bones: weights_data = weights_per_vertex.get(vertex_index, []) weight_values = [w for _, w in weights_data] bone_indices = [bone_palette.index(bone_index) for bone_index, _ in weights_data] array_size = ctypes.sizeof(vertex_struct.bone_indices) vertex_struct.bone_indices = (ctypes.c_ubyte * array_size)(*bone_indices) vertex_struct.weight_values = (ctypes.c_ubyte * array_size)(*weight_values) + vertex_struct.position_x = xyz[0] + vertex_struct.position_y = xyz[1] + vertex_struct.position_z = xyz[2] + vertex_struct.position_w = 32767 + try: + vertex_struct.normal_x = round(((normals[vertex_index][0] * 0.5) + 0.5) * 255) + vertex_struct.normal_y = round(((normals[vertex_index][2] * 0.5) + 0.5) * 255) + vertex_struct.normal_z = round(((normals[vertex_index][1] * 0.5) + 0.5) * 255) * -1 + vertex_struct.normal_w = 255 + vertex_struct.tangent_x = round(((tangents[vertex_index][0] * 0.5) + 0.5) * 255) + vertex_struct.tangent_y = round(((tangents[vertex_index][2] * 0.5) + 0.5) * 255) + vertex_struct.tangent_z = round(((tangents[vertex_index][1] * 0.5) + 0.5) * 255) * -1 + vertex_struct.tangent_w = 255 + except KeyError: + # should not happen. TODO: investigate cases where it did happen + print('Missing normal in vertex {}, mesh {}'.format(vertex_index, mesh_index)) vertex_struct.uv_x = uvs_per_vertex.get(vertex_index, (0, 0))[0] if uvs_per_vertex else 0 vertex_struct.uv_y = uvs_per_vertex.get(vertex_index, (0, 0))[1] if uvs_per_vertex else 0 - if has_second_uv_layer: - vertex_struct.uv2_x = 0 - vertex_struct.uv2_y = 0 return vertices_array @@ -376,7 +410,6 @@ def _export_meshes(blender_meshes, bounding_box, bone_palettes, exported_materia vertex_position = 0 face_position = 0 for mesh_index, blender_mesh_ob in enumerate(blender_meshes): - level_of_detail = _infer_level_of_detail(blender_mesh_ob.name) bone_palette_index = 0 bone_palette = [] diff --git a/albam/engines/mtframework/blender_import.py b/albam/engines/mtframework/blender_import.py index 9913515..6ea2542 100644 --- a/albam/engines/mtframework/blender_import.py +++ b/albam/engines/mtframework/blender_import.py @@ -22,7 +22,6 @@ from albam.engines.mtframework.mappers import BONE_INDEX_TO_GROUP from albam.lib.misc import chunks from albam.lib.half_float import unpack_half_float -from albam.lib.geometry import y_up_to_z_up from albam.lib.blender import strip_triangles_to_triangles_list, create_mesh_name from albam.registry import blender_registry @@ -97,21 +96,35 @@ def import_mod(blender_object, file_path, **kwargs): def _build_blender_mesh_from_mod(mod, mesh, mesh_index, name, materials): + me_ob = bpy.data.meshes.new(name) + ob = bpy.data.objects.new(name, me_ob) + imported_vertices = _import_vertices(mod, mesh) vertex_locations = imported_vertices['locations'] + vertex_normals = imported_vertices['normals'] + uvs_per_vertex = imported_vertices['uvs'] + weights_per_bone = imported_vertices['weights_per_bone'] indices = get_indices_array(mod, mesh) indices = strip_triangles_to_triangles_list(indices) + faces = chunks(indices, 3) uvs_per_vertex = imported_vertices['uvs'] weights_per_bone = imported_vertices['weights_per_bone'] - me_ob = bpy.data.meshes.new(name) - ob = bpy.data.objects.new(name, me_ob) + assert min(indices) >= 0, "Bad face indices" # Blender crashes if not + me_ob.from_pydata(vertex_locations, [], faces) + + me_ob.create_normals_split() - assert min(indices) >= 0 # Blender crashes if not - me_ob.from_pydata(vertex_locations, [], chunks(indices, 3)) + me_ob.validate(clean_customdata=False) me_ob.update(calc_edges=True) me_ob.polygons.foreach_set("use_smooth", [True] * len(me_ob.polygons)) - me_ob.validate() + + loop_normals = [] + for loop in me_ob.loops: + loop_normals.append(vertex_normals[loop.vertex_index]) + + me_ob.normals_split_custom_set_from_vertices(vertex_normals) + me_ob.use_auto_smooth = True mesh_material = materials[mesh.material_index] if not mesh.use_cast_shadows and mesh_material.use_cast_shadows: @@ -161,16 +174,23 @@ def _import_vertices_mod156(mod, mesh): vertices_array = get_vertices_array(mod, mesh) if mesh.vertex_format != 0: - vertices = (transform_vertices_from_bbox(vf, box_width, box_height, box_length) - for vf in vertices_array) + locations = (transform_vertices_from_bbox(vf, box_width, box_height, box_length) + for vf in vertices_array) else: - vertices = ((vf.position_x, vf.position_y, vf.position_z) for vf in vertices_array) - vertices = (y_up_to_z_up(vertex_tuple) for vertex_tuple in vertices) - vertices = ((x / 100, y / 100, z / 100) for x, y, z in vertices) + locations = ((vf.position_x, vf.position_y, vf.position_z) for vf in vertices_array) + + locations = map(lambda t: (t[0] / 100, t[2] / -100, t[1] / 100), locations) + # from [0, 255] o [-1, 1] + normals = map(lambda v: (((v.normal_x / 255) * 2) - 1, + ((v.normal_y / 255) * 2) - 1, + ((v.normal_z / 255) * 2) - 1), vertices_array) + # y up to z up + normals = map(lambda n: (n[0], n[2] * -1, n[1]), normals) - # TODO: investigate why uvs don't appear above the image in the UV editor list_of_tuples = [(unpack_half_float(v.uv_x), unpack_half_float(v.uv_y) * -1) for v in vertices_array] - return {'locations': list(vertices), + return {'locations': list(locations), + 'normals': list(normals), + # TODO: investigate why uvs don't appear above the image in the UV editor 'uvs': list(chain.from_iterable(list_of_tuples)), 'weights_per_bone': _get_weights_per_bone(mod, mesh, vertices_array) } diff --git a/albam/engines/mtframework/mod_156.py b/albam/engines/mtframework/mod_156.py index c707b4a..ab5b609 100644 --- a/albam/engines/mtframework/mod_156.py +++ b/albam/engines/mtframework/mod_156.py @@ -229,14 +229,14 @@ class VertexFormat0(Structure): _fields_ = (('position_x', c_float), ('position_y', c_float), ('position_z', c_float), - ('normal_x', c_byte), - ('normal_y', c_byte), - ('normal_z', c_byte), - ('normal_w', c_byte), - ('tangent_x', c_byte), - ('tangent_y', c_byte), - ('tangent_z', c_byte), - ('tangent_w', c_byte), + ('normal_x', c_ubyte), + ('normal_y', c_ubyte), + ('normal_z', c_ubyte), + ('normal_w', c_ubyte), + ('tangent_x', c_ubyte), + ('tangent_y', c_ubyte), + ('tangent_z', c_ubyte), + ('tangent_w', c_ubyte), ('uv_x', c_ushort), # half float ('uv_y', c_ushort), # half float ('uv2_x', c_ushort), # half float @@ -254,14 +254,14 @@ class VertexFormat(Structure): ('position_w', c_short), ('bone_indices', c_ubyte * 4), ('weight_values', c_ubyte * 4), - ('normal_x', c_byte), - ('normal_y', c_byte), - ('normal_z', c_byte), - ('normal_w', c_byte), - ('tangent_x', c_byte), - ('tangent_y', c_byte), - ('tangent_z', c_byte), - ('tangent_w', c_byte), + ('normal_x', c_ubyte), + ('normal_y', c_ubyte), + ('normal_z', c_ubyte), + ('normal_w', c_ubyte), + ('tangent_x', c_ubyte), + ('tangent_y', c_ubyte), + ('tangent_z', c_ubyte), + ('tangent_w', c_ubyte), ('uv_x', c_ushort), # half float ('uv_y', c_ushort), # half float ('uv2_x', c_ushort), # half float @@ -288,10 +288,10 @@ class VertexFormat5(Structure): ('position_w', c_short), ('bone_indices', c_ubyte * 8), ('weight_values', c_ubyte * 8), - ('normal_x', c_byte), - ('normal_y', c_byte), - ('normal_z', c_byte), - ('normal_w', c_byte), + ('normal_x', c_ubyte), + ('normal_y', c_ubyte), + ('normal_z', c_ubyte), + ('normal_w', c_ubyte), ('uv_x', c_ushort), # half float ('uv_y', c_ushort), # half float ) diff --git a/tests/mtframework/conftest.py b/tests/mtframework/conftest.py index a1028e6..a831334 100644 --- a/tests/mtframework/conftest.py +++ b/tests/mtframework/conftest.py @@ -69,6 +69,28 @@ def pytest_generate_tests(metafunc): TEMP_FILES_TO_DELETE.update(exported_files) metafunc.parametrize("mod156_original, mod156_exported", mods, scope='module', ids=ids_exported) + # XXX TODO: simplify this, too much duplication! + elif 'mod156_mesh_original' and 'mod156_mesh_exported' in metafunc.fixturenames: + exported_files = [] + blender_path = metafunc.config.getoption('blender') + if not blender_path: + pytest.skip('No blender bin path supplied') + else: + if not ARC_FILES_EXPORTED: + albam_import_export(blender_path, ARC_FILES) + ARC_FILES_EXPORTED = True + exported_files = [f + '.exported' for f in ARC_FILES] + + mod_files_original, ids_original = _get_files_from_arcs(extension='.mod', arc_list=ARC_FILES) + mod_files_exported, ids_exported = _get_files_from_arcs(extension='.mod', arc_list=exported_files) + + meshes_original, ids_original = _get_array_members_from_files(mod_files_original, ids_original, Mod156, 'meshes_array') + meshes_exported, ids_exported = _get_array_members_from_files(mod_files_exported, ids_exported, Mod156, 'meshes_array') + meshes = list(zip(meshes_original, meshes_exported)) + ids = list(zip(ids_original, ids_exported)) + TEMP_FILES_TO_DELETE.update(exported_files) + + metafunc.parametrize("mod156_mesh_original, mod156_mesh_exported", meshes, scope='module', ids=ids_exported) def pytest_sessionfinish(session, exitstatus): diff --git a/tests/mtframework/test_mod156_export.py b/tests/mtframework/test_mod156_export.py index 44fda74..cda6243 100644 --- a/tests/mtframework/test_mod156_export.py +++ b/tests/mtframework/test_mod156_export.py @@ -1,5 +1,7 @@ from itertools import chain +import pytest + from albam.engines.mtframework.utils import get_vertices_array from tests.conftest import assert_same_attributes, assert_approximate_fields @@ -81,9 +83,113 @@ def test_meshes_array_immutable_fields(mod156_original, mod156_exported): assert_same_attributes(mesh_original, mesh_exported, 'vertex_stride') -def test_mesh_vertices_bone_weights_sum(mod156_original, mod156_exported): - # almost duplicate from test_mod156.py - for mesh_index, mesh in enumerate(mod156_exported.meshes_array): - mesh_vertices = get_vertices_array(mod156_exported, mesh) - for vertex_index, vertex in enumerate(mesh_vertices): - assert not mod156_exported.bone_count or sum(vertex.weight_values) == 255 +XFAILS = { + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-0]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-6]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-7]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-8]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-9]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-16]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-17]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-18]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-25]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-26]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-27]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-35]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-41]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-42]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-43]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-50]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-51]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-59]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-60]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-67]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-72]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-73]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-74]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-75]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-79]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-82]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-83]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-84]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-89]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-90]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-97]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-114]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-154]', + 'test_mesh_vertices[uPl00ChrisNormal.arc.exported-->pl0000.mod-->meshes_array-156]', +} + + +def test_mesh_vertices(request, mod156_mesh_original, mod156_mesh_exported): + FAILURE_RATIO = 0.15 + TANGENT_LIMIT = 20 + + mod_original = mod156_mesh_original._parent_structure + mod_exported = mod156_mesh_exported._parent_structure + + mesh_original_vertices = get_vertices_array(mod_original, mod156_mesh_original) + mesh_exported_vertices = get_vertices_array(mod_exported, mod156_mesh_exported) + + if mod156_mesh_original.vertex_count != mod156_mesh_exported.vertex_count: + pytest.xfail('Mesh different vertex count. Using second vertex buffer? Research needed') + elif request.node.name in XFAILS: + pytest.xfail('Normals expected to be above failure ratio. Needs research in Blender') + + failed_pos_vertices = [] + failed_uvs = [] + failed_norm_x_vertices = [] + failed_norm_y_vertices = [] + failed_norm_z_vertices = [] + failed_norm_w_vertices = [] + failed_tang_x_vertices = [] + failed_tang_y_vertices = [] + failed_tang_z_vertices = [] + failed_tang_w_vertices = [] + + for vertex_index, vertex_ori in enumerate(mesh_original_vertices): + vertex_exp = mesh_exported_vertices[vertex_index] + pos_original = vertex_ori.position_x, vertex_ori.position_y, vertex_ori.position_z + pos_exported = vertex_exp.position_x, vertex_exp.position_y, vertex_exp.position_z + uv_original = vertex_ori.uv_x, vertex_ori.uv_y + uv_exported = vertex_exp.uv_x, vertex_ori.uv_y + + if pos_original != pos_exported: + failed_pos_vertices.append(vertex_index) + if uv_original != uv_exported: + failed_uvs.append(vertex_index) + + check_normal(vertex_index, vertex_ori.normal_x, vertex_exp.normal_x, failed_norm_x_vertices) + check_normal(vertex_index, vertex_ori.normal_y, vertex_exp.normal_y, failed_norm_y_vertices) + check_normal(vertex_index, vertex_ori.normal_z, vertex_exp.normal_z, failed_norm_z_vertices) + check_normal(vertex_index, vertex_ori.normal_w, vertex_exp.normal_w, failed_norm_w_vertices) + + try: + check_normal(vertex_index, vertex_ori.tangent_x, vertex_exp.tangent_x, failed_tang_x_vertices, TANGENT_LIMIT) + check_normal(vertex_index, vertex_ori.tangent_y, vertex_exp.tangent_y, failed_tang_y_vertices, TANGENT_LIMIT) + check_normal(vertex_index, vertex_ori.tangent_z, vertex_exp.tangent_z, failed_tang_z_vertices, TANGENT_LIMIT) + check_normal(vertex_index, vertex_ori.tangent_w, vertex_exp.tangent_w, failed_tang_w_vertices, TANGENT_LIMIT) + except AttributeError: + pass + + assert not failed_pos_vertices + assert not failed_uvs + assert not failed_norm_w_vertices + assert not failed_tang_w_vertices + assert len(failed_norm_x_vertices) / len(mesh_original_vertices) < FAILURE_RATIO + assert len(failed_norm_y_vertices) / len(mesh_original_vertices) < FAILURE_RATIO + assert len(failed_norm_z_vertices) / len(mesh_original_vertices) < FAILURE_RATIO + # TODO: Improve and research tangets. For now there are many failures, but good enough in-game + """ + FAILURE_RATIO_TANGENT = 0.30 + assert len(failed_tang_x_vertices) / len(mesh_original_vertices) < FAILURE_RATIO_TANGENT + assert len(failed_tang_y_vertices) / len(mesh_original_vertices) < FAILURE_RATIO_TANGENT + assert len(failed_tang_z_vertices) / len(mesh_original_vertices) < FAILURE_RATIO_TANGENT + """ + + +def check_normal(vertex_index, normal_original, normal_exported, failed_list, limit=10): + is_ok = normal_original == pytest.approx(normal_exported, abs=limit) + + if not is_ok: + failed_list.append((vertex_index, normal_original, normal_exported))