"""Generate the Web Blender PBR asset library with desktop Blender. Usage: build_blender_native/bin/blender --background --factory-startup \ --python tools/web/generate-pbr-library.py -- OUTPUT_DIRECTORY Outputs (inside OUTPUT_DIRECTORY): pbr-library.blend marked Object/Material assets + packed textures blender_assets.cats.txt catalog definition previews/.png 256x256 EEVEE previews (material spheres + models) preview-manifest.json preview files keyed by Blender identity build-report.json generator/executable/output hashes LICENSE.txt The .blend is saved BEFORE preview-only objects are created, so the library contains only the marked assets. """ import hashlib import json import math import pathlib import sys import bpy from mathutils import Vector CATALOGS = ( ("a1111111-1111-4111-8111-111111111111", "Models/Characters", "Characters"), ("a2222222-2222-4222-8222-222222222222", "Models/Primitives", "Primitives"), ("a3333333-3333-4333-8333-333333333333", "Materials/Metal", "Metal"), ("a4444444-4444-4444-8444-444444444444", "Materials/Plastic", "Plastic"), ("a5555555-5555-4555-8555-555555555555", "Materials/Wood", "Wood"), ("a6666666-6666-4666-8666-666666666666", "Materials/Glass", "Glass"), ("a7777777-7777-4777-8777-777777777777", "Materials/Emission", "Emission"), ) CATALOG_BY_PATH = {path: catalog_id for catalog_id, path, _name in CATALOGS} PREVIEW_SIZE = 256 PREVIEW_SAMPLES = 16 ID_OBJECT = 16975 # GS("OB") ID_MATERIAL = 16717 # GS("MA") ID_IMAGE = 19785 # GS("IM") def sha256_file(path: pathlib.Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def set_input(node, names, value): for name in names: socket = node.inputs.get(name) if socket is not None: socket.default_value = value return socket return None def new_material(name: str) -> tuple[bpy.types.Material, bpy.types.ShaderNodeBsdfPrincipled]: material = bpy.data.materials.new(name) material.use_nodes = True principled = material.node_tree.nodes.get("Principled BSDF") if principled is None: raise RuntimeError(f"Principled BSDF missing for {name}") return material, principled def add_wood_texture(material, principled): """Pack a deterministic 256px wood grain and wire UV Map -> Mapping -> Image.""" size = 256 pixels = [0.0] * (size * size * 4) for y in range(size): for x in range(size): warp = 0.18 * math.sin(x * 0.035) + 0.10 * math.sin(y * 0.011) bands = 0.5 + 0.5 * math.sin((x * 0.14) + warp * 6.0) grain = 0.5 + 0.5 * math.sin(y * 0.45 + bands * 3.0) value = 0.16 + 0.30 * bands + 0.10 * grain index = (y * size + x) * 4 pixels[index] = value * 1.00 pixels[index + 1] = value * 0.52 pixels[index + 2] = value * 0.22 pixels[index + 3] = 1.0 image = bpy.data.images.new("PBR Wood Grain", width=size, height=size, alpha=False) image.pixels = pixels image.pack() image.name = "PBR Wood Grain" nodes = material.node_tree.nodes links = material.node_tree.links uv = nodes.new("ShaderNodeUVMap") uv.name = "PBR Wood UV" uv.uv_map = "UVMap" mapping = nodes.new("ShaderNodeMapping") mapping.name = "PBR Wood Mapping" mapping.inputs["Scale"].default_value = (2.0, 4.0, 1.0) texture = nodes.new("ShaderNodeTexImage") texture.name = "PBR Wood Image" texture.image = image texture.interpolation = "Linear" links.new(uv.outputs["UV"], mapping.inputs["Vector"]) links.new(mapping.outputs["Vector"], texture.inputs["Vector"]) links.new(texture.outputs["Color"], principled.inputs["Base Color"]) set_input(principled, ["Roughness"], 0.52) set_input(principled, ["Metallic"], 0.0) return image def build_materials(): materials = [] metal_brushed, p = new_material("PBR Metal Brushed") set_input(p, ["Base Color"], (0.55, 0.57, 0.60, 1.0)) set_input(p, ["Metallic"], 1.0) set_input(p, ["Roughness"], 0.30) materials.append((metal_brushed, CATALOG_BY_PATH["Materials/Metal"], "Brushed sheet metal", ("metal", "brushed"))) metal_polished, p = new_material("PBR Metal Polished") set_input(p, ["Base Color"], (0.78, 0.80, 0.83, 1.0)) set_input(p, ["Metallic"], 1.0) set_input(p, ["Roughness"], 0.06) materials.append((metal_polished, CATALOG_BY_PATH["Materials/Metal"], "Mirror-polished metal", ("metal", "polished"))) plastic_red, p = new_material("PBR Plastic Red") set_input(p, ["Base Color"], (0.62, 0.02, 0.02, 1.0)) set_input(p, ["Metallic"], 0.0) set_input(p, ["Roughness"], 0.34) set_input(p, ["Coat Weight"], 0.35) set_input(p, ["Coat Roughness"], 0.08) materials.append((plastic_red, CATALOG_BY_PATH["Materials/Plastic"], "Glossy red plastic", ("plastic", "red"))) plastic_white, p = new_material("PBR Plastic White") set_input(p, ["Base Color"], (0.82, 0.82, 0.80, 1.0)) set_input(p, ["Metallic"], 0.0) set_input(p, ["Roughness"], 0.45) materials.append((plastic_white, CATALOG_BY_PATH["Materials/Plastic"], "Matte white plastic", ("plastic", "white"))) wood, p = new_material("PBR Wood") add_wood_texture(wood, p) materials.append((wood, CATALOG_BY_PATH["Materials/Wood"], "Varnished wood with packed grain", ("wood", "texture"))) glass, p = new_material("PBR Glass") set_input(p, ["Base Color"], (0.92, 0.96, 1.0, 1.0)) set_input(p, ["Metallic"], 0.0) set_input(p, ["Roughness"], 0.03) set_input(p, ["IOR"], 1.45) set_input(p, ["Transmission Weight", "Transmission"], 1.0) materials.append((glass, CATALOG_BY_PATH["Materials/Glass"], "Clear glass with transmission", ("glass", "transmission"))) emission, p = new_material("PBR Emission Cyan") set_input(p, ["Base Color"], (0.0, 0.0, 0.0, 1.0)) set_input(p, ["Roughness"], 0.4) set_input(p, ["Emission Color", "Emission"], (0.0, 0.85, 1.0, 1.0)) set_input(p, ["Emission Strength"], 5.0) materials.append((emission, CATALOG_BY_PATH["Materials/Emission"], "Cyan emissive surface", ("emission", "cyan"))) rubber, p = new_material("PBR Rubber Black") set_input(p, ["Base Color"], (0.015, 0.015, 0.017, 1.0)) set_input(p, ["Metallic"], 0.0) set_input(p, ["Roughness"], 0.86) set_input(p, ["Specular IOR Level"], 0.25) materials.append((rubber, CATALOG_BY_PATH["Materials/Plastic"], "Matte black rubber", ("rubber", "black"))) return materials def set_metadata(asset, *, catalog_id, description, tags, id_code, extra_properties=None): metadata = asset.asset_data metadata.catalog_id = catalog_id metadata.author = "Web Blender PBR Library" metadata.description = description metadata.copyright = "CC0-1.0 generated by Web Blender" metadata.license = "CC0-1.0" for tag in tags: metadata.tags.new(tag, skip_if_exists=False) metadata.active_tag = 0 metadata.use_preferred_import_method = True metadata.preferred_import_method = "APPEND" metadata["web.idCode"] = id_code metadata["web.datablockName"] = asset.name for name, value in (extra_properties or {}).items(): metadata[name] = value def ensure_uv(obj): if obj.type != "MESH": return if not obj.data.uv_layers: bpy.ops.object.select_all(action="DESELECT") obj.select_set(True) 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=math.radians(66), island_margin=0.02) bpy.ops.object.mode_set(mode="OBJECT") def build_model(kind, name, material, catalog_path): bpy.ops.object.select_all(action="DESELECT") if kind == "monkey": bpy.ops.mesh.primitive_monkey_add(size=2.0, location=(0.0, 0.0, 0.0)) bpy.ops.object.shade_smooth() elif kind == "cube": bpy.ops.mesh.primitive_cube_add(size=2.0, location=(0.0, 0.0, 0.0)) elif kind == "sphere": bpy.ops.mesh.primitive_uv_sphere_add(segments=64, ring_count=32, radius=1.0, location=(0.0, 0.0, 0.0)) bpy.ops.object.shade_smooth() elif kind == "torus": bpy.ops.mesh.primitive_torus_add(major_radius=1.0, minor_radius=0.34, major_segments=64, minor_segments=24, location=(0.0, 0.0, 0.0)) bpy.ops.object.shade_smooth() elif kind == "cylinder": bpy.ops.mesh.primitive_cylinder_add(vertices=64, radius=0.75, depth=2.0, location=(0.0, 0.0, 0.0)) bpy.ops.object.shade_smooth() elif kind == "plane": bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0.0, 0.0, 0.0)) else: raise RuntimeError(f"unknown model kind {kind}") obj = bpy.context.object obj.name = name obj.data.name = f"{name} Mesh" bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) ensure_uv(obj) obj.data.materials.clear() obj.data.materials.append(material) obj.asset_mark() set_metadata( obj, catalog_id=CATALOG_BY_PATH[catalog_path], description=f"{name} PBR model", tags=("model", kind), id_code=ID_OBJECT, extra_properties={"web.meshName": obj.data.name, "web.materialName": material.name}, ) return obj def write_catalog_definition(path: pathlib.Path): lines = [ "# This is an Asset Catalog Definition file for Blender.", "# Generated by tools/web/generate-pbr-library.py", "", "VERSION 1", "", ] lines.extend(f"{catalog_id}:{catalog_path}:{simple_name}" for catalog_id, catalog_path, simple_name in CATALOGS) path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") def configure_preview_scene(scene): scene.render.engine = "BLENDER_EEVEE" scene.render.resolution_x = PREVIEW_SIZE scene.render.resolution_y = PREVIEW_SIZE scene.render.resolution_percentage = 100 scene.render.image_settings.file_format = "PNG" scene.render.image_settings.color_mode = "RGBA" scene.render.image_settings.color_depth = "8" scene.render.film_transparent = False scene.render.dither_intensity = 0 scene.eevee.taa_render_samples = PREVIEW_SAMPLES scene.view_settings.look = "None" scene.view_settings.exposure = 0 scene.view_settings.gamma = 1 world = bpy.data.worlds.new("PBR Preview World") world.use_nodes = True background = world.node_tree.nodes.get("Background") background.inputs["Color"].default_value = (0.045, 0.05, 0.06, 1.0) background.inputs["Strength"].default_value = 0.8 scene.world = world bpy.ops.object.light_add(type="AREA", location=(3.2, -3.2, 4.0)) key = bpy.context.object key.name = "PBR Preview Key" key.data.energy = 600 key.data.size = 4.0 key.rotation_euler = (-Vector((3.2, -3.2, 4.0))).to_track_quat("-Z", "Y").to_euler() bpy.ops.object.light_add(type="AREA", location=(-4.0, -2.0, 2.0)) fill = bpy.context.object fill.name = "PBR Preview Fill" fill.data.energy = 260 fill.data.size = 5.0 fill.rotation_euler = (-Vector((-4.0, -2.0, 2.0))).to_track_quat("-Z", "Y").to_euler() bpy.ops.object.light_add(type="AREA", location=(0.0, 4.0, 3.0)) rim = bpy.context.object rim.name = "PBR Preview Rim" rim.data.energy = 380 rim.data.size = 3.0 rim.rotation_euler = (-Vector((0.0, 4.0, 3.0))).to_track_quat("-Z", "Y").to_euler() bpy.ops.object.camera_add() camera = bpy.context.object camera.name = "PBR Preview Camera" camera.data.type = "PERSP" camera.data.lens = 50 camera.data.sensor_width = 36 camera.data.clip_start = 0.01 camera.data.clip_end = 1000 scene.camera = camera return camera def frame_object(scene, camera, obj, margin=1.35): bpy.context.view_layer.update() corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] center = sum(corners, Vector((0.0, 0.0, 0.0))) / len(corners) radius = max((corner - center).length for corner in corners) radius = max(radius, 0.001) half_angle = camera.data.angle / 2.0 distance = radius / math.tan(half_angle) * margin direction = Vector((1.0, -1.0, 0.72)).normalized() camera.location = center + direction * distance camera.rotation_euler = (center - camera.location).to_track_quat("-Z", "Y").to_euler() scene.view_layers[0].update() def render_preview(scene, output_path: pathlib.Path): output_path.parent.mkdir(parents=True, exist_ok=True) scene.render.filepath = str(output_path) bpy.ops.render.render(write_still=True) return output_path def store_preview(path: pathlib.Path, preview_dir: pathlib.Path): data = path.read_bytes() digest = sha256_bytes(data) final = preview_dir / f"{digest}.png" if path != final: path.replace(final) return { "file": f"previews/{final.name}", "sha256": digest, "byteLength": len(data), "width": PREVIEW_SIZE, "height": PREVIEW_SIZE, } def main(output_directory): output = pathlib.Path(output_directory).resolve() preview_dir = output / "previews" preview_dir.mkdir(parents=True, exist_ok=True) for stale in preview_dir.glob("*.png"): stale.unlink() bpy.ops.wm.read_factory_settings(use_empty=True) scene = bpy.context.scene materials = build_materials() for material, catalog_id, description, tags in materials: material.asset_mark() set_metadata( material, catalog_id=catalog_id, description=description, tags=tags, id_code=ID_MATERIAL, extra_properties={"web.materialName": material.name}, ) material_by_name = {material.name: material for material, *_rest in materials} models = [ build_model("monkey", "PBR Suzanne", material_by_name["PBR Metal Brushed"], "Models/Characters"), build_model("cube", "PBR Cube", material_by_name["PBR Plastic Red"], "Models/Primitives"), build_model("sphere", "PBR UV Sphere", material_by_name["PBR Wood"], "Models/Primitives"), build_model("torus", "PBR Torus", material_by_name["PBR Metal Polished"], "Models/Primitives"), build_model("cylinder", "PBR Cylinder", material_by_name["PBR Glass"], "Models/Primitives"), build_model("plane", "PBR Plane", material_by_name["PBR Rubber Black"], "Models/Primitives"), ] catalog_path = output / "blender_assets.cats.txt" library_path = output / "pbr-library.blend" write_catalog_definition(catalog_path) bpy.ops.wm.save_as_mainfile(filepath=str(library_path), compress=True) camera = configure_preview_scene(scene) collection = scene.collection preview_entries = [] for model in models: model.hide_render = True for material, catalog_id, description, tags in materials: bpy.ops.mesh.primitive_uv_sphere_add(segments=96, ring_count=64, radius=1.0, location=(0.0, 0.0, 0.0)) sphere = bpy.context.object sphere.name = f"Preview {material.name}" bpy.ops.object.shade_smooth() sphere.data.materials.append(material) frame_object(scene, camera, sphere) tmp = preview_dir / "tmp.png" render_preview(scene, tmp) entry = store_preview(tmp, preview_dir) bpy.data.objects.remove(sphere, do_unlink=True) entry.update({"idType": "MATERIAL", "name": material.name}) preview_entries.append(entry) for model in models: model.hide_render = False frame_object(scene, camera, model) tmp = preview_dir / "tmp.png" render_preview(scene, tmp) entry = store_preview(tmp, preview_dir) model.hide_render = True entry.update({"idType": "OBJECT", "name": model.name}) preview_entries.append(entry) preview_path = output / "preview-manifest.json" preview_path.write_text(json.dumps({"schemaVersion": 1, "previews": preview_entries}, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n") script_path = pathlib.Path(__file__).resolve() report = { "schemaVersion": 1, "generator": "tools/web/generate-pbr-library.py", "scriptSha256": sha256_file(script_path), "blenderVersion": ".".join(str(value) for value in bpy.app.version), "blenderExecutable": bpy.app.binary_path, "library": { "path": "libraries/pbr-library.blend", "sha256": sha256_file(library_path), "byteLength": library_path.stat().st_size, }, "catalogDefinition": { "path": "blender_assets.cats.txt", "sha256": sha256_file(catalog_path), "byteLength": catalog_path.stat().st_size, }, "previewManifest": { "path": "preview-manifest.json", "sha256": sha256_file(preview_path), "byteLength": preview_path.stat().st_size, }, "counts": {"models": len(models), "materials": len(materials), "previews": len(preview_entries)}, "colorManagement": {"viewTransform": scene.view_settings.view_transform, "look": scene.view_settings.look}, "render": {"engine": scene.render.engine, "resolution": PREVIEW_SIZE, "samples": PREVIEW_SAMPLES}, } (output / "build-report.json").write_text(json.dumps(report, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n") (output / "LICENSE.txt").write_text( "PBR asset library generated by tools/web/generate-pbr-library.py.\n" "Generated assets are released under CC0-1.0.\n" "Blender itself is GPL-2.0-or-later; this library only contains generated data.\n", encoding="utf-8", newline="\n", ) print(json.dumps({"library": report["library"]["sha256"], "models": len(models), "materials": len(materials), "previews": len(preview_entries)})) if __name__ == "__main__": arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] if len(arguments) != 1: raise SystemExit("usage: blender --background --factory-startup --python generate-pbr-library.py -- OUTPUT_DIRECTORY") main(arguments[0])