Files
workinf_Blender_Wasm/tools/web/generate-re-pbr-library.py
mes123456 d5dd647881
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
- Add 11:07 Chinese-narrated lesson video, VTT, poster and 13 chapter screenshots.
- Regenerate 20-render WebGL2 matrix and 15 operation captures on RTX 4060.
- Fix WASM staging for non-ASCII import filenames (猴头.glb); rebuild web_engine.
- Harden lesson video recording pipeline; fix stale lesson-link E2E selector.
- Add RE PBR derived libraries, lesson page, evidence and status report.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-12 21:49:24 +08:00

330 lines
12 KiB
Python

"""Derive the Web Blender RE 5000PBR asset library from the desktop catalog.
Usage:
build_blender_native/bin/blender --background --factory-startup \
--python tools/web/generate-re-pbr-library.py -- \
"/path/to/5000+ PBR catalog.blend" OUTPUT_DIRECTORY \
[--base-color-size 1024] [--normal-size 1024] [--scalar-size 512] [--preview-size 256]
The source .blend is never modified. The derived library keeps the original
shader graphs (FRAME/REROUTE/DISPLACEMENT/MAPPING/VALUE included) so the Web
compiler/reader must support them; this script only marks assets, downsamples
packed textures, repacks them and renders material previews.
Outputs (inside OUTPUT_DIRECTORY):
re-pbr-library.blend six marked MATERIAL assets, no scene objects
blender_assets.cats.txt Materials/RE5000PBR catalog
previews/<sha256>.png 256x256 EEVEE material previews
preview-manifest.json
build-report.json
"""
import hashlib
import importlib.util
import json
import pathlib
import sys
import bpy
CATALOG_ID = "a8000000-0000-4000-8000-000000000001"
CATALOG_PATH = "Materials/RE5000PBR"
CATALOG_NAME = "RE5000PBR"
LIBRARY_NAME = "re-pbr-library.blend"
EXPECTED_MATERIALS = 6
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 load_common():
path = pathlib.Path(__file__).with_name("generate-pbr-library.py")
spec = importlib.util.spec_from_file_location("pbr_library_common", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def parse_arguments():
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(arguments) < 2:
raise SystemExit(
"usage: blender -b --python generate-re-pbr-library.py -- "
"SOURCE_BLEND OUTPUT_DIR [--base-color-size N] [--normal-size N] "
"[--scalar-size N] [--preview-size N]"
)
source = pathlib.Path(arguments[0]).resolve()
output = pathlib.Path(arguments[1]).resolve()
options = {
"base_color_size": 1024,
"normal_size": 1024,
"scalar_size": 512,
"preview_size": 256,
"preserve_original": False,
"material_suffix": "",
"library_id": "library:re-pbr-v1",
}
index = 2
while index < len(arguments):
flag = arguments[index]
if flag == "--preserve-original":
options["preserve_original"] = True
index += 1
continue
if flag not in {"--base-color-size", "--normal-size", "--scalar-size", "--preview-size", "--texture-size", "--material-suffix", "--library-id"}:
raise SystemExit(f"unknown option {flag}")
if index + 1 >= len(arguments):
raise SystemExit(f"missing value for {flag}")
key = flag.removeprefix("--").replace("-", "_")
value = arguments[index + 1]
if key == "texture_size":
size = int(value)
options["base_color_size"] = size
options["normal_size"] = size
options["scalar_size"] = size
elif key in {"base_color_size", "normal_size", "scalar_size", "preview_size"}:
options[key] = int(value)
else:
options[key] = value
index += 2
if not source.is_file():
raise SystemExit(f"source blend not found: {source}")
return source, output, options
def target_size(image_name: str, options) -> int:
lowered = image_name.lower()
if "normal" in lowered:
return options["normal_size"]
if "base_color" in lowered or "basecolor" in lowered or "albedo" in lowered:
return options["base_color_size"]
return options["scalar_size"]
def resample_image(image, size: int, temporary: pathlib.Path, preserve_original: bool = False):
if image.size[0] == 0 or image.size[1] == 0:
return
if preserve_original and image.size[0] == size and image.size[1] == size:
return
if image.size[0] != size or image.size[1] != size:
image.scale(size, size)
image.file_format = "JPEG"
temporary.parent.mkdir(parents=True, exist_ok=True)
try:
image.save(filepath=str(temporary), quality=84)
except TypeError:
image.filepath_raw = str(temporary)
image.save()
if not temporary.is_file() or temporary.stat().st_size == 0:
raise RuntimeError(f"failed to write resampled image {temporary}")
data = temporary.read_bytes()
# Image.pack() with no data re-packs the original packed file. Pack the
# resampled JPEG bytes explicitly so the library actually shrinks.
image.pack(data=data, data_len=len(data))
def material_uses_principled(material) -> bool:
if not material.use_nodes or material.node_tree is None:
return False
return any(node.type == "BSDF_PRINCIPLED" for node in material.node_tree.nodes)
def collect_materials():
materials = [
material for material in bpy.data.materials
if material.name != "Dots Stroke" and material_uses_principled(material)
]
materials.sort(key=lambda material: material.name)
if len(materials) != EXPECTED_MATERIALS:
raise RuntimeError(
f"expected {EXPECTED_MATERIALS} Principled materials, found {len(materials)}: "
+ ", ".join(material.name for material in materials)
)
return materials
def write_catalog_definition(path: pathlib.Path):
lines = [
"# This is an Asset Catalog Definition file for Blender.",
"# Derived from the desktop RE 5000PBR catalog by generate-re-pbr-library.py",
"",
"VERSION 1",
"",
f"{CATALOG_ID}:{CATALOG_PATH}:{CATALOG_NAME}",
]
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
def mark_materials(materials):
for material in materials:
if material.asset_data is None:
material.asset_mark()
material.asset_data.catalog_id = CATALOG_ID
material.asset_data.description = "RE 5000PBR material for the Web Blender render lesson"
material.asset_data.tags.new("PBR")
material.asset_data.tags.new("RE5000PBR")
def resample_material_images(materials, options, temporary_dir: pathlib.Path):
processed = set()
resampled = []
for material in materials:
if material.node_tree is None:
continue
for node in material.node_tree.nodes:
if node.type != "TEX_IMAGE" or node.image is None:
continue
image = node.image
if image.name in processed or not image.packed_file:
continue
processed.add(image.name)
size = target_size(image.name, options)
temporary = temporary_dir / f"{sha256_bytes(image.name.encode('utf-8'))[:16]}.jpg"
before = image.size[:]
colorspace = image.colorspace_settings.name
resample_image(image, size, temporary, options.get("preserve_original", False))
image.colorspace_settings.name = colorspace
resampled.append({
"name": image.name,
"from": list(before),
"to": list(image.size[:]),
"colorspace": colorspace,
"packedBytes": image.packed_file.size if image.packed_file else 0,
})
return resampled
def remove_scene_objects():
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
def remove_unused_data(materials):
keep_material = {material.name for material in materials}
for material in list(bpy.data.materials):
if material.name not in keep_material:
bpy.data.materials.remove(material)
used_images = set()
for material in materials:
if material.node_tree is None:
continue
for node in material.node_tree.nodes:
if node.type == "TEX_IMAGE" and node.image is not None:
used_images.add(node.image.name)
for image in list(bpy.data.images):
if image.name not in used_images and not image.name.startswith("Render Result"):
bpy.data.images.remove(image)
def render_previews(common, materials, preview_dir: pathlib.Path, preview_size: int):
common.PREVIEW_SIZE = preview_size
scene = bpy.context.scene
camera = common.configure_preview_scene(scene)
scene.render.resolution_x = preview_size
scene.render.resolution_y = preview_size
entries = []
for material 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)
common.frame_object(scene, camera, sphere)
temporary = preview_dir / "tmp.png"
common.render_preview(scene, temporary)
entry = common.store_preview(temporary, preview_dir)
bpy.data.objects.remove(sphere, do_unlink=True)
entry.update({"idType": "MATERIAL", "name": material.name})
entries.append(entry)
return entries
def main():
source, output, options = parse_arguments()
output.mkdir(parents=True, exist_ok=True)
preview_dir = output / "previews"
preview_dir.mkdir(parents=True, exist_ok=True)
for stale in preview_dir.glob("*.png"):
stale.unlink()
common = load_common()
bpy.ops.wm.open_mainfile(filepath=str(source))
materials = collect_materials()
suffix = str(options.get("material_suffix", ""))
if suffix:
for material in materials:
material.name = f"{material.name}{suffix}"
mark_materials(materials)
temporary_dir = output / ".resample"
resampled = resample_material_images(materials, options, temporary_dir)
remove_scene_objects()
remove_unused_data(materials)
catalog_path = output / "blender_assets.cats.txt"
write_catalog_definition(catalog_path)
library_path = output / LIBRARY_NAME
bpy.ops.wm.save_as_mainfile(filepath=str(library_path), compress=True)
preview_entries = render_previews(common, materials, preview_dir, options["preview_size"])
preview_manifest = output / "preview-manifest.json"
preview_manifest.write_text(
json.dumps({"schemaVersion": 1, "previews": preview_entries}, indent=2, ensure_ascii=True) + "\n",
encoding="utf-8",
newline="\n",
)
for item in temporary_dir.glob("*"):
if item.is_file():
item.unlink()
if temporary_dir.is_dir():
temporary_dir.rmdir()
source_sha = sha256_file(source)
library_sha = sha256_file(library_path)
preview_bytes = sum(entry["byteLength"] for entry in preview_entries)
report = {
"schemaVersion": 1,
"generator": "tools/web/generate-re-pbr-library.py",
"scriptSha256": sha256_file(pathlib.Path(__file__).resolve()),
"source": {"path": str(source), "sha256": source_sha, "byteLength": source.stat().st_size},
"library": {
"path": str(library_path),
"sha256": library_sha,
"byteLength": library_path.stat().st_size,
"libraryId": "library:re-pbr-v1",
"name": LIBRARY_NAME,
},
"catalog": {"id": CATALOG_ID, "path": CATALOG_PATH, "name": CATALOG_NAME},
"materials": [material.name for material in materials],
"textures": resampled,
"textureOptions": options,
"previewCount": len(preview_entries),
"previewBytes": preview_bytes,
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
}
(output / "build-report.json").write_text(
json.dumps(report, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n"
)
print(json.dumps({
"materials": len(materials),
"previews": len(preview_entries),
"librarySha256": library_sha,
"libraryBytes": library_path.stat().st_size,
"textureBytes": sum(item["packedBytes"] for item in resampled),
}, indent=2))
if __name__ == "__main__":
main()