Skip to content

Rewrite the mitsuba-blender addon - #156

Merged
wjakob merged 231 commits into
mitsuba-renderer:masterfrom
Arthur926564:io-experiments
Sep 6, 2026
Merged

wjakob merged 231 commits into
mitsuba-renderer:masterfrom
Arthur926564:io-experiments

Conversation

@Arthur926564

@Arthur926564 Arthur926564 commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Replace the addon with a new architecture that handles complex production scenes.

Context

When Fable came out in summer 2026, @wjakob used some of his unused token balance to do automated passes over the Mitsuba/Blender plugin to handle various material nodes encountered in production assets. This work can be found on the mitsuba-renderer:exporter-experiment branch. The resulting code ended up having over-engineered parts and missing features. The goal after this has been to clean-up, simplify over-engineered code and add missing features. This work can be found on the branch Arthur926564/io-experiments.

Exporter

  • The exporter walks Blender's shader node graph and converts each node into a Mitsuba texture. Nodes without a C++ equivalent are exported using Python texture plugin implemented inside mitsuba_blender/plugins.
  • Lights, cameras, world background, and mesh geometry are converted with matching radiometric units.
  • Instancing, node groups, multi-material meshes, per-emitter visibility, and two-sided emission are supported.

Two export modes (strict and relaxed) control how unsupported nodes are handled:

  • strict will render the whole material of an unsupported nodes as a pink principled bsdf named ERROR_BSDF.
  • relaxed mode will warn and use the socket default.

Importer

The importer converts Mitsuba XML scenes back into Blender, reconstructing materials as Cycles shader node trees.

Mesh

The old exporter passed the memory addresses of Blender's in-memory mesh structures to a blender shape plugin inside Mitsuba, which mirrored Blender's struct layout per version. When that layout changed, the result was silent memory corruption rather than an error. Commit (3eeb16c) updated this in order to export meshes through mi.Mesh by reading triangulated geometry into numpy arrays and welding shared face corners. It also builds Mitsuba mesh objects directly using the official Blender's API, without any raw pointers.

Commit b601d5a updated mesh export to match what has been updated from Mitsuba PR#1933. The exporter now builds meshes with the two-phase mi.Mesh(props) and from_corners() API, which welds and splits face corners in C++. All meshes of a scene go into one shared meshes.serialized file.

Other

  • The render engine, preferences and properties have been reworked.
  • The python plugins are discoverable via pip entry points.
  • The test suite covers 429 tests across unit, roundtrip, and golden image comparisons, running inside headless Blender with pytest.
  • The addon is packaged as a Blender 4.2 extension and no longer imports bpy at the top level, making the plugins importable outside Blender.

@Arthur926564
Arthur926564 marked this pull request as ready for review September 4, 2026 11:30
Bump the supported dependency version to 3.9.0, only reload the mitsuba
module when a custom path is in use (reloading the 3.9 alias package
raises), and drop the dead MI_DEFAULT_VARIANT lookup in the render
settings, which 3.9 no longer exposes and whose result was overwritten
unconditionally anyway.
tests/run.py launches headless Blender (found via the BLENDER env var),
runs pytest inside it through tests/_bootstrap.py, and propagates the
exit code back to the host via a result file, so failures and crashes
both fail the process. It prints the pytest report tail and a PASS/FAIL
summary, keeping the full Blender output in a log file. Shared fixtures
register the in-repo addon, reset the scene, render Mitsuba scene dicts
and compare image statistics. Legacy tests directly under tests/ keep
working with scripts/run_tests.py and are ignored by the new harness.
tests/README.md documents usage and the slow/packaging markers.
The mean and second-moment images were both read from the m2 split of the
moment integrator output, so the comparison test compared second moments
instead of means and computed a bogus variance.
The render engine crashed before producing any output: RenderEngine
subclasses must forward constructor arguments to the base class, the
Mitsuba thread environment API (ScopedSetThreadEnvironment/Thread) no
longer exists, the referenced b_scene.thread_env property was never
registered, and scene_to_dict() was called without the newly required
window_manager argument. Make window_manager optional (None disables
progress updates) and drop the temporary directory block that only fed
the removed file resolver call.

Also write the root film image to Blender's built-in Combined pass
(RGBA, padding alpha when Mitsuba produces RGB) instead of a custom
'Main' pass that never reached the saved render result.
The export context's 'directory' field was never set, so meshes and
textures silently landed in subfolders of the process working directory.
The export operator now derives it from the target file path and the
render engine uses a temporary directory that lives until the scene
dict has been loaded; dict_to_scene() resolves the relative file
references against it via the Mitsuba file resolver.

