From ebfc478976a1c274f5b1563f861e6bf69d16877d Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sun, 18 Oct 2026 09:03:39 +0200 Subject: [PATCH] [Docs] The glass reference scene: scripts to generate, render and cook the six-pane scene of #1308 The scene behind the comparison image of #1308, as scripts under scripts/tests/blender/scenes/glass_panes/: make_scene.py builds the .blend (six kinds of glass in front of a striped wall, a solid behind each, the stripes texture packed, the camera of the reference view), render_reference.py renders the Blender reference with Cycles under one of the engine's HDR environments and writes the camera in engine coordinates, and a README gives the three steps and what to expect. The script tests' README lists it and the glass section of the materials docs points to it. --- docs/API/UsingMaterials.md | 2 + scripts/tests/README.md | 11 ++ .../blender/scenes/glass_panes/README.md | 69 ++++++++++ .../blender/scenes/glass_panes/make_scene.py | 124 ++++++++++++++++++ .../scenes/glass_panes/render_reference.py | 86 ++++++++++++ 5 files changed, 292 insertions(+) create mode 100644 scripts/tests/blender/scenes/glass_panes/README.md create mode 100644 scripts/tests/blender/scenes/glass_panes/make_scene.py create mode 100644 scripts/tests/blender/scenes/glass_panes/render_reference.py diff --git a/docs/API/UsingMaterials.md b/docs/API/UsingMaterials.md index c281004b7..8184ddffd 100644 --- a/docs/API/UsingMaterials.md +++ b/docs/API/UsingMaterials.md @@ -214,6 +214,8 @@ How glass is drawn: > On a device whose GPU cannot blend with two sources, tinted glass darkens what is behind it by the brightness of its color and does not color it. This is also how the simulator draws it. +A reference scene for glass, six kinds of pane in front of a striped wall with the steps to generate it, render it in Blender and cook it, is in the repository under `scripts/tests/blender/scenes/glass_panes/`. + --- ## Textures diff --git a/scripts/tests/README.md b/scripts/tests/README.md index 620c16ad2..d81883c94 100644 --- a/scripts/tests/README.md +++ b/scripts/tests/README.md @@ -58,3 +58,14 @@ blender --background --factory-startup --python-exit-code 1 --python scripts/tes blender --background --factory-startup --python-exit-code 1 --python scripts/tests/blender/material_bake_checks.py blender --background --factory-startup --python-exit-code 1 --python scripts/tests/blender/material_slot_checks.py ``` + +## Reference scenes + +`blender/scenes/` holds generated scenes for comparing Blender's render with +the engine's drawing of the cooked file, each with a README that gives the +steps: generate the `.blend`, render the reference in Cycles under one of the +engine's HDR environments, cook it, and draw it from the same camera. + +- `blender/scenes/glass_panes/`: six kinds of glass in front of a striped wall, + the scene of the comparison image of #1308 and the setup for a golden image + of glass. diff --git a/scripts/tests/blender/scenes/glass_panes/README.md b/scripts/tests/blender/scenes/glass_panes/README.md new file mode 100644 index 000000000..2866a2020 --- /dev/null +++ b/scripts/tests/blender/scenes/glass_panes/README.md @@ -0,0 +1,69 @@ +# The glass reference scene + +Six panes of glass in front of a striped wall, each with a solid object behind +it, under one of the engine's HDR environments. It is the scene behind the +comparison image of #1308 (glass transmission), and the setup for a golden +image of glass: Blender's render of it and the engine's drawing of the cooked +file show the same view. + +The scene is generated, so a change to it is a change to `make_scene.py`. + +## What is in it + +- Six panes, 1.4 × 1.7 m and 2 cm thick, 1.7 m apart, each a Principled BSDF + with the values of a kind of glass seen in architectural and vehicle models + (base color, roughness, transmission, IOR): clear window glass + (0.911/0.966/0.96, 0.04, 1, 1.46), solar control glass (0.34/0.48/0.53, 0.08, + 0.92, 1.48), channel glass (0.911/0.966/0.96, 0.25, 1, 1.46), a car window + (0.22/0.30/0.31, 0.06, 0.96, 1.46), a red lens (0.8/0.05/0.02, 0.1, 1, 1.5) + and black tempered glass (0/0/0, 0, 1, 1.45). +- Behind each pane a sphere or a cube, white, orange or blue. +- A wall of 24 colored stripes 3 m behind the panes, from a texture the script + generates and packs into the file, so the exporter carries it, and a grey + floor. +- The camera of the reference view: at (−1.6, −8.2, 1.7) looking at + (0.1, 0.6, 0.95), vertical field of view 40°. + +## Generate it + +From the repo root: + +```sh +blender --background --factory-startup --python scripts/tests/blender/scenes/glass_panes/make_scene.py -- out/glass_panes.blend +``` + +## Render the Blender reference + +```sh +blender --background out/glass_panes.blend --python scripts/tests/blender/scenes/glass_panes/render_reference.py -- out +``` + +Cycles, 256 samples, denoised, the Standard view transform, 1920 × 1080 at +50 %. The world is the engine's `teatro_massimo_2k.hdr` (another file can be +given as a second argument) rotated 90° about Z, because the engine reads the +centre of a panorama along its forward axis and Blender along −X; it is the +only light. Writes `out/glass_blender.png` and `out/view.json`, the camera in +engine coordinates. Set `GLASS_SAMPLES=8` for a quick look. + +## Cook it and draw it in the engine + +```sh +untoldengine export --input out/glass_panes.blend --output out/glass_panes.untold --convert-orientation +``` + +(or `scripts/export-untold` with the same arguments from the repo root). The +file holds several objects, so the cook writes a `.untoldpack` beside the +`.untold` output. Load the pack, set the environment to the same HDR, and place +the camera from `view.json`: `eye`, `target` and `fov` are in the engine's +coordinates, with the conversion the exporter applied (`--convert-orientation`, +source orientation `blender-native`). + +## What to expect + +- The exporter writes the black tempered pane as a solid surface: next to + nothing would show through it. The export log says so. +- The channel glass pane differs from Blender on purpose: Blender blurs what + is behind rough glass, the engine blurs nothing and shows part of it sharp + while the rest of the pane glows with the light from behind. +- The other four panes match Blender in tint, reflections and what shows + through, within the differences of a path tracer and a rasterizer. diff --git a/scripts/tests/blender/scenes/glass_panes/make_scene.py b/scripts/tests/blender/scenes/glass_panes/make_scene.py new file mode 100644 index 000000000..5e7cd88e7 --- /dev/null +++ b/scripts/tests/blender/scenes/glass_panes/make_scene.py @@ -0,0 +1,124 @@ +# The glass reference scene: six panes of the kinds of glass found in architectural and vehicle +# models, in front of a striped wall, each with a solid object behind it, and the camera of the +# reference view. The stripes texture is generated and packed into the file. +# +# blender --background --factory-startup --python scripts/tests/blender/scenes/glass_panes/make_scene.py -- out/glass_panes.blend +# +# Then render_reference.py renders the Blender reference of the same view, and the exporter cooks +# the file for the engine. README.md in this folder has the steps. +import math +import os +import sys + +import bpy +import numpy as np +from mathutils import Vector + +out = os.path.abspath(sys.argv[sys.argv.index("--") + 1]) +folder = os.path.dirname(out) +os.makedirs(folder, exist_ok=True) +bpy.ops.wm.read_factory_settings(use_empty=True) +scene = bpy.context.scene + +# The view of the reference image (render_reference.py uses the same eye, target and field of view). +EYE = Vector((-1.6, -8.2, 1.7)) +TARGET = Vector((0.1, 0.6, 0.95)) +FOV_Y = 40.0 + + +def principled(name, base, metallic=0.0, roughness=0.5, transmission=0.0, ior=1.5): + m = bpy.data.materials.new(name) + m.use_nodes = True + p = next(n for n in m.node_tree.nodes if n.bl_idname == "ShaderNodeBsdfPrincipled") + p.inputs["Base Color"].default_value = (*base, 1.0) + p.inputs["Metallic"].default_value = metallic + p.inputs["Roughness"].default_value = roughness + p.inputs["Transmission Weight"].default_value = transmission + p.inputs["IOR"].default_value = ior + return m, p + + +def box(name, size, location, material): + bpy.ops.mesh.primitive_cube_add(size=1.0, location=location) + obj = bpy.context.active_object + obj.name = name + obj.scale = size + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + obj.data.materials.append(material) + return obj + + +# The wall: vertical stripes of color, from an image so that the exporter carries it. +width, height = 1024, 256 +stripes = np.zeros((height, width, 4), np.float32) +colors = [(0.85, 0.85, 0.85), (0.8, 0.1, 0.1), (0.9, 0.75, 0.1), (0.1, 0.55, 0.2), (0.1, 0.3, 0.8), (0.05, 0.05, 0.05)] +count = 24 +for i in range(count): + stripes[:, i * width // count:(i + 1) * width // count, :3] = colors[i % len(colors)] +stripes[..., 3] = 1.0 +image = bpy.data.images.new("stripes", width, height, alpha=False) +image.pixels.foreach_set(stripes.ravel()) +image.filepath_raw = os.path.join(folder, "stripes.png") +image.file_format = "PNG" +image.save() +image = bpy.data.images.load(os.path.join(folder, "stripes.png")) +wall_material, wall_bsdf = principled("Wall", (1, 1, 1), roughness=0.9) +texture = wall_material.node_tree.nodes.new("ShaderNodeTexImage") +texture.image = image +wall_material.node_tree.links.new(texture.outputs["Color"], wall_bsdf.inputs["Base Color"]) +bpy.ops.mesh.primitive_plane_add(size=1.0, location=(0.0, 3.0, 1.5), rotation=(1.5708, 0.0, 0.0)) +wall = bpy.context.active_object +wall.name = "Wall" +wall.scale = (14.0, 3.0, 1.0) +bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) +wall.data.materials.append(wall_material) + +floor_material, _ = principled("Floor", (0.35, 0.35, 0.35), roughness=0.8) +bpy.ops.mesh.primitive_plane_add(size=1.0, location=(0.0, 0.0, 0.0)) +floor = bpy.context.active_object +floor.name = "Floor" +floor.scale = (14.0, 12.0, 1.0) +bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) +floor.data.materials.append(floor_material) + +# The panes, by the kind of glass each stands for: base color, metallic, roughness, transmission, IOR. +panes = [ + ("Clear window", (0.911, 0.966, 0.96), 0.0, 0.04, 1.0, 1.46), + ("Solar control", (0.34, 0.48, 0.53), 0.0, 0.08, 0.92, 1.48), + ("Channel glass", (0.911, 0.966, 0.96), 0.0, 0.25, 1.0, 1.46), + ("Car window", (0.22, 0.30, 0.31), 0.0, 0.06, 0.96, 1.46), + ("Red lens", (0.8, 0.05, 0.02), 0.0, 0.1, 1.0, 1.5), + ("Black tempered", (0.0, 0.0, 0.0), 0.0, 0.0, 1.0, 1.45), +] +solid_colors = [(0.9, 0.9, 0.9), (0.9, 0.5, 0.1), (0.9, 0.9, 0.9), (0.2, 0.6, 0.9), (0.9, 0.9, 0.9), (0.9, 0.5, 0.1)] +for i, (name, base, metallic, roughness, transmission, ior) in enumerate(panes): + x = (i - (len(panes) - 1) / 2.0) * 1.7 + material, _ = principled(name, base, metallic, roughness, transmission, ior) + box(name, (1.4, 0.02, 1.7), (x, 0.0, 1.0), material) + # Something solid behind each pane. + solid, _ = principled("Solid %d" % i, solid_colors[i], roughness=0.5) + if i % 2 == 0: + bpy.ops.mesh.primitive_uv_sphere_add(segments=48, ring_count=24, radius=0.4, location=(x, 1.3, 0.75)) + bpy.ops.object.shade_smooth() + else: + bpy.ops.mesh.primitive_cube_add(size=0.7, location=(x, 1.3, 0.72), rotation=(0.0, 0.0, 0.5)) + behind = bpy.context.active_object + behind.name = "Behind %d" % i + behind.data.materials.append(solid) + +# The camera of the reference view. +camera_data = bpy.data.cameras.new("Camera") +camera_data.sensor_fit = "VERTICAL" +camera_data.angle_y = math.radians(FOV_Y) +camera_data.clip_start = 0.05 +camera_data.clip_end = 1000 +camera = bpy.data.objects.new("Camera", camera_data) +camera.location = EYE +camera.rotation_euler = (TARGET - EYE).to_track_quat("-Z", "Y").to_euler() +scene.collection.objects.link(camera) +scene.camera = camera + +# The stripes image travels inside the file. +bpy.ops.file.pack_all() +bpy.ops.wm.save_as_mainfile(filepath=out) +print("SAVED", out, [o.name for o in scene.objects]) diff --git a/scripts/tests/blender/scenes/glass_panes/render_reference.py b/scripts/tests/blender/scenes/glass_panes/render_reference.py new file mode 100644 index 000000000..d2686158b --- /dev/null +++ b/scripts/tests/blender/scenes/glass_panes/render_reference.py @@ -0,0 +1,86 @@ +# Renders the glass reference scene with Cycles under one of the engine's HDR environments, and +# writes the camera in engine coordinates so the engine can draw the same view. Read-only on the +# .blend; the world and the camera it adds are not saved. +# +# blender --background out/glass_panes.blend --python scripts/tests/blender/scenes/glass_panes/render_reference.py -- out [hdr] +# +# Writes out/glass_blender.png and out/view.json. The HDR defaults to the engine's +# Sources/UntoldEngine/Resources/HDR/teatro_massimo_2k.hdr. GLASS_SAMPLES overrides the sample +# count (256) for a quick look. +import json +import math +import os +import sys + +import bpy +from mathutils import Vector + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, "..", "..", "..", "..", "..")) +sys.path.insert(0, os.path.join(REPO, "scripts")) +import untoldexplorer as ux # noqa: E402 + +arguments = sys.argv[sys.argv.index("--") + 1:] +out_dir = os.path.abspath(arguments[0]) +hdr_path = arguments[1] if len(arguments) > 1 else os.path.join(REPO, "Sources", "UntoldEngine", "Resources", "HDR", "teatro_massimo_2k.hdr") +os.makedirs(out_dir, exist_ok=True) + +# The same view as make_scene.py, in Blender's coordinates. +FOV_Y = 40.0 +EYE = Vector((-1.6, -8.2, 1.7)) +TARGET = Vector((0.1, 0.6, 0.95)) +# The exporter's conversion to the engine's axes, the one `--convert-orientation` applies. +conversion = ux.resolve_conversion_matrix(True, "blender-native") + + +def to_engine(vector, point=True): + v = conversion @ Vector((vector[0], vector[1], vector[2], 1.0 if point else 0.0)) + return [round(v[0], 5), round(v[1], 5), round(v[2], 5)] + + +scene = bpy.context.scene +scene.render.engine = "CYCLES" +scene.cycles.device = "CPU" +scene.cycles.samples = int(os.environ.get("GLASS_SAMPLES", "256")) +scene.cycles.use_denoising = True +scene.cycles.max_bounces = 16 +scene.cycles.transmission_bounces = 16 +scene.cycles.glossy_bounces = 8 +scene.render.resolution_x, scene.render.resolution_y = 1920, 1080 +scene.render.resolution_percentage = 50 +scene.render.image_settings.file_format = "PNG" +scene.view_settings.view_transform = "Standard" + +# The environment is the only light, as in the engine with the same HDR. +world = bpy.data.worlds.new("reference") +world.use_nodes = True +tree = world.node_tree +background = tree.nodes["Background"] +environment = tree.nodes.new("ShaderNodeTexEnvironment") +environment.image = bpy.data.images.load(hdr_path, check_existing=True) +tree.links.new(environment.outputs["Color"], background.inputs["Color"]) +# The engine reads the panorama's centre along its forward axis; Blender along -X. +coordinates = tree.nodes.new("ShaderNodeTexCoord") +mapping = tree.nodes.new("ShaderNodeMapping") +mapping.vector_type = "POINT" +mapping.inputs["Rotation"].default_value = (0.0, 0.0, math.radians(90.0)) +tree.links.new(coordinates.outputs["Generated"], mapping.inputs["Vector"]) +tree.links.new(mapping.outputs["Vector"], environment.inputs["Vector"]) +background.inputs["Strength"].default_value = 1.0 +scene.world = world + +camera_data = bpy.data.cameras.new("reference") +camera_data.sensor_fit = "VERTICAL" +camera_data.angle_y = math.radians(FOV_Y) +camera_data.clip_start = 0.05 +camera_data.clip_end = 1000 +camera = bpy.data.objects.new("reference", camera_data) +camera.location = EYE +camera.rotation_euler = (TARGET - EYE).to_track_quat("-Z", "Y").to_euler() +scene.collection.objects.link(camera) +scene.camera = camera +scene.render.filepath = os.path.join(out_dir, "glass_blender.png") +bpy.ops.render.render(write_still=True) +with open(os.path.join(out_dir, "view.json"), "w") as f: + json.dump({"eye": to_engine(EYE), "target": to_engine(TARGET), "fov": FOV_Y, "hdr": os.path.basename(hdr_path)}, f) +print("RENDERED", scene.render.filepath)