- generate a desktop-Blender PBR asset library and seed catalog v2 into OPFS - add React asset browser, material preview sphere and Web PBR render panel - extend shader graph/compiler to the finite 14-node set and native properties - add image colour-space editing, scene-camera still rendering and asset repair - add focused E2E/unit coverage and implementation status notes Co-Authored-By: Claude Code <noreply@anthropic.com>
463 lines
18 KiB
Python
463 lines
18 KiB
Python
"""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/<sha256>.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])
|