Skip to content

Instantly share code, notes, and snippets.

@fangzhangmnm
Last active July 2, 2026 06:05
Show Gist options
  • Select an option

  • Save fangzhangmnm/64631e90b0a2759886b66834c700f080 to your computer and use it in GitHub Desktop.

Select an option

Save fangzhangmnm/64631e90b0a2759886b66834c700f080 to your computer and use it in GitHub Desktop.
blender baking workflow 20260624
"""
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()
"""
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()
"""
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