Drop the transform_relocate() call when writing XML: the exporter
already places files in the proper subfolders, and relocating a file
onto itself truncates it to zero bytes.
Four regressions from the parser API port in mitsuba-renderer#137:

- write_mi_emitter_bsdf() left 'radiance'/'strength' unbound when the
  radiance was not stored as a Color; Float radiance is now converted
  through the same emissive-texture path and other types fall back to a
  warning plus the default.
- The mask BSDF fast path called get_texture('reflectance') on a
  diffuse child without an explicit reflectance, which crashes in
  Mitsuba 3.9; the black-diffuse check now verifies that the property
  exists and is a Color first.
- The same path inverted the opacity by writing back into the parser
  state, corrupting later conversions of the same material; the
  inverted value is now computed locally.
- A shape with an emitter but no BSDF lost its emitter, and a failed
  material conversion appended None to the material list; emissive
  shapes without a BSDF now get an emission material on top of a null
  BSDF, and None materials are skipped.
Blender extension ids must be valid Python identifiers, so the package
loses its hyphen ahead of the extension migration. All references in the
test harnesses, release packaging and CI workflows follow suit.

Note: scripts/run_tests.py already exits 1 at teardown on the base
revision because its symlink cleanup races the new conftest fixture;
the tests themselves pass (29 passed) and the next commit fixes the
cleanup.
When the legacy runner collects the new-harness tests, both it and the
session fixture in tests/conftest.py manage the same addon symlink, and
whichever tears down second crashed on the missing link, failing the run
after all tests passed. Guard both cleanups so either order works.
Add blender_manifest.toml (id mitsuba_blender, Blender 4.2+, BSD-3) and
replace the legacy __init__.py with a minimal one: no bl_info, no pip
subprocesses, no PATH surgery, no saved preference state machine. The
DRJIT_NO_RTLD_DEEPBIND environment variable is set before the first
mitsuba import. If mitsuba cannot be imported, only the preferences
panel registers and reports the failure instead of raising.

The one remaining preference is custom_mitsuba_path, a dev override
that is prepended to sys.path before the import; changing it shows a
'restart Blender' note instead of attempting reload magic.

Both test harnesses now install the addon by symlinking it into the
user_default extension repository and enabling
bl_ext.user_default.mitsuba_blender. The wheels list in the manifest is
empty for local development (mitsuba comes from Blender's
site-packages); CI fills it for release builds.
The 'if "bpy" in locals()' reload blocks existed to support Blender's
legacy F8 script reloading, which extensions no longer use.
Marked @pytest.mark.packaging (opt-in): builds the zip with
'blender --command extension build' and installs it into a factory
Blender with isolated user resources, asserting that the MITSUBA
render engine registers.
The JSON plugin descriptions and the create_plugin_props metaprogramming
are gone. properties.py now declares one PropertyGroup per supported
integrator (path, direct, volpath, volpathmis, aov, ptracer), sampler
(independent, stratified, multijitter, orthogonal, ldsampler) and
reconstruction filter (box, tent, gaussian, mitchell, catmullrom,
lanczos), and ui.py holds the panels, all with COMPAT_ENGINES handling.

The nested moment/stokes UIs are dropped in favor of a custom integrator
text field on the render settings: a Python dict literal that is parsed
with ast.literal_eval and used verbatim. The importer stores integrators
without a UI representation (such as moment) in this field, so they
still survive an import/export round trip.
The minimum supported Blender is 4.2 LTS, so every remaining
bpy.app.version branch was decidable: keep the modern path, delete the
rest. compat.py is now the only module allowed to inspect
bpy.app.version, enforced by a unit test, and provides the version
string that the blender mesh exporter embeds.
Enabling, disabling and re-enabling the extension must leave no
registered classes, panel COMPAT_ENGINES tags or RNA pointers behind,
and must restore all of them on the second enable.
The exporter no longer passes as_pointer() addresses to the 'blender'
plugin. It reads the triangulated geometry into numpy arrays via
foreach_get, welds corners that share vertex, normal, UV and color data,
and constructs mitsuba.Mesh objects directly. Per-corner normals from
Blender cover smooth, flat and custom split normals uniformly, so the
'face_normals' flag is no longer needed.

When rendering inside Blender, the meshes stay in memory and enter the
scene dict as instantiated objects, with BSDFs and area emitters
attached through the shape constructor; nothing is written to disk.
The file-export path keeps writing binary PLY next to the XML.

Materials assignments now come from the object's material slots instead
of the mesh data, so per-object material overrides are respected.
Mesh data that is rendered more than once, whether through linked
duplicates, instanced collections, vertex instancers or particle
systems, is now converted a single time and referenced by a shapegroup
with one Mitsuba instance per occurrence. Occurrences are identified by
the evaluated data's session_uid together with the object's material
slots, so per-object material overrides still export separately.

