"""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])