Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Operator to automatically bake lightmaps for selected objects. #281

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4af0244
Operator to automatically bake lightmaps for selected objects.
GottfriedHofmann Apr 22, 2024
567b121
Use smart UV project instead of lightmap pack
GottfriedHofmann May 8, 2024
a6bad91
Use Smart UV Project instead of Lightmap Pack
GottfriedHofmann May 17, 2024
496b9f4
Merge branch 'Hubs-Foundation:master' into LightMapBakeOperator
GottfriedHofmann Jul 30, 2024
deda117
Fix liniting issues
GottfriedHofmann Jul 30, 2024
6a69cfe
Fix liniting issues
GottfriedHofmann Jul 30, 2024
c0c68ea
Update addons/io_hubs_addon/components/operators.py
GottfriedHofmann Sep 3, 2024
4f06808
Add custom name in consts.py for the Lightmap UV Layer
GottfriedHofmann Sep 3, 2024
021d1ba
Return to old settings (render engine, bake etc.) after bake is finis…
GottfriedHofmann Sep 3, 2024
d5cfa46
Remove check whether file is saved, not needed anymore with packed im…
GottfriedHofmann Sep 3, 2024
5ca8cd1
Add warning to readme that the second UV layer could be overwritten b…
GottfriedHofmann Sep 3, 2024
8582eda
Adjust name of lightmap uv layer to be the same as in the readme.
GottfriedHofmann Sep 3, 2024
3d03bea
Suggestion: Create object groups with the same material for unwrappin…
GottfriedHofmann Sep 4, 2024
020d77d
Fix linting issues
GottfriedHofmann Sep 4, 2024
7e455ef
Remove TODO
GottfriedHofmann Sep 4, 2024
cf994da
Several fixes thanks to @vincentfretin
GottfriedHofmann Oct 8, 2024
a91a5b4
Fix: Create selection sets materialwise to omit overwriting already b…
GottfriedHofmann Oct 8, 2024
9a3f6f4
Microfix: Remove TODO
GottfriedHofmann Oct 8, 2024
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
172 changes: 171 additions & 1 deletion addons/io_hubs_addon/components/operators.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import bpy
from bpy.props import StringProperty, IntProperty, BoolProperty, CollectionProperty
from bpy.props import StringProperty, IntProperty, BoolProperty, CollectionProperty, FloatProperty
from bpy.types import Operator, PropertyGroup
from functools import reduce

Expand Down Expand Up @@ -670,6 +670,174 @@ def invoke(self, context, event):
return {'RUNNING_MODAL'}


class BakeLightmaps(Operator):
bl_idname = "object.bake_lightmaps"
bl_label = "Bake Lightmaps"
bl_description = "Bake lightmaps of selected objects using the Cycles render engine and pack them into the .blend."
bl_options = {'REGISTER', 'UNDO'}

default_intensity: FloatProperty(name="Lightmaps Intensity",
default=3.14,
description="Multiplier for hubs on how to interpert the brightness of the image. Set this to 1.0 if you have set up the lightmaps manually and use a non-HDR format like png or jpg.")
GottfriedHofmann marked this conversation as resolved.
Show resolved Hide resolved
resolution: IntProperty(name="Lightmaps Resolution",
default=2048,
description="The pixel resoltion of the resulting lightmap.")
GottfriedHofmann marked this conversation as resolved.
Show resolved Hide resolved
samples: IntProperty(name="Max Samples",
default=1024,
description="The number of samples to use for baking. Higher values reduce noise but take longer.")

def execute(self, context):
# Check selected objects
selected_objects = bpy.context.selected_objects

# filter mesh objects and others
mesh_objs, other_objs = [], []
for ob in selected_objects:
(mesh_objs if ob.type == 'MESH' else other_objs).append(ob)

for ob in other_objs:
# Remove non-mesh objects from selection to ensure baking will work
ob.select_set(False)

