Last active
July 2, 2026 06:05
-
-
Save fangzhangmnm/64631e90b0a2759886b66834c700f080 to your computer and use it in GitHub Desktop.
blender baking workflow 20260624
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Low-effort bake script for VR default world | |
| """ | |
| SCRIPT_VERSION = "v21" | |
| import bpy | |
| import os | |
| import tempfile | |
| from pathlib import Path | |
| # ---- Settings ---- | |
| RESOLUTION = 2048 | |
| SAMPLES = 128 | |
| PREFILL_SAMPLES = 32 # used only in BAKE_TWICE mode | |
| BAKE_UV = 'BakeUV' | |
| MARGIN = 4 # GPU bilinear only needs ~2; 4 is safe | |
| MARGIN_TYPE = 'ADJACENT_FACES' | |
| RECREATE_BAKE_UV = True | |
| UV_SNAP_TO_PIXELS = True # quantize UVs to pixel-center grid for sharp sampling | |
| DENOISE_MODE = 'COMPOSITOR_AUX' # 'COMPOSITOR_AUX' | 'BAKE_TWICE' | 'NONE' | |
| PIXEL_MODE = True # 'Closest' (nearest) sampling on final material; False = 'Linear' bilinear | |
| USE_GPU = True # add to settings block | |
| # Internal names used inside the temp .blend | |
| _TEMPLATE_SCENE_NAME = "__sb_denoise_template__" | |
| _TEMPLATE_NG_NAME = "__sb_denoise_template_ng__" | |
| _TEMPLATE_PATH = Path(tempfile.gettempdir()) / "sb_denoise_template.blend" | |
| # ---- Common helpers ---- | |
| def island_margin_for(resolution, bake_margin): | |
| # Constant pixel gap between islands: bleed on both sides + safety. | |
| px_gap = 2 * bake_margin + 4 # 4px bleed -> 12px gap | |
| return px_gap / resolution | |
| def enable_gpu_devices(): | |
| """Enable GPU compute in Cycles preferences. Returns True if a GPU backend | |
| was activated, False if falling back to CPU.""" | |
| prefs = bpy.context.preferences.addons['cycles'].preferences | |
| # Pick the best available backend for this machine. | |
| # OPTIX (NVIDIA RTX) > CUDA (older NVIDIA) > HIP (AMD) > METAL (Apple) > ONEAPI (Intel) | |
| chosen = None | |
| for backend in ('OPTIX', 'CUDA', 'HIP', 'METAL', 'ONEAPI'): | |
| try: | |
| prefs.compute_device_type = backend | |
| except (TypeError, ValueError): | |
| continue | |
| prefs.get_devices() | |
| if any(d.type == backend for d in prefs.devices): | |
| chosen = backend | |
| break | |
| if chosen is None: | |
| print(" GPU: no compatible backend found, using CPU") | |
| return False | |
| # Enable every device of the chosen type; optionally also the CPU | |
| enabled = 0 | |
| for d in prefs.devices: | |
| if d.type == chosen: | |
| d.use = True | |
| enabled += 1 | |
| elif d.type == 'CPU': | |
| d.use = True # CPU + GPU together; set False for GPU-only | |
| print(f" GPU: {chosen} active ({enabled} device(s))") | |
| return True | |
| def ensure_cycles(): | |
| scene = bpy.context.scene | |
| scene.render.engine = 'CYCLES' | |
| scene.cycles.samples = SAMPLES | |
| scene.cycles.use_denoising = False | |
| scene.render.bake.margin = MARGIN | |
| try: | |
| scene.render.bake.margin_type = MARGIN_TYPE | |
| except (AttributeError, TypeError): | |
| pass | |
| scene.render.bake.use_clear = True | |
| if scene.render.bake.target != 'IMAGE_TEXTURES': | |
| print(f" bake target was '{scene.render.bake.target}', " | |
| f"resetting to 'IMAGE_TEXTURES'") | |
| scene.render.bake.target = 'IMAGE_TEXTURES' | |
| if USE_GPU and enable_gpu_devices(): | |
| scene.cycles.device = 'GPU' | |
| else: | |
| scene.cycles.device = 'CPU' | |
| def get_or_create_image(name, size, colorspace='sRGB', float_buffer=False): | |
| """colorspace: 'sRGB' (default), 'Linear' for HDR data, 'Non-Color' for data passes. | |
| float_buffer: True for 32-bit float storage, False for 8-bit.""" | |
| # Resolve a working linear name across Blender versions (some use | |
| # 'Linear', some 'Linear Rec.709', some 'Linear sRGB'). | |
| if colorspace == 'Linear': | |
| # Try common aliases in order until one is accepted | |
| for cs in ('Linear Rec.709', 'Linear', 'Linear sRGB'): | |
| try: | |
| _probe = bpy.data.images.new('__probe__', 4, 4, alpha=False) | |
| _probe.colorspace_settings.name = cs | |
| bpy.data.images.remove(_probe) | |
| colorspace = cs | |
| break | |
| except (TypeError, RuntimeError, ReferenceError): | |
| continue | |
| if name in bpy.data.images: | |
| img = bpy.data.images[name] | |
| # Recreate if size or float-ness differs; just retag if only colorspace differs. | |
| matches = (img.size[0] == size and img.size[1] == size | |
| and img.is_float == float_buffer) | |
| if matches: | |
| img.colorspace_settings.name = colorspace | |
| return img | |
| bpy.data.images.remove(img) | |
| img = bpy.data.images.new(name, size, size, alpha=False, float_buffer=float_buffer) | |
| img.colorspace_settings.name = colorspace | |
| return img | |
| def make_materials_independent(obj): | |
| for slot in obj.material_slots: | |
| if slot.material is not None: | |
| slot.material = slot.material.copy() | |
| def snap_uvs_to_pixel_centers(obj, uv_layer_name, resolution): | |
| """Quantize every UV loop to the nearest pixel-center grid point. | |
| GPU bilinear sampling targets UV (n+0.5)/W to hit pixel centers exactly. | |
| If island edges fall between centers, bilinear filtering blurs texels | |
| from neighboring islands into edge pixels. Snapping prevents that. | |
| Math: u_snapped = (round(u * R - 0.5) + 0.5) / R | |
| """ | |
| mesh = obj.data | |
| uv_layer = mesh.uv_layers.get(uv_layer_name) | |
| if uv_layer is None: | |
| return | |
| R = float(resolution) | |
| n = len(uv_layer.uv) | |
| if n == 0: | |
| return | |
| # foreach_get/_set work on flat (u0, v0, u1, v1, ...) arrays and are | |
| # the cross-version-stable way to bulk-edit UVs. | |
| flat = [0.0] * (n * 2) | |
| uv_layer.uv.foreach_get('vector', flat) | |
| for i in range(0, len(flat), 2): | |
| u = flat[i] | |
| v = flat[i + 1] | |
| flat[i] = (round(u * R - 0.5) + 0.5) / R | |
| flat[i + 1] = (round(v * R - 0.5) + 0.5) / R | |
| uv_layer.uv.foreach_set('vector', flat) | |
| mesh.update() | |
| def ensure_bake_uv(obj): | |
| uv_layers = obj.data.uv_layers | |
| if not uv_layers: | |
| uv_layers.new(name='UVMap') | |
| if RECREATE_BAKE_UV and BAKE_UV in uv_layers: | |
| uv_layers.remove(uv_layers[BAKE_UV]) | |
| if BAKE_UV not in uv_layers: | |
| new_uv = uv_layers.new(name=BAKE_UV) | |
| uv_layers.active = new_uv | |
| bpy.context.view_layer.objects.active = obj | |
| bpy.ops.object.mode_set(mode='EDIT') | |
| bpy.ops.mesh.select_all(action='SELECT') | |
| bpy.ops.uv.smart_project( | |
| angle_limit=1.15192, | |
| island_margin=island_margin_for(RESOLUTION, MARGIN), | |
| ) | |
| bpy.ops.object.mode_set(mode='OBJECT') | |
| if UV_SNAP_TO_PIXELS: | |
| snap_uvs_to_pixel_centers(obj, BAKE_UV, RESOLUTION) | |
| uv_layers.active = uv_layers[BAKE_UV] | |
| def setup_bake_node(material, image): | |
| if not material.use_nodes: | |
| material.use_nodes = True | |
| nodes = material.node_tree.nodes | |
| bake_node = None | |
| for n in nodes: | |
| if n.type == 'TEX_IMAGE' and n.get('is_bake_target', False): | |
| bake_node = n | |
| break | |
| if bake_node is None: | |
| bake_node = nodes.new('ShaderNodeTexImage') | |
| bake_node['is_bake_target'] = True | |
| bake_node.label = 'BakeTarget' | |
| bake_node.location = (-700, 400) | |
| bake_node.image = image | |
| for n in nodes: | |
| n.select = False | |
| bake_node.select = True | |
| nodes.active = bake_node | |
| def _set_bake_target_on_all_slots(obj, image): | |
| if not obj.data.materials: | |
| obj.data.materials.append(bpy.data.materials.new(name=f"M_{obj.name}")) | |
| for slot in obj.material_slots: | |
| if slot.material is None: | |
| slot.material = bpy.data.materials.new(name=f"M_{obj.name}") | |
| setup_bake_node(slot.material, image) | |
| def _bake_combined(obj, image, samples, denoise, use_clear): | |
| scene = bpy.context.scene | |
| saved = (scene.cycles.samples, scene.cycles.use_denoising, | |
| scene.render.bake.use_clear) | |
| scene.cycles.samples = samples | |
| scene.cycles.use_denoising = denoise | |
| scene.render.bake.use_clear = use_clear | |
| try: | |
| _set_bake_target_on_all_slots(obj, image) | |
| bpy.ops.object.bake(type='COMBINED') | |
| finally: | |
| (scene.cycles.samples, scene.cycles.use_denoising, | |
| scene.render.bake.use_clear) = saved | |
| def _bake_aux_pass(obj, image, pass_type): | |
| scene = bpy.context.scene | |
| bake = scene.render.bake | |
| saved = (scene.cycles.samples, scene.cycles.use_denoising, bake.use_clear) | |
| scene.cycles.samples = 1 | |
| scene.cycles.use_denoising = False | |
| bake.use_clear = True | |
| try: | |
| _set_bake_target_on_all_slots(obj, image) | |
| if pass_type == 'DIFFUSE_COLOR': | |
| saved_p = (bake.use_pass_direct, bake.use_pass_indirect, bake.use_pass_color) | |
| bake.use_pass_direct = False | |
| bake.use_pass_indirect = False | |
| bake.use_pass_color = True | |
| try: | |
| bpy.ops.object.bake(type='DIFFUSE') | |
| finally: | |
| (bake.use_pass_direct, bake.use_pass_indirect, bake.use_pass_color) = saved_p | |
| elif pass_type == 'NORMAL': | |
| bpy.ops.object.bake(type='NORMAL') | |
| finally: | |
| (scene.cycles.samples, scene.cycles.use_denoising, bake.use_clear) = saved | |
| def bake_object(obj, color_img, albedo_img=None, normal_img=None): | |
| bpy.ops.object.select_all(action='DESELECT') | |
| obj.select_set(True) | |
| bpy.context.view_layer.objects.active = obj | |
| ensure_bake_uv(obj) | |
| if DENOISE_MODE == 'COMPOSITOR_AUX': | |
| print(f" bake COMBINED {obj.name} @ {SAMPLES} samples (no in-bake denoise)") | |
| _bake_combined(obj, color_img, SAMPLES, denoise=False, use_clear=True) | |
| if albedo_img is not None: | |
| print(f" bake ALBEDO {obj.name}") | |
| _bake_aux_pass(obj, albedo_img, 'DIFFUSE_COLOR') | |
| if normal_img is not None: | |
| print(f" bake NORMAL {obj.name}") | |
| _bake_aux_pass(obj, normal_img, 'NORMAL') | |
| _set_bake_target_on_all_slots(obj, color_img) | |
| elif DENOISE_MODE == 'BAKE_TWICE': | |
| print(f" pass 1/2 {obj.name} @ {PREFILL_SAMPLES} samples") | |
| _bake_combined(obj, color_img, PREFILL_SAMPLES, denoise=False, use_clear=True) | |
| print(f" pass 2/2 {obj.name} @ {SAMPLES} samples (denoised)") | |
| _bake_combined(obj, color_img, SAMPLES, denoise=True, use_clear=False) | |
| elif DENOISE_MODE == 'NONE': | |
| print(f" bake {obj.name} @ {SAMPLES} samples (no denoise)") | |
| _bake_combined(obj, color_img, SAMPLES, denoise=False, use_clear=True) | |
| # ---------------------------------------------------------------------------- | |
| # Temp-.blend roundtrip denoise (SimpleBake's reliable pattern, inlined) | |
| # ---------------------------------------------------------------------------- | |
| def _add_compositor_output(nt, source_node, source_socket='Image'): | |
| """Add the correct output node to a compositor node tree, version-aware. | |
| Blender 5.0+: the compositor tree is a NodeGroup. Output happens through | |
| a Group Output node, which requires an 'Image' socket on | |
| the group interface (analogous to geometry nodes). | |
| Blender 4.x: classic CompositorNodeComposite (removed in 5.0). | |
| Returns the created output node (for caller to position if desired).""" | |
| # Blender 5.0+: NodeGroup-based compositor | |
| if hasattr(nt, 'interface'): | |
| # Add an Image output socket to the group interface if not already there | |
| existing = [s for s in nt.interface.items_tree | |
| if getattr(s, 'in_out', None) == 'OUTPUT' and s.name == 'Image'] | |
| if not existing: | |
| nt.interface.new_socket(name='Image', | |
| socket_type='NodeSocketColor', | |
| in_out='OUTPUT') | |
| # Add the NodeGroupOutput node and link | |
| out = nt.nodes.new('NodeGroupOutput') | |
| nt.links.new(source_node.outputs[source_socket], out.inputs['Image']) | |
| return out | |
| # Blender 4.x and earlier: classic Composite node | |
| out = nt.nodes.new('CompositorNodeComposite') | |
| nt.links.new(source_node.outputs[source_socket], out.inputs['Image']) | |
| return out | |
| def _build_denoise_template_blend(): | |
| """Create a template scene in-memory, write it to a temp .blend via | |
| bpy.data.libraries.write, then remove it from the current file. | |
| The temp .blend becomes our reusable denoise stage. | |
| The write step is what makes this work: Blender serializes every | |
| internal compositor flag the saved scene needs, so when we append | |
| it back later, the compositor is in a fully initialized state.""" | |
| print(f" building denoise template at {_TEMPLATE_PATH}") | |
| for s in list(bpy.data.scenes): | |
| if s.name == _TEMPLATE_SCENE_NAME: | |
| bpy.data.scenes.remove(s) | |
| for ng in list(bpy.data.node_groups): | |
| if ng.name == _TEMPLATE_NG_NAME: | |
| bpy.data.node_groups.remove(ng) | |
| scene = bpy.data.scenes.new(_TEMPLATE_SCENE_NAME) | |
| scene.render.engine = 'BLENDER_WORKBENCH' | |
| # EXR 32-bit float preserves the linear data from compositor denoise. | |
| # Safe now because the destination color_img is Linear-tagged float — | |
| # no colorspace mismatch on load (which was v15's darkening bug). | |
| scene.render.image_settings.file_format = 'OPEN_EXR' | |
| scene.render.image_settings.color_depth = '32' | |
| scene.render.image_settings.color_mode = 'RGBA' | |
| try: | |
| scene.view_settings.view_transform = 'Standard' | |
| except (AttributeError, TypeError): | |
| pass | |
| scene.render.use_compositing = True | |
| scene.use_nodes = True | |
| owned_ng = None | |
| if hasattr(scene, 'compositing_node_group'): | |
| ng = bpy.data.node_groups.new(_TEMPLATE_NG_NAME, 'CompositorNodeTree') | |
| scene.compositing_node_group = ng | |
| owned_ng = ng | |
| nt = ng | |
| else: | |
| nt = scene.node_tree | |
| nt.nodes.clear() | |
| img_node = nt.nodes.new('CompositorNodeImage') | |
| img_node.label = "color_input" | |
| img_node.location = (-600, 200) | |
| alb_node = nt.nodes.new('CompositorNodeImage') | |
| alb_node.label = "albedo_input" | |
| alb_node.location = (-600, 0) | |
| nrm_node = nt.nodes.new('CompositorNodeImage') | |
| nrm_node.label = "normal_input" | |
| nrm_node.location = (-600, -200) | |
| denoise = nt.nodes.new('CompositorNodeDenoise') | |
| denoise.location = (-200, 0) | |
| try: | |
| denoise.prefilter = 'ACCURATE' | |
| except (AttributeError, TypeError): | |
| pass | |
| composite = _add_compositor_output(nt, denoise) | |
| composite.location = (200, 0) | |
| nt.links.new(img_node.outputs['Image'], denoise.inputs['Image']) | |
| if 'Albedo' in denoise.inputs: | |
| nt.links.new(alb_node.outputs['Image'], denoise.inputs['Albedo']) | |
| if 'Normal' in denoise.inputs: | |
| nt.links.new(nrm_node.outputs['Image'], denoise.inputs['Normal']) | |
| datablocks = {scene} | |
| if owned_ng is not None: | |
| datablocks.add(owned_ng) | |
| bpy.data.libraries.write(str(_TEMPLATE_PATH), datablocks, fake_user=True) | |
| # Drop the template from the current .blend; we'll append it back per-image | |
| bpy.data.scenes.remove(scene) | |
| if owned_ng is not None and owned_ng.name in bpy.data.node_groups: | |
| bpy.data.node_groups.remove(bpy.data.node_groups[owned_ng.name]) | |
| def _append_template_scene(): | |
| """Append the denoise scene from the temp .blend. Returns the new scene.""" | |
| # Clean up any leftovers from a previous run | |
| for s in list(bpy.data.scenes): | |
| if s.name.startswith(_TEMPLATE_SCENE_NAME): | |
| bpy.data.scenes.remove(s) | |
| for ng in list(bpy.data.node_groups): | |
| if ng.name.startswith(_TEMPLATE_NG_NAME): | |
| bpy.data.node_groups.remove(ng) | |
| with bpy.data.libraries.load(str(_TEMPLATE_PATH)) as (data_from, data_to): | |
| scenes_to_load = [n for n in data_from.scenes if n == _TEMPLATE_SCENE_NAME] | |
| if not scenes_to_load: | |
| raise RuntimeError(f"Scene '{_TEMPLATE_SCENE_NAME}' not found in template") | |
| data_to.scenes = scenes_to_load | |
| return data_to.scenes[0] | |
| def _cleanup_appended(appended_scene): | |
| # Track and remove the node_group that came with it (Blender 5.0+) | |
| ng_to_remove = None | |
| if hasattr(appended_scene, 'compositing_node_group'): | |
| ng_to_remove = appended_scene.compositing_node_group | |
| try: | |
| bpy.data.scenes.remove(appended_scene) | |
| except (RuntimeError, ReferenceError): | |
| pass | |
| if ng_to_remove is not None and ng_to_remove.name in bpy.data.node_groups: | |
| try: | |
| bpy.data.node_groups.remove(ng_to_remove) | |
| except (RuntimeError, ReferenceError): | |
| pass | |
| def denoise_image_via_appended_scene(color_img, albedo_img, normal_img): | |
| appended_scene = _append_template_scene() | |
| temp_out = None | |
| loaded_img = None | |
| try: | |
| appended_scene.render.resolution_x = color_img.size[0] | |
| appended_scene.render.resolution_y = color_img.size[1] | |
| appended_scene.render.resolution_percentage = 100 | |
| # Find the compositor node tree (works for both 4.x and 5.0+) | |
| if hasattr(appended_scene, 'compositing_node_group') and \ | |
| appended_scene.compositing_node_group is not None: | |
| nt = appended_scene.compositing_node_group | |
| else: | |
| nt = appended_scene.node_tree | |
| for n in nt.nodes: | |
| if n.type != 'IMAGE': | |
| continue | |
| if n.label == 'color_input': | |
| n.image = color_img | |
| elif n.label == 'albedo_input': | |
| n.image = albedo_img # may be None; that's fine, denoise still runs | |
| elif n.label == 'normal_input': | |
| n.image = normal_img | |
| # SimpleBake key 1: explicit scene= parameter | |
| bpy.ops.render.render(use_viewport=False, scene=appended_scene.name) | |
| # SimpleBake key 2: save_render to EXR, load back | |
| temp_out = Path(tempfile.gettempdir()) / f"sb_out_{color_img.name}_{os.getpid()}.exr" | |
| render_result = bpy.data.images.get("Render Result") | |
| if render_result is None: | |
| raise RuntimeError("No Render Result after render call") | |
| render_result.save_render(str(temp_out), scene=appended_scene) | |
| loaded_img = bpy.data.images.load(str(temp_out)) | |
| if len(loaded_img.pixels) == len(color_img.pixels): | |
| color_img.pixels = list(loaded_img.pixels) | |
| color_img.update() | |
| print(f" denoise OK: {color_img.name}") | |
| else: | |
| print(f" WARN: size mismatch for {color_img.name}") | |
| except Exception as e: | |
| print(f" WARN: denoise via appended scene failed ({type(e).__name__}: {e})") | |
| print(f" -> Switch DENOISE_MODE to 'BAKE_TWICE' as a fallback.") | |
| finally: | |
| if loaded_img is not None: | |
| try: | |
| bpy.data.images.remove(loaded_img) | |
| except (RuntimeError, ReferenceError): | |
| pass | |
| if temp_out is not None: | |
| try: | |
| os.unlink(str(temp_out)) | |
| except OSError: | |
| pass | |
| _cleanup_appended(appended_scene) | |
| # ---------------------------------------------------------------------------- | |
| def encode_linear_float_to_srgb8(linear_img, srgb_name=None): | |
| """Take a Linear-tagged float image and produce a new 8-bit, sRGB-tagged, | |
| sRGB-encoded image. This is what every glTF/FBX consumer expects and is | |
| forgiving across renderers/loaders that ignore Blender's colorspace tag. | |
| sRGB transfer curve (IEC 61966-2-1): | |
| f(x) = 12.92 * x for x <= 0.0031308 | |
| f(x) = 1.055 * x^(1/2.4) - 0.055 for x > 0.0031308 | |
| """ | |
| if srgb_name is None: | |
| srgb_name = linear_img.name + "_sRGB" | |
| # Remove any stale prior version | |
| if srgb_name in bpy.data.images: | |
| bpy.data.images.remove(bpy.data.images[srgb_name]) | |
| W, H = linear_img.size | |
| n_floats = W * H * 4 # RGBA | |
| # Pull pixels out as flat float array | |
| src = [0.0] * n_floats | |
| linear_img.pixels.foreach_get(src) | |
| try: | |
| import numpy as np | |
| a = np.asarray(src, dtype=np.float32).reshape(-1, 4) | |
| rgb = a[:, :3] | |
| alpha = a[:, 3:4] | |
| # sRGB encode (clamp to [0, +inf), then apply piecewise curve) | |
| rgb = np.clip(rgb, 0.0, None) | |
| low = rgb <= 0.0031308 | |
| encoded = np.empty_like(rgb) | |
| encoded[low] = 12.92 * rgb[low] | |
| # ^(1/2.4) on non-negative values | |
| encoded[~low] = 1.055 * np.power(rgb[~low], 1.0 / 2.4) - 0.055 | |
| encoded = np.clip(encoded, 0.0, 1.0) | |
| out = np.concatenate([encoded, np.clip(alpha, 0.0, 1.0)], axis=1) | |
| out_flat = out.flatten().tolist() | |
| except ImportError: | |
| # Numpy is bundled with Blender; this fallback is rare but safe | |
| out_flat = [0.0] * n_floats | |
| for i in range(0, n_floats, 4): | |
| for c in range(3): | |
| x = max(src[i + c], 0.0) | |
| if x <= 0.0031308: | |
| y = 12.92 * x | |
| else: | |
| y = 1.055 * (x ** (1.0 / 2.4)) - 0.055 | |
| out_flat[i + c] = min(max(y, 0.0), 1.0) | |
| out_flat[i + 3] = min(max(src[i + 3], 0.0), 1.0) | |
| # Create as 8-bit (float_buffer=False) sRGB-tagged image | |
| out_img = bpy.data.images.new(srgb_name, W, H, | |
| alpha=True, float_buffer=False) | |
| out_img.colorspace_settings.name = 'sRGB' | |
| out_img.pixels.foreach_set(out_flat) | |
| out_img.update() | |
| out_img.pack() | |
| return out_img | |
| mat = bpy.data.materials.new(name=name) | |
| mat.use_nodes = True | |
| nodes = mat.node_tree.nodes | |
| links = mat.node_tree.links | |
| nodes.clear() | |
| out = nodes.new('ShaderNodeOutputMaterial') | |
| out.location = (400, 0) | |
| emit = nodes.new('ShaderNodeEmission') | |
| emit.location = (100, 0) | |
| emit.inputs['Strength'].default_value = 1.0 | |
| tex = nodes.new('ShaderNodeTexImage') | |
| tex.location = (-200, 0) | |
| tex.image = image | |
| uvmap = nodes.new('ShaderNodeUVMap') | |
| uvmap.location = (-500, 0) | |
| uvmap.uv_map = uv_layer_name | |
| links.new(uvmap.outputs['UV'], tex.inputs['Vector']) | |
| links.new(tex.outputs['Color'], emit.inputs['Color']) | |
| links.new(emit.outputs['Emission'], out.inputs['Surface']) | |
| return mat | |
| def make_emissive_material(name, image, uv_layer_name): | |
| mat = bpy.data.materials.new(name=name) | |
| mat.use_nodes = True | |
| nodes = mat.node_tree.nodes | |
| links = mat.node_tree.links | |
| nodes.clear() | |
| out = nodes.new('ShaderNodeOutputMaterial') | |
| out.location = (400, 0) | |
| emit = nodes.new('ShaderNodeEmission') | |
| emit.location = (100, 0) | |
| emit.inputs['Strength'].default_value = 1.0 | |
| tex = nodes.new('ShaderNodeTexImage') | |
| tex.location = (-200, 0) | |
| tex.image = image | |
| tex.interpolation = 'Closest' if PIXEL_MODE else 'Linear' # <-- added | |
| uvmap = nodes.new('ShaderNodeUVMap') | |
| uvmap.location = (-500, 0) | |
| uvmap.uv_map = uv_layer_name | |
| links.new(uvmap.outputs['UV'], tex.inputs['Vector']) | |
| links.new(tex.outputs['Color'], emit.inputs['Color']) | |
| links.new(emit.outputs['Emission'], out.inputs['Surface']) | |
| return mat | |
| def strip_extra_uv_layers(obj, keep_name): | |
| uv_layers = obj.data.uv_layers | |
| if keep_name not in uv_layers: | |
| return | |
| to_remove = [uv.name for uv in uv_layers if uv.name != keep_name] | |
| for name in to_remove: | |
| uv_layers.remove(uv_layers[name]) | |
| uv_layers.active = uv_layers[keep_name] | |
| uv_layers[keep_name].active_render = True | |
| def force_visible(obj): | |
| obj.hide_set(False) | |
| obj.hide_render = False | |
| obj.hide_viewport = False | |
| def collapse_material_slots(obj): | |
| """All slots reference the same baked material after dedup, so push every | |
| face onto slot 0 and drop the now-unused slots.""" | |
| me = obj.data | |
| for poly in me.polygons: | |
| poly.material_index = 0 | |
| me.update() | |
| bpy.context.view_layer.objects.active = obj | |
| bpy.ops.object.material_slot_remove_unused() | |
| def set_display_for_unlit_preview(): | |
| """Match Blender's viewport to a default three.js / glTF viewer: | |
| sRGB output, no filmic tonemap. Blender 5.0 defaults to AgX, which would | |
| tonemap the preview and diverge from the unlit GLB shown in three.js.""" | |
| scene = bpy.context.scene | |
| vs = scene.view_settings | |
| prior = getattr(vs, 'view_transform', None) | |
| if prior is not None and prior != 'Standard': | |
| print(f" WARNING: view transform was '{prior}', not 'Standard'. The " | |
| f"viewport was tonemapping the preview, so baked colors looked " | |
| f"different here than in the VR/three.js (unlit GLB) output. " | |
| f"Resetting to 'Standard' so the preview matches the export.") | |
| try: | |
| vs.view_transform = 'Standard' | |
| except (AttributeError, TypeError): | |
| pass | |
| for attr, val in (('look', 'None'), ('exposure', 0.0), ('gamma', 1.0)): | |
| try: | |
| setattr(vs, attr, val) | |
| except (AttributeError, TypeError): | |
| pass | |
| try: | |
| scene.display_settings.display_device = 'sRGB' | |
| except (AttributeError, TypeError): | |
| pass | |
| # ---------------------------------------------------------------------------- | |
| def main(): | |
| print(f"\n========== bake script {SCRIPT_VERSION} RUNNING ==========") | |
| ensure_cycles() | |
| if bpy.context.mode != 'OBJECT': | |
| bpy.ops.object.mode_set(mode='OBJECT') | |
| originals = [o for o in bpy.context.selected_objects if o.type == 'MESH'] | |
| if not originals: | |
| raise RuntimeError("Select one or more mesh objects first.") | |
| print(f"\n=== Bake {len(originals)} objs @ {RESOLUTION}px, {SAMPLES} samples, " | |
| f"margin={MARGIN}px ({MARGIN_TYPE}), denoise={DENOISE_MODE} ===") | |
| if DENOISE_MODE == 'COMPOSITOR_AUX': | |
| _build_denoise_template_blend() | |
| for obj in originals: | |
| force_visible(obj) | |
| bpy.ops.object.select_all(action='DESELECT') | |
| for obj in originals: | |
| obj.select_set(True) | |
| bpy.context.view_layer.objects.active = originals[0] | |
| bpy.ops.object.duplicate(linked=False) | |
| duplicates = list(bpy.context.selected_objects) | |
| for dup in duplicates: | |
| force_visible(dup) | |
| for dup in duplicates: | |
| make_materials_independent(dup) | |
| for obj in originals: | |
| obj.hide_set(True) | |
| obj.hide_render = True | |
| use_aux = (DENOISE_MODE == 'COMPOSITOR_AUX') | |
| obj_to_image = {} | |
| for dup in duplicates: | |
| clean_name = dup.name.rsplit('.', 1)[0] | |
| # Color image: 32-bit float, Linear-tagged. Preserves path-tracing | |
| # precision through the compositor denoise step. | |
| color_img = get_or_create_image(f"BK_{clean_name}", RESOLUTION, | |
| colorspace='Linear', float_buffer=True) | |
| # Aux images: 8-bit is plenty (1-sample bakes of flat material values). | |
| albedo_img = (get_or_create_image(f"BK_{clean_name}_albedo", RESOLUTION, | |
| colorspace='sRGB', float_buffer=False) | |
| if use_aux else None) | |
| normal_img = (get_or_create_image(f"BK_{clean_name}_normal", RESOLUTION, | |
| colorspace='Non-Color', float_buffer=False) | |
| if use_aux else None) | |
| obj_to_image[dup] = color_img | |
| bake_object(dup, color_img, albedo_img, normal_img) | |
| if DENOISE_MODE == 'COMPOSITOR_AUX': | |
| denoise_image_via_appended_scene(color_img, albedo_img, normal_img) | |
| if albedo_img is not None: | |
| bpy.data.images.remove(albedo_img) | |
| if normal_img is not None: | |
| bpy.data.images.remove(normal_img) | |
| bpy.ops.object.select_all(action='DESELECT') | |
| for dup in duplicates: | |
| dup.select_set(True) | |
| bpy.context.view_layer.objects.active = duplicates[0] | |
| bpy.ops.object.join() | |
| merged = bpy.context.active_object | |
| merged.name = "Baked_Merged" | |
| strip_extra_uv_layers(merged, BAKE_UV) | |
| seen = {} | |
| encoded_for = {} # linear_img -> sRGB-encoded image | |
| for slot in merged.material_slots: | |
| if slot.material is None: | |
| continue | |
| img = None | |
| for n in slot.material.node_tree.nodes: | |
| if n.type == 'TEX_IMAGE' and n.get('is_bake_target', False): | |
| img = n.image | |
| break | |
| if img is None: | |
| continue | |
| # Encode the linear float image to sRGB-tagged 8-bit for the final material | |
| if img not in encoded_for: | |
| print(f" encoding {img.name} -> sRGB 8-bit for export") | |
| encoded_for[img] = encode_linear_float_to_srgb8(img) | |
| export_img = encoded_for[img] | |
| if export_img not in seen: | |
| seen[export_img] = make_emissive_material(f"E_{export_img.name}", | |
| export_img, BAKE_UV) | |
| slot.material = seen[export_img] | |
| collapse_material_slots(merged) | |
| set_display_for_unlit_preview() | |
| bpy.ops.file.pack_all() | |
| # Optional: clean up the temp .blend; comment out to keep for debugging | |
| if DENOISE_MODE == 'COMPOSITOR_AUX' and _TEMPLATE_PATH.exists(): | |
| try: | |
| os.unlink(str(_TEMPLATE_PATH)) | |
| except OSError: | |
| pass | |
| print(f"=== Done. {merged.name}, {len(obj_to_image)} textures packed. ===\n") | |
| if __name__ == "__main__": | |
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Low-effort vertex-color bake for VR default world. | |
| Companion to the texture-bake script. Instead of baking lighting into a new | |
| texture (which replaces the original), this bakes the DIFFUSE LIGHTING into a | |
| per-vertex color attribute and KEEPS the original texture untouched. | |
| In-engine the result is reconstructed as texture * COLOR_0 (e.g. Wonderland | |
| Engine "Phong Opaque Textured"), following: | |
| https://wonderlandengine.com/news/vertex-color-baked-lighting/ | |
| Why this stays clean (no duplicate-material trash): | |
| bake.target = 'VERTEX_COLORS' writes into the mesh's active color attribute. | |
| It never touches the material node tree, needs no image, and needs no UV | |
| unwrap. So the baked duplicates can keep SHARING the originals' materials - | |
| join() then merges identical material datablocks into a single slot each. | |
| We never call material.copy(), so nothing is duplicated. | |
| Preview (matches a future unlit GLB export): | |
| We bake DIFFUSE LIGHT (direct+indirect, no albedo, linear) into a BYTE_COLOR | |
| attribute 'Col', keeping the original texture. For a faithful preview of the | |
| eventual unlit glTF (baseColorTexture * COLOR_0, no lighting), each material on the | |
| baked result is rebuilt as a SHADELESS preview by repurposing its own Principled | |
| BSDF: Base Color / specular / etc. are zeroed and 'texture * Col' is pushed into | |
| the Principled's Emission input, so only the emissive term shows (no relighting). | |
| Keeping the Principled (rather than swapping in a bare Emission node) preserves its | |
| native Alpha socket, so cutout (alpha-clip, via a Greater Than threshold node) and | |
| transparency (alpha-blend) materials survive into the preview. The view transform | |
| is set to 'Standard' so the viewport matches a plain glTF viewer's sRGB output | |
| (AgX/Filmic would tonemap differently). | |
| Export is intentionally NOT done here - a dedicated export script handles GLB + | |
| KHR_materials_unlit. This script only bakes and sets up the preview. | |
| NOTE: a shadeless Principled+Emission surface does NOT auto-trigger the exporter's | |
| KHR_materials_unlit (a bare Emission surface would). That is fine here - this is | |
| preview-only and the export script builds its own unlit materials.""" | |
| SCRIPT_VERSION = "vtx-v4" | |
| import bpy | |
| # ---- Settings ---- | |
| SAMPLES = 256 # vertex bake has no post-denoise pass; raise if noisy | |
| USE_GPU = True | |
| # Lighting-only keeps the texture (texture * vertexColor in-engine). | |
| # Set False to bake the full diffuse (albedo + light) for the untextured Flat | |
| # shader path from the blog - then the texture is no longer needed. | |
| KEEP_TEXTURE = True | |
| COLOR_ATTR_NAME = 'Col' # exported as COLOR_0 in glTF (name is cosmetic) | |
| COLOR_ATTR_TYPE = 'BYTE_COLOR' # 'BYTE_COLOR' -> exported as unorm16x4 = 8 bytes/vertex, | |
| # 16-bit so no banding; exporter does sRGB->linear for you, | |
| # so COLOR_0 lands LINEAR as glTF/three.js require. Clamps | |
| # to [0,1] (fine for unlit). 'FLOAT_COLOR' = 16 bytes/vertex | |
| # (2x size) + risk of double colorspace conversion. Use byte. | |
| COLOR_ATTR_DOMAIN = 'CORNER' # 'CORNER' = per-loop, sharp lighting edges (blog's choice) | |
| # 'POINT' = per-vertex, smoother + fewer verts on export | |
| CLAMP_TO_LDR = False # clamp baked color to [0,1] (engines expecting normalized | |
| # COLOR_0). BYTE_COLOR already clamps; relevant for FLOAT. | |
| LIGHT_GAIN = 1.0 # multiply baked lighting (brighten/darken). 1.0 = untouched. | |
| # Diffuse-only drops emission/glossy your COMBINED texture | |
| # bake captured; nudge up if the lit result reads too dim. | |
| # Rebuild each material on the baked result as Emission(texture * Col) so Material | |
| # Preview / Rendered shows the true UNLIT look (texture * baked light, no relighting). | |
| # Copies each unique material once; originals keep their clean lit materials. | |
| EMISSION_PREVIEW = True | |
| SET_STANDARD_VIEW = True # set View Transform to 'Standard' (match glTF sRGB output) | |
| MERGE_OBJECTS = True # join baked duplicates into one 'Baked_Merged' | |
| # ---- Artifact mitigation (all AUTOMATIC, applied only to the throwaway bake copy; | |
| # originals never move. No artist action / geometry cleanup required) ---- | |
| # 1) Normal offset: a vertex buried in / flush against other geometry (incl. decals | |
| # floating on a surface) is occluded, so its baked light -> black or a contact-shadow | |
| # smudge. Blender's VERTEX_COLORS bake exposes no ray/cage offset, so we nudge each | |
| # vertex a hair OUT along its normal, bake, then restore the exact original positions: | |
| # the light is sampled just off-surface but stored on the real geometry. This is the | |
| # same trick VertexOven / Bakery / Marmoset use to kill self-intersection black spots. | |
| NORMAL_OFFSET = True | |
| NORMAL_OFFSET_DIST = 0.0 # local units; 0.0 = auto = MEDIAN EDGE LENGTH x the fraction | |
| # below. Median edge length tracks LOCAL feature/mesh density, | |
| # so the offset stays mm-scale on a dense island instead of | |
| # blowing up to cm with the overall bbox. Set a positive number | |
| # to force an explicit distance. Raise if black remains, lower | |
| # if you see light leaking through thin walls. | |
| NORMAL_OFFSET_EDGE_FRAC = 0.05 # auto offset = this x median edge length (used when DIST==0). | |
| # ~5% of a typical edge: enough to clear coincident/z-fight | |
| # surfaces, well under any real wall thickness. | |
| # 2) Dark-outlier repair: whatever still bakes black gets inpainted from neighbors. A | |
| # vertex FAR darker than its topological neighbours is almost certainly a buried-vertex | |
| # artifact (real shading varies smoothly), so we overwrite it with the neighbour mean. | |
| # Conservative thresholds -> genuinely dark regions (real shadow) are left alone. | |
| REPAIR_DARK_OUTLIERS = True | |
| DARK_OUTLIER_ABS = 0.06 # only consider a vertex if its luminance is below this... | |
| DARK_OUTLIER_RATIO = 0.35 # ...AND below this fraction of its neighbours' median luminance | |
| DARK_REPAIR_PASSES = 2 # extra passes let small black CLUSTERS fill in from the rim | |
| # 3) Alpha-math safety: the baked 'Col' is RGBA and exports as COLOR_0. If a viewer does | |
| # texture * COLOR_0 including the ALPHA channel, a stray baked alpha (0/garbage) would | |
| # wrongly fade or hide the surface and collide with real cutout/blend alpha. 'Col' is | |
| # lighting-only, so we force its alpha to 1.0 - it can never gate material transparency. | |
| FORCE_OPAQUE_VTX_ALPHA = True | |
| # ---- GPU / Cycles (kept from the texture script) ---- | |
| def enable_gpu_devices(): | |
| """Enable GPU compute in Cycles preferences. Returns True if a GPU backend | |
| was activated, False if falling back to CPU.""" | |
| prefs = bpy.context.preferences.addons['cycles'].preferences | |
| # OPTIX (NVIDIA RTX) > CUDA (older NVIDIA) > HIP (AMD) > METAL (Apple) > ONEAPI (Intel) | |
| chosen = None | |
| for backend in ('OPTIX', 'CUDA', 'HIP', 'METAL', 'ONEAPI'): | |
| try: | |
| prefs.compute_device_type = backend | |
| except (TypeError, ValueError): | |
| continue | |
| prefs.get_devices() | |
| if any(d.type == backend for d in prefs.devices): | |
| chosen = backend | |
| break | |
| if chosen is None: | |
| print(" GPU: no compatible backend found, using CPU") | |
| return False | |
| enabled = 0 | |
| for d in prefs.devices: | |
| if d.type == chosen: | |
| d.use = True | |
| enabled += 1 | |
| elif d.type == 'CPU': | |
| d.use = True # CPU + GPU together; set False for GPU-only | |
| print(f" GPU: {chosen} active ({enabled} device(s))") | |
| return True | |
| def ensure_cycles(): | |
| scene = bpy.context.scene | |
| scene.render.engine = 'CYCLES' | |
| scene.cycles.samples = SAMPLES | |
| # Vertex-color baking does not run the OIDN/OptiX post-denoise (no image | |
| # buffer with albedo/normal aux), so we just lean on sample count. | |
| scene.cycles.use_denoising = False | |
| scene.render.bake.target = 'VERTEX_COLORS' | |
| if USE_GPU and enable_gpu_devices(): | |
| scene.cycles.device = 'GPU' | |
| else: | |
| scene.cycles.device = 'CPU' | |
| # Match a plain glTF viewer's sRGB output: no AgX/Filmic tonemap on the preview. | |
| if SET_STANDARD_VIEW: | |
| set_display_for_unlit_preview() | |
| def set_display_for_unlit_preview(): | |
| """Match Blender's viewport to a default three.js / glTF viewer: | |
| sRGB output, no filmic tonemap. Blender 5.0 defaults to AgX, which would | |
| tonemap the preview and diverge from the unlit GLB shown in three.js.""" | |
| scene = bpy.context.scene | |
| vs = scene.view_settings | |
| prior = getattr(vs, 'view_transform', None) | |
| if prior is not None and prior != 'Standard': | |
| print(f" WARNING: view transform was '{prior}', not 'Standard'. The " | |
| f"viewport was tonemapping the preview, so baked colors looked " | |
| f"different here than in the VR/three.js (unlit GLB) output. " | |
| f"Resetting to 'Standard' so the preview matches the export.") | |
| try: | |
| vs.view_transform = 'Standard' | |
| except (AttributeError, TypeError): | |
| pass | |
| for attr, val in (('look', 'None'), ('exposure', 0.0), ('gamma', 1.0)): | |
| try: | |
| setattr(vs, attr, val) | |
| except (AttributeError, TypeError): | |
| pass | |
| try: | |
| scene.display_settings.display_device = 'sRGB' | |
| except (AttributeError, TypeError): | |
| pass | |
| # ---- Mesh prep ---- | |
| def force_visible(obj): | |
| obj.hide_set(False) | |
| obj.hide_render = False | |
| obj.hide_viewport = False | |
| def ensure_has_material(obj): | |
| """Cycles needs a surface shader to compute diffuse lighting. Most objects | |
| already have a Principled material; only add a default if a slot is empty.""" | |
| if not obj.data.materials: | |
| m = bpy.data.materials.new(name=f"M_{obj.name}") | |
| m.use_nodes = True | |
| obj.data.materials.append(m) | |
| for slot in obj.material_slots: | |
| if slot.material is None: | |
| m = bpy.data.materials.new(name=f"M_{obj.name}") | |
| m.use_nodes = True | |
| slot.material = m | |
| def ensure_color_attribute(obj): | |
| """Create (or re-create) the bake target color attribute and make it the | |
| active + render color attribute so the bake writes to it and glTF exports | |
| it as COLOR_0. Same NAME/TYPE/DOMAIN on every object so join() merges them | |
| into a single attribute.""" | |
| me = obj.data | |
| ca = me.color_attributes | |
| existing = ca.get(COLOR_ATTR_NAME) | |
| if existing is not None and (existing.domain != COLOR_ATTR_DOMAIN | |
| or existing.data_type != COLOR_ATTR_TYPE): | |
| ca.remove(existing) | |
| existing = None | |
| if existing is None: | |
| ca.new(name=COLOR_ATTR_NAME, type=COLOR_ATTR_TYPE, domain=COLOR_ATTR_DOMAIN) | |
| set_active_color_attribute(me) | |
| def set_active_color_attribute(me): | |
| ca = me.color_attributes | |
| names = [a.name for a in ca] | |
| if COLOR_ATTR_NAME not in names: | |
| return | |
| idx = names.index(COLOR_ATTR_NAME) | |
| try: | |
| ca.active_color_index = idx | |
| ca.render_color_index = idx # render_color_index is what glTF exports | |
| except (AttributeError, TypeError): | |
| pass | |
| def strip_extra_color_attributes(me): | |
| """Remove every color attribute except COLOR_ATTR_NAME so the baked 'Col' is the | |
| ONLY one - guaranteeing it exports as COLOR_0 (the slot viewers multiply into base | |
| color). A pre-existing/white attribute left on the asset would otherwise win COLOR_0 | |
| and push the real light to COLOR_1, where no viewer reads it.""" | |
| ca = me.color_attributes | |
| for a in list(ca): | |
| if a.name != COLOR_ATTR_NAME: | |
| ca.remove(a) | |
| set_active_color_attribute(me) | |
| def apply_light_gain(me): | |
| """Scale the baked lighting by LIGHT_GAIN (diffuse-only misses emission/glossy | |
| that a COMBINED bake captured, so a small boost often matches the look).""" | |
| if LIGHT_GAIN == 1.0: | |
| return | |
| ca = me.color_attributes.get(COLOR_ATTR_NAME) | |
| if ca is None or len(ca.data) == 0: | |
| return | |
| flat = [0.0] * (len(ca.data) * 4) | |
| ca.data.foreach_get('color', flat) | |
| for i in range(len(flat)): | |
| if i % 4 != 3: # scale RGB, leave alpha | |
| flat[i] *= LIGHT_GAIN | |
| ca.data.foreach_set('color', flat) | |
| me.update() | |
| # ---- Artifact mitigation helpers ---- | |
| def _median_edge_length(me, co): | |
| """Median edge length (local space) from a flat [x,y,z,...] vertex-coord array. | |
| A LOCAL feature-scale proxy: tracks mesh density, so it does not explode with the | |
| overall object size the way a bbox diagonal does. Median (not mean) ignores stray | |
| long edges. Subsamples on very dense meshes to stay cheap.""" | |
| ne = len(me.edges) | |
| if ne == 0: | |
| return 0.0 | |
| ev = [0] * (ne * 2) | |
| me.edges.foreach_get('vertices', ev) | |
| step = max(1, ne // 50000) # cap at ~50k length samples | |
| lengths = [] | |
| for e in range(0, ne, step): | |
| a, b = ev[e * 2], ev[e * 2 + 1] | |
| dx = co[a * 3] - co[b * 3] | |
| dy = co[a * 3 + 1] - co[b * 3 + 1] | |
| dz = co[a * 3 + 2] - co[b * 3 + 2] | |
| lengths.append((dx * dx + dy * dy + dz * dz) ** 0.5) | |
| return _median(lengths) | |
| def offset_along_normals(me): | |
| """Push every vertex OUT along its normal by a small feature-scaled distance and return | |
| the original coordinates so the caller can restore them after the bake. Lifts buried / | |
| decal / flush vertices clear of the surfaces that would occlude them (-> no black). | |
| Auto distance (DIST<=0) is a fraction of the MEDIAN EDGE LENGTH, not the bbox, so it | |
| stays proportional to local mesh density instead of overall object size.""" | |
| verts = me.vertices | |
| n = len(verts) | |
| if n == 0: | |
| return None | |
| co = [0.0] * (n * 3) | |
| no = [0.0] * (n * 3) | |
| verts.foreach_get('co', co) | |
| verts.foreach_get('normal', no) | |
| dist = NORMAL_OFFSET_DIST | |
| if dist <= 0.0: | |
| med = _median_edge_length(me, co) | |
| dist = med * NORMAL_OFFSET_EDGE_FRAC | |
| print(f" normal offset: median edge {med:.4g} x {NORMAL_OFFSET_EDGE_FRAC} " | |
| f"-> {dist:.4g} (local units)") | |
| if dist <= 0.0: | |
| return None | |
| moved = list(co) | |
| for i in range(n): | |
| moved[i * 3] = co[i * 3] + no[i * 3] * dist | |
| moved[i * 3 + 1] = co[i * 3 + 1] + no[i * 3 + 1] * dist | |
| moved[i * 3 + 2] = co[i * 3 + 2] + no[i * 3 + 2] * dist | |
| verts.foreach_set('co', moved) | |
| me.update() | |
| return co | |
| def restore_coords(me, co): | |
| """Put vertices back exactly where they were (undo offset_along_normals).""" | |
| if co is None: | |
| return | |
| me.vertices.foreach_set('co', co) | |
| me.update() | |
| def force_opaque_alpha(me): | |
| """Set the baked color attribute's alpha channel to 1.0 everywhere. 'Col' carries | |
| LIGHTING only; a stray alpha would wrongly multiply into a viewer's material alpha.""" | |
| ca = me.color_attributes.get(COLOR_ATTR_NAME) | |
| if ca is None or len(ca.data) == 0: | |
| return | |
| flat = [0.0] * (len(ca.data) * 4) | |
| ca.data.foreach_get('color', flat) | |
| for i in range(3, len(flat), 4): | |
| flat[i] = 1.0 | |
| ca.data.foreach_set('color', flat) | |
| me.update() | |
| def _vertex_adjacency(me): | |
| """Undirected vertex adjacency (list of neighbour-index lists) from mesh edges.""" | |
| nv = len(me.vertices) | |
| ev = [0] * (len(me.edges) * 2) | |
| me.edges.foreach_get('vertices', ev) | |
| adj = [[] for _ in range(nv)] | |
| for i in range(0, len(ev), 2): | |
| a, b = ev[i], ev[i + 1] | |
| adj[a].append(b) | |
| adj[b].append(a) | |
| return adj | |
| def _median(vals): | |
| s = sorted(vals) | |
| m = len(s) | |
| if m == 0: | |
| return 0.0 | |
| return s[m // 2] if m % 2 else 0.5 * (s[m // 2 - 1] + s[m // 2]) | |
| def repair_dark_outliers(me): | |
| """Inpaint isolated near-black vertices (buried-vertex artifacts) from their neighbours. | |
| A vertex whose luminance is both below DARK_OUTLIER_ABS and below DARK_OUTLIER_RATIO x | |
| the MEDIAN of its neighbours is treated as an artifact and overwritten with the mean of | |
| its non-artifact neighbours. Real (smoothly varying) shadow keeps a luminance close to | |
| its neighbours' and is never flagged. Works for POINT and CORNER color attributes by | |
| aggregating to per-vertex values, repairing, then writing back to the original domain.""" | |
| ca = me.color_attributes.get(COLOR_ATTR_NAME) | |
| if ca is None or len(ca.data) == 0: | |
| return | |
| nv = len(me.vertices) | |
| if nv == 0: | |
| return | |
| cols = [0.0] * (len(ca.data) * 4) | |
| ca.data.foreach_get('color', cols) | |
| corner = (ca.domain == 'CORNER') | |
| # loop -> vertex map (CORNER only); POINT data is already per-vertex/1:1. | |
| if corner: | |
| lv = [0] * len(me.loops) | |
| me.loops.foreach_get('vertex_index', lv) | |
| # Aggregate a representative RGB per vertex. | |
| vsum = [[0.0, 0.0, 0.0] for _ in range(nv)] | |
| vcnt = [0] * nv | |
| if corner: | |
| for li in range(len(lv)): | |
| v = lv[li] | |
| b = li * 4 | |
| vsum[v][0] += cols[b]; vsum[v][1] += cols[b + 1]; vsum[v][2] += cols[b + 2] | |
| vcnt[v] += 1 | |
| else: | |
| for v in range(nv): | |
| b = v * 4 | |
| vsum[v][0] = cols[b]; vsum[v][1] = cols[b + 1]; vsum[v][2] = cols[b + 2] | |
| vcnt[v] = 1 | |
| vcol = [[vsum[v][0] / c, vsum[v][1] / c, vsum[v][2] / c] if (c := vcnt[v]) else [0.0, 0.0, 0.0] | |
| for v in range(nv)] | |
| lum = [0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] for c in vcol] | |
| adj = _vertex_adjacency(me) | |
| repaired = set() | |
| for _ in range(max(1, DARK_REPAIR_PASSES)): | |
| # Flag artifacts against the CURRENT (already partly repaired) neighbourhood. | |
| flagged = [] | |
| for v in range(nv): | |
| nb = adj[v] | |
| if not nb or lum[v] >= DARK_OUTLIER_ABS: | |
| continue | |
| nmed = _median([lum[u] for u in nb]) | |
| if lum[v] < DARK_OUTLIER_RATIO * nmed: | |
| flagged.append(v) | |
| if not flagged: | |
| break | |
| flset = set(flagged) | |
| new = {} | |
| for v in flagged: | |
| good = [u for u in adj[v] if u not in flset] # prefer non-artifact neighbours | |
| src = good if good else adj[v] | |
| r = sum(vcol[u][0] for u in src) / len(src) | |
| g = sum(vcol[u][1] for u in src) / len(src) | |
| b = sum(vcol[u][2] for u in src) / len(src) | |
| new[v] = [r, g, b] | |
| for v, c in new.items(): | |
| vcol[v] = c | |
| lum[v] = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] | |
| repaired.update(new.keys()) | |
| if not repaired: | |
| return | |
| # Write per-vertex repaired RGB back to the color attribute (alpha untouched here). | |
| if corner: | |
| for li in range(len(lv)): | |
| c = vcol[lv[li]] | |
| b = li * 4 | |
| cols[b], cols[b + 1], cols[b + 2] = c[0], c[1], c[2] | |
| else: | |
| for v in range(nv): | |
| c = vcol[v] | |
| b = v * 4 | |
| cols[b], cols[b + 1], cols[b + 2] = c[0], c[1], c[2] | |
| ca.data.foreach_set('color', cols) | |
| me.update() | |
| print(f" repaired {len(repaired)} dark-vertex artifact(s)") | |
| def _set_shadeless_principled(bsdf): | |
| """Kill diffuse + specular so only the Emission term shows -> shadeless preview.""" | |
| def _set(name, val): | |
| if name in bsdf.inputs: | |
| try: | |
| bsdf.inputs[name].default_value = val | |
| except (TypeError, ValueError): | |
| pass | |
| _set('Base Color', (0.0, 0.0, 0.0, 1.0)) | |
| _set('Metallic', 0.0) | |
| _set('Specular IOR Level', 0.0) # Blender 4.x | |
| _set('Specular', 0.0) # Blender 3.x | |
| _set('Roughness', 1.0) | |
| _set('Sheen Weight', 0.0) | |
| _set('Coat Weight', 0.0) | |
| def _is_threshold_node(node): | |
| return (node is not None and node.type == 'MATH' | |
| and getattr(node, 'operation', '') == 'GREATER_THAN') | |
| def _alpha_is_clip(material, alpha_socket): | |
| """Should this hard-cut (clip) rather than blend?""" | |
| if alpha_socket is not None and _is_threshold_node(alpha_socket.node): | |
| return True # already a Greater Than (4.2+/authored) | |
| return getattr(material, 'blend_method', 'OPAQUE') == 'CLIP' # legacy EEVEE flag | |
| def _enable_alpha_render(material, clip=False): | |
| if hasattr(material, 'surface_render_method'): # Blender 4.2+ (EEVEE Next) | |
| material.surface_render_method = 'DITHERED' if clip else 'BLENDED' | |
| elif hasattr(material, 'blend_method'): # Blender < 4.2 | |
| material.blend_method = 'CLIP' if clip else 'BLEND' | |
| try: | |
| material.show_transparent_back = False | |
| except AttributeError: | |
| pass | |
| def make_emission_preview(material): | |
| """Shadeless preview of the unlit export, preserving Alpha. | |
| Repurposes the material's Principled BSDF as a shadeless emissive carrier: | |
| Emission Color = texture * Col (base color/specular zeroed -> no relighting) | |
| Alpha = original Alpha source, through a Greater Than iff clip. | |
| Keeping the Principled gives us its native Alpha socket - no Mix Shader needed. | |
| (Preview-only: this Principled+Emission surface is NOT exported as unlit; the | |
| dedicated export script builds its own KHR_materials_unlit materials.) | |
| Idempotent.""" | |
| if material is None or not material.use_nodes: | |
| return | |
| nt = material.node_tree | |
| nodes, links = nt.nodes, nt.links | |
| if any(n.type == 'VERTEX_COLOR' and getattr(n, 'layer_name', '') == COLOR_ATTR_NAME | |
| for n in nodes): | |
| return | |
| bsdf = next((n for n in nodes if n.type == 'BSDF_PRINCIPLED'), None) | |
| if bsdf is None: # give ourselves a real Alpha socket | |
| bsdf = nodes.new('ShaderNodeBsdfPrincipled') | |
| tex = next((n for n in nodes if n.type == 'TEX_IMAGE'), None) | |
| if tex is not None: | |
| links.new(tex.outputs['Color'], bsdf.inputs['Base Color']) | |
| if 'Alpha' in tex.outputs: | |
| links.new(tex.outputs['Alpha'], bsdf.inputs['Alpha']) | |
| # Base Color source for the emission term. | |
| base_in = bsdf.inputs['Base Color'] | |
| base_socket, base_const = None, (1.0, 1.0, 1.0, 1.0) | |
| if base_in.is_linked: | |
| base_socket = base_in.links[0].from_socket | |
| else: | |
| try: | |
| base_const = tuple(base_in.default_value) | |
| except (TypeError, ValueError): | |
| pass | |
| # Alpha source (chain left intact; we relink its final socket). | |
| alpha_in = bsdf.inputs['Alpha'] | |
| alpha_socket, alpha_const = None, 1.0 | |
| if alpha_in.is_linked: | |
| alpha_socket = alpha_in.links[0].from_socket | |
| else: | |
| try: | |
| alpha_const = float(alpha_in.default_value) | |
| except (TypeError, ValueError): | |
| pass | |
| clip = _alpha_is_clip(material, alpha_socket) | |
| ox, oy = bsdf.location.x, bsdf.location.y | |
| # texture * Col | |
| ca = nodes.new('ShaderNodeVertexColor') | |
| ca.layer_name = COLOR_ATTR_NAME | |
| ca.location = (ox - 600, oy - 320) | |
| try: | |
| mix = nodes.new('ShaderNodeMix') | |
| mix.data_type = 'RGBA' | |
| mix.blend_type = 'MULTIPLY' | |
| in_fac, in_a, in_b, out_c = mix.inputs[0], mix.inputs[6], mix.inputs[7], mix.outputs[2] | |
| except (RuntimeError, TypeError): | |
| mix = nodes.new('ShaderNodeMixRGB') | |
| mix.blend_type = 'MULTIPLY' | |
| in_fac, in_a, in_b, out_c = (mix.inputs['Fac'], mix.inputs['Color1'], | |
| mix.inputs['Color2'], mix.outputs['Color']) | |
| in_fac.default_value = 1.0 | |
| mix.location = (ox - 300, oy) | |
| if base_socket is not None: | |
| links.new(base_socket, in_a) | |
| else: | |
| try: | |
| in_a.default_value = list(base_const) | |
| except (TypeError, ValueError): | |
| pass | |
| links.new(ca.outputs['Color'], in_b) | |
| # Push texture*Col into Emission, unlink base color so diffuse is dead. | |
| emit_in = bsdf.inputs.get('Emission Color') or bsdf.inputs.get('Emission') | |
| if emit_in is not None: | |
| for l in list(base_in.links): | |
| links.remove(l) | |
| links.new(out_c, emit_in) | |
| if 'Emission Strength' in bsdf.inputs: | |
| bsdf.inputs['Emission Strength'].default_value = 1.0 | |
| _set_shadeless_principled(bsdf) | |
| # --- Alpha: straight in, or through a Greater Than when clip and not already one --- | |
| if alpha_socket is not None: | |
| if clip and not _is_threshold_node(alpha_socket.node): | |
| gt = nodes.new('ShaderNodeMath') | |
| gt.operation = 'GREATER_THAN' | |
| gt.location = (ox - 300, oy - 220) | |
| gt.inputs[1].default_value = getattr(material, 'alpha_threshold', 0.5) | |
| links.new(alpha_socket, gt.inputs[0]) | |
| links.new(gt.outputs['Value'], alpha_in) | |
| else: | |
| links.new(alpha_socket, alpha_in) # existing chain / greater-than kept | |
| else: | |
| try: | |
| alpha_in.default_value = alpha_const | |
| except (TypeError, ValueError): | |
| pass | |
| # Route output to this Principled. | |
| out = next((n for n in nodes if n.type == 'OUTPUT_MATERIAL' and n.is_active_output), None) | |
| if out is None: | |
| out = next((n for n in nodes if n.type == 'OUTPUT_MATERIAL'), None) | |
| if out is None: | |
| out = nodes.new('ShaderNodeOutputMaterial') | |
| out.location = (ox + 300, oy) | |
| links.new(bsdf.outputs['BSDF'], out.inputs['Surface']) | |
| if alpha_socket is not None or alpha_const < 1.0: | |
| _enable_alpha_render(material, clip) | |
| def clamp_color_attribute(me): | |
| """Optionally clamp baked values to [0,1]. foreach_get/_set on a flat RGBA | |
| array is the cross-version-stable way to bulk-edit color attribute data.""" | |
| ca = me.color_attributes.get(COLOR_ATTR_NAME) | |
| if ca is None: | |
| return | |
| n = len(ca.data) | |
| if n == 0: | |
| return | |
| flat = [0.0] * (n * 4) | |
| ca.data.foreach_get('color', flat) | |
| flat = [min(max(v, 0.0), 1.0) for v in flat] | |
| ca.data.foreach_set('color', flat) | |
| me.update() | |
| # ---- Bake ---- | |
| def bake_vertex_lighting(obj): | |
| scene = bpy.context.scene | |
| bake = scene.render.bake | |
| bpy.ops.object.select_all(action='DESELECT') | |
| obj.select_set(True) | |
| bpy.context.view_layer.objects.active = obj | |
| ensure_has_material(obj) | |
| ensure_color_attribute(obj) | |
| # bake.target is already 'VERTEX_COLORS' from ensure_cycles(). | |
| # KEEP_TEXTURE -> bake the LIGHT term only (no albedo), so texture stays | |
| # meaningful and texture * COLOR_0 reproduces the lit look. | |
| # Otherwise bake full diffuse (light * albedo) for the untextured path. | |
| bake.use_pass_direct = True | |
| bake.use_pass_indirect = True | |
| bake.use_pass_color = (not KEEP_TEXTURE) | |
| mode = "lighting-only" if KEEP_TEXTURE else "full diffuse" | |
| print(f" bake DIFFUSE ({mode}) -> vertex colors: {obj.name} @ {SAMPLES} samples") | |
| # Normal offset: sample light just OFF the surface (clears buried/decal/flush verts), | |
| # then restore the exact geometry so only the color attribute keeps the offset benefit. | |
| saved_co = offset_along_normals(obj.data) if NORMAL_OFFSET else None | |
| try: | |
| bpy.ops.object.bake(type='DIFFUSE') | |
| finally: | |
| restore_coords(obj.data, saved_co) | |
| apply_light_gain(obj.data) | |
| if REPAIR_DARK_OUTLIERS: | |
| repair_dark_outliers(obj.data) | |
| if CLAMP_TO_LDR: | |
| clamp_color_attribute(obj.data) | |
| if FORCE_OPAQUE_VTX_ALPHA: | |
| force_opaque_alpha(obj.data) | |
| # ---- Main (merge flow kept from the texture script) ---- | |
| def main(): | |
| print(f"\n========== vertex bake script {SCRIPT_VERSION} RUNNING ==========") | |
| ensure_cycles() | |
| if bpy.context.mode != 'OBJECT': | |
| bpy.ops.object.mode_set(mode='OBJECT') | |
| originals = [o for o in bpy.context.selected_objects if o.type == 'MESH'] | |
| if not originals: | |
| raise RuntimeError("Select one or more mesh objects first.") | |
| print(f"\n=== Bake {len(originals)} objs -> vertex colors, {SAMPLES} samples, " | |
| f"keep_texture={KEEP_TEXTURE}, attr={COLOR_ATTR_NAME}" | |
| f"({COLOR_ATTR_TYPE}/{COLOR_ATTR_DOMAIN}) ===") | |
| for obj in originals: | |
| force_visible(obj) | |
| # Duplicate so originals stay untouched. linked=False gives independent mesh | |
| # data; materials remain SHARED references (we never copy them -> no trash). | |
| bpy.ops.object.select_all(action='DESELECT') | |
| for obj in originals: | |
| obj.select_set(True) | |
| bpy.context.view_layer.objects.active = originals[0] | |
| bpy.ops.object.duplicate(linked=False) | |
| duplicates = list(bpy.context.selected_objects) | |
| for dup in duplicates: | |
| force_visible(dup) | |
| for obj in originals: | |
| obj.hide_set(True) | |
| obj.hide_render = True | |
| for dup in duplicates: | |
| bake_vertex_lighting(dup) | |
| if MERGE_OBJECTS and len(duplicates) > 1: | |
| bpy.ops.object.select_all(action='DESELECT') | |
| for dup in duplicates: | |
| dup.select_set(True) | |
| bpy.context.view_layer.objects.active = duplicates[0] | |
| bpy.ops.object.join() | |
| result = bpy.context.active_object # join() leaves the merged obj active | |
| result.name = "Baked_Merged" | |
| # Keep ONLY 'Col' so it exports as COLOR_0 (not a stray attribute as COLOR_1). | |
| # strip_extra_color_attributes() re-asserts the active color attribute for us. | |
| strip_extra_color_attributes(result.data) | |
| bpy.ops.object.material_slot_remove_unused() | |
| else: | |
| result = duplicates[0] | |
| result.name = "Baked_Merged" | |
| strip_extra_color_attributes(result.data) | |
| # Keep textures travelling with the file. | |
| bpy.ops.file.pack_all() | |
| # Rebuild the result's materials as Emission(texture * Col) so Material Preview / | |
| # Rendered shows the true UNLIT look. Copy each unique material once (originals keep | |
| # their clean lit materials untouched). | |
| if EMISSION_PREVIEW: | |
| remap = {} | |
| for slot in result.material_slots: | |
| m = slot.material | |
| if m is None: | |
| continue | |
| if m not in remap: | |
| cm = m.copy() | |
| make_emission_preview(cm) | |
| remap[m] = cm | |
| slot.material = remap[m] | |
| print(f" emission preview built for {len(remap)} material(s)") | |
| n_mats = len([s for s in result.material_slots if s.material]) | |
| print(f"=== Done. {result.name}: {n_mats} material slot(s), " | |
| f"textures preserved + packed. Preview in Material Preview/Rendered. ===\n") | |
| if __name__ == "__main__": | |
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Export the 'Export' collection to a standard GLB, with correct material conventions | |
| for BOTH bake pipelines, then opens a normal Save-As file dialog. | |
| Why a dedicated script: | |
| - It always exports EVERY object in the EXPORT_COLLECTION, regardless of selection | |
| or visibility. No more forgetting the "Selection Only" / "Visible Only" checkboxes; | |
| the collection IS the export set, so every export is consistent. | |
| - It fixes the one thing Blender's builtin glTF export structurally cannot do for | |
| vertex-baked-lighting: mark the material KHR_materials_unlit so a standard viewer | |
| shows baseColorTexture * COLOR_0 (texture * baked light) with NO relighting. | |
| Compatibility (handled per material, by signature): | |
| - bake_vtx (kept-texture vertex lighting): the baked object's material is shadeless | |
| Emission(texture) and the mesh carries COLOR_0 (the light). On export Blender writes | |
| emissiveTexture=texture + COLOR_0. This script rewrites those materials to | |
| baseColorTexture=texture, base white, no emissive, metallic 0, + KHR_materials_unlit. | |
| Result: texture * COLOR_0, unlit. | |
| - bake_tex (texture-emission): the baked image already contains the lighting and sits | |
| in emissiveTexture, with NO COLOR_0. Those materials are LEFT UNTOUCHED - emissive is | |
| additive and already renders correctly in any viewer, exactly as today. | |
| The distinguishing signal is COLOR_0: a material is converted only if a primitive that | |
| uses it has a COLOR_0 attribute. (Run the updated bake_vtx so 'Col' is the only color | |
| attribute, i.e. COLOR_0 = the light, not a stray white attribute.) | |
| """ | |
| import bpy | |
| import json | |
| import struct | |
| from bpy.props import StringProperty | |
| from bpy_extras.io_utils import ExportHelper | |
| # ---- Settings ---- | |
| EXPORT_COLLECTION = "Export" # only objects in this collection are exported | |
| GLB_MAGIC, JSON_T, BIN_T = 0x46546C67, 0x4E4F534A, 0x004E4942 | |
| # ---------------------------------------------------------------------------- | |
| # GLB material fixup | |
| # ---------------------------------------------------------------------------- | |
| def _materials_with_color0(gltf): | |
| """Indices of materials used by at least one primitive that has COLOR_0.""" | |
| hit = set() | |
| for mesh in gltf.get('meshes', []): | |
| for prim in mesh.get('primitives', []): | |
| if 'COLOR_0' in prim.get('attributes', {}) and 'material' in prim: | |
| hit.add(prim['material']) | |
| return hit | |
| def make_vertex_lit_materials_unlit(gltf): | |
| """For materials used with COLOR_0, convert to unlit baseColorTexture * COLOR_0. | |
| Materials without COLOR_0 (e.g. texture-emission bakes) are left exactly as-is. | |
| Alpha / cutout (mirrors bake_vtx_v2's preview, which keeps the Principled Alpha | |
| socket alive so opacity survives the shadeless rebuild): | |
| - Cutout mats (bake_vtx_v2's clip path: Greater Than node / blend_method CLIP -> | |
| DITHERED) export as alphaMode=MASK + alphaCutoff; transparency exports as BLEND. | |
| Those are top-level material fields, so we NEVER touch them here - they pass | |
| straight through, keeping the cutout threshold bake_vtx_v2 set up. | |
| - For MASK/BLEND, bake_vtx_v2 feeds the texture COLOUR into Emission and leaves Base | |
| Color a black constant, wiring only Alpha from the texture. Blender then exports TWO | |
| image copies: emissiveTexture = the real RGB *and* the cutout alpha (source PNG is | |
| RGBA), while the baseColorTexture it synthesises to carry the alpha has WHITE RGB. | |
| Keeping that white one shows foliage as white*COLOR_0 (white where lit). Since the | |
| emissive copy already carries both colour AND the same alpha, we make IT the | |
| baseColorTexture, replacing any synthesised white alpha-only carrier. | |
| - We whiten baseColorFactor RGB (it was black under the emission setup) but | |
| PRESERVE its alpha: a constant-alpha BLEND material stores its opacity there, | |
| and forcing 1.0 would make it fully opaque.""" | |
| targets = _materials_with_color0(gltf) | |
| changed = 0 | |
| for i, m in enumerate(gltf.get('materials', [])): | |
| if i not in targets: | |
| continue | |
| pbr = m.setdefault('pbrMetallicRoughness', {}) | |
| # Emission(texture) -> baseColorTexture (so COLOR_0 can multiply it). The emissive | |
| # copy carries the real colour AND (for cutout/blend) the same alpha as the white | |
| # carrier Blender synthesised, so prefer it: overwrite any existing baseColorTexture. | |
| et = m.pop('emissiveTexture', None) | |
| if et is not None: | |
| pbr['baseColorTexture'] = et | |
| m.pop('emissiveFactor', None) | |
| prev = pbr.get('baseColorFactor') or [0.0, 0.0, 0.0, 1.0] | |
| alpha = prev[3] if len(prev) >= 4 else 1.0 # keep opacity of const-alpha BLEND | |
| pbr['baseColorFactor'] = [1.0, 1.0, 1.0, alpha] # RGB was black under emission | |
| pbr['metallicFactor'] = 0.0 | |
| pbr.pop('roughnessFactor', None) | |
| m.setdefault('extensions', {})['KHR_materials_unlit'] = {} | |
| changed += 1 | |
| if changed: | |
| used = gltf.setdefault('extensionsUsed', []) | |
| if 'KHR_materials_unlit' not in used: | |
| used.append('KHR_materials_unlit') | |
| return changed | |
| _COMP_SIZE = {5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4} | |
| def force_color0_opaque(gltf, buf): | |
| """Force every COLOR_0 vertex-color alpha to fully opaque, in place (buf = bytearray). | |
| COLOR_0 here is the baked LIGHT; its alpha is meaningless and should be 1.0. On opaque | |
| meshes the bake leaves it at 1.0, but on cutout meshes it comes out non-opaque - and | |
| glTF folds COLOR_0.a into the alpha test (alpha = baseColorTexture.a * baseColorFactor.a | |
| * COLOR_0.a), so MASK cutouts over-clip or vanish entirely (e.g. an all-zero-alpha | |
| window). We zero this out by writing full-scale alpha, leaving RGB (the light) intact.""" | |
| accs = gltf.get('accessors', []) | |
| bvs = gltf.get('bufferViews', []) | |
| seen, n = set(), 0 | |
| for mesh in gltf.get('meshes', []): | |
| for prim in mesh.get('primitives', []): | |
| ai = prim.get('attributes', {}).get('COLOR_0') | |
| if ai is None or ai in seen: | |
| continue | |
| seen.add(ai) | |
| a = accs[ai] | |
| if a.get('type') != 'VEC4': # VEC3 color has no alpha channel to fix | |
| continue | |
| comp = a['componentType'] | |
| cs = _COMP_SIZE.get(comp) | |
| if comp == 5126: # float | |
| packed = struct.pack('<f', 1.0) | |
| elif comp == 5123: # unorm16 | |
| packed = b'\xff\xff' | |
| elif comp == 5121: # unorm8 | |
| packed = b'\xff' | |
| else: | |
| continue | |
| bv = bvs[a['bufferView']] | |
| base = bv.get('byteOffset', 0) + a.get('byteOffset', 0) | |
| stride = bv.get('byteStride') or cs * 4 | |
| aoff = 3 * cs # alpha is the 4th component | |
| for i in range(a['count']): | |
| o = base + i * stride + aoff | |
| buf[o:o + cs] = packed | |
| a.pop('min', None) # any stale per-alpha min/max no longer holds | |
| a.pop('max', None) | |
| n += 1 | |
| return n | |
| def optimize_glb(gltf, bin_buf): | |
| """Shrink the file: drop textures/images no longer referenced (the emissive copies the | |
| unlit conversion orphans), merge byte-identical duplicate images, and repack the binary | |
| so the dropped bytes actually leave the file. Reindexes bufferViews / images / textures | |
| and every accessor / material that points at them. Returns (new_bin_bytes, stats).""" | |
| images = gltf.get('images', []) | |
| textures = gltf.get('textures', []) | |
| bvs = gltf.get('bufferViews', []) | |
| accs = gltf.get('accessors', []) | |
| # Textures actually referenced by a material (core slots + any extension texture refs). | |
| used_tex = set() | |
| def note(ref): | |
| if isinstance(ref, dict) and 'index' in ref: | |
| used_tex.add(ref['index']) | |
| def each_texref(m): | |
| pbr = m.get('pbrMetallicRoughness', {}) | |
| yield pbr.get('baseColorTexture') | |
| yield pbr.get('metallicRoughnessTexture') | |
| yield m.get('normalTexture') | |
| yield m.get('occlusionTexture') | |
| yield m.get('emissiveTexture') | |
| for ext in m.get('extensions', {}).values(): | |
| if isinstance(ext, dict): | |
| for v in ext.values(): | |
| yield v | |
| for m in gltf.get('materials', []): | |
| for ref in each_texref(m): | |
| note(ref) | |
| def img_key(si): | |
| im = images[si] | |
| if 'bufferView' in im: | |
| bv = bvs[im['bufferView']] | |
| o = bv.get('byteOffset', 0) | |
| return bytes(bin_buf[o:o + bv['byteLength']]) | |
| return ('uri', im.get('uri')) # external image: dedup by uri | |
| # Dedup used images by content -> map each used image to a canonical image index. | |
| content_canon, img_remap = {}, {} | |
| for t in used_tex: | |
| si = textures[t].get('source') | |
| if si is None: | |
| continue | |
| key = img_key(si) | |
| img_remap[si] = content_canon.setdefault(key, si) | |
| kept_imgs = sorted(set(img_remap.values())) | |
| new_img_index = {old: i for i, old in enumerate(kept_imgs)} | |
| kept_texs = sorted(used_tex) | |
| new_tex_index = {old: i for i, old in enumerate(kept_texs)} | |
| # bufferViews to keep = those used by any accessor + those backing a kept image. | |
| keep_bv = {a['bufferView'] for a in accs if 'bufferView' in a} | |
| for oi in kept_imgs: | |
| if 'bufferView' in images[oi]: | |
| keep_bv.add(images[oi]['bufferView']) | |
| # Repack the binary, 4-byte aligned, and remap bufferView indices. | |
| new_bin, new_bv, new_bv_index = bytearray(), [], {} | |
| for old in sorted(keep_bv): | |
| src = bvs[old] | |
| o = src.get('byteOffset', 0) | |
| ln = src['byteLength'] | |
| new_bin += b'\x00' * ((4 - (len(new_bin) % 4)) % 4) | |
| nb = dict(src) | |
| nb['byteOffset'] = len(new_bin) | |
| nb['buffer'] = 0 | |
| new_bv_index[old] = len(new_bv) | |
| new_bv.append(nb) | |
| new_bin += bin_buf[o:o + ln] | |
| for a in accs: | |
| if 'bufferView' in a: | |
| a['bufferView'] = new_bv_index[a['bufferView']] | |
| new_images = [] | |
| for old in kept_imgs: | |
| im = dict(images[old]) | |
| if 'bufferView' in im: | |
| im['bufferView'] = new_bv_index[im['bufferView']] | |
| new_images.append(im) | |
| new_textures = [] | |
| for old in kept_texs: | |
| t = dict(textures[old]) | |
| if t.get('source') is not None: | |
| t['source'] = new_img_index[img_remap[t['source']]] | |
| new_textures.append(t) | |
| for m in gltf.get('materials', []): | |
| for ref in each_texref(m): | |
| if isinstance(ref, dict) and 'index' in ref: | |
| ref['index'] = new_tex_index[ref['index']] | |
| gltf['bufferViews'] = new_bv | |
| gltf['images'] = new_images | |
| gltf['textures'] = new_textures | |
| gltf['buffers'] = [{'byteLength': len(new_bin)}] | |
| stats = {'images': len(images) - len(new_images), | |
| 'textures': len(textures) - len(new_textures), | |
| 'bufferViews': len(bvs) - len(new_bv), | |
| 'bytes': len(bin_buf) - len(new_bin)} | |
| return bytes(new_bin), stats | |
| def patch_glb_materials(path): | |
| with open(path, 'rb') as f: | |
| data = f.read() | |
| magic, _ver, length = struct.unpack('<III', data[:12]) | |
| if magic != GLB_MAGIC: | |
| print(" WARN: not a GLB, skipping material patch") | |
| return | |
| chunks, off = [], 12 | |
| while off < length: | |
| clen, ctype = struct.unpack('<II', data[off:off + 8]) | |
| chunks.append([ctype, data[off + 8:off + 8 + clen]]) | |
| off += 8 + clen | |
| json_chunk = next(c for c in chunks if c[0] == JSON_T) | |
| gltf = json.loads(json_chunk[1].decode('utf-8')) | |
| changed = make_vertex_lit_materials_unlit(gltf) | |
| # Binary passes (need the BIN chunk). Order matters: fix COLOR_0 alpha using the | |
| # ORIGINAL bufferView offsets, THEN compact (which renumbers/moves everything). | |
| bin_chunk = next((c for c in chunks if c[0] == BIN_T), None) | |
| opaque, stats = 0, None | |
| if bin_chunk is not None: | |
| buf = bytearray(bin_chunk[1]) | |
| opaque = force_color0_opaque(gltf, buf) | |
| new_bin, stats = optimize_glb(gltf, buf) | |
| bin_chunk[1] = new_bin | |
| json_chunk[1] = json.dumps(gltf, separators=(',', ':')).encode('utf-8') | |
| body = b'' | |
| for ctype, payload in chunks: | |
| pad = (4 - (len(payload) % 4)) % 4 | |
| if pad: | |
| payload = payload + (b' ' if ctype == JSON_T else b'\x00') * pad | |
| body += struct.pack('<II', len(payload), ctype) + payload | |
| with open(path, 'wb') as f: | |
| f.write(struct.pack('<III', GLB_MAGIC, 2, 12 + len(body)) + body) | |
| print(f" unlit-converted {changed} material(s); left the rest untouched") | |
| print(f" forced COLOR_0 alpha opaque on {opaque} primitive(s) (fixes cutout masking)") | |
| if stats: | |
| print(f" optimized: -{stats['images']} image(s), -{stats['textures']} texture(s), " | |
| f"-{stats['bufferViews']} bufferView(s), binary -{stats['bytes']} bytes") | |
| # ---------------------------------------------------------------------------- | |
| # Collection export (everything in the collection, ignoring selection/visibility) | |
| # ---------------------------------------------------------------------------- | |
| def export_collection_glb(filepath): | |
| coll = bpy.data.collections.get(EXPORT_COLLECTION) | |
| if coll is None: | |
| raise RuntimeError(f"No collection named '{EXPORT_COLLECTION}'. Create it and " | |
| f"put the objects to export inside it (or change EXPORT_COLLECTION).") | |
| # MESH = geometry; EMPTY = marker nodes (e.g. player spawn), exported as bare | |
| # glTF nodes carrying name + transform. all_objects = incl. nested sub-collections. | |
| objs = [o for o in coll.all_objects if o.type in {'MESH', 'EMPTY'}] | |
| if not objs: | |
| raise RuntimeError(f"Collection '{EXPORT_COLLECTION}' has no mesh or empty objects.") | |
| # Save state so the user's scene is untouched afterwards. | |
| view_objs = bpy.context.view_layer.objects | |
| saved_active = view_objs.active | |
| saved_selected = [o for o in bpy.context.selected_objects] | |
| saved_hide = {o: (o.hide_get(), o.hide_viewport, o.hide_render) for o in objs} | |
| # Force the whole collection visible + selected so use_selection picks all of it, | |
| # regardless of how it was hidden or selected before. | |
| bpy.ops.object.select_all(action='DESELECT') | |
| selected_any = False | |
| for o in objs: | |
| o.hide_viewport = False | |
| o.hide_render = False | |
| try: | |
| o.hide_set(False) | |
| o.select_set(True) | |
| selected_any = True | |
| except RuntimeError: | |
| print(f" WARN: '{o.name}' is not in the active view layer; skipped. " | |
| f"(Make sure '{EXPORT_COLLECTION}' is linked to the scene, not excluded.)") | |
| if selected_any: | |
| view_objs.active = objs[0] | |
| print(f" exporting {len(objs)} object(s) from collection '{EXPORT_COLLECTION}'") | |
| try: | |
| bpy.ops.export_scene.gltf( | |
| filepath=filepath, export_format='GLB', | |
| use_selection=True, use_visible=False, use_renderable=False, | |
| export_vertex_color='ACTIVE', # active color attr -> COLOR_0 | |
| ) | |
| except TypeError: | |
| # Older exporters lack some args. | |
| bpy.ops.export_scene.gltf(filepath=filepath, export_format='GLB', | |
| use_selection=True) | |
| # Restore state. | |
| for o, (hg, hv, hr) in saved_hide.items(): | |
| try: | |
| o.hide_set(hg) | |
| except RuntimeError: | |
| pass | |
| o.hide_viewport = hv | |
| o.hide_render = hr | |
| bpy.ops.object.select_all(action='DESELECT') | |
| for o in saved_selected: | |
| try: | |
| o.select_set(True) | |
| except RuntimeError: | |
| pass | |
| view_objs.active = saved_active | |
| patch_glb_materials(filepath) | |
| print(f" done -> {filepath}") | |
| # ---------------------------------------------------------------------------- | |
| # Operator: standard Save-As file dialog | |
| # ---------------------------------------------------------------------------- | |
| class EXPORT_OT_baked_unlit_glb(bpy.types.Operator, ExportHelper): | |
| bl_idname = "export_scene.baked_unlit_glb" | |
| bl_label = "Export Baked GLB" | |
| bl_options = {'REGISTER'} | |
| filename_ext = ".glb" | |
| filter_glob: StringProperty(default="*.glb", options={'HIDDEN'}) | |
| def execute(self, context): | |
| try: | |
| export_collection_glb(self.filepath) | |
| except RuntimeError as e: | |
| self.report({'ERROR'}, str(e)) | |
| return {'CANCELLED'} | |
| self.report({'INFO'}, f"Exported '{EXPORT_COLLECTION}' -> {self.filepath}") | |
| return {'FINISHED'} | |
| def _register_and_invoke(): | |
| try: | |
| bpy.utils.unregister_class(EXPORT_OT_baked_unlit_glb) | |
| except Exception: | |
| pass | |
| bpy.utils.register_class(EXPORT_OT_baked_unlit_glb) | |
| # Pop the standard file browser; export runs when the user confirms. | |
| bpy.ops.export_scene.baked_unlit_glb('INVOKE_DEFAULT') | |
| if __name__ == "__main__": | |
| _register_and_invoke() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment