- 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>
162 lines
6.3 KiB
Python
162 lines
6.3 KiB
Python
"""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])
|