# set up UV layer structure. The first layer has to be UV0, the second one UV1 for the lightmap.
for obj in mesh_objs:
obj_uv_layers = obj.data.uv_layers
# Check whether there are any UV layers and if not, create the two that are required.
if len(obj_uv_layers) == 0:
obj_uv_layers.new(name='UV0')
obj_uv_layers.new(name='UV1')
GottfriedHofmann marked this conversation as resolved.
Show resolved Hide resolved

# In case there is only one UV layer create a second one named "UV1" for the lightmap.
if len(obj_uv_layers) == 1:
obj_uv_layers.new(name='UV1')
# Check if object has a second UV layer. If it is named "UV1", assume it is used for the lightmap.
# Otherwise add a new UV layer "UV1" and place it second in the slot list.
elif obj_uv_layers[1].name != 'UV1':
print("The second UV layer in hubs should be named UV1 and is reserved for the lightmap, all the layers >1 are ignored.")
obj_uv_layers.new(name='UV1')
# The new layer is the last in the list, swap it for position 1
obj_uv_layers[1], obj_uv_layers[-1] = obj_uv_layers[-1], obj_uv_layers[1]

# The layer for the lightmap needs to be the active one before lightmap packing
obj_uv_layers.active = obj_uv_layers['UV1']

# run UV lightmap packing on all selected objects
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
# TODO: We need to warn the user at some place like the README that the uv_layer[1] gets completely overwritten if it is called 'UV1'
GottfriedHofmann marked this conversation as resolved.
Show resolved Hide resolved
# bpy.ops.uv.lightmap_pack()
bpy.ops.uv.smart_project()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You create an image texture per material that is not shared between several materials if I'm not mistaken. Don't we need to do the smart_project for each group of objects sharing the same material instead?

I'm commenting on this PR because I'm doing my own Python script from the command line that I started before your PR that I'm continuing now, so I'm comparing with what I have.
I'm using
bpy.ops.uv.smart_project(island_margin=island_margin, correct_aspect=True)
with island_margin=0.03 or 0.001 it depends, that fixed some black artifacts on some of my scene.
Changing bake_settings.margin like you do below may also fix the issue. I just wanted to share that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea with the margin, I think we had some tests where the margin was actually counter productive so I put it into the consts.py config. Now it is bpy.ops.uv.smart_project(island_margin=LIGHTMAP_UV_ISLAND_MARGIN) so you can adjust it to your needs. Might even make sense to include that in the popup.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You create an image texture per material that is not shared between several materials if I'm not mistaken. Don't we need to do the smart_project for each group of objects sharing the same material instead?

I have changed the PR to this behavior. On the upside this makes the objects use the available UV space a lot better. On the downside there are some corner cases where we might get overlapping UVs. We should look into combining this with PR #48 to make it work in those cases.

bpy.ops.object.mode_set(mode='OBJECT')
# Update the view layer so all parts take notice of the changed UV layout
bpy.context.view_layer.update()

# Gather all materials on the selected objects
materials = []
for obj in mesh_objs:
GottfriedHofmann marked this conversation as resolved.
Show resolved Hide resolved
if len(obj.material_slots) >= 1:
# TODO: Make more efficient
GottfriedHofmann marked this conversation as resolved.
Show resolved Hide resolved
for slot in obj.material_slots:
if slot.material not in materials:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slot.material should be checked to make sure there is a material present in the slot.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition doesn't check if there are materials used. You may have one material slot but not assigned to material.
Why not just iterating over obj.data.materials here?

for obj in mesh_objs:
    for material in obj.data.materials:
        if material not in materials:

should work correctly.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah actually it may not work, obj.data.materials may give an error
AttributeError: 'PointLight' object has no attribute 'materials'
I had that condition in my code
if obj.type == 'MESH' and getattr(ob.data, "materials", None)
so yeah probably do what @Exairnous suggested:

for obj in mesh_objs:
    for slot in obj.material_slots:
        if slot.material is not None and slot.material not in materials:
            materials.append(slot.material)

Copy link
Contributor Author