The dependency graph is iterated twice (a counting pass and a
conversion pass) because evaluated objects must not be kept alive
across iteration steps; storing them crashed Blender for particle
instances. This replaces both the bpy.data.particles scan and the
name-based dedup of the old exporter, and prototype objects of vertex
instancers are no longer rendered at their own location.
Mesh-backed shape plugins (ply, obj, serialized, and anything else
Mitsuba can instantiate as a mesh) are now loaded by Mitsuba itself and
their vertex buffers copied into a Blender mesh with foreach_set,
including normals, UVs and per-vertex attributes such as vertex colors.
This deletes the vendored PLY and OBJ importers (over 1000 lines) and
automatically supports every file feature Mitsuba understands.

Analytic shapes (sphere, rectangle, cube, disk) keep mapping to native
Blender primitives.
A smooth-shaded Suzanne is exported through the render-mode pipeline
and rendered at 64x64 with scalar_rgb, spp=16 and a fixed seed, then
compared to a committed reference image by mean error and RMSE.
The new --update-refs pytest option regenerates the references.
Material converters resolve their input sockets through a new _eval
module: links are followed through reroutes and muted nodes, and
subgraphs built entirely from value nodes (Math, Vector Math, Mix, RGB,
Value, Invert, Gamma, Brightness/Contrast, Map Range, Clamp,
Separate/Combine XYZ/Color, RGB to BW) are evaluated in Python. A
socket resolves to a constant, a Mitsuba texture dict (via a registry
that texture converters plug into), or an unsupported marker with a
precise warning message.

