feat(web): complete PBR shader, asset library and offscreen render chain
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions

- 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>
This commit is contained in:
mes123456
2026-11-11 14:59:43 +08:00
parent d1e6b63d54
commit a078648297
65 changed files with 6083 additions and 157 deletions

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* Compose the catalog-v2 manifest for the generated PBR asset library.
*
* Inputs (produced by generate-pbr-library.py + export-asset-catalog-v1.py):
* <dir>/desktop-canonical.json
* <dir>/preview-manifest.json
* <dir>/pbr-library.blend
*
* Output:
* <dir>/manifest.json
*
* Usage: node tools/web/generate-pbr-library-manifest.mjs [--dir <path>]
*/
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const REPO = path.resolve(import.meta.dirname, "../..");
const dirIndex = process.argv.indexOf("--dir");
const DIR = dirIndex > 0 && process.argv[dirIndex + 1]
? path.resolve(process.argv[dirIndex + 1])
: path.join(REPO, "web/app/public/assets/pbr-library");
const LIBRARY_ID = "library:pbr-essentials-v1";
const LIBRARY_NAME = "PBR Essentials";
const LIBRARY_SOURCE = "libraries/pbr-library.blend";
const readJson = (name) => JSON.parse(fs.readFileSync(path.join(DIR, name), "utf8"));
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
async function loadProtocol() {
const sourcePath = path.join(REPO, "web/protocol/asset-catalog-v2.ts");
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const temporary = path.join(DIR, ".asset-catalog-v2.generated.mjs");
fs.writeFileSync(temporary, transpiled.outputText);
try {
return await import(`${pathToFileURL(temporary).href}?v=${Date.now()}`);
} finally {
fs.rmSync(temporary, { force: true });
}
}
const desktop = readJson("desktop-canonical.json");
const previewManifest = readJson("preview-manifest.json");
const libraryBytes = fs.readFileSync(path.join(DIR, "pbr-library.blend"));
const sourceSha256 = sha256(libraryBytes);
const previews = new Map();
for (const entry of previewManifest.previews) {
const key = `${entry.idType}:${entry.name}`;
if (previews.has(key)) throw new Error(`duplicate preview ${key}`);
if (!fs.existsSync(path.join(DIR, entry.file))) throw new Error(`missing preview file ${entry.file}`);
const bytes = fs.readFileSync(path.join(DIR, entry.file));
if (sha256(bytes) !== entry.sha256) throw new Error(`preview hash mismatch ${entry.file}`);
previews.set(key, {
sha256: entry.sha256,
mimeType: "image/png",
width: entry.width,
height: entry.height,
byteLength: entry.byteLength,
});
}
const protocol = await loadProtocol();
const base = await protocol.createAssetCatalogManifestV2FromDesktop(desktop, 1);
const assets = [];
for (const asset of base.assets) {
const key = `${asset.idType}:${asset.name}`;
const preview = previews.get(key);
if (!preview) throw new Error(`asset ${key} has no preview`);
const assetLibraryIdentifier = LIBRARY_ID;
assets.push({
...asset,
assetLibraryIdentifier,
assetId: await protocol.createAssetCatalogV2StableId({
assetLibraryIdentifier,
relativeAssetIdentifier: asset.relativeAssetIdentifier,
}),
sourcePath: LIBRARY_SOURCE,
sourceSha256,
preview,
});
}
const candidate = {
schemaVersion: 2,
revision: 1,
catalogs: base.catalogs,
assets,
libraries: [{
libraryId: LIBRARY_ID,
name: LIBRARY_NAME,
sourcePath: LIBRARY_SOURCE,
sourceSha256,
dependencyIds: [],
readOnly: true,
}],
};
const manifest = await protocol.parseAssetCatalogManifestV2(candidate);
fs.writeFileSync(path.join(DIR, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
const totalBytes = assets.reduce((sum, asset) => sum + (asset.preview?.byteLength ?? 0), 0);
console.log(JSON.stringify({
manifest: "manifest.json",
catalogs: manifest.catalogs.length,
assets: manifest.assets.length,
libraries: manifest.libraries.length,
previewBytes: totalBytes,
sourceSha256,
}, null, 2));

View File

@@ -0,0 +1,462 @@
"""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])

File diff suppressed because it is too large Load Diff