@GottfriedHofmann GottfriedHofmann Sep 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, tested and it works! (luckily I am already testing for mesh objects further up)

materials.append(slot.material)
else:
# an object without materials should not be selected when running the bake operator
print("Object " + obj.name + " does not have material slots, removing from selection")
obj.select_set(False)
GottfriedHofmann marked this conversation as resolved.
Show resolved Hide resolved
# Check for the required nodes and set them up if not present
lightmap_texture_nodes = []
for mat in materials:
mat_nodes = mat.node_tree.nodes
lightmap_nodes = [node for node in mat_nodes if node.bl_idname == 'moz_lightmap.node']
if len(lightmap_nodes) > 1:
print("Too many lightmap nodes in node tree of material", mat.name)
elif len(lightmap_nodes) < 1:
lightmap_texture_nodes.append(self.setup_moz_lightmap_nodes(mat.node_tree))
else:
# TODO: Check wether all nodes are set up correctly, for now assume they are
lightmap_nodes[0].intensity = self.default_intensity
# the image texture node needs to be the active one for baking, it is connected to the lightmap node so get it from there
lightmap_texture_node = lightmap_nodes[0].inputs[0].links[0].from_node
mat.node_tree.nodes.active = lightmap_texture_node
lightmap_texture_nodes.append(lightmap_texture_node)

# Baking has to happen in Cycles, it is not supported in EEVEE yet
render_engine_tmp = context.scene.render.engine
context.scene.render.engine = 'CYCLES'
samples_tmp = context.scene.cycles.samples
context.scene.cycles.samples = self.samples
# Baking needs to happen without the color pass because we only want the direct and indirect light contributions
bake_settings = context.scene.render.bake
bake_settings.use_pass_direct = True
bake_settings.use_pass_indirect = True
bake_settings.use_pass_color = False
# The should be small because otherwise it could overwrite UV islands
bake_settings.margin = 2
# Not sure whether this has any influence
bake_settings.image_settings.file_format = 'HDR'
GottfriedHofmann marked this conversation as resolved.
Show resolved Hide resolved
context.scene.render.image_settings.file_format = 'HDR'
bpy.ops.object.bake(type='DIFFUSE')
# After baking is done, return everything back to normal
context.scene.cycles.samples = samples_tmp
context.scene.render.engine = render_engine_tmp
# Pack all newly created or updated images
for node in lightmap_texture_nodes:
file_path = bpy.path.abspath(f"{bpy.app.tempdir}/{node.image.name}.hdr")
# node.image.save_render(file_path)
node.image.filepath_raw = file_path
node.image.file_format = 'HDR'
node.image.save()
node.image.pack()
# Update the filepath so it unpacks nicely for the user.
# TODO: Mechanism taken from reflection_probe.py line 300-306, de-duplicate
new_filepath = f"//{node.image.name}.hdr"
node.image.packed_files[0].filepath = new_filepath
node.image.filepath_raw = new_filepath
node.image.filepath = new_filepath

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested those four lines in my own script on blender 4.2.1, it doesn't seem to be needed if you want to unpack the textures later, the names are correct in the textures folder as far as I can tell, and actually those lines gave me an error during the execution saying the file didn't exist. I didn't test your PR though, so may not apply, but that's something to verify.


# Remove file from temporary directory to de-clutter the system. Especially on windows the temporary directory is rarely purged.
if os.path.exists(file_path):
os.remove(file_path)

return {'FINISHED'}

def invoke(self, context, event):
if bpy.data.is_saved is False:
def draw(self, context):
self.layout.label(
text="You ned to save the .blend file before running this operator.")
bpy.context.window_manager.popup_menu(
draw, title="File not saved.", icon='ERROR')
return {'CANCELLED'}
GottfriedHofmann marked this conversation as resolved.
Show resolved Hide resolved
# needed to get the dialoge with the intensity
GottfriedHofmann marked this conversation as resolved.
Show resolved Hide resolved
return context.window_manager.invoke_props_dialog(self)

