Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions bms_blender_plugin/common/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ def copy_collection_flat(
if copied_object:
bpy.context.view_layer.objects.active = copied_object
bpy.ops.object.mode_set(mode="OBJECT")

# Single scene update at the end to refresh all transform matrices - attempt to fix nested DOF transforms failing due to Blender quirk
bpy.context.view_layer.update()


def reset_dof(obj):
Expand Down Expand Up @@ -366,6 +369,13 @@ def apply_all_modifiers_on_obj(obj):
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.convert(target="MESH", keep_original=False)

# Store the world position before transform application for reference points
if (obj.type == "MESH" and
get_bml_type(obj) not in [BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT]):
# Store the position in a custom property that survives transform_apply
obj["bms_reference_point"] = tuple(obj.location)

# Apply transforms using original logic (restored)
if get_bml_type(obj) not in [
BlenderNodeType.DOF,
BlenderNodeType.SLOT,
Expand Down
2 changes: 2 additions & 0 deletions bms_blender_plugin/exporter/bml_mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ def get_bml_mesh_data(obj, max_vertex_index):

world_normal = world_coord.inverted_safe().transposed().to_3x3()



for face in mesh.polygons:
# loop over face loop
for vert in [mesh.loops[i] for i in face.loop_indices]:
Expand Down
141 changes: 107 additions & 34 deletions bms_blender_plugin/exporter/export_lods.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
"""
Performance Notes:
- Material batching optimization gives ~10-12% improvement in DOF/switch heavy scenes
- Mesh-heavy scenes should see higher gains (~50%?)
- Further perf improvements: batch DOF processing, reduce object selection calls
"""

import os
import struct

Expand Down Expand Up @@ -300,17 +307,22 @@ def _recursively_parse_nodes(objects):
def join_objects_with_same_materials(objects, materials_objects, auto_smooth_value):
"""Joins objects of the same BML node level (i.e. not separated by DOFs, Switches or Slots)
to a single Blender object. This is critical to reduce draw calls"""

# Instead of joining objects one-by-one, we first group them
# by material, then batch join all objects with the same material in a single operation.

object_names = []
for obj in objects:
if obj:
object_names.append(obj.name)

# Step 1: Categorize objects and prepare light data (no joining yet)
mesh_objects_by_material = {} # List of obj for batch join

for obj_name in object_names:
obj = bpy.data.objects[obj_name]


if obj.type == "MESH":
# regular meshes
# "do not merge" flag - just use a custom material name which will never be looked up
# enhance this by including BBOXs in the do not merge category - Otherwise joined objects will not render
if obj.bml_do_not_merge or get_bml_type(obj) == BlenderNodeType.BBOX:
Expand All @@ -330,7 +342,6 @@ def join_objects_with_same_materials(objects, materials_objects, auto_smooth_val
# before we join the lights into a common object, we need to store their individual object values in
# separate face variables, so we can create their vertices later
# the keys of all stored values is their face index

if get_bml_type(obj) == BlenderNodeType.PBR_LIGHT:
# make sure we only join lights with other lights - simply change the key
material_name = "BML_BBL_" + material_name
Expand All @@ -357,7 +368,6 @@ def join_objects_with_same_materials(objects, materials_objects, auto_smooth_val
layer_normal_x.data[face.index].value = face.normal.x
layer_normal_y.data[face.index].value = face.normal.y
layer_normal_z.data[face.index].value = face.normal.z

else:
# omnidirectional
layer_normal_x.data[face.index].value = 0
Expand All @@ -370,35 +380,10 @@ def join_objects_with_same_materials(objects, materials_objects, auto_smooth_val
layer_color_b.data[face.index].value = obj.color[2]
layer_color_a.data[face.index].value = obj.color[3]

object_with_same_material_list = materials_objects.get(material_name)

# join objects with the same material name
if object_with_same_material_list is not None:
if len(object_with_same_material_list) != 1:
raise Exception("Invalid length of material list objects")

object_with_same_material = object_with_same_material_list[0]

# force autosmooth on the objects to be merged (reason: when joining, Blender will override the
# smoothing options to the last object selected)
if (
object_with_same_material.data.use_auto_smooth
or obj.data.use_auto_smooth
):
force_auto_smoothing_on_object(
object_with_same_material, auto_smooth_value
)
force_auto_smoothing_on_object(obj, auto_smooth_value)

bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
object_with_same_material.select_set(True)
bpy.context.view_layer.objects.active = object_with_same_material
bpy.ops.object.join()

else:
# no entries found, add material and obj as new entries
materials_objects[material_name] = [obj]
# Group mesh objects by material for batch processing
if material_name not in mesh_objects_by_material:
mesh_objects_by_material[material_name] = []
mesh_objects_by_material[material_name].append(obj)

# make sure that DOFs, Switches and Slots are never joined
elif obj.type == "EMPTY" and (
Expand All @@ -413,5 +398,93 @@ def join_objects_with_same_materials(objects, materials_objects, auto_smooth_val
elif obj.type == "EMPTY":
# add default empties as well so their children can be parsed
materials_objects["_EMPTY_" + obj.name] = [obj]


# Step 2: Batch join - one join per material group instead of one join per object pair
for material_name, objects_with_same_material in mesh_objects_by_material.items():
if len(objects_with_same_material) == 1:
# Single object with this material, no joining needed
materials_objects[material_name] = objects_with_same_material
else:
# Multiple objects with same material - batch join them all at once
print(f"Batch join {len(objects_with_same_material)} objects, material: '{material_name}'")

# Fix UV layer preservation during join (Issue #21)
# Blender's join tends to favor a layer literally named "UVMap". Keep exactly one primary UV layer.
# Exporter only uses a single UV layer, so we can safely collapse multiples.
for obj in objects_with_same_material:
uv_layers = obj.data.uv_layers
if len(uv_layers) == 0:
continue # No UV layers, nothing to normalize

# Ensure some layer is active
if not uv_layers.active:
uv_layers.active_index = 0
print(f"[BML Export] Warning: Object '{obj.name}' had no active UV layer; first layer set active")

# Prefer an existing primary layer actually named "UVMap" if present
primary_layer = uv_layers.get("UVMap")
if primary_layer is not None:
# Make sure it's the active layer for downstream ops
for i, layer in enumerate(uv_layers):
if layer == primary_layer:
uv_layers.active_index = i
break
else:
# No layer named "UVMap"; use the active layer as the primary and rename it
primary_layer = uv_layers.active
if primary_layer.name != "UVMap":
print(f"📝 Info: Renaming active UV layer '{primary_layer.name}' on '{obj.name}' to 'UVMap'")
primary_layer.name = "UVMap"

# Remove ALL other layers (exporter uses only one); collect NAMES first so we can re-resolve
removable_names = [layer.name for layer in uv_layers if layer.name != "UVMap"]
for lname in removable_names:
# Re-fetch by name to avoid stale pointer if Blender reallocated internally
layer_obj = uv_layers.get(lname)
if layer_obj is None:
# Already removed/renamed by previous operation
continue
try:
uv_layers.remove(layer_obj)
except RuntimeError as e:
print(f"⚠️ Warning: Failed to remove secondary UV layer '{lname}' from '{obj.name}': {e}")

# Safety check
if uv_layers.active is None or uv_layers.active.name != "UVMap":
# If something unexpected happened, fall back to first layer and rename
if len(uv_layers):
uv_layers.active_index = 0
if uv_layers.active and uv_layers.active.name != "UVMap":
try:
uv_layers.active.name = "UVMap"
except Exception:
pass
print(f"📝 Info: Repaired primary UVMap layer on '{obj.name}' after cleanup")

# force autosmooth on all objects to be merged (reason: when joining, Blender will override the
# smoothing options to the last object selected)
# Check if ANY object in this group has auto_smooth enabled
any_object_has_auto_smooth = any(obj.data.use_auto_smooth for obj in objects_with_same_material)

if any_object_has_auto_smooth:
# If ANY object has auto_smooth, apply it to ALL objects in the group (original behavior)
for obj in objects_with_same_material:
if not obj.data.use_auto_smooth:
print(f"⚠️ Warning: Object '{obj.name}' does not have auto-smoothing enabled but will be forced to match other objects in material group '{material_name}'")
force_auto_smoothing_on_object(obj, auto_smooth_value)

# Select all objects with this material at once
bpy.ops.object.select_all(action="DESELECT")
for obj in objects_with_same_material:
obj.select_set(True)

# Set first object as active (target for join operation)
bpy.context.view_layer.objects.active = objects_with_same_material[0]

# Perform ONE join operation for all objects with this material
bpy.ops.object.join()

# Store the joined result (first object now contains all the merged geometry)
materials_objects[material_name] = [objects_with_same_material[0]]

return [item for sublist in materials_objects.values() for item in sublist]
22 changes: 17 additions & 5 deletions bms_blender_plugin/exporter/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,15 @@ def parse_mesh(
for obj_vertex in obj_vertices:
obj_vertices_data += obj_vertex.to_data()

# DOF children use coordinates local to their DOF
if get_bml_type(obj.parent) == BlenderNodeType.DOF and obj.parent.dof_type != DofType.TRANSLATE.name:
reference_point = to_bms_coords((0, 0, 0))
# Use stored reference point if available, otherwise fall back to current location.
# Property assigned in util.py - preserves Blender origin to use as reference point for alpha sorting
# All objects now use their origins for reference points, including DOF children
if "bms_reference_point" in obj:
stored_position = Vector(obj["bms_reference_point"])
reference_point = to_bms_coords(stored_position)
else:
reference_point = get_objcenter(obj)
# Fallback for objects without stored reference point
reference_point = to_bms_coords(obj.location)

node = Primitive(
index=len(nodes),
Expand Down Expand Up @@ -122,7 +126,15 @@ def parse_bbl_light(
for obj_vertex in obj_vertices:
obj_vertices_data += obj_vertex.to_data()

reference_point = get_objcenter(obj)
# Use stored reference point if available, otherwise fall back to world translation
# All objects now use their origins for reference points, including DOF children
if "bms_reference_point" in obj:
stored_position = Vector(obj["bms_reference_point"])
reference_point = to_bms_coords(stored_position)
else:
# Fallback for objects without stored reference point
reference_point = to_bms_coords(obj.matrix_world.translation)

node = Primitive(
index=len(nodes),
topology=PrimitiveTopology.TRIANGLE_LIST,
Expand Down
91 changes: 90 additions & 1 deletion bms_blender_plugin/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,83 @@

from bms_blender_plugin.common.blender_types import BlenderNodeType
from bms_blender_plugin.common.bml_structs import DofType
from bms_blender_plugin.common.util import get_bml_type
from bms_blender_plugin.common.util import get_bml_type, get_dofs, get_switches, get_callbacks


class ReloadDofList(Operator):
"""Reload DOF list from DOF.xml file"""
bl_idname = "bml.reload_dof_list"
bl_label = "Reload DOF.xml"
bl_description = "Reload the DOF list from the DOF.xml file. Use this after modifying the XML file"
bl_options = {"REGISTER"}

def execute(self, context):
# Clear the global cache first
import bms_blender_plugin.common.util as util_module
util_module.dofs = []

# Clear the scene cache
context.scene.dof_list.clear()

# Repopulate the scene cache immediately
for dof in get_dofs():
item = context.scene.dof_list.add()
item.name = dof.name
item.dof_number = int(dof.dof_number)

self.report({'INFO'}, f"Reloaded {len(context.scene.dof_list)} DOFs from DOF.xml")
return {'FINISHED'}


class ReloadSwitchList(Operator):
"""Reload Switch list from switch.xml file"""
bl_idname = "bml.reload_switch_list"
bl_label = "Reload switch.xml"
bl_description = "Reload the Switch list from the switch.xml file. Use this after modifying the XML file"
bl_options = {"REGISTER"}

def execute(self, context):
# Clear the global cache first
import bms_blender_plugin.common.util as util_module
util_module.switches = []

# Clear the scene cache
context.scene.switch_list.clear()

# Repopulate the scene cache immediately
for switch in get_switches():
item = context.scene.switch_list.add()
item.name = switch.name
item.switch_number = int(switch.switch_number)
item.branch_number = int(switch.branch)

self.report({'INFO'}, f"Reloaded {len(context.scene.switch_list)} Switches from switch.xml")
return {'FINISHED'}


class ReloadCallbackList(Operator):
"""Reload Callback list from callbacks.xml file"""
bl_idname = "bml.reload_callback_list"
bl_label = "Reload callbacks.xml"
bl_description = "Reload the Callback list from the callbacks.xml file. Use this after modifying the XML file"
bl_options = {"REGISTER"}

def execute(self, context):
# Clear the global cache first
import bms_blender_plugin.common.util as util_module
util_module.callbacks = []

# Clear the scene cache
context.scene.bml_all_callbacks.clear()

# Repopulate the scene cache immediately
for callback in get_callbacks():
new_callback = context.scene.bml_all_callbacks.add()
new_callback.name = callback.name
new_callback.group = callback.group

self.report({'INFO'}, f"Reloaded {len(context.scene.bml_all_callbacks)} Callbacks from callbacks.xml")
return {'FINISHED'}


class ExporterPreferences(bpy.types.AddonPreferences):
Expand Down Expand Up @@ -114,6 +190,13 @@ def draw(self, context):

box.operator(ApplyEmptyDisplaysToDofs.bl_idname, icon="CHECKMARK")

layout.separator()
layout.label(text="Data Management")
box = layout.box()
box.operator(ReloadDofList.bl_idname, icon="FILE_REFRESH")
box.operator(ReloadSwitchList.bl_idname, icon="FILE_REFRESH")
box.operator(ReloadCallbackList.bl_idname, icon="FILE_REFRESH")

layout.separator()
layout.row().label(text="Debug options")
layout.row().label(text="Use at your own risk. All options should be OFF by default.", icon="ERROR")
Expand Down Expand Up @@ -164,10 +247,16 @@ def execute(self, context):


def register():
bpy.utils.register_class(ReloadDofList)
bpy.utils.register_class(ReloadSwitchList)
bpy.utils.register_class(ReloadCallbackList)
bpy.utils.register_class(ApplyEmptyDisplaysToDofs)
bpy.utils.register_class(ExporterPreferences)


def unregister():
bpy.utils.unregister_class(ExporterPreferences)
bpy.utils.unregister_class(ApplyEmptyDisplaysToDofs)
bpy.utils.unregister_class(ReloadCallbackList)
bpy.utils.unregister_class(ReloadSwitchList)
bpy.utils.unregister_class(ReloadDofList)