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>
This commit is contained in:
161
tools/web/generate-pbr-tutorial-studio.py
Normal file
161
tools/web/generate-pbr-tutorial-studio.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Generate the PBR tutorial studio scene with desktop Blender.
|
||||
|
||||
Usage:
|
||||
build_blender_native/bin/blender --background --factory-startup \
|
||||
--python tools/web/generate-pbr-tutorial-studio.py -- \
|
||||
web/app/public/tutorial/pbr/studio.blend \
|
||||
tests/golden/PBR_TUTORIAL-001/studio-build.json
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
|
||||
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 add_area_light(name, location, energy, size):
|
||||
# Point lights match the bounded three.js PBR renderer without needing
|
||||
# RectAreaLight uniform support; the original size becomes a soft falloff.
|
||||
bpy.ops.object.light_add(type="POINT", location=location)
|
||||
light = bpy.context.object
|
||||
light.name = name
|
||||
light.data.energy = energy
|
||||
light.data.shadow_soft_size = size * 0.5
|
||||
light.rotation_euler = (-Vector(location)).to_track_quat("-Z", "Y").to_euler()
|
||||
return light
|
||||
|
||||
|
||||
def main(output_blend, report_path):
|
||||
output = pathlib.Path(output_blend).resolve()
|
||||
report = pathlib.Path(report_path).resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
report.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
scene = bpy.context.scene
|
||||
scene.name = "PBR Studio"
|
||||
|
||||
bpy.ops.object.camera_add(location=(7.2, -7.2, 5.4))
|
||||
camera = bpy.context.object
|
||||
camera.name = "PBR Studio Camera"
|
||||
camera.data.name = "PBR Studio Camera"
|
||||
camera.data.type = "PERSP"
|
||||
camera.data.lens = 55
|
||||
camera.data.sensor_width = 36
|
||||
camera.data.sensor_fit = "HORIZONTAL"
|
||||
camera.data.clip_start = 0.01
|
||||
camera.data.clip_end = 1000
|
||||
camera.rotation_euler = (-camera.location).to_track_quat("-Z", "Y").to_euler()
|
||||
scene.camera = camera
|
||||
|
||||
key = add_area_light("PBR Studio Key", (3.2, -3.2, 4.0), 900, 4.0)
|
||||
fill = add_area_light("PBR Studio Fill", (-4.0, -2.0, 2.0), 500, 5.0)
|
||||
rim = add_area_light("PBR Studio Rim", (0.0, 4.0, 3.0), 700, 3.0)
|
||||
bpy.ops.object.light_add(type="SUN", location=(4.0, -4.0, 6.0))
|
||||
sun = bpy.context.object
|
||||
sun.name = "PBR Studio Sun"
|
||||
sun.data.energy = 30.0
|
||||
sun.data.angle = 0.15
|
||||
sun.rotation_euler = (-sun.location).to_track_quat("-Z", "Y").to_euler()
|
||||
|
||||
world = bpy.data.worlds.new("PBR Studio 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 = 1.4
|
||||
|
||||
# Packed equirectangular studio environment: point lights alone leave
|
||||
# metallic materials almost black in the bounded MeshPhysicalMaterial path.
|
||||
width, height = 512, 256
|
||||
pixels = [0.0] * (width * height * 4)
|
||||
for y in range(height):
|
||||
v = y / (height - 1)
|
||||
base = 0.35 + 0.65 * (v ** 1.2)
|
||||
for x in range(width):
|
||||
u = x / (width - 1)
|
||||
glow = 0.0
|
||||
for cx, cy, radius, strength in (
|
||||
(0.20, 0.78, 0.12, 5.0),
|
||||
(0.52, 0.85, 0.10, 4.0),
|
||||
(0.82, 0.70, 0.13, 3.0),
|
||||
):
|
||||
dx = (u - cx) * 2.0
|
||||
dy = (v - cy) * 1.4
|
||||
glow += strength * max(0.0, 1.0 - (dx * dx + dy * dy) / (radius * radius))
|
||||
value = min(8.0, base + glow)
|
||||
index = (y * width + x) * 4
|
||||
pixels[index] = value
|
||||
pixels[index + 1] = value * 1.02
|
||||
pixels[index + 2] = value * 1.06
|
||||
pixels[index + 3] = 1.0
|
||||
environment = bpy.data.images.new("PBR Studio Environment", width=width, height=height, alpha=False)
|
||||
environment.pixels = pixels
|
||||
environment.filepath_raw = str(output.parent / "_studio-environment.png")
|
||||
environment.file_format = "PNG"
|
||||
environment.save()
|
||||
environment.pack()
|
||||
pathlib.Path(environment.filepath_raw).unlink(missing_ok=True)
|
||||
environment_node = world.node_tree.nodes.new("ShaderNodeTexEnvironment")
|
||||
environment_node.name = "PBR Studio Environment"
|
||||
environment_node.image = environment
|
||||
world.node_tree.links.new(environment_node.outputs["Color"], background.inputs["Color"])
|
||||
scene.world = world
|
||||
|
||||
bpy.ops.mesh.primitive_plane_add(size=20.0, location=(0.0, 0.0, -1.0))
|
||||
ground = bpy.context.object
|
||||
ground.name = "PBR Studio Ground"
|
||||
ground.data.name = "PBR Studio Ground Mesh"
|
||||
material = bpy.data.materials.new("PBR Studio Ground Material")
|
||||
material.use_nodes = True
|
||||
principled = material.node_tree.nodes.get("Principled BSDF")
|
||||
principled.inputs["Base Color"].default_value = (0.32, 0.32, 0.33, 1.0)
|
||||
principled.inputs["Roughness"].default_value = 0.65
|
||||
principled.inputs["Metallic"].default_value = 0.0
|
||||
ground.data.materials.append(material)
|
||||
|
||||
scene.render.engine = "BLENDER_EEVEE"
|
||||
scene.render.resolution_x = 1024
|
||||
scene.render.resolution_y = 1024
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = "PNG"
|
||||
scene.view_settings.look = "None"
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(output), compress=True)
|
||||
|
||||
lights = [light.name for light in (key, fill, rim, sun)]
|
||||
payload = {
|
||||
"schemaVersion": 1,
|
||||
"generator": "tools/web/generate-pbr-tutorial-studio.py",
|
||||
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
|
||||
"blenderExecutable": bpy.app.binary_path,
|
||||
"blend": {
|
||||
"path": str(output),
|
||||
"sha256": sha256_file(output),
|
||||
"byteLength": output.stat().st_size,
|
||||
},
|
||||
"objects": sorted(object.name for object in scene.objects),
|
||||
"camera": camera.name,
|
||||
"lights": lights,
|
||||
"world": world.name,
|
||||
"ground": ground.name,
|
||||
}
|
||||
report.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n")
|
||||
print(json.dumps({"studio": str(output), "sha256": payload["blend"]["sha256"], "objects": payload["objects"]}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(arguments) != 2:
|
||||
raise SystemExit("usage: blender --background --factory-startup --python generate-pbr-tutorial-studio.py -- BLEND REPORT")
|
||||
main(arguments[0], arguments[1])
|
||||
Reference in New Issue
Block a user