def setup_moz_lightmap_nodes(self, node_tree):
''' Returns the lightmap texture node of the newly created setup '''
mat_nodes = node_tree.nodes
# This function gets called when no lightmap node is present
lightmap_node = mat_nodes.new(type="moz_lightmap.node")
lightmap_node.intensity = self.default_intensity

lightmap_texture_node = mat_nodes.new(type="ShaderNodeTexImage")
lightmap_texture_node.location[0] -= 300

img = bpy.data.images.new('LightMap', self.resolution, self.resolution, alpha=False, float_buffer=True)
lightmap_texture_node.image = img
lightmap_texture_node.image.colorspace_settings.name = 'Linear'
GottfriedHofmann marked this conversation as resolved.
Show resolved Hide resolved

UVmap_node = mat_nodes.new(type="ShaderNodeUVMap")
UVmap_node.uv_map = "UV1"
UVmap_node.location[0] -= 500

node_tree.links.new(UVmap_node.outputs['UV'], lightmap_texture_node.inputs['Vector'])
node_tree.links.new(lightmap_texture_node.outputs['Color'], lightmap_node.inputs['Lightmap'])

# the image texture node needs to be the active one for baking
node_tree.nodes.active = lightmap_texture_node

return lightmap_texture_node


def register():
bpy.utils.register_class(AddHubsComponent)
bpy.utils.register_class(RemoveHubsComponent)
Expand All @@ -681,6 +849,7 @@ def register():
bpy.utils.register_class(ViewReportInInfoEditor)
bpy.utils.register_class(CopyHubsComponent)
bpy.utils.register_class(OpenImage)
bpy.utils.register_class(BakeLightmaps)
bpy.types.WindowManager.hubs_report_scroll_index = IntProperty(
default=0, min=0)
bpy.types.WindowManager.hubs_report_scroll_percentage = IntProperty(
Expand All @@ -700,6 +869,7 @@ def unregister():
bpy.utils.unregister_class(ViewReportInInfoEditor)
bpy.utils.unregister_class(CopyHubsComponent)
bpy.utils.unregister_class(OpenImage)
bpy.utils.unregister_class(BakeLightmaps)
del bpy.types.WindowManager.hubs_report_scroll_index
del bpy.types.WindowManager.hubs_report_scroll_percentage
del bpy.types.WindowManager.hubs_report_last_title
Expand Down
15 changes: 15 additions & 0 deletions addons/io_hubs_addon/components/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ def draw(self, context):
draw_components_list(self, context)


class HubsObjectLightmapPanel(bpy.types.Panel):
bl_label = "Hubs Lightmap Baker"
bl_idname = "OBJECT_PT_hubs_lightmap_baker"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "object"

def draw(self, context):
layout = self.layout
row = layout.row()
row.operator("object.bake_lightmaps", text="Bake Lightmaps of selected objects")


class HUBS_PT_ToolsPanel(bpy.types.Panel):
bl_idname = "HUBS_PT_ToolsPanel"
bl_space_type = 'VIEW_3D'
Expand Down Expand Up @@ -230,6 +243,7 @@ def register():
bpy.utils.register_class(HubsBonePanel)
bpy.utils.register_class(TooltipLabel)
bpy.utils.register_class(HUBS_PT_ToolsPanel)
bpy.utils.register_class(HubsObjectLightmapPanel)

bpy.types.TOPBAR_MT_window.append(window_menu_addition)
bpy.types.VIEW3D_MT_object.append(object_menu_addition)
Expand All @@ -243,6 +257,7 @@ def unregister():
bpy.utils.unregister_class(HubsBonePanel)
bpy.utils.unregister_class(TooltipLabel)
bpy.utils.unregister_class(HUBS_PT_ToolsPanel)
bpy.utils.unregister_class(HubsObjectLightmapPanel)

bpy.types.TOPBAR_MT_window.remove(window_menu_addition)
bpy.types.VIEW3D_MT_object.remove(object_menu_addition)
Expand Down
Loading