Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/API/UsingMaterials.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions scripts/tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
69 changes: 69 additions & 0 deletions scripts/tests/blender/scenes/glass_panes/README.md
Original file line number Diff line number Diff line change
@@ -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.
124 changes: 124 additions & 0 deletions scripts/tests/blender/scenes/glass_panes/make_scene.py
Original file line number Diff line number Diff line change
@@ -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])
86 changes: 86 additions & 0 deletions scripts/tests/blender/scenes/glass_panes/render_reference.py
Original file line number Diff line number Diff line change
@@ -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)
Loading