# This file is part of Korman. # # Korman is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Korman is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Korman. If not, see . import bpy from contextlib import contextmanager import itertools from .explosions import * from .logger import ExportProgressLogger, ExportVerboseLogger from .mesh import _MeshManager, _VERTEX_COLOR_LAYERS from ..helpers import * _NUM_RENDER_LAYERS = 20 class LightBaker: """ExportTime Lighting""" def __init__(self, *, mesh=None, report=None, verbose=False): self._lightgroups = {} if report is None: self._report = ExportVerboseLogger() if verbose else ExportProgressLogger() self.add_progress_steps(self._report, True) self._report.progress_start("BAKING LIGHTING") self._own_report = True else: self._report = report self._own_report = False # This used to be the base class, but due to the need to access the export state # which may be stored in the exporter's mesh manager, we've changed from is-a to has-a # semantics. Sorry for this confusion! self._mesh = _MeshManager(self._report) if mesh is None else mesh self.vcol_layer_name = "autocolor" self.lightmap_name = "{}_LIGHTMAPGEN.png" self.lightmap_uvtex_name = "LIGHTMAPGEN" self.retain_lightmap_uvtex = True self.force = False self._lightmap_images = {} self._uvtexs = {} self._active_vcols = {} def __del__(self): if self._own_report: self._report.progress_end() def __enter__(self): self._mesh.__enter__() return self def __exit__(self, *exc_info): self._mesh.__exit__(*exc_info) @staticmethod def add_progress_steps(report, add_base=False): if add_base: _MeshManager.add_progress_presteps(report) report.progress_add_step("Searching for Bahro") report.progress_add_step("Baking Static Lighting") def _apply_render_settings(self, toggle, vcols): render = bpy.context.scene.render # Remember, lightmaps carefully control the enabled textures such that light # can be cast through transparent materials. See diatribe in lightmap prep. toggle.track(render, "use_textures", not vcols) toggle.track(render, "use_shadows", True) toggle.track(render, "use_envmaps", True) toggle.track(render, "use_raytrace", True) toggle.track(render, "bake_type", "FULL") toggle.track(render, "use_bake_clear", True) toggle.track(render, "use_bake_to_vertex_color", vcols) def _associate_image_with_uvtex(self, uvtex, im): # Associate the image with all the new UVs # NOTE: no toggle here because it's the artist's problem if they are looking at our # super swagalicious LIGHTMAPGEN uvtexture... for i in uvtex.data: i.image = im def _bake_lightmaps(self, objs, layers): with GoodNeighbor() as toggle: scene = bpy.context.scene scene.layers = layers self._apply_render_settings(toggle, False) self._select_only(objs, toggle) bpy.ops.object.bake_image() self._pack_lightmaps(objs) def _bake_vcols(self, objs, layers): with GoodNeighbor() as toggle: bpy.context.scene.layers = layers self._apply_render_settings(toggle, True) self._select_only(objs, toggle) bpy.ops.object.bake_image() def bake_static_lighting(self, objs): """Bakes all static lighting for Plasma geometry""" self._report.msg("\nBaking Static Lighting...") with GoodNeighbor() as toggle, self._report.indent(): try: # reduce the amount of indentation bake = self._harvest_bakable_objects(objs, toggle) result = self._bake_static_lighting(bake, toggle) finally: # this stuff has been observed to be problematic with GoodNeighbor self._pop_lightgroups() self._restore_uvtexs() self._restore_vcols() if not self.retain_lightmap_uvtex: self._remove_stale_uvtexes(bake) return result def _bake_static_lighting(self, bake, toggle): inc_progress = self._report.progress_increment # Lightmap passes are expensive, so we will warn about any passes that seem # particularly wasteful. try: largest_pass = max((len(value) for key, value in bake.items() if key[0] != "vcol")) except ValueError: largest_pass = 0 # Step 0.9: Make all layers visible. # This prevents context operators from phailing. bpy.context.scene.layers = (True,) * _NUM_RENDER_LAYERS # Step 1: Prepare... Apply UVs, etc, etc, etc self._report.progress_advance() self._report.progress_range = len(bake) self._report.msg("Preparing to bake...") with self._report.indent(): for key, value in bake.items(): if key[0] == "lightmap": for i in range(len(value)-1, -1, -1): obj = value[i] if not self._prep_for_lightmap(obj, toggle): self._report.msg(f"Lightmap '{obj.name}' will not be baked -- no applicable lights") value.pop(i) elif key[0] == "vcol": for i in range(len(value)-1, -1, -1): obj = value[i] if not self._prep_for_vcols(obj, toggle): if self._has_valid_material(obj): self._report.msg(f"VCols '{obj.name}' will not be baked -- no applicable lights") value.pop(i) else: raise RuntimeError(key[0]) inc_progress() self._report.msg(" ...") # Step 2: BAKE! self._report.progress_advance() self._report.progress_range = len(bake) for key, value in bake.items(): if value: if key[0] == "lightmap": num_objs = len(value) self._report.msg("{} Lightmap(s) [H:{:X}]", num_objs, hash(key[1:])) if largest_pass > 1 and num_objs < round(largest_pass * 0.02): pass_names = set((i.plasma_modifiers.lightmap.bake_pass_name for i in value)) pass_msg = ", ".join(pass_names) with self._report.indent(): self._report.warn(f"Small lightmap bake pass! Bake Pass(es): {pass_msg}") self._bake_lightmaps(value, key[1:]) elif key[0] == "vcol": self._report.msg("{} Vertex Color(s) [H:{:X}]", len(value), hash(key[1:])) self._bake_vcols(value, key[1:]) self._fix_vertex_colors(value) else: raise RuntimeError(key[0]) inc_progress() # Return how many thingos we baked return sum(map(len, bake.values())) @contextmanager def _bmesh_from_mesh(self, mesh): bm = bmesh.new() try: # from_object would likely cause Blender to crash further in the export process, # so use the safer from_mesh instead. bm.from_mesh(mesh) yield bm finally: bm.free() def _fix_vertex_colors(self, blender_objects): # Blender's lightmapper has a bug which allows vertices to "self-occlude" when shared between # two faces. See here https://forum.guildofwriters.org/viewtopic.php?f=9&t=6576&p=68939 # What we're doing here is an improved version of the algorithm in the previous link. # For each loop, we find all other loops in the mesh sharing the same vertex, which aren't # separated by a sharp edge. We then take the brightest color out of all those loops, and # assign it back to the base loop. # "Sharp edges" include edges manually tagged as sharp by the user, or part of a non-smooth # face, or edges for which the face angle is superior to the mesh's auto-smooth threshold. # (If the object has an edge split modifier, well, screw you!) for bo in blender_objects: mesh = bo.data if self.vcol_layer_name not in mesh.vertex_colors: # No vertex color. Baking either failed or is turned off. continue with self._bmesh_from_mesh(mesh) as bm: bm.faces.ensure_lookup_table() light_vcol = bm.loops.layers.color.get(self.vcol_layer_name) for face in bm.faces: for loop in face.loops: vert = loop.vert max_color = loop[light_vcol] if not face.smooth: # Face is sharp, so we can't smooth anything. continue # Now that we have a loop and its vertex, find all edges the vertex connects to. for edge in vert.link_edges: if len(edge.link_faces) != 2: # Either a border edge, or an abomination. continue if mesh.use_auto_smooth and (not edge.smooth or edge.calc_face_angle() > mesh.auto_smooth_angle): # Normals are split for edges marked as sharp by the user, and edges # whose angle is above the theshold. Auto smooth must be on in both cases. continue if face in edge.link_faces: # Alright, this edge is connected to our loop AND our face. # Now for the Fun Stuff(c)... First, actually get ahold of the other # face (the one we're connected to via this edge). other_face = next(f for f in edge.link_faces if f != face) if not other_face.calc_area(): # Zero area face, ignore it. continue # Now get ahold of the loop sharing our vertex on the OTHER SIDE # of that damnable edge... other_loop = next(loop for loop in other_face.loops if loop.vert == vert) if not other_loop.is_convex: # Happens with complex polygons after edge dissolving. Ignore it. continue other_color = other_loop[light_vcol] # Phew ! Good, now just pick whichever color has the highest average value if sum(max_color) / 3 < sum(other_color) / 3: max_color = other_color # Assign our hard-earned color back loop[light_vcol] = max_color bm.to_mesh(mesh) def _generate_lightgroup(self, bo, user_lg=None): """Makes a new light group for the baking process that excludes all Plasma RT lamps""" shouldibake = (user_lg is not None and bool(user_lg.objects)) mesh = bo.data for material in mesh.materials: if material is None: # material is not assigned to this material... (why is this even a thing?) continue # Already done it? lg, mat_name = material.light_group, material.name if mat_name not in self._lightgroups: self._lightgroups[mat_name] = lg if not user_lg: if not lg or bool(lg.objects) is False: source = [i for i in bpy.context.scene.objects if i.type == "LAMP"] else: source = lg.objects dest = bpy.data.groups.new("_LIGHTMAPGEN_{}_{}".format(bo.name, mat_name)) # Rules: # 1) No animated lights, period. # 2) If we accept runtime lighting, no Plasma Objects rtl_mod = bo.plasma_modifiers.lighting for obj in source: if obj.plasma_object.has_animation_data: continue if rtl_mod.rt_lights and obj.plasma_object.enabled: continue dest.objects.link(obj) shouldibake = True else: # The aforementioned rules do not apply. You better hope you know WTF you are # doing. I'm not going to help! dest = user_lg material.light_group = dest return shouldibake def get_lightmap(self, bo): return self._lightmap_images.get(bo.name) def get_lightmap_name(self, bo): return self.lightmap_name.format(bo.name) def _has_valid_material(self, bo): for material in bo.data.materials: if material is not None: return True return False def _harvest_bakable_objects(self, objs, toggle): # The goal here is to minimize the calls to bake_image, so we are going to collect everything # that needs to be baked and sort it out by configuration. default_layers = tuple((True,) * _NUM_RENDER_LAYERS) bake, bake_passes = {}, bpy.context.scene.plasma_scene.bake_passes bake_vcol = bake.setdefault(("vcol",) + default_layers, []) def lightmap_bake_required(obj) -> bool: mod = obj.plasma_modifiers.lightmap if mod.bake_lightmap: if self.force: return True if mod.image is not None: uv_texture_names = frozenset((i.name for i in obj.data.uv_textures)) if self.lightmap_uvtex_name in uv_texture_names: self._report.msg("'{}': Skipping due to valid lightmap override", obj.name) else: self._report.warn("'{}': Have lightmap, but regenerating UVs", obj.name) self._prep_for_lightmap_uvs(obj, mod.image, toggle) return False return True return False def vcol_bake_required(obj) -> bool: if obj.plasma_modifiers.lightmap.bake_lightmap: return False vcol_layer_names = frozenset((vcol_layer.name.lower() for vcol_layer in obj.data.vertex_colors)) manual_layer_names = _VERTEX_COLOR_LAYERS & vcol_layer_names if manual_layer_names: self._report.msg("'{}': Skipping due to valid manual vertex color layer(s): '{}'", obj.name, manual_layer_names.pop()) return False if self.force: return True if self.vcol_layer_name.lower() in vcol_layer_names: self._report.msg("'{}': Skipping due to valid matching vertex color layer(s): '{}'", obj.name, self.vcol_layer_name) return False return True for i in filter(lambda x: x.type == "MESH" and bool(x.data.materials), objs): mods = i.plasma_modifiers lightmap_mod = mods.lightmap if lightmap_mod.enabled: if lightmap_mod.bake_pass_name: bake_pass = bake_passes.get(lightmap_mod.bake_pass_name, None) if bake_pass is None: raise ExportError("Bake Lighting '{}': Could not find pass '{}'".format(i.name, lightmap_mod.bake_pass_name)) lm_layers = tuple(bake_pass.render_layers) else: lm_layers = default_layers # In order for Blender to be able to bake this properly, at least one of the # layers this object is on must be selected. We will sanity check this now. obj_layers = tuple(i.layers) lm_active_layers = set((i for i, value in enumerate(lm_layers) if value)) obj_active_layers = set((i for i, value in enumerate(obj_layers) if value)) if not lm_active_layers & obj_active_layers: raise ExportError("Bake Lighting '{}': At least one layer the object is on must be selected".format(i.name)) if lightmap_bake_required(i) is False and vcol_bake_required(i) is False: continue method = "lightmap" if lightmap_mod.bake_lightmap else "vcol" key = (method,) + lm_layers bake_pass = bake.setdefault(key, []) bake_pass.append(i) self._report.msg("'{}': Bake to {}", i.name, method) elif mods.lighting.preshade and vcol_bake_required(i): self._report.msg("'{}': Bake to vcol (crappy)", i.name) bake_vcol.append(i) return bake def _pack_lightmaps(self, objs): for bo in objs: im = self.get_lightmap(bo) if im is not None and im.is_dirty: im.pack(as_png=True) def _pop_lightgroups(self): materials = bpy.data.materials for mat_name, lg in self._lightgroups.items(): materials[mat_name].light_group = lg self._lightgroups.clear() groups = bpy.data.groups for i in groups: if i.name.startswith("_LIGHTMAPGEN_"): bpy.data.groups.remove(i) def _prep_for_lightmap(self, bo, toggle): mesh = bo.data modifier = bo.plasma_modifiers.lightmap uv_textures = mesh.uv_textures # Previously, we told Blender to just ignore textures althogether when baking # VCols or lightmaps. This is easy, but it prevents us from doing tricks like # using the "Receive Transparent" option, which allows for light to be cast # through sections of materials that are transparent. Therefore, on objects # that are lightmapped, we will disable all the texture slots... # Due to our batching, however, materials that are transparent cannot be lightmapped. for material in (i for i in mesh.materials if i is not None): if material.use_transparency: raise ExportError("'{}': Cannot lightmap material '{}' because it is transparnt".format(bo.name, material.name)) for slot in (j for j in material.texture_slots if j is not None): toggle.track(slot, "use", False) # Create a special light group for baking if not self._generate_lightgroup(bo, modifier.lights): return False # We need to ensure that we bake onto the "BlahObject_LIGHTMAPGEN" image data_images = bpy.data.images im_name = self.get_lightmap_name(bo) size = modifier.resolution im = data_images.get(im_name) if im is None: im = data_images.new(im_name, width=size, height=size) elif im.size[0] != size: # Force delete and recreate the image because the size is out of date data_images.remove(im) im = data_images.new(im_name, width=size, height=size) self._lightmap_images[bo.name] = im with self._report.indent(): self._prep_for_lightmap_uvs(bo, im, toggle) # Now, set the new LIGHTMAPGEN uv layer as what we want to render to... # NOTE that this will need to be reset by us to what the user had previously # Not using toggle.track due to observed oddities for i in uv_textures: value = i.name == self.lightmap_uvtex_name i.active = value i.active_render = value # Indicate we should bake return True def _prep_for_lightmap_uvs(self, bo, image, toggle): mesh = bo.data modifier = bo.plasma_modifiers.lightmap uv_textures = mesh.uv_textures # If there is a cached LIGHTMAPGEN uvtexture, nuke it uvtex = uv_textures.get(self.lightmap_uvtex_name, None) if uvtex is not None: uv_textures.remove(uvtex) # Make sure we can enter Edit Mode(TM) toggle.track(bo, "hide", False) # Because the way Blender tracks active UV layers is massively stupid... if uv_textures.active is not None: self._uvtexs[mesh.name] = uv_textures.active.name # We must make this the active object before touching any operators bpy.context.scene.objects.active = bo # Originally, we used the lightmap unpack UV operator to make our UV texture, however, # this tended to create sharp edges. There was already a discussion about this on the # Guild of Writers forum, so I'm implementing a code version of dendwaler's process, # as detailed here: https://forum.guildofwriters.org/viewtopic.php?p=62572#p62572 # This has been amended with Sirius's observations in GH-265 about forced uv map # packing. Namely, don't do it unless modifiers make us. uv_base = uv_textures.get(modifier.uv_map) if modifier.uv_map else None if uv_base is not None: uv_textures.active = uv_base # this will copy the UVs to the new UV texture uvtex = uv_textures.new(self.lightmap_uvtex_name) uv_textures.active = uvtex # if the artist hid any UVs, they will not be baked to... fix this now with self._set_mode("EDIT"): bpy.ops.uv.reveal() self._associate_image_with_uvtex(uv_textures.active, image) # Meshes with modifiers need to have islands packed to prevent generated vertices # from sharing UVs. Sigh. if self._mesh.is_collapsed(bo): # Danger: uv_base.name -> UnicodeDecodeError (wtf? another blender bug?) self._report.warn("'{}': packing islands in UV Texture '{}' due to modifier collapse", bo.name, modifier.uv_map) with self._set_mode("EDIT"): bpy.ops.mesh.select_all(action="SELECT") bpy.ops.uv.select_all(action="SELECT") bpy.ops.uv.pack_islands(margin=0.01) else: # same thread, see Sirius's suggestion RE smart unwrap. this seems to yield good # results in my tests. it will be good enough for quick exports. uvtex = uv_textures.new(self.lightmap_uvtex_name) uv_textures.active = uvtex self._associate_image_with_uvtex(uvtex, image) with self._set_mode("EDIT"): bpy.ops.mesh.select_all(action="SELECT") bpy.ops.uv.smart_project(island_margin=0.05) def _prep_for_vcols(self, bo, toggle): mesh = bo.data modifier = bo.plasma_modifiers.lightmap vcols = mesh.vertex_colors # Create a special light group for baking user_lg = modifier.lights if modifier.enabled else None if not self._generate_lightgroup(bo, user_lg): return False vcol_layer_name = self.vcol_layer_name autocolor = vcols.get(vcol_layer_name) needs_vcol_layer = autocolor is None if needs_vcol_layer: autocolor = vcols.new(vcol_layer_name) self._active_vcols[mesh] = ( next(i for i, vc in enumerate(mesh.vertex_colors) if vc.active), next(i for i, vc in enumerate(mesh.vertex_colors) if vc.active_render), ) # Mark "autocolor" as our active render layer for vcol_layer in mesh.vertex_colors: autocol = vcol_layer.name == vcol_layer_name vcol_layer.active_render = autocol vcol_layer.active = autocol mesh.update() # Vertex colors are sort of ephemeral, so if we have an exit stack, we want to # terminate this layer when the exporter is done. But, this is not an unconditional # nukage. If we're in the lightmap operators, we clearly want this to persist for # future exports as an optimization. We won't reach this point if there is already an # autocolor layer (gulp). if not self.force and needs_vcol_layer: self._mesh.context_stack.enter_context(TemporaryObject(vcol_layer.name, lambda layer_name: vcols.remove(vcols[layer_name]))) # Indicate we should bake return True def _remove_stale_uvtexes(self, bake): lightmap_iter = itertools.chain.from_iterable((value for key, value in bake.items() if key[0] == "lightmap")) for bo in lightmap_iter: uv_textures = bo.data.uv_textures uvtex = uv_textures.get(self.lightmap_uvtex_name, None) if uvtex is not None: uv_textures.remove(uvtex) def _restore_uvtexs(self): for mesh_name, uvtex_name in self._uvtexs.items(): mesh = bpy.data.meshes[mesh_name] for i in mesh.uv_textures: i.active = uvtex_name == i.name mesh.uv_textures.active = mesh.uv_textures[uvtex_name] def _restore_vcols(self): for mesh, (vcol_index, vcol_render_index) in self._active_vcols.items(): mesh.vertex_colors[vcol_index].active = True mesh.vertex_colors[vcol_render_index].active_render = True def _select_only(self, objs, toggle): if isinstance(objs, bpy.types.Object): toggle.track(objs, "hide_render", False) for i in bpy.data.objects: if i == objs: # prevents proper baking to texture for mat in (j for j in i.data.materials if j is not None): toggle.track(mat, "use_vertex_color_paint", False) i.select = True else: i.select = False if isinstance(i.data, bpy.types.Mesh) and not self._has_valid_material(i): toggle.track(i, "hide_render", True) else: for i in bpy.data.objects: value = i in objs if value: # prevents proper baking to texture for mat in (j for j in i.data.materials if j is not None): toggle.track(mat, "use_vertex_color_paint", False) toggle.track(i, "hide_render", False) elif isinstance(i.data, bpy.types.Mesh) and not self._has_valid_material(i): toggle.track(i, "hide_render", True) i.select = value @contextmanager def _set_mode(self, mode): bpy.ops.object.mode_set(mode=mode) try: yield finally: bpy.ops.object.mode_set(mode="OBJECT")