The RGB node value is read from its Color output socket rather than
node.color, which is the header color (from PR mitsuba-renderer#153).
Shader node converters now self-register per node type with a
@node_converter decorator; converter modules dropped into
convert/export/materials are auto-discovered. convert_material wraps
every conversion in an error boundary that logs a warning and
substitutes a gray diffuse, so one broken material cannot abort an
export, and a material whose Surface input is unlinked gets the same
fallback instead of raising (from PR mitsuba-renderer#153). A diffuse converter serves
as the reference implementation.

The export entry point only routes materials with registered
converters through the registry; everything else still takes the
legacy path until the remaining converter families land.
Mitsuba BSDF plugins now map to Blender node builders through a
registry keyed by plugin name, mirroring the export side. Converters
receive a MaterialBuilder that creates and links nodes, converts
nested BSDFs and textures through the same registries, and writes
float/color properties with constant and texture handling. Failures
never abort an import: they are logged and produce a distinctive pink
error BSDF. A diffuse converter serves as the reference
implementation; plugins without a registered converter still take the
legacy import path.
Map the Principled node onto Mitsuba's principled BSDF through the
converter registry: reflective materials export the 'specular' parameter
and a twosided wrapper, transmissive ones stay one-sided and share
Blender's IOR as 'eta'. Emission becomes a separate area emitter dict,
an Alpha below one wraps the BSDF in a mask, and a tangent-space Normal
Map input wraps it in a normalmap adapter.

Unlike the legacy exporter, roughness is passed through unchanged
(Blender and Mitsuba both square the perceptual value internally) and
Coat Roughness maps to the inverse of clearcoat_gloss.
Build a Principled node from Mitsuba's principled BSDF via the import
registry. The mutually exclusive eta/specular parameters fill both the
IOR and Specular IOR Level inputs consistently (F0 = 0.08 * specular
level), spec_tint maps back to a grayscale Specular Tint color and
clearcoat_gloss to the inverse Coat Roughness, mirroring the export
converter so that principled materials round-trip.
Glossy exports to a twosided (rough)conductor, including Cycles'
anisotropy mapping to alpha_u/alpha_v. Glass and Refraction export to
dielectric, thindielectric or roughdielectric depending on IOR and
roughness; Mitsuba has no transmission-only BSDF, so Refraction reuses
the dielectric path with a warning. Transparent exports to null, or to
a mask over a black diffuse when the color is tinted. Translucent
exports to principledthin with full diffuse transmission, the closest
match to a diffuse transmitter.

Constant roughness values are squared to match Mitsuba's alpha
convention; textured roughness passes through unchanged. The registry
test that expected Glass to be unhandled now uses the Sheen node.
conductor and roughconductor import as Glossy nodes; the reflection
color comes from evaluating the BSDF head-on (smooth conductors are
delta lobes, so they are evaluated through an equivalent rough one).
Constant alpha_u/alpha_v pairs are inverted back into Cycles'
roughness/anisotropy parametrization. dielectric, thindielectric and
roughdielectric import as Glass nodes, with named IOR materials mapped
to their numeric values. plastic and roughplastic import as a
Principled BSDF approximation.

The registry error-boundary test now restores the plastic converter it
overrides instead of deleting the registry entry.
Mix Shader maps to blendbsdf (constant or textured factor), or to mask
when one input is a Transparent BSDF; a textured factor with the
transparent input second becomes a blend against a null BSDF since the
factor cannot be inverted. Add Shader supports one Emission plus one
BSDF, or two Emissions with summed radiance. The Emission node becomes
an area emitter dict (zero radiance falls back to a black diffuse since
Mitsuba rejects black emitters) and Holdout is approximated by null.
twosided with one child converts the child directly (Blender materials
are two-sided by default); with two children it selects between them
with a Mix Shader driven by Backfacing. blendbsdf becomes a Mix Shader
with the weight on Fac, mask becomes a Transparent BSDF when it wraps a
black diffuse with constant opacity and a Mix Shader with a Transparent
input otherwise, and null becomes a Transparent BSDF.

Nested BSDFs whose plugins have not moved to the registry yet are
bridged through the legacy writers via a temporary reroute node, so
scenes like twosided(principled) keep importing at full fidelity until
the remaining converter groups land.
Registers converters for Image Texture, Checker and Color Attribute
nodes, translates Mapping/Texture Coordinate chains into to_uv
transforms (conjugated with the UV flip of the mesh exporter), and
provides convert_normal_input for BSDF converters to wrap their result
in normalmap/bumpmap plugins as well as convert_environment_texture for
the world exporter.

Unlike the legacy exporter, referencing an image never mutates the
datablock: files that exist on disk are copied verbatim and everything
else is saved through a temporary copy, so image.file_format and
filepath are left untouched. In render mode pixel data is passed to
Mitsuba as in-memory bitmaps without touching the disk at all.
Registers texture converters for bitmap (with wrap mode, filter type,
raw color space and a Mapping/Texture Coordinate reconstruction of
to_uv), checkerboard (folding the UV flip and cell-size conventions
into the Scale input or a Mapping node), scale (a multiplying Mix node)
and mesh_attribute (Color Attribute or Attribute node). The normalmap
and bumpmap BSDF wrappers become Normal Map and Bump nodes feeding the
Normal input of the wrapped BSDF, with chained perturbations rerouted
through the Bump node.

Loaded images are cached by path so shared textures reuse one
datablock, and missing files degrade to the socket default with a
warning instead of aborting the material.
The flat absolutify only handled top-level filenames, missing bitmaps
nestied inside compound textures (math expressions, color ramps, etc.).
Walk the params dict recursively so every filename is resolved.
Archinteriors scenes have duplicate datablocks where one has a broken
Windows path and another has the actual data. When an image has no pixel
data, search for a sibling with the same base name and use it instead.
Also force lazy load on packed siblings.
A null bsdf should not be used for emitter in Mitsuba. Instead we can
use a BSDF that reflect 0 light.
…ble emitters

Object with visible_camera=False and no emitter are not exported at all
since they contribute nothing visible. Objects with an emitter keep the
shape for lighting but use a black diffuse BSDF and set visible=false on
the emitter so the surface doesn't appear in renders.
Blender lights and emitter-only materials export with a black diffuse
BSDF, since Mitsuba shapes always need one. The importer only treated a
null BSDF as a placeholder, so those shapes came back as emissive meshes
instead of lights.
The float branch safely probes for the Steps input whereas the vector
branch didn't.
A lot of images in Blender's scene have sibling (e.g. image.png and
image.005.png), those sibling are usually equals. Those are useful to
catch as it can happens that a texture image references one of those
file which doesn't exist but the siblings exists. However this is not
always the case it has happened that two siblings are not equal and not
used for the same material. We therefore need to check the image itself
first in order to try to not fall in the false sibling case.
Add a log to warn the use that this image exported here might not be the
correct one. Also force lazy load for any image, not just packed.
Resolve filename the same way as does scalar_from_socket.
Since every emitter now carries an explicit diffuse BSDF with albedo 0.0, no converter returns a None BSDF anymore, so the branch in add_material_to_dict that fell back to a shared empty-emitter-bsdf entry was unreachable. An emitter only material stores its black diffuse under its own material id instead, which also preserves the material name across roundtrip. Update the tests and the docstrings that still described the shared placeholder
@wjakob

wjakob commented Sep 6, 2026

Copy link
Copy Markdown
Member

GitHub is not able to do a rebase merge of this branch, despite it being rebased on origin/master. I will do it manually.
Screenshot 2026-09-06 at 12 27 02

@wjakob
wjakob merged commit ef90a64 into mitsuba-renderer:master Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants