- 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>
330 lines
12 KiB
Python
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()
|