Skip to content

Commit

Permalink
- fixed texture maps not showing up in GUI
Browse files Browse the repository at this point in the history
- fixed Wreck textures in some cases importing as normal map textures
- fixed BundledMesh export in some cases reporting false errors when Geom hierarchies differ
- made some optimisations to import
- same BF2 mesh materials can now merged into one Blender material when importing
- added texture suffix checks during export
  • Loading branch information
marekzajac97 committed May 31, 2024
1 parent 35f20b8 commit 5dd69b8
Show file tree
Hide file tree
Showing 9 changed files with 314 additions and 195 deletions.
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,6 @@ I'm probably like 15 years late but anyway, here are some tools for importing an
- BundledMesh (`.bundledMesh`) import/export
- CollisionMesh (`.collisionMesh`) import/export

## Limitations and known issues:
- CollisionMesh exports to a slightly older file version (9) than 3DsMax exporter (10), which may make BF2 regenerate some missing data on load time, not a big deal.
- Generating `.samples` for StaticMeshes is not yet supported, use [bfmeshview](http://www.bytehazard.com/bfstuff/bfmeshview/)!
- SkinnedMeshes using Object Space normal maps will have shading issues when deformed/animated inside of Blender.

Please report any other issues found!

## Compatibility
Blender 4.1 only, pre-build binaries available for Windows, Linux and macOS (Intel). For other platforms see building instructions at [BSP Builder](bsp_builder/README.md).

Expand All @@ -27,6 +20,14 @@ NOTE: Removing the add-on through Blender **will not work properly**, you have t
## Usage
- Head over to the [Documentation](docs/README.md) for details on how to use this add-on

## Limitations and known issues:
- Generating `.samples` for StaticMeshes is not yet supported, use [bfmeshview](http://www.bytehazard.com/bfstuff/bfmeshview/)!
- SkinnedMeshes using Object Space normal maps will have shading issues when deformed/animated inside of Blender.
- Blender does not allow to import custom tangent data, therefore when re-exporting meshes, vertex tangents always get re-calculated. This may increase the number of unique vertices being exported. Generated tangents may also be totally wrong if the normal map used was not generated using Mikk TSpace method (which Blender uses).
- CollisionMesh exports to a slightly older file version (9) than 3DsMax exporter (10). Latest file version contains some extra data for drawing debug meshes which is disabled by default in-game anyway.

Please report any other issues found!

## Credits
- [rpoxo](https://github.com/rpoxo) for the [BF2 mesh file parser](https://github.com/rpoxo/bf2mesh) (MIT License)
- Remdul for guidance and [bfmeshview](http://www.bytehazard.com/bfstuff/bfmeshview/) (a lot of the stuff is ported over from there)
Expand Down
6 changes: 4 additions & 2 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@
"author" : "Marek Zajac",
"description" : "Import and export asset files for DICE's Refractor 2 engine",
"blender" : (4, 1, 1),
"version" : (0, 7, 5),
"location" : "",
"version" : (0, 7, 6),
"location": "File -> Import/Export -> BF2",
"warning" : "",
"doc_url": "https://github.com/marekzajac97/bf2-blender/blob/main/docs/README.md",
"tracker_url": "https://github.com/marekzajac97/bf2-blender/issues/new",
"category" : "Import-Export"
}

Expand Down
289 changes: 179 additions & 110 deletions core/mesh.py

Large diffs are not rendered by default.

48 changes: 37 additions & 11 deletions core/mesh_material.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,37 @@
]

STATICMESH_TEXUTRE_MAP_TYPES = ['Base', 'Detail', 'Dirt', 'Crack', 'NDetail', 'NCrack']
BUNDLEDMESH_TEXTURE_MAP_TYPES = ['Diffuse', 'Normal', 'Shadow']
BUNDLEDMESH_TEXTURE_MAP_TYPES = ['Diffuse', 'Normal', 'Wreck']
SKINNEDMESH_TEXTURE_MAP_TYPES = ['Diffuse', 'Normal']

TEXTURE_MAPS = {
'STATICMESH': STATICMESH_TEXUTRE_MAP_TYPES,
'BUNDLEDMESH': BUNDLEDMESH_TEXTURE_MAP_TYPES,
'SKINNEDMESH': SKINNEDMESH_TEXTURE_MAP_TYPES
}

TEXTURE_TYPE_TO_SUFFIXES = {
# BundledMesh/SkinnedMesh
'Diffuse': ('_c',),
'Normal': ('_b', '_b_os'),
'Wreck': ('_w',),
# StaticMesh
'Base': ('_c',),
'Detail': ('_de','_c'),
'Dirt': ('_di','_w'),
'Crack': ('_cr',),
'NDetail': ('_deb','_b'),
'NCrack': ('_crb',)
}

def get_texture_suffix(texture_type):
return TEXTURE_TYPE_TO_SUFFIXES[texture_type][0]

def texture_suffix_is_valid(texture_path, texture_type):
map_filename = os.path.splitext(os.path.basename(texture_path))[0]
suffixes = TEXTURE_TYPE_TO_SUFFIXES[texture_type]
return any([map_filename.endswith(sfx) for sfx in suffixes])

def _create_bf2_axes_swap():
if 'BF2AxesSwap' in bpy.data.node_groups:
return bpy.data.node_groups['BF2AxesSwap']
Expand Down Expand Up @@ -124,12 +152,6 @@ def is_staticmesh_map_allowed(material, mapname):
technique += map_type
return technique in STATICMESH_TECHNIQUES

TEXTURE_MAPS = {
'STATICMESH': STATICMESH_TEXUTRE_MAP_TYPES,
'BUNDLEDMESH': BUNDLEDMESH_TEXTURE_MAP_TYPES,
'SKINNEDMESH': SKINNEDMESH_TEXTURE_MAP_TYPES
}

def get_material_maps(material):
texture_maps = OrderedDict()
for i, map_type in enumerate(TEXTURE_MAPS[material.bf2_shader]):
Expand All @@ -145,9 +167,14 @@ def get_tex_type_to_file_mapping(material, texture_files):
if material.bf2_shader in ('SKINNEDMESH', 'BUNDLEDMESH'):
map_name_to_file['Diffuse'] = texture_files[0]
if len(texture_files) > 1:
map_name_to_file['Normal'] = texture_files[1]
if material.bf2_shader == 'SKINNEDMESH':
map_name_to_file['Normal'] = texture_files[1]
elif texture_suffix_is_valid(texture_files[1], 'Normal'): # guess which is which by suffix
map_name_to_file['Normal'] = texture_files[1]
else:
map_name_to_file['Wreck'] = texture_files[1]
if len(texture_files) > 2:
map_name_to_file['Shadow'] = texture_files[2]
map_name_to_file['Wreck'] = texture_files[2]
elif material.bf2_shader == 'STATICMESH':
if material.bf2_technique not in STATICMESH_TECHNIQUES:
raise ImportException(f'Unsupported staticmesh technique "{material.bf2_technique}"')
Expand Down Expand Up @@ -256,7 +283,7 @@ def setup_material(material, uvs=None, texture_path='', reporter=DEFAULT_REPORTE

diffuse = texture_nodes['Diffuse']
normal = texture_nodes.get('Normal')
shadow = texture_nodes.get('Shadow')
shadow = texture_nodes.get('Wreck')

node_tree.links.new(uv_map_nodes[UV_CHANNEL].outputs['UV'], diffuse.inputs['Vector'])
if normal:
Expand Down Expand Up @@ -539,7 +566,6 @@ def _create_normal_map_node(nmap, uv_chan):

# ---- transparency ------
if has_alpha:
# TODO alpha blend for statics, is this even used ?
if detail:
alpha_output = detail.outputs['Alpha']
else:
Expand Down
131 changes: 69 additions & 62 deletions core/object_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@
SKIN_PREFIX = 'SKIN__'
ANCHOR_PREFIX = 'ANCHOR__'

class GeomPartInfo:
def __init__(self, part_id, obj) -> None:
self.part_id = part_id
self.name = _strip_prefix(obj.name)
self.bf2_object_type = obj.bf2_object_type
self.location = obj.location.copy()
self.rotation_quaternion = obj.rotation_quaternion.copy()
self.matrix_local = obj.matrix_local.copy()
self.children = []

def import_object(context, con_filepath, import_collmesh=False, import_rig=('AUTO', None), reload=False, reporter=DEFAULT_REPORTER, **kwargs):
BF2Engine().shutdown() # clear previous state
obj_template_manager = BF2Engine().get_manager(ObjectTemplate)
Expand Down Expand Up @@ -126,17 +136,22 @@ def export_object(mesh_obj, con_file, geom_export=True, colmesh_export=True,
if anchor_obj:
anchor_obj.parent = mesh_obj

main_lod, obj_to_geom_part_id = _find_main_lod_and_geom_parts(mesh_geoms)
obj_to_geom_part = _find_geom_parts(mesh_geoms)

for obj_name, geom_part in obj_to_geom_part.items():
if geom_part.part_id == 0:
root_geom_part = geom_part
break

for geom_obj in mesh_geoms:
for lod_obj in geom_obj:
_verify_lods_consistency(main_lod, lod_obj)
_verify_lods_consistency(root_geom_part, lod_obj)

collmesh_parts, obj_to_col_part_id = _find_collmeshes(mesh_geoms)

root_obj_template = _create_object_template(main_lod, obj_to_geom_part_id, obj_to_col_part_id)
root_obj_template = _create_object_template(root_geom_part, obj_to_geom_part, obj_to_col_part_id)
if root_obj_template is None:
raise ExportException(f"root object '{main_lod.name}' is missing ObjectTemplate type, check object properties!")
raise ExportException(f"root object '{root_geom_part.name}' is missing ObjectTemplate type, check object properties!")
root_obj_template.save_in_separate_file = True
root_obj_template.creator_name = getuser()
root_obj_template.geom = GeometryTemplate(geometry_type, obj_name)
Expand All @@ -155,7 +170,7 @@ def export_object(mesh_obj, con_file, geom_export=True, colmesh_export=True,
try:
if geometry_type == 'BundledMesh':
print(f"joining LODs...")
_join_lods(temp_mesh_geoms, obj_to_geom_part_id)
_join_lods(temp_mesh_geoms, obj_to_geom_part)

root_obj_template.geom.nr_of_animated_uv_matrix = _get_nr_of_animted_uvs(temp_mesh_geoms)

Expand Down Expand Up @@ -210,33 +225,27 @@ def export_object(mesh_obj, con_file, geom_export=True, colmesh_export=True,
_dump_con_file(root_obj_template, con_file)


def _find_main_lod_and_geom_parts(mesh_geoms):
main_lod = None
main_obj_to_part_id = None
parts_num = 0
def _find_geom_parts(mesh_geoms):
obj_to_part = dict()
for geom_obj in mesh_geoms:
for lod_obj in geom_obj:
geom_parts = list()
obj_to_part_id = dict()
_collect_geometry_parts(lod_obj, geom_parts, obj_to_part_id)
lod_parts_num = len(geom_parts)
if lod_parts_num > parts_num:
parts_num = lod_parts_num
main_lod = lod_obj
main_obj_to_part_id = obj_to_part_id

return main_lod, main_obj_to_part_id

def _collect_geometry_parts(obj, geom_parts, obj_to_part_id):
part_id = len(geom_parts)
_collect_geometry_parts(lod_obj, obj_to_part)
return obj_to_part

def _collect_geometry_parts(obj, obj_to_part):
object_name = _strip_prefix(obj.name)
obj_to_part_id[object_name] = part_id
geom_parts.append(obj)
# process childs
for _, child_obj in sorted([(child.name, child) for child in obj.children]):
geom_part = obj_to_part.get(object_name)
if geom_part is None:
part_id = len(obj_to_part)
geom_part = GeomPartInfo(part_id, obj)
obj_to_part[object_name] = geom_part

for _, child_obj in sorted([(_strip_prefix(child.name), child) for child in obj.children]):
if not _is_colmesh_dummy(child_obj):
_collect_geometry_parts(child_obj, geom_parts, obj_to_part_id)
return geom_parts
child_geom_part = _collect_geometry_parts(child_obj, obj_to_part)
if child_geom_part not in geom_part.children:
geom_part.children.append(child_geom_part)
return geom_part

def _find_collmeshes(mesh_geoms):
collmesh_parts_per_geom = list()
Expand Down Expand Up @@ -292,48 +301,47 @@ def _collect_collmesh_parts(obj, collmesh_parts, obj_to_part_id):
def _is_colmesh_dummy(obj):
return obj.name.lower().startswith(NONVIS_PRFX.lower())

def _verify_lods_consistency(main_lod_obj, lod_obj):
main_lod_name = _strip_prefix(main_lod_obj.name)
def _verify_lods_consistency(root_geom_part, lod_obj):
lod_name = _strip_prefix(lod_obj.name)

if any([c.isspace() for c in lod_obj.name]):
raise ExportException(f"'{child_obj.name}' name contain spaces!")
raise ExportException(f"'{child_obj.name}' name contain spaces!")

if lod_obj.data is None:
raise ExportException(f"Object '{lod_obj.name}' has no mesh data! If you don't want it to contain any, simply make it a mesh object and delete all vertices")

def _inconsistency(item, val, exp_val):
raise ExportException(f"{lod_obj.name}: Inconsistent {item} for different LODs, got '{val}' but expected '{exp_val}'")
raise ExportException(f"{lod_obj.name}: Inconsistent {item} for different Geoms/LODs, got '{val}' but other Geom/LOD has '{exp_val}'")

if lod_name != main_lod_name:
_inconsistency('object names', lod_obj.name, main_lod_obj.name)
if lod_obj.bf2_object_type != main_lod_obj.bf2_object_type:
_inconsistency('BF2 Object Types', lod_obj.bf2_object_type, main_lod_obj.bf2_object_type)
if (main_lod_obj.location - lod_obj.location).length > 0.0001:
_inconsistency('object locations', lod_obj.location, main_lod_obj.location)
if main_lod_obj.rotation_quaternion.rotation_difference(lod_obj.rotation_quaternion).angle > 0.0001:
_inconsistency('object rotations', lod_obj.rotation_quaternion, main_lod_obj.rotation_quaternion)
if lod_name != root_geom_part.name:
_inconsistency('object names', lod_obj.name, root_geom_part.name)
if lod_obj.bf2_object_type != root_geom_part.bf2_object_type:
_inconsistency('BF2 Object Types', lod_obj.bf2_object_type, root_geom_part.bf2_object_type)
if (root_geom_part.location - lod_obj.location).length > 0.0001:
_inconsistency('object locations', lod_obj.location, root_geom_part.location)
if root_geom_part.rotation_quaternion.rotation_difference(lod_obj.rotation_quaternion).angle > 0.0001:
_inconsistency('object rotations', lod_obj.rotation_quaternion, root_geom_part.rotation_quaternion)

main_lod_children = dict()
for child_obj in main_lod_obj.children:
main_lod_children[_strip_prefix(child_obj.name)] = child_obj
root_geom_children = dict()
for child_geom_part in root_geom_part.children:
root_geom_children[child_geom_part.name] = child_geom_part

for child_obj in lod_obj.children:
child_name = _strip_prefix(child_obj.name)
if _is_colmesh_dummy(child_obj):
continue
if child_name not in main_lod_children:
if child_name not in root_geom_children:
raise ExportException(f"Unexpected object '{child_obj.name}' found, hierarchy does not match with other LOD(s)")

prev_lod_child = main_lod_children[child_name]
_verify_lods_consistency(prev_lod_child, child_obj)
geom_part_child = root_geom_children[child_name]
_verify_lods_consistency(geom_part_child, child_obj)

def _create_object_template(obj, obj_to_geom_part_id, obj_to_col_part_id, is_vehicle=None) -> ObjectTemplate:
if obj.bf2_object_type == '': # special case, geom part which has no object template (see GenericFirearm)
def _create_object_template(root_geom_part, obj_to_geom_part, obj_to_col_part_id, is_vehicle=None) -> ObjectTemplate:
if root_geom_part.bf2_object_type == '': # special case, geom part which has no object template (see GenericFirearm)
return None

obj_name = _strip_prefix(obj.name)
obj_template = ObjectTemplate(obj.bf2_object_type, obj_name)
obj_name = root_geom_part.name
obj_template = ObjectTemplate(root_geom_part.bf2_object_type, obj_name)

if is_vehicle is None: # root object
# TODO: no idea how to properly detect whether the exported object
Expand All @@ -347,13 +355,13 @@ def _create_object_template(obj, obj_to_geom_part_id, obj_to_col_part_id, is_veh
obj_template.has_collision_physics = True
obj_template.col_part = obj_to_col_part_id[obj_name]

if obj_name in obj_to_geom_part_id:
obj_template.geom_part = obj_to_geom_part_id[obj_name]
if obj_name in obj_to_geom_part:
obj_template.geom_part = obj_to_geom_part[obj_name].part_id

for _, child_obj in sorted([(child.name, child) for child in obj.children]):
for _, child_obj in sorted([(child.name, child) for child in root_geom_part.children]):
if _is_colmesh_dummy(child_obj): # skip collmeshes
continue
child_template = _create_object_template(child_obj, obj_to_geom_part_id, obj_to_col_part_id, is_vehicle)
child_template = _create_object_template(child_obj, obj_to_geom_part, obj_to_col_part_id, is_vehicle)
if child_template is None:
continue
child_object = ObjectTemplate.ChildObject(child_template.name)
Expand Down Expand Up @@ -720,27 +728,27 @@ def _create_mesh_vertex_group(obj, obj_to_vertex_group):
if not _is_colmesh_dummy(child_obj):
_create_mesh_vertex_group(child_obj, obj_to_vertex_group)

def _map_objects_to_vertex_groups(obj, obj_to_geom_part_id, obj_to_vertex_group):
def _map_objects_to_vertex_groups(obj, obj_to_geom_part, obj_to_vertex_group):
org_obj_name = _strip_tmp_prefix(obj.name)
obj_name = _strip_prefix(org_obj_name)
part_id = obj_to_geom_part_id[obj_name]
part_id = obj_to_geom_part[obj_name].part_id
group_name = f'mesh{part_id + 1}'
obj_to_vertex_group[obj_name] = group_name

for child_obj in obj.children:
if not _is_colmesh_dummy(child_obj):
_map_objects_to_vertex_groups(child_obj, obj_to_geom_part_id, obj_to_vertex_group)
_map_objects_to_vertex_groups(child_obj, obj_to_geom_part, obj_to_vertex_group)

def _select_all_geometry_parts(obj):
obj.select_set(True)
for child_obj in obj.children:
if not _is_colmesh_dummy(child_obj):
_select_all_geometry_parts(child_obj)

def _join_lod_hierarchy_into_single_mesh(lod_obj, obj_to_geom_part_id):
def _join_lod_hierarchy_into_single_mesh(lod_obj, obj_to_geom_part):
# first, assign one vertex group for each mesh which corresponds to geom part id
obj_to_vertex_group = dict()
_map_objects_to_vertex_groups(lod_obj, obj_to_geom_part_id, obj_to_vertex_group)
_map_objects_to_vertex_groups(lod_obj, obj_to_geom_part, obj_to_vertex_group)
_create_mesh_vertex_group(lod_obj, obj_to_vertex_group)

# select all geom parts with meshes
Expand All @@ -749,11 +757,10 @@ def _join_lod_hierarchy_into_single_mesh(lod_obj, obj_to_geom_part_id):
_select_all_geometry_parts(lod_obj)
bpy.ops.object.join()


def _join_lods(mesh_geoms, obj_to_geom_part_id):
def _join_lods(mesh_geoms, obj_to_geom_part):
for geom_obj in mesh_geoms:
for lod_obj in geom_obj:
_join_lod_hierarchy_into_single_mesh(lod_obj, obj_to_geom_part_id)
_join_lod_hierarchy_into_single_mesh(lod_obj, obj_to_geom_part)

def _duplicate_object(obj, recursive=True, prefix=TMP_PREFIX):
bpy.context.view_layer.objects.active = obj
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ There are two ways of importing BF2 meshes. One is to use `Import -> BF2` menu t
## Materials and UVs
- Each material that is assigned to any visible mesh must be set up for export. To setup BF2 material go to `Material Properties`, you should see `BF2 Material` panel there. Enable `Is BF2 Material` and choose appropriate settings: `Alpha Mode`, `Shader` and `Technique` (BundledMesh/SkinnedMesh only) as well as desired texture maps to load.
- For StaticMesh: There will be 6 texture slots for Base, Detail, Dirt, Crack, Detail Normal, and Crack Normal. Only Base texture is mandatory, if others are not meant to be used, leave them empty.
- For BundledMesh: There should be 3 texture slots for Diffuse, Normal, and Shadow. Only Diffuse texture is mandatory, if others are not meant to be used, leave them empty.
- For BundledMesh: There should be 3 texture slots for Diffuse, Normal, and Wreck. Only Diffuse texture is mandatory, if others are not meant to be used, leave them empty.
- For SkinnedMesh: There should be 2 texture slots for Diffuse and Normal. Only Diffuse texture is mandatory, if Normal is not meant to be used, leave it empty.
- Clicking on `Apply Material` changes some material settings, loads textures and builds a tree of Shader Nodes that try to mimic BF2 rendering.
- Each LOD's mesh must have a minimum of 1 and a maximum of 5 UV layers assigned and each UV layer must be called `UV<index>`, where each one corresponds to the following texture maps:
Expand Down
Loading

0 comments on commit 5dd69b8

Please sign in to comment.