Note
Go to the end to download the full example code.
Interactive PBR Demo#
Physically based rendering (PBR) describes a surface by how it interacts with
light rather than by an ad-hoc colour formula, so the same material reads
convincingly under any lighting. This tutorial puts every property FURY’s
'physical' material exposes on a slider, grouped into tabs, so you can watch
each one act on a single sphere in real time.
Most of the light here comes from the environment: a metal has no diffuse
response, so what you see on it is the skybox reflected back. Scene(skybox=...)
hands the cube map to the material as its env_map automatically. A single
directional light sits on top of that, because sheen is retroreflective –
it bounces light back towards wherever it came from, so with environment
lighting alone its characteristic rim barely registers. It is a light in the
scene rather than FURY’s default camera-mounted one: a light riding the camera
keeps its highlight pinned to the same spot on the sphere however you orbit,
which on a mirror reads as a headlamp instead of a reflection.
Several properties only mean something on the right kind of surface, which is what the Presets tab is for – each preset moves the whole material, including the base colour, to a state where the effect it is named for is the thing you notice:
Index of refraction and specular intensity shape the reflectance of dielectrics. A metal takes its reflectance from the base colour instead, so at
Metalness = 1both are ignored outright and the render does not change by a single pixel. Pull Metalness to 0 and they come alive.Sheen is a cloth layer: a retroreflective rim that reads on a rough dielectric, not on polished metal. It is also tinted by
sheen_color, which defaults to black – a white tint at full strength simply bleaches the sphere. Try the Velvet preset.Iridescence is a thin film whose interference colours are drowned by a saturated albedo. It needs a dark, smooth dielectric to show its rainbow shift. Try the Soap bubble preset.
import numpy as np
from fury.data import fetch_viz_cubemaps, read_viz_cubemap
from fury.io import load_cube_map_texture
from fury.lib import AmbientLight, DirectionalLight
from fury.ui import LineSlider2D, TabUI, TextBlock2D, TextButton2D
from fury.window import Scene, ShowManager
import fury.actor as actor
Fetch a cube map and build the scene. The skybox is both the backdrop and, just as importantly, the light source for the material.
fetch_viz_cubemaps()
cube_map = load_cube_map_texture(read_viz_cubemap("skybox"))
Dataset is already in place. If you want to fetch it again please first remove the folder C:\Users\magor\.fury\cubemaps
The light. camera_light=False on the ShowManager below drops the
default light that rides along with the camera, and this one takes its place,
pointed from where the skybox’s own sun is so that the highlight it casts
lands on the sun the sphere is already reflecting. Reading that direction off
the cube map’s face layout gets the sign of x wrong; it was measured instead,
by rendering a mirror sphere from several angles and looking for the light
direction whose highlight sits on top of the reflected sun – 30 degrees up,
and 60 degrees round from +Z towards -X. The sun’s glow in this cube map is
a good ten degrees wide, so there is no more precision to be had than that.
A DirectionalLight shines from its position towards its target, which is
the origin by default, so the position is that direction pushed out beyond the
sphere – distance does not matter to a directional light, only the direction
it points. The payoff is a highlight that belongs to the scene: it stays with
the reflection while the sphere is turned under it, so what the sliders do to
a highlight can be watched from any angle.
SUN_DIRECTION = np.array([-0.75, 0.50, 0.43])
sun = DirectionalLight(color="#fff4e0", intensity=3.0)
sun.local.position = tuple(40.0 * SUN_DIRECTION)
# Passing ``lights`` replaces the ambient light a bare ``Scene`` would add, so
# put one back: it is what keeps the side facing away from the sun from going
# flat black on the materials the environment map does not reach.
scene = Scene(skybox=cube_map, lights=[AmbientLight(), sun])
The sphere itself. material='physical' selects pygfx’s
MeshPhysicalMaterial, the superset that adds clearcoat, sheen, anisotropy
and iridescence on top of the metallic-roughness workflow.
Two of the properties below are modulated by a colour that defaults to black,
which would make their sliders look broken: sheen is tinted by
sheen_color and emissive_intensity scales emissive. Both colours
are seeded here so the sliders have something to act on.
INITIAL = {
"metalness": 1.0,
"roughness": 0.25,
"ior": 1.5,
"specular_intensity": 1.0,
"env_map": cube_map,
"env_map_intensity": 1.0,
"clearcoat": 0.0,
"clearcoat_roughness": 0.0,
"anisotropy": 0.0,
"anisotropy_rotation": 0.0,
"sheen": 0.0,
"sheen_roughness": 1.0,
"iridescence": 0.0,
"iridescence_ior": 1.3,
"emissive_intensity": 0.0,
}
Four of those properties – clearcoat, sheen, anisotropy and
iridescence – are shader gates as much as they are values. pygfx compiles
each of their branches into the shader only when the property is non-zero at
the time the material is first drawn; a later write lands in the uniform
buffer and never triggers a rebuild. Left at 0.0 they are compiled out for
good, and their sliders – along with every preset that leans on them – move
without changing a pixel. Flooring them at a value far too small to see keeps
each branch in the shader, ready for the slider to drive.
SHADER_GATED = ("clearcoat", "sheen", "anisotropy", "iridescence")
GATE_EPSILON = 1e-6
def gated(name, value):
"""Keep a shader-gated property non-zero so its branch stays compiled."""
if name in SHADER_GATED:
return max(value, GATE_EPSILON)
return value
# The public factory supplies smooth radial normals, split UV endpoints and
# pole-safe tangents. Without an image, material color and environment reflections
# remain in control, including when anisotropy is enabled by a preset.
sphere_actor = actor.texture_on_sphere(
phi=96,
theta=96,
material="physical",
material_params={name: gated(name, value) for name, value in INITIAL.items()},
)
sphere_actor.local.scale = 8.0
# Silver: near-white reflectance, very slightly warm. On a metal the base
# colour *is* the reflectance, so this is what tints every reflection.
BASE_COLOR = (0.97, 0.96, 0.92, 1.0)
sphere_actor.material.color = BASE_COLOR
sphere_actor.material.sheen_color = (1.0, 1.0, 1.0)
sphere_actor.material.emissive = (0.35, 0.12, 0.5)
scene.add(sphere_actor)
material = sphere_actor.material
Every control is one row: a label, a slider, and the value the slider prints.
TABS is the whole specification – tab title, then one entry per property
giving its label, range, starting value and how to apply it – so adding
another property is a single line.
anisotropy_rotation is in radians. The iridescence film slider drives the
upper bound of iridescence_thickness_range in nanometres: it is the film
thickness that decides which colours the interference shifts towards.
def _set(name):
"""Build a callback that writes a slider's value onto the material."""
def apply(slider):
setattr(material, name, gated(name, slider.value))
return apply
def _set_iridescence_thickness(slider):
material.iridescence_thickness_range = (100.0, slider.value)
#: tab title -> rows of (label, min, max, initial, callback, decimals)
TABS = [
(
"Base",
[
("Metalness", 0.0, 1.0, INITIAL["metalness"], _set("metalness"), 2),
("Roughness", 0.0, 1.0, INITIAL["roughness"], _set("roughness"), 2),
("Index of refraction", 1.0, 2.3, INITIAL["ior"], _set("ior"), 2),
(
"Specular intensity",
0.0,
1.0,
INITIAL["specular_intensity"],
_set("specular_intensity"),
2,
),
(
"Environment light",
0.0,
3.0,
INITIAL["env_map_intensity"],
_set("env_map_intensity"),
2,
),
],
),
(
"Coating",
[
("Clearcoat", 0.0, 1.0, INITIAL["clearcoat"], _set("clearcoat"), 2),
(
"Clearcoat roughness",
0.0,
1.0,
INITIAL["clearcoat_roughness"],
_set("clearcoat_roughness"),
2,
),
("Sheen", 0.0, 1.0, INITIAL["sheen"], _set("sheen"), 2),
(
"Sheen roughness",
0.0,
1.0,
INITIAL["sheen_roughness"],
_set("sheen_roughness"),
2,
),
(
"Emissive intensity",
0.0,
3.0,
INITIAL["emissive_intensity"],
_set("emissive_intensity"),
2,
),
],
),
(
"Optical",
[
("Anisotropy", 0.0, 1.0, INITIAL["anisotropy"], _set("anisotropy"), 2),
(
"Anisotropy rotation",
0.0,
2 * np.pi,
INITIAL["anisotropy_rotation"],
_set("anisotropy_rotation"),
2,
),
(
"Iridescence",
0.0,
1.0,
INITIAL["iridescence"],
_set("iridescence"),
2,
),
(
"Iridescence IoR",
1.0,
2.3,
INITIAL["iridescence_ior"],
_set("iridescence_ior"),
2,
),
(
"Iridescence film (nm)",
100.0,
800.0,
400.0,
_set_iridescence_thickness,
0,
),
],
),
]
Lay the rows out in a fury.ui.TabUI. Grouping the fifteen properties
into three tabs keeps the widget short enough to sit beside the sphere, and
TabUI is not draggable by default, so it stays where it is put.
Coordinates in add_element are pixel offsets measured down from the
content panel’s top-left corner. Each slider prints its value just above the
track, which is why the rows are spaced more generously than the text alone
would need.
# The tab bar splits its width evenly between the tabs, so each title has to
# fit in TAB_SIZE[0] / n_tabs pixels. Long titles wrap onto a second line and
# spill out of their header into the neighbouring ones.
TAB_SIZE = (420, 260)
ROW_HEIGHT = 44
FIRST_ROW_Y = 34
LABEL_X = 14
SLIDER_X = 195
SLIDER_LENGTH = 120
PRESET_TAB_INDEX = len(TABS)
tab_ui = TabUI(
position=(20, 20),
size=TAB_SIZE,
tab_titles=[title for title, _rows in TABS] + ["Presets"],
startup_tab_id=0,
font_size=15,
draggable=True,
inactive_color=(0.09, 0.09, 0.12),
)
sliders = []
for tab_index, (_title, rows) in enumerate(TABS):
for row, (label, low, high, start, callback, decimals) in enumerate(rows):
y = FIRST_ROW_Y + row * ROW_HEIGHT
tab_ui.add_element(
tab_index,
TextBlock2D(
text=label,
font_size=14,
color=(0.88, 0.88, 0.92),
vertical_justification="middle",
dynamic_bbox=True,
),
(LABEL_X, y),
)
slider = LineSlider2D(
initial_value=start,
min_value=low,
max_value=high,
length=SLIDER_LENGTH,
line_width=5,
shape="disk",
outer_radius=7,
font_size=13,
text_template=f"{{value:.{decimals}f}}",
)
slider.on_moving_slider = callback
tab_ui.add_element(tab_index, slider, (SLIDER_X, y))
sliders.append(slider)
Presets. Each one restages the whole material so the property it is named for is the thing you notice, then pushes the new values back into the sliders so the panel keeps telling the truth.
PRESETS = [
(
"Polished metal",
{"color": BASE_COLOR, "metalness": 1.0, "roughness": 0.08},
),
(
"Brushed metal",
{
"color": BASE_COLOR,
"metalness": 1.0,
"roughness": 0.45,
"anisotropy": 1.0,
"anisotropy_rotation": 1.5,
},
),
(
"Car paint",
{
"color": (0.55, 0.05, 0.09, 1.0),
"metalness": 0.9,
"roughness": 0.5,
"clearcoat": 1.0,
"clearcoat_roughness": 0.03,
},
),
(
"Velvet",
{
"color": (0.25, 0.05, 0.12, 1.0),
"metalness": 0.0,
"roughness": 1.0,
"sheen": 1.0,
"sheen_roughness": 0.3,
"sheen_color": (0.9, 0.5, 0.6),
},
),
(
"Soap bubble",
{
"color": (0.02, 0.02, 0.02, 1.0),
"metalness": 0.0,
"roughness": 0.05,
"iridescence": 1.0,
"iridescence_ior": 2.0,
"iridescence_thickness_range": (100.0, 550.0),
},
),
(
"Glass",
{
"color": (0.85, 0.92, 0.95, 1.0),
"metalness": 0.0,
"roughness": 0.02,
"ior": 1.8,
"specular_intensity": 1.0,
},
),
]
#: Every property a preset may touch, with the value to fall back to when it
#: does not mention one. Without this a preset would inherit whatever the last
#: one left behind.
PRESET_DEFAULTS = {
"color": BASE_COLOR,
"sheen_color": (1.0, 1.0, 1.0),
"iridescence_thickness_range": (100.0, 400.0),
**INITIAL,
}
#: slider label -> the material property it drives, for syncing after a preset
SLIDER_PROPERTY = {
"Metalness": "metalness",
"Roughness": "roughness",
"Index of refraction": "ior",
"Specular intensity": "specular_intensity",
"Environment light": "env_map_intensity",
"Clearcoat": "clearcoat",
"Clearcoat roughness": "clearcoat_roughness",
"Sheen": "sheen",
"Sheen roughness": "sheen_roughness",
"Emissive intensity": "emissive_intensity",
"Anisotropy": "anisotropy",
"Anisotropy rotation": "anisotropy_rotation",
"Iridescence": "iridescence",
"Iridescence IoR": "iridescence_ior",
}
SLIDER_ROWS = [row for _title, rows in TABS for row in rows]
def apply_preset(values):
"""Put the material into a named state and resync every slider."""
settings = dict(PRESET_DEFAULTS)
settings.update(values)
for name, value in settings.items():
setattr(material, name, gated(name, value))
for slider, (label, *_rest) in zip(sliders, SLIDER_ROWS, strict=True):
if label.startswith("Iridescence film"):
slider.value = settings["iridescence_thickness_range"][1]
else:
slider.value = settings[SLIDER_PROPERTY[label]]
The buttons themselves. TextButton2D takes the same states mapping
viz_button.py demonstrates – one entry per state the button can resolve –
and here each state is a colour rather than a colour and a caption, so the
label stays put and only the plate under it reacts: it lifts on hover, and the
preset currently on the sphere stays in the accent colour. That last part is
what is_toggle buys; the click handler switches the others off, which
turns six independent buttons into one group with a current selection.
PRESET_STATES = {
"default": (0.20, 0.21, 0.27),
"hover": (0.28, 0.30, 0.40),
"pressed": (0.35, 0.44, 0.68),
"disabled": (0.12, 0.12, 0.15),
}
PRESET_LABEL_COLOR = (0.90, 0.91, 0.96)
# Two columns of three, sized from the panel rather than by eye: the grid is
# centred in TAB_SIZE[0] and starts below the tab bar, so every button lands
# inside the content area with room to spare.
BUTTON_SIZE = (186, 38)
BUTTON_GAP = (12, 12)
BUTTON_COLUMNS = 2
GRID_WIDTH = BUTTON_COLUMNS * BUTTON_SIZE[0] + (BUTTON_COLUMNS - 1) * BUTTON_GAP[0]
GRID_X = (TAB_SIZE[0] - GRID_WIDTH) // 2
GRID_Y = FIRST_ROW_Y # start on the same line the slider tabs start on
preset_buttons = []
preset_status = TextBlock2D(
text="Pick a preset",
font_size=13,
vertical_justification="middle",
dynamic_bbox=True,
)
def _select_preset(name, values):
"""Apply a preset and leave its own button lit, the others dark."""
def clicked(button):
apply_preset(values)
for other in preset_buttons:
other.toggled = other is button
preset_status.message = f"On the sphere: {name}"
return clicked
for index, (name, values) in enumerate(PRESETS):
row, column = divmod(index, BUTTON_COLUMNS)
button = TextButton2D(
label=name,
states=PRESET_STATES,
size=BUTTON_SIZE,
font_size=14,
is_toggle=True,
)
button.child.color = PRESET_LABEL_COLOR
button.on_clicked = _select_preset(name, values)
tab_ui.add_element(
PRESET_TAB_INDEX,
button,
(
GRID_X + column * (BUTTON_SIZE[0] + BUTTON_GAP[0]),
GRID_Y + row * (BUTTON_SIZE[1] + BUTTON_GAP[1]),
),
)
preset_buttons.append(button)
tab_ui.add_element(
PRESET_TAB_INDEX,
preset_status,
(GRID_X, GRID_Y + 3 * (BUTTON_SIZE[1] + BUTTON_GAP[1]) + 6),
)
scene.add(tab_ui)
Frame the sphere with camera.show_object. That does more than place the
camera: it also leaves the controller orbiting about the sphere, so dragging
rotates around the object rather than about some unrelated point. scale
pulls the camera back a little so the sphere clears the tab widget.
The default camera light stays on. Turning it off leaves the skybox as the only illumination, which looks fine for metals but flattens sheen to almost nothing – a retroreflective lobe needs a light near the viewer to show up.
show_manager = ShowManager(
scene=scene,
size=(1360, 820),
title="FURY Interactive PBR Demo",
camera_light=False,
)
camera = show_manager.screens[0].camera
camera.show_object(sphere_actor, scale=1.6)
Keep the tab widget anchored to the top-left corner as the window is resized.

Total running time of the script: (0 minutes 0.897 seconds)