diff --git a/.github/workflows/nightly_release.yml b/.github/workflows/nightly_release.yml index 3b370e9b..51a5a4e6 100644 --- a/.github/workflows/nightly_release.yml +++ b/.github/workflows/nightly_release.yml @@ -1,35 +1,34 @@ -# This workflow runs on every commit on the master branch. It runs the test suite -# and packages the add-on. It then updates the "nightly release" in the release section -# of the repository. +# Runs on every commit on the master branch. It runs the test suite, builds +# the extension zips, and updates the rolling "nightly" release. name: Nightly Release -# Triggers the workflow on pushes on the master branch on: push: branches: [master] -# Workflow steps jobs: tests: uses: ./.github/workflows/test.yml - package: - name: "Package Release" + release: + name: "Publish nightly release" needs: [tests] - runs-on: "ubuntu-latest" + runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Git checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v2 - - - name: Create package - run: python release/package.py + - name: Download the extension zips + uses: actions/download-artifact@v4 + with: + name: extension-zips + path: dist - name: Advance `latest` tag - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: script: | try { @@ -55,7 +54,7 @@ jobs: prerelease: true allowUpdates: true replacesArtifacts: true - artifacts: "mitsuba-blender.zip" + artifacts: "dist/*.zip" artifactErrorsFailBuild: true tag: "latest" bodyFile: "release/nightly_release_body.md" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b6de3dc..4c63affa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,43 +1,38 @@ -# This workflow runs on every pushed version tags. It runs the tests and -# packages the release. It then creates a new entry in the release section -# of the repo. +# Runs on every pushed version tag. It runs the test suite, builds the +# extension zips, and creates a new draft entry in the release section. name: Release -# Triggers the workflow on tags with a specific format on: push: tags: - v* -# Workflow steps jobs: tests: uses: ./.github/workflows/test.yml - - package: - name: "Package Release" + + release: + name: "Publish draft release" needs: [tests] - runs-on: "ubuntu-latest" + runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Git checkout - uses: actions/checkout@v2 - - - name: Query Git Tag - id: git-tag - run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} + uses: actions/checkout@v4 - - name: Setup Python - uses: "actions/setup-python@v2" - - - name: Create package - run: python release/package.py + - name: Download the extension zips + uses: actions/download-artifact@v4 + with: + name: extension-zips + path: dist - name: Create GitHub draft release uses: ncipollo/release-action@v1 with: - artifacts: "mitsuba-blender.zip" + artifacts: "dist/*.zip" artifactErrorsFailBuild: true bodyFile: "release/release_body_template.md" draft: true - name: "Mitsuba-Blender ${{ steps.git-tag.outputs.tag }}" + name: "Mitsuba-Blender ${{ github.ref_name }}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44819f6a..f614c9b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,91 +1,109 @@ -# This workflow runs on every pull request and push on any other branch other than master. -# It executes the test suite on different environments to validate the add-on. +# Runs the test suite inside headless Blender and builds the extension zip. name: Test suite -# Triggers the workflow on pull requests created for the master branch as well as -# push events on other branches than master. on: - push: - branches: - - master + # Master pushes are covered by nightly_release.yml, which calls this + # workflow; a push trigger here would run the whole matrix twice. + # No branch filter, so pull requests against any base branch run the suite. pull_request: - branches: [ master ] workflow_call: + schedule: + # The weekly run additionally tests the daily Blender development build. + - cron: '0 5 * * 1' + +# Supersede in-flight runs when a pull request is updated. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} -# Workflow steps jobs: - build: - name: "${{ matrix.environment.os }} | Blender ${{ matrix.blender.version }}" - runs-on: ${{ matrix.environment.os }} + test: + name: "Blender ${{ matrix.blender-version }}" + runs-on: ubuntu-latest + # The daily Blender build only runs on the weekly schedule, so letting it + # fail the (scheduled) workflow is the early-warning signal we want; it + # never gates pull requests or releases. strategy: - # Do not stop other jobs if one fails fail-fast: false matrix: - environment: - - { - os: "ubuntu-latest", - mitsuba-version: "3.7.1" - } - - { - os: "windows-latest", - mitsuba-version: "3.7.1" - } - blender: - - { - version: "3.6", - python-version: "3.10" - } - - { - version: "4.2", - python-version: "3.11" - } - - { - version: "4.5", - python-version: "3.11" - } + blender-version: ${{ fromJSON(github.event_name == 'schedule' && '["4.2", "4.5", "daily"]' || '["4.2", "4.5"]') }} + steps: - name: Git checkout uses: actions/checkout@v4 - name: Setup Python - uses: "actions/setup-python@v2" + uses: actions/setup-python@v5 with: - python-version: ${{ matrix.blender.python-version }} + python-version: '3.11' - - name: Install Python dependencies + - name: Install host Python dependencies run: python -m pip install --upgrade requests - name: Retrieve Blender full version id: blender-version - shell: bash - run: | - echo "::set-output name=BLENDER_VERSION::$(python scripts/blender_downloader.py ${{ matrix.blender.version }} --print-version)" + run: echo "version=$(python scripts/blender_downloader.py ${{ matrix.blender-version }} --print-version)" >> "$GITHUB_OUTPUT" - name: Cache Blender archive id: cache-blender - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: blender - key: ${{ matrix.environment.os }}-${{ steps.blender-version.outputs.BLENDER_VERSION }}-v1 + key: ${{ runner.os }}-${{ steps.blender-version.outputs.version }} - name: Download Blender if: steps.cache-blender.outputs.cache-hit != 'true' - run: python scripts/blender_downloader.py ${{ matrix.blender.version }} -o blender + run: python scripts/blender_downloader.py ${{ matrix.blender-version }} -o blender - - name: Install Blender dependencies - shell: bash - run: | - BLENDER_PYTHON=$(find blender/ -regextype posix-extended -regex '.*bin\/python(.exe|[0-9].[0-9]{1,2}m?)' -print -quit) - echo "Blender Python is $BLENDER_PYTHON" - ./$BLENDER_PYTHON -m ensurepip - ./$BLENDER_PYTHON -m pip install -U pip - ./$BLENDER_PYTHON -m pip install --upgrade pytest pytest-cov - ./$BLENDER_PYTHON -m pip install mitsuba==${{ matrix.environment.mitsuba-version }} --force-reinstall - - - name: Run Addon test suite - shell: bash + - name: Install test dependencies into Blender's Python run: | - BLENDER_EXECUTABLE=$(find blender/ -maxdepth 1 -regextype posix-extended -regex '.*blender(.exe)?' -print -quit) - echo "Blender Executable is $BLENDER_EXECUTABLE" - ./$BLENDER_EXECUTABLE -b -noaudio --factory-startup --python scripts/run_tests.py -- -v --cov=mitsuba-blender + blender_python=$(find blender -path '*/python/bin/python3*' -type f -print -quit) + echo "Blender Python is $blender_python" + "$blender_python" -m ensurepip + "$blender_python" -m pip install --upgrade pip + "$blender_python" -m pip install pytest mitsuba==3.9.0 + + - name: Run the test suite + run: BLENDER="$PWD/blender/blender" python tests/run.py --all + + package: + name: Package extension + needs: test + runs-on: ubuntu-latest + + steps: + - name: Git checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install host Python dependencies + run: python -m pip install --upgrade requests + + - name: Retrieve Blender full version + id: blender-version + run: echo "version=$(python scripts/blender_downloader.py 4.2 --print-version)" >> "$GITHUB_OUTPUT" + + - name: Cache Blender archive + id: cache-blender + uses: actions/cache@v4 + with: + path: blender + key: ${{ runner.os }}-${{ steps.blender-version.outputs.version }} + - name: Download Blender + if: steps.cache-blender.outputs.cache-hit != 'true' + run: python scripts/blender_downloader.py 4.2 -o blender + + - name: Build the extension with bundled wheels + run: python release/build_extension.py --blender blender/blender --output-dir dist + + - name: Upload the extension zips + uses: actions/upload-artifact@v4 + with: + name: extension-zips + path: dist/*.zip + if-no-files-found: error diff --git a/.gitignore b/.gitignore index f16d3194..95c56161 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ tests/**/out blender/ blender-*.tar.xz .coverage.* +*.serialized diff --git a/README.md b/README.md index c252f776..457ef2a6 100644 --- a/README.md +++ b/README.md @@ -2,70 +2,169 @@ # Mitsuba Blender Add-on +[![Test suite](https://github.com/mitsuba-renderer/mitsuba-blender/actions/workflows/test.yml/badge.svg)](https://github.com/mitsuba-renderer/mitsuba-blender/actions/workflows/test.yml) [![Nightly Release](https://github.com/mitsuba-renderer/mitsuba-blender/actions/workflows/nightly_release.yml/badge.svg)](https://github.com/mitsuba-renderer/mitsuba-blender/actions/workflows/nightly_release.yml) -This add-on integrates the Mitsuba renderer into Blender. +This add-on integrates the [Mitsuba](https://github.com/mitsuba-renderer/mitsuba3) +renderer into Blender. -## Main Features +## Main features -* **Mitsuba scene import**: Import Mitsuba XML scenes in Blender to edit and preview them. Materials are converted to Cycles shader node trees. +* **Render engine**: Select *Mitsuba* as the render engine and press F12 to + render the current scene with Mitsuba, with progress reporting, cancel + support and AOV passes. -* **Mitsuba scene export**: Export a Blender scene to a Mitsuba XML scene for rendering. +* **Mitsuba scene export**: Export a Blender scene to a Mitsuba XML scene for + rendering. Cycles shader node trees are translated to Mitsuba BSDFs, + textures and emitters. -More in-depth information about the features of the add-on are available on the [wiki](https://github.com/mitsuba-renderer/mitsuba-blender/wiki). +* **Mitsuba scene import**: Import Mitsuba XML scenes in Blender to edit and + preview them. Materials are converted to Cycles shader node trees. ## Installation -- Download the latest release from the [release section](https://github.com/mitsuba-renderer/mitsuba-blender/releases). -- In Blender, go to **Edit** -> **Preferences** -> **Add-ons** -> **Install**. -- Select the downloaded ZIP archive. -- Find the add-on using the search bar and enable it. -- To point the add-on to the Mitsuba dependencies, either click on *Install dependencies using pip* to download the latest package, or check *Use custom Mitsuba path* and browse to your Mitsuba build directory. +The add-on is packaged as a Blender **extension** and requires **Blender 4.2 +LTS or newer**. + +- Download the zip matching your platform from the + [release section](https://github.com/mitsuba-renderer/mitsuba-blender/releases) + (a rolling *Nightly Release* tracks the master branch). +- In Blender, go to **Edit** -> **Preferences** -> **Get Extensions**, click + the dropdown arrow in the top-right corner and choose **Install from + Disk...**, then select the downloaded zip. Alternatively, drag and drop the + zip into the Blender window. + +The release zips bundle the matching `mitsuba` and `drjit` wheels, so no +further setup is needed. Wheels are included for Python 3.11 (Blender 4.2 +through 5.0) and Python 3.13 (Blender 5.1 and newer); Blender deploys the +ones matching its bundled Python. Release builds are available for Linux +(x86-64), Windows (x86-64) and macOS (Apple Silicon); on other platforms, +install the `mitsuba` package into Blender's Python yourself and install the +extension built from source (see below). + +To use a self-compiled Mitsuba instead of the bundled one, set *Custom +Mitsuba path* in the add-on preferences to the `python` directory of your +build (it must be compiled against Blender's Python version) and restart +Blender. + +## Supported versions + +- **Blender**: 4.2 LTS and newer. The test suite runs against the 4.2 and + 4.5 LTS releases on every change, and weekly against Blender's daily + development build. +- **Mitsuba**: 3.10.0 (bundled with the release zips). + +## Feature coverage + +Geometry is converted through `mitsuba.Mesh` in both directions: any +mesh-backed Mitsuba shape (`obj`, `ply`, `serialized`, ...) imports as a +Blender mesh, and analytic `sphere`, `rectangle`, `cube` and `disk` shapes map +to Blender primitives. Repeated meshes, collection instances and particle +instances export as Mitsuba `shapegroup`/`instance` pairs. Cameras +(perspective, orthographic, depth of field, lens shift), all Blender light +types and world backgrounds (constant and environment maps) convert in both +directions with matching radiometric units. + +### Material export + +| Blender node | Mitsuba plugin | +|---|---| +| Principled BSDF | `principled` (+ `mask` for alpha, `area` emitter for emission) | +| Diffuse BSDF | `diffuse` | +| Glossy BSDF | `conductor` / `roughconductor` | +| Glass BSDF | `dielectric` / `thindielectric` / `roughdielectric` | +| Refraction BSDF | `dielectric` / `roughdielectric` (with a warning) | +| Transparent BSDF | `null` / `mask` | +| Translucent BSDF | `principledthin` | +| Emission | `area` emitter | +| Mix Shader | `blendbsdf` / `mask` | +| Add Shader | BSDF + emitter, or summed emitters | +| Holdout | `null` (with a warning) | +| Image Texture | `bitmap` | +| Checker Texture | `checkerboard` | +| Environment Texture | `envmap` (world background) | +| Color Attribute | `mesh_attribute` | +| Normal Map / Bump | `normalmap` / `bumpmap` | +| Mapping / Texture Coordinate | `to_uv` transform | + +Value and color nodes feeding the above become Mitsuba texture plugins at export: `Math`, `Mix`, `Invert`, `Gamma`, `Brightness/Contrast`, `Map Range`, `Clamp`, `RGB Curves`, `Hue/Saturation/Value`, `Color Ramp`, `RGB to BW`, `Separate/Combine XYZ` and `Color`, and `Vector Math`. `RGB` and `Value` nodes stay constants, since there is nothing to evaluate per shading point. + +Some Mitsuba parameters are read as a scalar rather than a texture (e.g. Principled's `eta` and `specular`). Hence we cannot use the same mechanism, it is resolved by sampling over a UV grid and averaged. + +### Unsupported nodes + +Nodes outside the tables above are handle according to the **export mode**, set in the render properties panel and in the export operator: + +- **Strict** (default): any unsupported input fails the whole material, which exports as a pink error BSDF. The problem is then visible in the render rather than buried in the console. +- **Relaxed**: the socket default is used and a warning is logged, which gives a usable scene from one that contains nodes the exporter cannot represent. + +Currently unsupported: procedural textures (Voronoi, Wave, Musgrave, ...) +except for Noise Texture (3D FBM, Fac output only). + +### Material import + +`principled`, `diffuse`, `conductor`, `roughconductor`, `dielectric`, +`thindielectric`, `roughdielectric`, `plastic`, `roughplastic`, `twosided`, +`blendbsdf`, `mask`, `null`, `normalmap`, `bumpmap`, and the `bitmap`, +`checkerboard`, `scale` and `mesh_attribute` textures. Unsupported plugins +import as an error-colored placeholder material, and the import never aborts: +it reports the number of warnings at the end. + +## Development setup + +Clone the repository and symlink (or copy) the `mitsuba_blender` directory +into Blender's `user_default` extension repository, e.g. on Linux: -## Common issues - -:warning: For versions of blender prior to 3.5, you may encounter the error message `Failed to load Mitsuba package` after installing the dependencies via pip. In order to fix that, you need to run blender with the `--python-use-system-env` flag in order for it to correctly pick up the dependencies. In order to do so, find the path to the blender executable, and in a command prompt run: -``` - --python-use-system-env +```bash +ln -s $(pwd)/mitsuba_blender ~/.config/blender/4.2/extensions/user_default/ ``` -You can refer to the [Installation & Update Guide](https://github.com/mitsuba-renderer/mitsuba-blender/wiki/Installation-&-Update-Guide) on the wiki for more detailed instructions. - -### Supported versions - -Blender version should be at least `2.93`. The addon has been extensively tested -on LTS versions of blender (`4.2`, `4.5`). We recommend using those whenever -possible. - -## Running the unit tests - -The unit tests require a local Blender installation. +Then enable *Mitsuba Blender* in the extensions preferences. With no bundled +wheels in the source tree, Mitsuba must be available in Blender's Python: -The repository includes a script to locally download Blender for the unit tests: ```bash -python3 scripts/blender_downloader.py 4.4 -o blender -``` -Alternatively, the following steps can also be carries out using an already installed version of Blender. Next, we define environment variables for both the main executable and the Blender's Python interpreter. If Blender was downloaded locally, simply use: -```bash -BLENDER_PYTHON=blender/4.4/python/bin/python3.11 -BLENDER=blender/blender +/path/to/blender/4.2/python/bin/python3.11 -m pip install mitsuba==3.9.0 ``` +### Standalone plugin installation + +The add-on's Python texture plugins can also be made available to Mitsuba +without Blender by installing the package: -Locally install the required Python modules into Blender's Python environment: ```bash -$BLENDER_PYTHON -m ensurepip -$BLENDER_PYTHON -m pip install -U pip -$BLENDER_PYTHON -m pip install --upgrade pytest pytest-cov -$BLENDER_PYTHON -m pip install mitsuba +pip install mitsuba-blender ``` -To run the tests using Blender's Python, use: +Mitsuba discovers the plugins automatically through an entry point after + `set_variant` is called. No manual imports are needed. + +### Running the tests + +The test suite runs with pytest inside headless Blender: ```bash -$BLENDER -b -noaudio --factory-startup --python scripts/run_tests.py -- -v --cov=mitsuba-blender +/path/to/blender/4.2/python/bin/python3.11 -m pip install pytest +export BLENDER=/path/to/blender/blender +python3 tests/run.py # fast tests +python3 tests/run.py --all # includes slow and packaging tests ``` -If you prefer using a custom Mitsuba version, specify a path to the Mitsuba build directory: -```bash -$BLENDER -b -noaudio --factory-startup --python scripts/run_tests.py --mitsuba /some/path/mitsuba3/build -- -v --cov=mitsuba-blender -``` +Set `MITSUBA_PYTHON=/path/to/mitsuba3/build/python` to run the suite against +a local Mitsuba build instead of the wheel. See `tests/README.md` for the +full harness documentation. + +## Release process + +- CI (`.github/workflows/test.yml`) runs `tests/run.py --all` on every push + and pull request and uploads the per-platform extension zips (built by + `release/build_extension.py`, which bundles the Mitsuba wheels) as an + artifact. +- Every commit on master updates the *Nightly Release*. +- Pushing a `v*` tag creates a draft release with the same zips. Bump + `version` in `mitsuba_blender/blender_manifest.toml` first; the Mitsuba + version is pinned in `release/build_extension.py` and in the CI workflow. +- Each zip bundles mitsuba/drjit wheels for every Python version shipped by + a supported Blender release (`PYTHON_VERSIONS` in + `release/build_extension.py`, currently 3.11 and 3.13). When a new Blender + series moves to another Python version, add it there, provided Mitsuba + publishes wheels for it; otherwise cap `blender_version_max` in the + manifest until wheels exist. diff --git a/mitsuba-blender/__init__.py b/mitsuba-blender/__init__.py deleted file mode 100644 index aaee6851..00000000 --- a/mitsuba-blender/__init__.py +++ /dev/null @@ -1,307 +0,0 @@ -bl_info = { - 'name': 'Mitsuba-Blender', - 'author': 'Baptiste Nicolet, Dorian Ros, Rami Tabbara', - 'version': (0, 5, 0), - 'blender': (2, 93, 0), - 'category': 'Render', - 'location': 'File menu, render engine menu', - 'description': 'Mitsuba integration for Blender', - 'wiki_url': 'https://github.com/mitsuba-renderer/mitsuba-blender/wiki', - 'tracker_url': 'https://github.com/mitsuba-renderer/mitsuba-blender/issues/new/choose', - 'warning': 'alpha', -} - -import bpy -from bpy.props import StringProperty, BoolProperty -from bpy.types import Operator, AddonPreferences -from bpy.utils import register_class, unregister_class - -import os -import sys -import subprocess - -from . import io, engine - -DEPS_MITSUBA_VERSION = "3.7.1" - - -def get_addon_preferences(context): - return context.preferences.addons[__name__].preferences - -def init_mitsuba(context): - # Make sure we can load mitsuba from blender - try: - os.environ['DRJIT_NO_RTLD_DEEPBIND'] = 'True' - should_reload_mitsuba = 'mitsuba' in sys.modules - import mitsuba - # If mitsuba was already loaded and we change the path, we need to reload it, since the import above will be ignored - if should_reload_mitsuba: - import importlib - importlib.reload(mitsuba) - mitsuba.set_variant('scalar_rgb') - return True - except ModuleNotFoundError: - return False - -def try_register_mitsuba(context): - prefs = get_addon_preferences(context) - prefs.mitsuba_dependencies_status_message = '' - - could_init_mitsuba = False - if prefs.using_mitsuba_custom_path: - update_additional_custom_paths(prefs, context) - could_init_mitsuba = init_mitsuba(context) - if could_init_mitsuba: - import mitsuba - prefs.mitsuba_custom_version = mitsuba.__version__ - if prefs.has_valid_mitsuba_custom_version: - prefs.mitsuba_dependencies_status_message = f'Found custom Mitsuba v{prefs.mitsuba_custom_version}.' - else: - prefs.mitsuba_dependencies_status_message = f'Found custom Mitsuba v{prefs.mitsuba_custom_version}. Supported version is v{DEPS_MITSUBA_VERSION}.' - else: - prefs.mitsuba_dependencies_status_message = f'Failed to load custom Mitsuba. Please verify the path to the build directory. Used: {prefs.mitsuba_custom_path}' - elif prefs.has_pip_dependencies: - if prefs.has_valid_dependencies_version: - could_init_mitsuba = init_mitsuba(context) - if could_init_mitsuba: - import mitsuba - prefs.mitsuba_dependencies_status_message = f'Found pip Mitsuba v{mitsuba.__version__}.' - else: - prefs.mitsuba_dependencies_status_message = 'Failed to load Mitsuba package.' - else: - prefs.mitsuba_dependencies_status_message = f'Found pip Mitsuba v{prefs.installed_dependencies_version}. Supported version is v{DEPS_MITSUBA_VERSION}.' - else: - prefs.mitsuba_dependencies_status_message = 'Mitsuba dependencies not installed.' - - prefs.is_mitsuba_initialized = could_init_mitsuba - - if could_init_mitsuba: - io.register() - engine.register() - - return could_init_mitsuba - -def try_unregister_mitsuba(): - ''' - Try unregistering Addon classes. - This may fail if Mitsuba wasn't found, hence the try catch guard - ''' - try: - io.unregister() - engine.unregister() - return True - except RuntimeError: - return False - -def try_reload_mitsuba(context): - try_unregister_mitsuba() - if try_register_mitsuba(context): - # Save user preferences - bpy.ops.wm.save_userpref() - -def ensure_pip(): - result = subprocess.run([sys.executable, '-m', 'ensurepip'], capture_output=True) - return result.returncode == 0 - -def check_pip_dependencies(context): - prefs = get_addon_preferences(context) - result = subprocess.run([sys.executable, '-m', 'pip', 'list'], capture_output=True) - - prefs.has_pip_dependencies = False - prefs.has_valid_dependencies_version = False - - if result.returncode == 0: - output_str = result.stdout.decode('utf-8') - lines = output_str.splitlines(keepends=False) - for line in lines: - parts = line.split() - if len(parts) >= 2 and parts[0] == 'mitsuba': - prefs.has_pip_dependencies = True - prefs.installed_dependencies_version = parts[1] - -def clean_additional_custom_paths(self, context): - # Remove old values from system PATH and sys.path - if self.additional_python_path in sys.path: - sys.path.remove(self.additional_python_path) - if self.additional_path: - items = os.environ['PATH'].split(os.pathsep) - if self.additional_path in items: - items.remove(self.additional_path) - os.environ['PATH'] = os.pathsep.join(items) - -def update_additional_custom_paths(self, context): - build_path = bpy.path.abspath(self.mitsuba_custom_path) - if len(build_path) > 0: - clean_additional_custom_paths(self, context) - - # Add path to the binaries to the system PATH - self.additional_path = build_path - if self.additional_path not in os.environ['PATH']: - os.environ['PATH'] += os.pathsep + self.additional_path - - # Add path to python libs to sys.path - self.additional_python_path = os.path.join(build_path, 'python') - if self.additional_python_path not in sys.path: - # NOTE: We insert in the first position here, so that the custom path - # supersede the pip version - sys.path.insert(0, self.additional_python_path) - -class MITSUBA_OT_install_pip_dependencies(Operator): - bl_idname = 'mitsuba.install_pip_dependencies' - bl_label = 'Install Mitsuba pip dependencies' - bl_description = 'Use pip to install the add-on\'s required dependencies' - - @classmethod - def poll(cls, context): - prefs = get_addon_preferences(context) - return not prefs.has_pip_dependencies or not prefs.has_valid_dependencies_version - - def execute(self, context): - result = subprocess.run([sys.executable, '-m', 'pip', 'install', f'mitsuba=={DEPS_MITSUBA_VERSION}', '--force-reinstall'], capture_output=False) - if result.returncode != 0: - self.report({'ERROR'}, f'Failed to install Mitsuba with return code {result.returncode}.') - return {'CANCELLED'} - - check_pip_dependencies(context) - - try_reload_mitsuba(context) - - return {'FINISHED'} - -def update_using_mitsuba_custom_path(self, context): - self.require_restart = True - if self.using_mitsuba_custom_path: - update_mitsuba_custom_path(self, context) - else: - clean_additional_custom_paths(self, context) - -def update_mitsuba_custom_path(self, context): - if self.is_mitsuba_initialized: - self.require_restart = True - if self.using_mitsuba_custom_path and len(self.mitsuba_custom_path) > 0: - update_additional_custom_paths(self, context) - if not self.is_mitsuba_initialized: - try_reload_mitsuba(context) - -def update_installed_dependencies_version(self, context): - self.has_valid_dependencies_version = self.installed_dependencies_version == DEPS_MITSUBA_VERSION - -def update_mitsuba_custom_version(self, context): - self.has_valid_mitsuba_custom_version = self.mitsuba_custom_version == DEPS_MITSUBA_VERSION - -class MitsubaPreferences(AddonPreferences): - bl_idname = __name__ - - is_mitsuba_initialized : BoolProperty( - name = 'Is Mitsuba initialized', - ) - - has_pip_dependencies : BoolProperty( - name = 'Has pip dependencies installed', - ) - - installed_dependencies_version : StringProperty( - name = 'Installed Mitsuba dependencies version string', - default = '', - update = update_installed_dependencies_version, - ) - - has_valid_dependencies_version : BoolProperty( - name = 'Has the correct version of dependencies' - ) - - mitsuba_dependencies_status_message : StringProperty( - name = 'Mitsuba dependencies status message', - default = '', - ) - - require_restart : BoolProperty( - name = 'Require a Blender restart', - ) - - # Advanced settings - - using_mitsuba_custom_path : BoolProperty( - name = 'Using custom Mitsuba path', - update = update_using_mitsuba_custom_path, - ) - - mitsuba_custom_path : StringProperty( - name = 'Custom Mitsuba path', - description = 'Path to the custom Mitsuba build directory', - default = '', - subtype = 'DIR_PATH', - update = update_mitsuba_custom_path, - ) - - mitsuba_custom_version : StringProperty( - name = 'Custom Mitsuba build version', - default = '', - update = update_mitsuba_custom_version, - ) - - has_valid_mitsuba_custom_version : BoolProperty( - name = 'Has the correct version of custom Mitsuba build' - ) - - additional_path : StringProperty( - name = 'Addition to PATH', - default = '', - subtype = 'DIR_PATH', - ) - - additional_python_path : StringProperty( - name = 'Addition to sys.path', - default = '', - subtype = 'DIR_PATH', - ) - - def draw(self, context): - layout = self.layout - - row = layout.row() - icon = 'ERROR' - row.alert = True - if self.require_restart: - self.mitsuba_dependencies_status_message = 'A restart is required to apply the changes.' - elif self.is_mitsuba_initialized and (not self.using_mitsuba_custom_path or (self.using_mitsuba_custom_path and self.has_valid_mitsuba_custom_version)): - icon = 'CHECKMARK' - row.alert = False - row.label(text=self.mitsuba_dependencies_status_message, icon=icon) - - operator_text = 'Install dependencies' - if self.has_pip_dependencies and not self.has_valid_dependencies_version: - operator_text = 'Update dependencies' - layout.operator(MITSUBA_OT_install_pip_dependencies.bl_idname, text=operator_text) - - box = layout.box() - box.label(text='Advanced Settings') - box.prop(self, 'using_mitsuba_custom_path', text=f'Use custom Mitsuba path (Supported version is v{DEPS_MITSUBA_VERSION})') - if self.using_mitsuba_custom_path: - box.prop(self, 'mitsuba_custom_path') - -classes = ( - MITSUBA_OT_install_pip_dependencies, - MitsubaPreferences, -) - -def register(): - for cls in classes: - register_class(cls) - - context = bpy.context - prefs = get_addon_preferences(context) - prefs.require_restart = False - - if not ensure_pip(): - raise RuntimeError('Cannot activate mitsuba-blender add-on. Python pip module cannot be initialized.') - - check_pip_dependencies(context) - if try_register_mitsuba(context): - import mitsuba - print(f'mitsuba-blender v{".".join(str(e) for e in bl_info["version"])}{bl_info["warning"] if "warning" in bl_info else ""} registered (with mitsuba v{mitsuba.__version__})') - -def unregister(): - for cls in classes: - unregister_class(cls) - try_unregister_mitsuba() diff --git a/mitsuba-blender/engine/__init__.py b/mitsuba-blender/engine/__init__.py deleted file mode 100644 index 740b492c..00000000 --- a/mitsuba-blender/engine/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -import bpy -from .final import MitsubaRenderEngine - -def get_panels(): - exclude_panels = { - 'VIEWLAYER_PT_filter', - 'VIEWLAYER_PT_layer_passes', - 'RENDER_PT_simplify', - 'RENDER_PT_color_management', - 'RENDER_PT_freestyle' - } - - panels = [] - for panel in bpy.types.Panel.__subclasses__(): - if hasattr(panel, 'COMPAT_ENGINES') and 'BLENDER_RENDER' in panel.COMPAT_ENGINES: - if panel.__name__ not in exclude_panels: - panels.append(panel) - - return panels - -def register(): - from . import properties - properties.register() - bpy.utils.register_class(MitsubaRenderEngine) - for panel in get_panels(): - panel.COMPAT_ENGINES.add('MITSUBA') - -def unregister(): - from . import properties - properties.unregister() - bpy.utils.unregister_class(MitsubaRenderEngine) - for panel in get_panels(): - if 'MITSUBA' in panel.COMPAT_ENGINES: - panel.COMPAT_ENGINES.remove('MITSUBA') diff --git a/mitsuba-blender/engine/final.py b/mitsuba-blender/engine/final.py deleted file mode 100644 index b9715ee9..00000000 --- a/mitsuba-blender/engine/final.py +++ /dev/null @@ -1,67 +0,0 @@ -import bpy -import tempfile -import os -import numpy as np -from ..io.exporter import SceneConverter - -class MitsubaRenderEngine(bpy.types.RenderEngine): - - bl_idname = "MITSUBA" - bl_label = "Mitsuba" - bl_use_preview = False - - # Init is called whenever a new render engine instance is created. Multiple - # instances may exist at the same time, for example for a viewport and final - # render. - def __init__(self): - self.scene_data = None - self.draw_data = None - self.converter = SceneConverter(render=True) - - # When the render engine instance is destroy, this is called. Clean up any - # render engine data here, for example stopping running render threads. - def __del__(self): - pass - - # This is the method called by Blender for both final renders (F12) and - # small preview for materials, world and lights. - def render(self, depsgraph): - from mitsuba import set_variant - b_scene = depsgraph.scene - set_variant(b_scene.mitsuba.variant) - from mitsuba import ScopedSetThreadEnvironment, Thread - with ScopedSetThreadEnvironment(b_scene.thread_env): - scale = b_scene.render.resolution_percentage / 100.0 - self.size_x = int(b_scene.render.resolution_x * scale) - self.size_y = int(b_scene.render.resolution_y * scale) - - # Temporary workaround as long as the dict creation writes stuff to dict - with tempfile.TemporaryDirectory() as dummy_dir: - filepath = os.path.join(dummy_dir, "scene.xml") - self.converter.scene_to_dict(depsgraph) - Thread.thread().file_resolver().prepend(dummy_dir) - mts_scene = self.converter.dict_to_scene() - - sensor = mts_scene.sensors()[0] - mts_scene.integrator().render(mts_scene, sensor) - render_results = sensor.film().bitmap().split() - - for result in render_results: - buf_name = result[0].replace("", "Main") - channel_count = result[1].channel_count() if result[1].channel_count() != 2 else 3 - - self.add_pass(buf_name, channel_count, ''.join([f.name.split('.')[-1] for f in result[1].struct_()])) - - blender_result = self.begin_result(0, 0, self.size_x, self.size_y) - - for result in render_results: - render_pixels = np.array(result[1]) - if result[1].channel_count() == 2: - # Add a dummy third channel - render_pixels = np.dstack((render_pixels, np.zeros((*render_pixels.shape[:2], 1)))) - #render_pixels = np.array(render.convert(Bitmap.PixelFormat.RGBA, Struct.Type.Float32, srgb_gamma=False)) - # Here we write the pixel values to the RenderResult - buf_name = result[0].replace("", "Main") - layer = blender_result.layers[0].passes[buf_name] - layer.rect = np.flip(render_pixels, 0).reshape((self.size_x*self.size_y, -1)) - self.end_result(blender_result) diff --git a/mitsuba-blender/engine/integrators.json b/mitsuba-blender/engine/integrators.json deleted file mode 100644 index c4e70a6f..00000000 --- a/mitsuba-blender/engine/integrators.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "path" : { - "label" : "Path Tracer", - "description" : "Standard path tracer.", - "parameters" : { - "max_depth" : { - "label" : "Max. Depth", - "description" : "Longest path depth.", - "type" : "integer", - "default" : -1, - "min" : -1 - }, - "rr_depth" : { - "label" : "Russian Roulette Depth", - "description" : "Minimum path depth at which russian roulette is used.", - "type" : "integer", - "default" : 5, - "min" : 0 - }, - "hide_emitters" : { - "label" : "Hide Emitters", - "description" : "Hide directly visible emitters.", - "type" : "boolean", - "default" : false - } - } - }, - "direct" : { - "label" : "Direct Illumination Integrator", - "description" : "Direct illumination integrator with multi-importance sampling.", - "parameters" : { - "emitter_samples" : { - "label" : "Emitter Samples", - "description" : "Specifies the number of samples that should be generated using the direct illumination strategy.", - "type" : "integer", - "min" : 1, - "default" : 5 - }, - "bsdf_samples" : { - "label" : "BSDF Samples", - "description" : "Specifies the number of samples that should be generated using the BSDF sampling strategy.", - "type" : "integer", - "min" : 1, - "default" : 5 - }, - "hide_emitters" : { - "label" : "Hide Emitters", - "description" : "Hide directly visible emitters.", - "type" : "boolean", - "default" : false - } - } - }, - "aov" : { - "label" : "AOVs", - "description" : "This integrator returns one or more AOVs (Arbitraty Output Variables) describing the visible surfaces.", - "parameters" : { - "aovs" : { - "label" : "Variables", - "description" : "Variables to render.", - "type" : "list", - "values_type" : "string", - "choices" : { - "depth" : "Depth", - "position" : "Position", - "uv" : "UV Coordinates", - "geo_normal" : "Geometric Normals", - "sh_normal" : "Shading Normals", - "dp_du" : "Position Partial Derivative w.r.t. U", - "dp_dv" : "Position Partial Derivative w.r.t. V", - "duv_dx" : "UV Partial Derivative w.r.t. X", - "duv_dy" : "UV Partial Derivative w.r.t. Y" - - }, - "default" : [] - }, - "integrators" : { - "label" : "Integrators", - "description" : "Integrators to sample along the AOVs.", - "type" : "list", - "values_type" : "integrator", - "default" : [] - } - } - }, - "moment" : { - "label" : "Moment Integrator", - "description" : "Returns one AOVs recording the second moment of the samples of the nested integrator.", - "parameters" : { - "integrators" : { - "label" : "Integrators", - "description" : "Integrators to sample.", - "type" : "list", - "values_type" : "integrator", - "default" : [] - } - } - }, - "stokes" : { - "label" : "Stokes Vector Integrator", - "description" : "Returns a multi-channel image describing the complete measured polarization state at the sensor.", - "parameters" : { - "integrator" : { - "label" : "Integrator", - "description" : "Integrator to sample.", - "type" : "integrator" - } - } - }, - "depth" : { - "label": "Depth Integrator", - "description": "Returns the per-pixel distances to the intersected objects.", - "parameters": {} - } -} \ No newline at end of file diff --git a/mitsuba-blender/engine/properties.py b/mitsuba-blender/engine/properties.py deleted file mode 100644 index d956c667..00000000 --- a/mitsuba-blender/engine/properties.py +++ /dev/null @@ -1,447 +0,0 @@ -import bpy -from bpy.props import ( - BoolProperty, - CollectionProperty, - EnumProperty, - FloatProperty, - IntProperty, - PointerProperty, - StringProperty -) -from bpy.types import Panel, PropertyGroup, Operator -import os -from os.path import basename, dirname - -import json -# Read plugin data from JSON files -with open(os.path.join(dirname(__file__), "integrators.json")) as file: - integrator_data = json.load(file) -with open(os.path.join(dirname(__file__), "samplers.json")) as file: - sampler_data = json.load(file) -with open(os.path.join(dirname(__file__), "rfilters.json")) as file: - rfilter_data = json.load(file) - -def create_plugin_props(name, arg_dict, depth=1, prefix=""): - ''' - Dynamically create a PropertyGroup for a given plugin defined in arg_dict. - This allows us to avoid manually creating classes for each plugin (e.g. integrator, BSDF, etc.) - - Params - ------ - - name: the name of the Mitsuba plugin - arg_dict: the labels, description and properties defined in the JSON plugin files - depth: Recursion depth (for nested plugins, e.g. Stokes integrator) We only allow a certain amount of nesting, to avoid infinite definition of properties - prefix: Prefix to use to declare a class with a unique name - ''' - prefix += name.title() - plugin_props = type("%sProps" % prefix, (PropertyGroup, ), { - "args": arg_dict - }) - bpy.utils.register_class(plugin_props) - custom_draw = set() # List of parameter names that need to call their own draw function (nested plugins) - props_draw = set() # List of parameters to draw normally, using layout.prop() - if 'parameters' in arg_dict: - for param_name, param_dict in arg_dict['parameters'].items(): - param_type = param_dict['type'] - label = param_dict['label'] - description = param_dict['description'] if 'description' in param_dict else '' - if 'advanced' in param_dict and param_dict['advanced']: - continue # TODO - if param_type == 'integer': - props_draw.add(param_name) - setattr(plugin_props, param_name, IntProperty( - name = label, - description = description, - default = param_dict.get('default', 0), - soft_min = param_dict.get('min', -2**31), - soft_max = param_dict.get('max', 2**31-1) - )) - elif param_type == 'boolean': - props_draw.add(param_name) - setattr(plugin_props, param_name, BoolProperty( - name = label, - description = description, - default = param_dict.get('default', False) - )) - elif param_type == 'float': - props_draw.add(param_name) - setattr(plugin_props, param_name, FloatProperty( - name = label, - description = description, - default = param_dict.get('default', 0.0), - )) - # Nested plugin - elif param_type == 'integrator' or param_type == 'list' and param_dict['values_type'] == 'integrator': - enum_integrators = [] - # Nested Property group encapsulating the nested integrators - nested_props_name = "%sNestedIntProps" % prefix - nested_props = type(nested_props_name, (PropertyGroup, ), {}) - bpy.utils.register_class(nested_props) - # Property group containint one property group per integrator - int_props = type("%sIntegratorProps" % prefix, (PropertyGroup, ), {}) - bpy.utils.register_class(int_props) - for int_name, int_params in integrator_data.items(): - is_nested = False - if 'parameters' in int_params: - for param in int_params['parameters'].values(): - if param['type'] == 'integrator' or param.get('values_type', '') == 'integrator': - is_nested = True - break - if not is_nested or is_nested and depth <= 2: - setattr(int_props, int_name, PointerProperty( - name = label, - description = description, - type = create_plugin_props(int_name, int_params, depth=depth+1, prefix=prefix) - )) - enum_integrators.append((int_name, int_params['label'], int_params['description'])) - - setattr(nested_props, "active_integrator", EnumProperty( - name = "Integrator", - items = enum_integrators - )) - setattr(nested_props, "available_integrators", PointerProperty( - type = int_props - )) - - if param_type == 'integrator': - def draw_int(self, layout): - layout = layout.box() - layout.prop(self, "active_integrator") - getattr(self.available_integrators, self.active_integrator).draw(layout) - setattr(nested_props, "draw", draw_int) - setattr(plugin_props, param_name, PointerProperty( - name = label, - description = description, - type = nested_props - )) - else: # List of integrators - # In this case, we store the list in a Collection Property and nest it in a PropertyGroup, to add a custom draw method - collection_name = "%sIntCollectionProps" % prefix - collection_props = type(collection_name, (PropertyGroup, ), { - '__annotations__' : { - 'collection' : CollectionProperty( - name = label, - description = description, - type = nested_props - ), - 'selection' : IntProperty( - name = "Selected Integrator", - default = 0 - ), - 'count' : IntProperty(default=0) # Count of created instances, to give unique names - } - }) - def new(self, name="Integrator"): - new_int = self.collection.add() - if self.count == 0: - new_int.name = name - else: # Avoid duplicate names - zero_count = len(str(self.count)) - new_int.name = "%s.%s%d" % (name, '0'*(3-zero_count), self.count) - self.count += 1 - setattr(collection_props, "new", new) - bpy.utils.register_class(collection_props) - def find_class(self, context): - ''' - Look for the given class in the mitsuba settings - ''' - settings = getattr(context.scene.mitsuba.available_integrators, context.scene.mitsuba.active_integrator) - while True: - for param in dir(settings): - prop = getattr(settings, param) - prop_type = type(prop).__name__ - if 'IntCollection' in prop_type: - if self.class_name == prop_type: - return prop - else: - selection = prop.collection[prop.selection] # Currently selected integrator in the list - settings = getattr(selection.available_integrators, selection.active_integrator) - break # Go to the next depth in the param tree - elif 'NestedInt' in prop_type: - settings = getattr(prop.available_integrators, prop.active_integrator) - break # Go to the next depth in the param tree - - def execute(self, context): - ''' - add/remove an integrator - ''' - if self.action == 'ADD': - settings = self.find_class(context) - settings.new() - else: # 'REMOVE - settings = self.find_class(context) - settings.collection.remove(settings.selection) - settings.selection = max(settings.selection-1, 0) - return {'FINISHED'} - # Custom operator to add an integrator - custom_name = "OT%s" % prefix - custom_id = "custom_ot.%s" % custom_name.lower() - custom_operator = type(custom_name, (Operator, ), { - 'bl_label' : custom_name, - 'bl_idname' : custom_id, - 'bl_description' : "Add/Remove an integrator.", - 'class_name' : collection_name, - '__annotations__' : { - 'action' : EnumProperty(items=( - ('ADD', "Add", ""), - ('REMOVE', "Remove", "") - ))}, - 'find_class' : find_class, - 'execute' : execute - }) - bpy.utils.register_class(custom_operator) - - def draw_coll(self, layout): - # TODO: add the option to hide this - layout.label(text = "Integrators List", icon='VIEW_CAMERA') - layout.template_list("UI_UL_list", "UL%s"%prefix, self, "collection", self, "selection", rows=4) - split = layout.split() - split.operator(custom_id, icon='ADD', text="").action = 'ADD' - split.operator(custom_id, icon='REMOVE', text="").action = 'REMOVE' - if len(self.collection) > self.selection: - layout.label(text="Integrator Settings", icon='TOOL_SETTINGS') - layout = layout.box() - # Nested integrator to display - integrator = self.collection[self.selection] - layout.prop(integrator, "active_integrator") - getattr(integrator.available_integrators, integrator.active_integrator).draw(layout) - setattr(collection_props, "draw", draw_coll) - setattr(plugin_props, param_name, PointerProperty( - name = label, - description = description, - type = collection_props)) - custom_draw.add(param_name) - - elif param_type == 'list': - list_type = param_dict['values_type'] - if list_type == 'string': - choices = param_dict['choices'] - for choice, label in choices.items(): - props_draw.add(choice) - setattr(plugin_props, choice, BoolProperty( - name = label - )) - else: - raise NotImplementedError("Unsupported attribute type: %s in plugin '%s'" % (param_type, name)) - - def draw(self, layout): - if 'parameters' in arg_dict: - for param_name in props_draw: - layout.prop(self, param_name) - for param_name in custom_draw: - getattr(self, param_name).draw(layout) - setattr(plugin_props, "draw", draw) - - def to_dict(self): - ''' - Function that converts the plugin into a dict that can be loaded or saved by mitsuba's API - ''' - plugin_params = {'type' : name} - if 'parameters' in self.args: - for param_name, param in self.args['parameters'].items(): - if param['type'] in ('boolean', 'float', 'integer'): - plugin_params[param_name] = getattr(self, param_name) - elif param_type == 'integrator': - prop = getattr(self, param_name) - plugin_params[param_name] = getattr(prop.available_integrators, prop.active_integrator).to_dict() - elif param_type == 'list': - list_type = param['values_type'] - if list_type == 'integrator': - for integrator in self.integrators.collection: - # Make sure we don't have any leading underscores for names - Mitsuba will otherwise complain! - plugin_params[integrator.name.lstrip('_')] = getattr(integrator.available_integrators, integrator.active_integrator).to_dict() - elif list_type == 'string': - selected_items = [] - for choice in param['choices']: - if getattr(self, choice): - selected_items.append("%s:%s" % (choice, choice)) #For AOVs, paris have same name and type - plugin_params[param_name] = ','.join(selected_items) - return plugin_params - setattr(plugin_props, "to_dict", to_dict) - return plugin_props - -class MitsubaRenderSettings(PropertyGroup): - ''' - Mitsuba main rendering properties - It creates classes for each plugin described in the JSON files dynamically. - ''' - - from mitsuba import variant, variants, config - enum_variants = [] - for var in variants(): - enum_variants.append((var, var, "")) - - if config.MI_DEFAULT_VARIANT: - default_variant = config.MI_DEFAULT_VARIANT - default_variant = variant() - - variant : EnumProperty( - name = "Variant", - items = enum_variants, - default = default_variant - ) - # TODO: break variant into its subcomponents (backend/color/polarization/precision) - enum_integrators = [(name, integrator['label'], integrator['description']) for name, integrator in integrator_data.items()] - - active_integrator : EnumProperty( - name = "Integrator", - items = enum_integrators, - default = "path" - ) - # Dynamic class for integrator parameters - IntegratorProperties = type("IntegratorProperties", - (PropertyGroup, ), - { - '__annotations__' : { - # One entry per integrator plugin - name : PointerProperty(type=create_plugin_props(name, integrator)) for name, integrator in integrator_data.items() - } - } - ) - bpy.utils.register_class(IntegratorProperties) - available_integrators : PointerProperty(type = IntegratorProperties) - - @classmethod - def register(cls): - bpy.types.Scene.mitsuba = PointerProperty( - name="Mitsuba Render Settings", - description="Mitsuba render settings", - type=cls, - ) - - @classmethod - def unregister(cls): - del bpy.types.Scene.mitsuba - -class MitsubaCameraSettings(PropertyGroup): - ''' - Mitsuba main camera properties - It creates classes for each plugin described in the JSON files for rfilters and samplers dynamically. - ''' - - enum_samplers = [(name, sampler['label'], sampler['description']) for name, sampler in sampler_data.items()] - - active_sampler : EnumProperty( - name = "Sampler", - items = enum_samplers, - default = "independent" - ) - - # Dynamic class for sampler parameters - SamplerProperties = type("SamplerProperties", - (PropertyGroup, ), - { - '__annotations__' : { - # One entry per sampler plugin - name : PointerProperty(type=create_plugin_props(name, sampler)) for name, sampler in sampler_data.items() - } - } - ) - bpy.utils.register_class(SamplerProperties) - samplers : PointerProperty(type = SamplerProperties) - - enum_rfilters = [(name, rfilter['label'], rfilter['description']) for name, rfilter in rfilter_data.items()] - - active_rfilter : EnumProperty( - name = "Reconstruction Filter", - items = enum_rfilters, - default = "box" - ) - - # Dynamic class for reconstruction filter parameters - RfilterProperties = type("RfilterProperties", - (PropertyGroup, ), - { - '__annotations__' : { - # One entry per rfilter plugin - name : PointerProperty(type=create_plugin_props(name, rfilter)) for name, rfilter in rfilter_data.items() - } - } - ) - bpy.utils.register_class(RfilterProperties) - rfilters : PointerProperty(type = RfilterProperties) - - @classmethod - def register(cls): - bpy.types.Camera.mitsuba = PointerProperty( - name="Mitsuba Camera Settings", - description="Mitsuba camera settings", - type=cls, - ) - - @classmethod - def unregister(cls): - del bpy.types.Camera.mitsuba - -class MITSUBA_RENDER_PT_integrator(bpy.types.Panel): - bl_idname = "MITSUBA_RENDER_PT_integrator" - bl_label = "Integrator" - bl_space_type = 'PROPERTIES' - bl_region_type = 'WINDOW' - bl_context = 'render' - COMPAT_ENGINES = {'MITSUBA'} - - @classmethod - def poll(cls, context): - return context.engine in cls.COMPAT_ENGINES - - def draw(self, context): - layout = self.layout - mts_settings = context.scene.mitsuba - layout.prop(mts_settings, "active_integrator", text="Integrator") - getattr(mts_settings.available_integrators, mts_settings.active_integrator).draw(layout) - -class MITSUBA_CAMERA_PT_sampler(bpy.types.Panel): - bl_idname = "MITSUBA_CAMERA_PT_sampler" - bl_label = "Sampler" - bl_space_type = 'PROPERTIES' - bl_region_type = 'WINDOW' - bl_context = 'render' - def draw(self, context): - layout = self.layout - if hasattr(context.scene.camera, 'data'): - cam_settings = context.scene.camera.data.mitsuba - layout.prop(cam_settings, "active_sampler", text="Sampler") - getattr(cam_settings.samplers, cam_settings.active_sampler).draw(layout) - -class MITSUBA_CAMERA_PT_rfilter(bpy.types.Panel): - bl_idname = "MITSUBA_CAMERA_PT_rfilter" - bl_label = "Reconstruction Filter" - bl_space_type = 'PROPERTIES' - bl_region_type = 'WINDOW' - bl_context = 'render' - def draw(self, context): - layout = self.layout - if hasattr(context.scene.camera, 'data'): - cam_settings = context.scene.camera.data.mitsuba - layout.prop(cam_settings, "active_rfilter", text="Filter") - getattr(cam_settings.rfilters, cam_settings.active_rfilter).draw(layout) - -def draw_device(self, context): - scene = context.scene - layout = self.layout - layout.use_property_split = True - layout.use_property_decorate = False - - if context.engine == 'MITSUBA': - mts_settings = scene.mitsuba - - col = layout.column() - col.prop(mts_settings, "variant") - -def register(): - bpy.types.RENDER_PT_context.append(draw_device) - bpy.utils.register_class(MitsubaRenderSettings) - bpy.utils.register_class(MitsubaCameraSettings) - bpy.utils.register_class(MITSUBA_RENDER_PT_integrator) - bpy.utils.register_class(MITSUBA_CAMERA_PT_sampler) - bpy.utils.register_class(MITSUBA_CAMERA_PT_rfilter) - -def unregister(): - bpy.types.RENDER_PT_context.remove(draw_device) - bpy.utils.unregister_class(MitsubaRenderSettings) - bpy.utils.unregister_class(MitsubaCameraSettings) - bpy.utils.unregister_class(MITSUBA_RENDER_PT_integrator) - bpy.utils.unregister_class(MITSUBA_CAMERA_PT_sampler) - bpy.utils.unregister_class(MITSUBA_CAMERA_PT_rfilter) \ No newline at end of file diff --git a/mitsuba-blender/engine/rfilters.json b/mitsuba-blender/engine/rfilters.json deleted file mode 100644 index 5fb601fd..00000000 --- a/mitsuba-blender/engine/rfilters.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "box" : { - "label" : "Box", - "description" : "Box reconstruction filter." - }, - "tent" : { - "label" : "Tent", - "description" : "Simple triangular reconstruction filter." - }, - "gaussian" : { - "label" : "Gaussian", - "description" : "Windowed gaussian reconstruction filter.", - "parameters" : { - "stddev" : { - "label" : "Standard Deviation", - "description" : "Standard deviation of the gaussian.", - "type" : "float", - "default" : 0.5, - "min" : 0.0 - } - } - }, - "mitchell" : { - "label" : "Mitchell", - "description" : "Separable cubic spline reconstruction filter.", - "parameters" : { - "B" : { - "label" : "B", - "description" : "Value of the B parameter defined in the original paper.", - "type" : "float", - "default" : 0.333333 - }, - "C" : { - "label" : "C", - "description" : "Value of the C parameter defined in the original paper.", - "type" : "float", - "default" : 0.333333 - } - } - }, - "catmullrom" : { - "label" : "Catmull-Rom", - "description" : "Mitchell filter matching a Catmull-Rom spline." - }, - "lanczos" : { - "label" : "Lanczos", - "description" : "Windowed version of the theoretically optimal low-pass filter.", - "parameters" : { - "lobes" : { - "label" : "Lobes", - "description" : "sets the dnumber of filter side-lobes.", - "type" : "integer", - "default" : 3, - "min" : 1 - } - } - } -} \ No newline at end of file diff --git a/mitsuba-blender/engine/samplers.json b/mitsuba-blender/engine/samplers.json deleted file mode 100644 index ada0f293..00000000 --- a/mitsuba-blender/engine/samplers.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "independent" : { - "label" : "Independent", - "description" : "Produces independent and uniformly distributed samples.", - "parameters" : { - "sample_count" : { - "label" : "Sample Count", - "description" : "Number of samples per pixel.", - "type" : "integer", - "default" : 4, - "min" : 1 - }, - "seed" : { - "label" : "Seed", - "description" : "Seed offset.", - "type" : "integer", - "default" : 0 - } - } - }, - "stratified" : { - "label" : "Stratified", - "description" : "Divides the domain into a discrete number of strata and produces a sample within each one of them.", - "parameters" : { - "sample_count" : { - "label" : "Sample Count", - "description" : "Number of samples per pixel.", - "type" : "integer", - "default" : 4, - "min" : 1 - }, - "seed" : { - "label" : "Seed", - "description" : "Seed offset.", - "type" : "integer", - "default" : 0 - }, - "jitter": { - "label": "Jitter", - "description": "Additional random jitter withing the stratum.", - "type": "boolean", - "default": true - } - } - }, - "multijitter" : { - "label" : "Multijitter", - "description" : "Produces samples that are well stratified in 2D but also well stratified when projected onto one dimension.", - "parameters" : { - "sample_count" : { - "label" : "Sample Count", - "description" : "Number of samples per pixel.", - "type" : "integer", - "default" : 4, - "min" : 1 - }, - "seed" : { - "label" : "Seed", - "description" : "Seed offset.", - "type" : "integer", - "default" : 0 - }, - "jitter": { - "label": "Jitter", - "description": "Additional random jitter withing the substratum.", - "type": "boolean", - "default": true - } - } - } -} \ No newline at end of file diff --git a/mitsuba-blender/io/bl_utils.py b/mitsuba-blender/io/bl_utils.py deleted file mode 100644 index bb068e8c..00000000 --- a/mitsuba-blender/io/bl_utils.py +++ /dev/null @@ -1,100 +0,0 @@ - -import bpy - -def init_empty_scene(bl_context, name='Scene', clear_all_scenes=False): - ''' Create an empty Blender scene with a specific name. - - If a scene already exists with the same name, it will be - cleared. - - Params - ------ - bl_context : Blender context - name : str, optional - Name of the newly created scene - clear_all_scenes : bool, optional - Delete all other scenes from the context - - Returns - ------- - The newly created Blender scene - ''' - # Create a temporary scene to be able to delete others. - # This is required as Blender needs at least one scene - tmp_scene = bpy.data.scenes.new('mi-tmp') - - if clear_all_scenes: - # Delete all scenes that are not the temporary one - for scene in bpy.data.scenes: - if scene.name != 'mi-tmp': - bpy.data.scenes.remove(scene) - # Clear all orphaned data - if bpy.app.version < (2, 93, 0): - # NOTE: Calling `orphans_purge` on Blender 2.83 - # results in a segfault. - for data_type in ( - bpy.data.objects, - bpy.data.meshes, - bpy.data.cameras, - bpy.data.images, - bpy.data.lights, - bpy.data.materials, - bpy.data.meshes, - bpy.data.textures, - bpy.data.worlds - ): - for data in data_type: - if data and data.users == 0: - data_type.remove(data) - else: - bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) - - # Check if the scene already exists - bl_scene = bpy.data.scenes.get(name) - if bl_scene is not None: - # Delete the scene if it exists - bpy.data.scenes.remove(bl_scene) - - bl_scene = bpy.data.scenes.new(name) - - # Delete the temporary scene - bpy.data.scenes.remove(tmp_scene) - - # Clear all orphaned data - if bpy.app.version < (2, 93, 0): - bpy.ops.outliner.orphans_purge() - else: - bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) - - return bl_scene - -def init_empty_collection(bl_scene, name='Collection'): - ''' Create an empty Blender collection with a specific name - in the provided scene. - - If a collection already exists with the same name, it will be - cleared. - - Params - ------ - bl_scene : Blender scene to instantiate the collection into - name : str, optional - The name of the new collection - - Returns - ------- - The newly created Blender collection - ''' - # Check if the collection already exists - bl_collection = bpy.data.collections.get(name) - if bl_collection is not None: - # Delete the collection if it exists - for obj in bl_collection.objects: - bpy.data.objects.remove(obj) - bpy.data.collections.remove(bl_collection) - else: - # Create the new collection - bl_collection = bpy.data.collections.new(name) - # Link the collection to the scene - bl_scene.collection.children.link(bl_collection) - return bl_collection \ No newline at end of file diff --git a/mitsuba-blender/io/exporter/__init__.py b/mitsuba-blender/io/exporter/__init__.py deleted file mode 100644 index 72297867..00000000 --- a/mitsuba-blender/io/exporter/__init__.py +++ /dev/null @@ -1,122 +0,0 @@ -import os - -if "bpy" in locals(): - import importlib - if "export_context" in locals(): - importlib.reload(export_context) - if "materials" in locals(): - importlib.reload(materials) - if "geometry" in locals(): - importlib.reload(geometry) - if "lights" in locals(): - importlib.reload(lights) - if "camera" in locals(): - importlib.reload(camera) - -import bpy - -from . import export_context -from . import materials -from . import geometry -from . import lights -from . import camera - -class SceneConverter: - ''' - Converts a blender scene to a Mitsuba-compatible dict. - Either save it as an XML or load it as a scene. - ''' - def __init__(self, render=False): - self.export_ctx = export_context.ExportContext() - self.render = render - - def scene_to_dict(self, depsgraph, window_manager, use_selection=False, ignore_background=True): - """ - Convert a Blender scene to a Mitsuba-compatible dict. - - Parameters - ---------- - - depsgraph : bpy.types.Depsgraph - The evaluated dependency graph of the scene to export. - window_manager : bpy.types.WindowManager - The window manager to update the progress bar. - use_selection : bool, optional - Only export selected objects. Defaults to False. - ignore_background : bool, optional - Ignore the default background in Blender's world settings. Defaults to True. - """ - # Switch to object mode before exporting stuff, so everything is defined properly - if bpy.ops.object.mode_set.poll(): - bpy.ops.object.mode_set(mode='OBJECT') - - #depsgraph = context.evaluated_depsgraph_get() - self.export_ctx.deg = depsgraph - - b_scene = depsgraph.scene #TODO: what if there are multiple scenes? - if b_scene.render.engine == 'MITSUBA': - integrator = getattr(b_scene.mitsuba.available_integrators,b_scene.mitsuba.active_integrator).to_dict() - else: - integrator = { - 'type':'path', - 'max_depth': b_scene.cycles.max_bounces - } - self.export_ctx.data_add(integrator) - - materials.export_world(self.export_ctx, b_scene.world, ignore_background) - - # Establish list of particle objects - particles = [] - for particle_sys in bpy.data.particles: - if particle_sys.render_type == 'OBJECT': - particles.append(particle_sys.instance_object.name) - elif particle_sys.render_type == 'COLLECTION': - for obj in particle_sys.instance_collection.objects: - particles.append(obj.name) - - progress_counter = 0 - # Main export loop - for object_instance in depsgraph.object_instances: - window_manager.progress_update(progress_counter) - progress_counter += 1 - - if use_selection: - #skip if it's not selected or if it's an instance and the parent object is not selected - if not object_instance.is_instance and not object_instance.object.original.select_get(): - continue - if (object_instance.is_instance and object_instance.object.parent - and not object_instance.object.parent.original.select_get()): - continue - - evaluated_obj = object_instance.object - object_type = evaluated_obj.type - #type: enum in [‘MESH’, ‘CURVE’, ‘SURFACE’, ‘META’, ‘FONT’, ‘ARMATURE’, ‘LATTICE’, ‘EMPTY’, ‘GPENCIL’, ‘CAMERA’, ‘LIGHT’, ‘SPEAKER’, ‘LIGHT_PROBE’], default ‘EMPTY’, (readonly) - if evaluated_obj.hide_render or (object_instance.is_instance - and evaluated_obj.parent and evaluated_obj.parent.original.hide_render): - self.export_ctx.log("Object: {} is hidden for render. Ignoring it.".format(evaluated_obj.name), 'INFO') - continue#ignore it since we don't want it rendered (TODO: hide_viewport) - if object_type in {'MESH', 'FONT', 'SURFACE', 'META'}: - geometry.export_object(object_instance, self.export_ctx, evaluated_obj.name in particles) - elif object_type == 'CAMERA': - # When rendering inside blender, export only the active camera - if (self.render and evaluated_obj.name_full == b_scene.camera.name_full) or not self.render: - camera.export_camera(object_instance, b_scene, self.export_ctx) - elif object_type == 'LIGHT': - lights.export_light(object_instance, self.export_ctx) - else: - self.export_ctx.log("Object: %s of type '%s' is not supported!" % (evaluated_obj.name_full, object_type), 'WARN') - - def dict_to_xml(self, filename): - import mitsuba as mi - config = mi.parser.ParserConfig(mi.variant()) - state = mi.parser.parse_dict(config, self.export_ctx.scene_data) - # Reorder the plugins so they are written in a legible order - mi.parser.transform_reorder(config, state) - # Place files in convenient subfolders (e.g. textures, meshes, etc.) - output_dir = os.path.dirname(filename) - mi.parser.transform_relocate(config, state, output_dir) - mi.parser.write_file(state, filename, True) - - def dict_to_scene(self): - from mitsuba import load_dict - return load_dict(self.export_ctx.scene_data) diff --git a/mitsuba-blender/io/exporter/camera.py b/mitsuba-blender/io/exporter/camera.py deleted file mode 100644 index f03b7ac8..00000000 --- a/mitsuba-blender/io/exporter/camera.py +++ /dev/null @@ -1,72 +0,0 @@ -from mathutils import Matrix -import numpy as np -from math import degrees - -def export_camera(camera_instance, b_scene, export_ctx): - #camera - b_camera = camera_instance.object#TODO: instances here too? - params = {} - params['type'] = 'perspective' - - res_x = b_scene.render.resolution_x - res_y = b_scene.render.resolution_y - - # Extract fov - sensor_fit = b_camera.data.sensor_fit - if sensor_fit == 'AUTO': - params['fov_axis'] = 'x' if res_x >= res_y else 'y' - params['fov'] = degrees(b_camera.data.angle_x) - elif sensor_fit == 'HORIZONTAL': - params['fov_axis'] = 'x' - params['fov'] = degrees(b_camera.data.angle_x) - elif sensor_fit == 'VERTICAL': - params['fov_axis'] = 'y' - params['fov'] = degrees(b_camera.data.angle_y) - else: - export_ctx.log(f'Unknown \'sensor_fit\' value when exporting camera: {sensor_fit}', 'ERROR') - - params["principal_point_offset_x"] = b_camera.data.shift_x / res_x * max(res_x, res_y) - params["principal_point_offset_y"] = -b_camera.data.shift_y / res_y * max(res_x, res_y) - - #TODO: test other parameters relevance (camera.lens, orthographic_scale, dof...) - params['near_clip'] = b_camera.data.clip_start - params['far_clip'] = b_camera.data.clip_end - #TODO: check that distance units are consistent everywhere (e.g. mm everywhere) - #TODO enable focus thin lens / cam.dof - - init_rot = Matrix.Rotation(np.pi, 4, 'Y') - params['to_world'] = export_ctx.transform_matrix(b_camera.matrix_world @ init_rot) - - if b_scene.render.engine == 'MITSUBA': - sampler = getattr(b_camera.data.mitsuba.samplers, b_camera.data.mitsuba.active_sampler).to_dict() - else: - sampler = {'type' : 'independent'} - sampler['sample_count'] = b_scene.cycles.samples - - params['sampler'] = sampler - - film = {} - film['type'] = 'hdrfilm' - - scale = b_scene.render.resolution_percentage / 100 - film['width'] = int(res_x * scale) - film['height'] = int(res_y * scale) - - - if b_scene.render.engine == 'MITSUBA': - film['rfilter'] = getattr(b_camera.data.mitsuba.rfilters, b_camera.data.mitsuba.active_rfilter).to_dict() - elif b_scene.render.engine == 'CYCLES': - if b_scene.cycles.pixel_filter_type == 'GAUSSIAN': - film['rfilter'] = { - 'type': 'gaussian', - 'stddev' : b_scene.cycles.filter_width - } - elif b_scene.cycles.pixel_filter_type == 'BOX': - film['rfilter'] = {'type' : 'box'} - - params['film'] = film - - if export_ctx.export_ids: - export_ctx.data_add(params, name=b_camera.name_full) - else: - export_ctx.data_add(params) diff --git a/mitsuba-blender/io/exporter/export_context.py b/mitsuba-blender/io/exporter/export_context.py deleted file mode 100644 index b7d782c2..00000000 --- a/mitsuba-blender/io/exporter/export_context.py +++ /dev/null @@ -1,233 +0,0 @@ -from collections import OrderedDict -import os -from shutil import copy2 -from numpy import pi - -from mathutils import Matrix - -import bpy - -texture_exts = { - 'BMP': '.bmp', - 'HDR': '.hdr', - 'JPEG': '.jpg', - 'JPEG2000': '.jpg', - 'PNG': '.png', - 'OPEN_EXR': '.exr', - 'OPEN_EXR_MULTILAYER': '.exr', - 'TARGA': '.tga', - 'TARGA_RAW': '.tga', -} - -convert_format = { - 'CINEON': 'EXR', - 'DPX': 'EXR', - 'TIFF': 'PNG', - 'IRIS': 'PNG' -} - -class ExportedMaterialsCache: - ''' - Store a list of the exported materials, that have both a BSDF and an emitter - We need it to add 2 refs to each shape using this material - This is useless when a material is only one bsdf/emitter, so we won't add those. - ''' - def __init__(self): - self.mats = {} # the mixed materials (1 BSDF, 1 emitter) - - def add_material(self, mat_dict, mat_id): - """ - Store a dict containing one ref to a bsdf and one emitter - - mat_dict: {'emitter':emitter_dict, 'bsdf': bsdf_id} - mat_id: id of the blender material that encapsulates all these - """ - self.mats[mat_id] = mat_dict - - def has_mat(self, mat_id): - """ - Determine if the given material is in the cache or not - """ - return mat_id in self.mats.keys() - -class Files: - MAIN = 0 - MATS = 1 - GEOM = 2 - EMIT = 3 - CAMS = 4 - #TODO: Volumes - -class ExportContext: - ''' - Export Context - ''' - - def __init__(self): - self.scene_data = OrderedDict([('type','scene')]) - self.counter = 0 # Counter to create unique IDs. - self.exported_mats = ExportedMaterialsCache() - self.export_ids = False # Export Object IDs in the XML file - self.exported_ids = set() - # All the args defined below are set in the Converter - self.directory = '' - self.axis_mat = Matrix() # Coordinate shift - self.deg = None # Dependency graph - self.subfolders = { - 'texture': 'textures', - 'emitter': 'textures', - 'shape': 'meshes', - 'spectrum': 'spectra' - } - - def sanitize(self, name): - ''' - Sanitize a name to be used in the scene dict. - The parser class of Mitsuba does not accept dots in the names. - ''' - return name.replace('.', '_') - - def create_ref(self, name): - ''' - Create a reference dict pointing to the given name, - after sanitizing it. - ''' - return { - 'type': 'ref', - 'id': self.sanitize(name) - } - - def data_add(self, mts_dict, name=''): - ''' - Function to add new elements to the scene dict. - If a name is provided it will be used as the key of the element. - Otherwise the Id of the element is used if it exists - or a new key is generated incrementally. - ''' - if mts_dict is None or not isinstance(mts_dict, dict) or len(mts_dict) == 0 or 'type' not in mts_dict: - return False - - if not name: - try: - name = mts_dict['id'] - #remove the corresponding entry - del mts_dict['id'] - - except KeyError: - name = 'elm__%i' % self.counter - - # Sanitize name - self.scene_data.update([(self.sanitize(name), mts_dict)]) - self.counter += 1 - - return True - - def data_get(self, name): - return self.scene_data.get(self.sanitize(name)) - - def log(self, message, level='INFO'): - ''' - Log something using mitsuba's logging API - - Params - ------ - - message: What to write - level: Level of logging - ''' - from mitsuba import Log, LogLevel - log_level = { - 'DEBUG': LogLevel.Debug, - 'INFO': LogLevel.Info, - 'WARN': LogLevel.Warn, - 'ERROR': LogLevel.Error, - 'TRACE': LogLevel.Trace - } - if level not in log_level: - raise ValueError("Invalid logging level '%s'!" % level) - Log(log_level[level], message) - - def export_texture(self, image): - """ - Return the path to a texture. - Ensure the image is on disk and of a correct type - - image : The Blender Image object - """ - # TODO: don't save packed images but convert them to a mitsuba texture, and let the XML writer save - textures_folder = os.path.join(self.directory, self.subfolders['texture']) - if image.file_format in convert_format: - msg = "Image format of '%s' is not supported. Converting it to %s." % (image.name, convert_format[image.file_format]) - self.log(msg, 'WARN') - image.file_format = convert_format[image.file_format] - original_name = os.path.basename(image.filepath) - if original_name != '' and image.name.startswith(original_name): # Try to remove extensions from names of packed files to avoid stuff like 'Image.png.001.png' - base_name, _ = os.path.splitext(original_name) - name = image.name.replace(original_name, base_name, 1) # Remove the extension - name += texture_exts[image.file_format] - else: - name = "%s%s" % (image.name, texture_exts[image.file_format]) - target_path = os.path.join(textures_folder, name) - if not os.path.isdir(textures_folder): - os.makedirs(textures_folder) - old_filepath = image.filepath - image.filepath_raw = target_path - image.save() - image.filepath_raw = old_filepath - return f"{self.subfolders['texture']}/{name}" - - def spectrum(self, value, mode='rgb'): - ''' - Given a spectrum value, format it for the scene dict. - - Params - ------ - - value: value of the spectrum: can be a list, a rgb triplet, a single number or a filename - mode: rgb or spectrum, defaults to rgb - ''' - spec = {} - - if isinstance(value, (float, int)): - spec = {'value': value, 'type': mode} - - elif isinstance(value, (str)): - spec = {'filename': value, 'type': 'spectrum'} - - else: - value = list(value) - if any(not isinstance(x, (float, int, tuple)) for x in value): - raise ValueError("Unknown spectrum entry: %s" % value) - if any(type(value[i]) != type(value[i+1]) for i in range(len(value)-1)): - raise ValueError("Mixed types in spectrum entry %s" % value) - totitems = len(value) - if isinstance(value[0], (float, int)): - if totitems == 3 or totitems == 4: - spec = { - 'type': 'rgb', - 'value': value[:3] - } - elif totitems == 1: - spec = {'value': value[0], 'type': mode} - else: - raise ValueError('Expected spectrum items to be 1,3 or 4 got %d: %s' % (len(value), value)) - - else: - #wavelength list - spec = {'value': value, 'type': 'spectrum'} - - if not spec: - spec = {'value': 0.0, 'type': 'spectrum'} - - return spec - - def transform_matrix(self, matrix): - ''' - Apply coordinate shift and convert to a mitsuba Transform 4f - ''' - from mitsuba import ScalarTransform4f - if len(matrix) == 4: - mat = self.axis_mat @ matrix - else: #3x3 - mat = matrix.to_4x4() - return ScalarTransform4f(list([list(x) for x in mat])) diff --git a/mitsuba-blender/io/exporter/geometry.py b/mitsuba-blender/io/exporter/geometry.py deleted file mode 100644 index fb6cfb01..00000000 --- a/mitsuba-blender/io/exporter/geometry.py +++ /dev/null @@ -1,233 +0,0 @@ -import os -import bpy - -from .materials import export_material -from .export_context import Files - - -def convert_mesh(export_ctx, b_mesh, matrix_world, name, mat_nr): - ''' - This method creates a mitsuba mesh from a blender mesh and returns it. - It constructs a dictionary containing the necessary info such as - pointers to blender's data strucures and then loads the BlenderMesh - plugin via load_dict. - - Params - ------ - export_ctx: The export context. - b_mesh: The blender mesh to export. - matrix_world: The mesh's transform matrix. - name: The name to give to the mesh. It will not be saved, so this is mostly - for logging/debug purposes. - mat_nr: The material ID to export. - ''' - from mitsuba import load_dict, Point3i - props = { - 'type': 'blender', - 'version': ".".join(map(str,bpy.app.version)) - } - - if bpy.app.version < (4, 0, 0): - b_mesh.calc_normals() - # Compute the triangle tesselation - b_mesh.calc_loop_triangles() - - props['name'] = name - loop_tri_count = len(b_mesh.loop_triangles) - if loop_tri_count == 0: - export_ctx.log(f"Mesh: {name} has no faces. Skipping.", 'WARN') - return - props['loop_tri_count'] = loop_tri_count - - if len(b_mesh.uv_layers) > 1: - export_ctx.log(f"Mesh: '{name}' has multiple UV layers. Mitsuba only supports one. Exporting the one set active for render.", 'WARN') - for uv_layer in b_mesh.uv_layers: - if uv_layer.active_render: # If there is only 1 UV layer, it is always active - if uv_layer.name in b_mesh.attributes: - props['uvs'] = b_mesh.attributes[uv_layer.name].data[0].as_pointer() - else: - props['uvs'] = uv_layer.data[0].as_pointer() - break - - for color_layer in b_mesh.vertex_colors: - if color_layer.name in b_mesh.attributes: - props[f'vertex_{color_layer.name}'] = b_mesh.attributes[color_layer.name].data[0].as_pointer() - else: - props[f'vertex_{color_layer.name}'] = color_layer.data[0].as_pointer() - - props['loop_tris'] = b_mesh.loop_triangles[0].as_pointer() - - if '.corner_vert' in b_mesh.attributes: - # Blender 3.6+ layout - props['loops'] = b_mesh.attributes['.corner_vert'].data[0].as_pointer() - else: - props['loops'] = b_mesh.loops[0].as_pointer() - - if 'sharp_face' in b_mesh.attributes: - props['sharp_face'] = b_mesh.attributes['sharp_face'].data[0].as_pointer() - - if bpy.app.version >= (3, 6, 0): - props['polys'] = b_mesh.loop_triangle_polygons[0].as_pointer() - else: - props['polys'] = b_mesh.polygons[0].as_pointer() - - if 'position' in b_mesh.attributes: - # Blender 3.5+ layout - props['verts'] = b_mesh.attributes['position'].data[0].as_pointer() - else: - props['verts'] = b_mesh.vertices[0].as_pointer() - - if bpy.app.version > (3, 0, 0): - props['normals'] = b_mesh.vertex_normals[0].as_pointer() - - props['vert_count'] = len(b_mesh.vertices) - # Apply coordinate change - if matrix_world: - props['to_world'] = export_ctx.transform_matrix(matrix_world) - - # material index to export, as only a single material per mesh is suported in mitsuba - props['mat_nr'] = mat_nr - if 'material_index' in b_mesh.attributes: - # Blender 3.4+ layout - props['mat_indices'] = b_mesh.attributes['material_index'].data[0].as_pointer() - else: - props['mat_indices'] = 0 - - # Return the mitsuba mesh - return load_dict(props) - - -def export_object(deg_instance, export_ctx, is_particle): - """ - Convert a blender object to mitsuba and save it as Binary PLY - """ - - b_object = deg_instance.object - # Remove spurious characters such as slashes - name_clean = bpy.path.clean_name(b_object.name_full) - object_id = f"mesh-{name_clean}" - - is_instance_emitter = b_object.parent is not None and b_object.parent.is_instancer - is_instance = deg_instance.is_instance - - # Only write to file objects that have never been exported before - if export_ctx.data_get(object_id) is None: - if b_object.type == 'MESH': - b_mesh = b_object.data - else: # Metaballs, text, surfaces - b_mesh = b_object.to_mesh() - - # Convert the mesh into one mitsuba mesh per different material - mat_count = len(b_mesh.materials) - converted_parts = [] - if is_instance or is_instance_emitter: - transform = None - else: - transform = b_object.matrix_world - - - if mat_count == 0: # No assigned material - mts_mesh = convert_mesh(export_ctx, b_mesh, transform, name_clean, 0) - if mts_mesh is not None and mts_mesh.face_count() > 0: - converted_parts.append((name_clean, -1, mts_mesh)) - else: - refs_per_mat = {} - for mat_nr in range(mat_count): - mat = b_mesh.materials[mat_nr] - if not mat: - continue - - # Ensures that the exported mesh parts have unique names, - # even if multiple material slots refer to the same material. - n_mat_refs = refs_per_mat.get(mat.name, 0) - name = f'{name_clean}-{mat.name}' - - if n_mat_refs >= 1: - name += f'-{n_mat_refs:03d}' - - mts_mesh = convert_mesh(export_ctx, - b_mesh, - transform, - name, - mat_nr) - if mts_mesh is not None and mts_mesh.face_count() > 0: - converted_parts.append((name, mat_nr, mts_mesh)) - refs_per_mat[mat.name] = n_mat_refs + 1 - - if n_mat_refs == 0: - # Only export this material once - export_material(export_ctx, mat) - - if b_object.type != 'MESH': - b_object.to_mesh_clear() - - # Use a ShapeGroup for instances and split meshes - use_shapegroup = is_instance or is_instance_emitter or is_particle - # TODO: Check if shapegroups for split meshes is worth it - if use_shapegroup: - group = { - 'type': 'shapegroup' - } - - for (name, mat_nr, mts_mesh) in converted_parts: - name = name_clean if len(converted_parts) == 1 else name - mesh_id = f"mesh-{name}" - - # Save as binary ply - mesh_folder = os.path.join(export_ctx.directory, export_ctx.subfolders['shape']) - if not os.path.isdir(mesh_folder): - os.makedirs(mesh_folder) - filepath = os.path.join(mesh_folder, f"{name}.ply") - mts_mesh.write_ply(filepath) - - # Build dictionary entry - params = { - 'type': 'ply', - 'filename': f"{export_ctx.subfolders['shape']}/{name}.ply" - } - - # Add flat shading flag if needed - if not mts_mesh.has_vertex_normals(): - params["face_normals"] = True - - # Add material info - if mat_nr == -1: - if not export_ctx.data_get('default-bsdf'): # We only need to add it once - default_bsdf = { - 'type': 'twosided', - 'id': 'default-bsdf', - 'bsdf': {'type':'diffuse'} - } - export_ctx.data_add(default_bsdf) - params['bsdf'] = export_ctx.create_ref('default-bsdf') - else: - mat_id = f"mat-{b_object.data.materials[mat_nr].name}" - if export_ctx.exported_mats.has_mat(mat_id): # Add one emitter *and* one bsdf - mixed_mat = export_ctx.exported_mats.mats[mat_id] - params['bsdf'] = export_ctx.create_ref(mixed_mat['bsdf']) - params['emitter'] = mixed_mat['emitter'] - else: - params['bsdf'] = export_ctx.create_ref(mat_id) - - # Add dict to the scene dict - if use_shapegroup: - group[name] = params - else: - if export_ctx.export_ids: - export_ctx.data_add(params, name=mesh_id) - else: - export_ctx.data_add(params) - - if use_shapegroup: - export_ctx.data_add(group, name=object_id) - - if is_instance or is_particle: - params = { - 'type': 'instance', - 'shape': { - 'type': 'ref', - 'id': object_id - }, - 'to_world': export_ctx.transform_matrix(deg_instance.matrix_world) - } - export_ctx.data_add(params) diff --git a/mitsuba-blender/io/exporter/lights.py b/mitsuba-blender/io/exporter/lights.py deleted file mode 100644 index 6dabe83b..00000000 --- a/mitsuba-blender/io/exporter/lights.py +++ /dev/null @@ -1,129 +0,0 @@ -from mathutils import Matrix -import numpy as np -from .export_context import Files -import bpy - -def convert_area_light(b_light, export_ctx): - params = {} - - # Mitsuba default disks and rectangles are twice as big as blender's - scale_mat = Matrix.Scale(0.5, 4) - # Mitsuba default disks and rectangles face up and blender's face down - params['flip_normals'] = True - - #Compute area and scale - if b_light.data.shape == 'SQUARE': - params['type'] = 'rectangle' - scale_mat = Matrix.Scale(b_light.data.size, 4) @ scale_mat - x = b_light.data.size * b_light.scale.x - y = b_light.data.size * b_light.scale.y - area = x*y - - elif b_light.data.shape == 'RECTANGLE': - params['type'] = 'rectangle' - scale = Matrix() - scale[0][0] = b_light.data.size - scale[1][1] = b_light.data.size_y - scale_mat = scale @ scale_mat - x = b_light.data.size * b_light.scale.x - y = b_light.data.size_y * b_light.scale.y - area = x*y - - elif b_light.data.shape == 'DISK': - params['type'] = 'disk' - scale_mat = Matrix.Scale(b_light.data.size, 4) @ scale_mat - # size is the diameter - diam_x = b_light.data.size * b_light.scale.x - diam_y = b_light.data.size * b_light.scale.y - area = (np.pi * 0.25 * diam_x * diam_y) - else: - raise NotImplementedError("Light shape: %s is not supported." % b_light.data.shape) - - #object transform - params['to_world'] = export_ctx.transform_matrix(b_light.matrix_world @ scale_mat) - emitter = { - 'type': 'area' - } - # Conversion factor used in Cycles - if bpy.app.version < (4, 0, 0): - conv_fac = 1.0 / (area * 4.0) - else: - conv_fac = 1.0 / (area * np.pi) - emitter['radiance'] = export_ctx.spectrum(conv_fac * b_light.data.energy * b_light.data.color) - params['emitter'] = emitter - - #adding a null bsdf - bsdf = { - 'type': 'null' - } - params['bsdf'] = bsdf - return params - -def convert_point_light(b_light, export_ctx): - #normalize by the solid angle of a sphere - energy = b_light.data.energy / (4*np.pi) - intensity = export_ctx.spectrum(energy * b_light.data.color) - - #get the world position. b_light.location is only local - transform = export_ctx.transform_matrix(b_light.matrix_world) - position = list(transform.translation()) - - if b_light.data.shadow_soft_size: - export_ctx.log("Light '%s' has a non-zero soft shadow radius. It will be ignored." % b_light.name_full, 'WARN') - - return { - 'type' : 'point', - 'position' : position, - 'intensity' : intensity - } - -def convert_sun_light(b_light, export_ctx): - params = { - 'type': 'directional' - } - irradiance = b_light.data.energy * b_light.data.color - params['irradiance'] = export_ctx.spectrum(irradiance) - init_mat = Matrix.Rotation(np.pi, 4, 'X') - #change default position, apply transform and change coordinates - params['to_world'] = export_ctx.transform_matrix(b_light.matrix_world @ init_mat) - return params - -def convert_spot_light(b_light, export_ctx): - params = { - 'type': 'spot' - } - if b_light.data.shadow_soft_size: - export_ctx.log("Light '%s' has a non-zero soft shadow radius. It will be ignored." % b_light.name_full, 'WARN') - intensity = b_light.data.energy * b_light.data.color / (4.0 * np.pi) - params['intensity'] = export_ctx.spectrum(intensity) - alpha = b_light.data.spot_size / 2.0 - params['cutoff_angle'] = alpha * 180 / np.pi - b = b_light.data.spot_blend - # interior angle, computed according to this code : https://developer.blender.org/diffusion/B/browse/master/intern/cycles/kernel/kernel_light.h$149 - params['beam_width'] = np.degrees(np.arccos(b + (1.0-b) * np.cos(alpha))) - init_mat = Matrix.Rotation(np.pi, 4, 'X') - #change default position, apply transform and change coordinates - params['to_world'] = export_ctx.transform_matrix(b_light.matrix_world @ init_mat) - #TODO: look_at - return params - -light_converters = { - 'AREA': convert_area_light, - 'POINT': convert_point_light, - 'SUN': convert_sun_light, - 'SPOT': convert_spot_light -} - -def export_light(light_instance, export_ctx): - - b_light = light_instance.object - try: - params = light_converters[b_light.data.type](b_light, export_ctx) - if export_ctx.export_ids: - export_ctx.data_add(params, name="emit-%s" % b_light.name_full) - else: - export_ctx.data_add(params) - except KeyError: - export_ctx.log("Could not export '%s', light type %s is not supported" % (b_light.name_full, b_light.data.type), 'WARN') - except NotImplementedError as err: - export_ctx.log("Error while exporting light: '%s': %s" % (b_light.name_full, err.args[0]), 'WARN') diff --git a/mitsuba-blender/io/exporter/materials.py b/mitsuba-blender/io/exporter/materials.py deleted file mode 100644 index decac1b3..00000000 --- a/mitsuba-blender/io/exporter/materials.py +++ /dev/null @@ -1,566 +0,0 @@ -import bpy -import numpy as np -from mathutils import Matrix -from .export_context import Files - -RoughnessMode = {'GGX': 'ggx', 'BECKMANN': 'beckmann', 'ASHIKHMIN_SHIRLEY':'beckmann', 'MULTI_GGX':'ggx'} -#TODO: update when other distributions are supported - -def export_texture_node(export_ctx, tex_node): - params = { - 'type':'bitmap' - } - # get the relative path to the copied texture from the full path to the original texture - params['filename'] = export_ctx.export_texture(tex_node.image) - #TODO: texture transform (mapping node) - if tex_node.image.colorspace_settings.name in ['Non-Color', 'Raw', 'Linear']: - #non color data, tell mitsuba not to apply gamma conversion to it - params['raw'] = True - elif tex_node.image.colorspace_settings.name != 'sRGB': - export_ctx.log("Mitsuba only supports sRGB textures for color data.", 'WARN') - - return params - -def convert_float_texture_node(export_ctx, socket): - params = None - - if socket.is_linked: - node = socket.links[0].from_node - - if node.type == "TEX_IMAGE": - params = export_texture_node(export_ctx, node) - else: - raise NotImplementedError( "Node type %s is not supported. Only texture nodes are supported for float inputs" % node.type) - - else: - #roughness values in blender are remapped with a square root - if 'Roughness' in socket.name: - params = pow(socket.default_value, 2) - else: - params = socket.default_value - - return params - -def convert_color_texture_node(export_ctx, socket): - params = None - - if socket.is_linked: - node = socket.links[0].from_node - - if node.type == "TEX_IMAGE": - params = export_texture_node(export_ctx, node) - - elif node.type == "RGB": - #input rgb node - params = export_ctx.spectrum(node.color) - elif node.type == "VERTEX_COLOR": - params = { - 'type': 'mesh_attribute', - 'name': 'vertex_%s' % node.layer_name - } - else: - raise NotImplementedError("Node type %s is not supported. Only texture & RGB nodes are supported for color inputs" % node.type) - - else: - params = export_ctx.spectrum(socket.default_value) - - return params - -def two_sided_bsdf(bsdf): - params = { - 'type':'twosided', - 'bsdf': bsdf - } - return params - -def convert_diffuse_materials_cycles(export_ctx, current_node): - params = {} - """ - roughness = convert_float_texture_node(export_ctx, current_node.inputs['Roughness']) - if roughness: - params.update({ - 'type': 'roughdiffuse', - 'alpha': roughness, - 'distribution': 'beckmann', - }) - """ - if current_node.inputs['Roughness'].is_linked or current_node.inputs['Roughness'].default_value != 0.0: - export_ctx.log("Warning: rough diffuse BSDF is currently not supported in Mitsuba. Ignoring alpha parameter.", 'WARN') - #Rough diffuse BSDF is currently not supported in Mitsuba - params.update({ - 'type': 'diffuse' - }) - - reflectance = convert_color_texture_node(export_ctx, current_node.inputs['Color']) - - if reflectance is not None: - params.update({ - 'reflectance': reflectance, - }) - - return two_sided_bsdf(params) - -def convert_glossy_materials_cycles(export_ctx, current_node): - params = {} - - roughness = convert_float_texture_node(export_ctx, current_node.inputs['Roughness']) - - if roughness and current_node.distribution != 'SHARP': - params.update({ - 'type': 'roughconductor', - 'alpha': roughness, - 'distribution': RoughnessMode[current_node.distribution], - }) - else: - params.update({ - 'type': 'conductor' - }) - - specular_reflectance = convert_color_texture_node(export_ctx, current_node.inputs['Color']) - - if specular_reflectance is not None: - params.update({ - 'specular_reflectance': specular_reflectance, - }) - - return two_sided_bsdf(params) - -def convert_glass_materials_cycles(export_ctx, current_node): - params = {} - - if current_node.inputs['IOR'].is_linked: - raise NotImplementedError("Only default IOR value is supported in Mitsuba.") - - ior = current_node.inputs['IOR'].default_value - - roughness = convert_float_texture_node(export_ctx, current_node.inputs['Roughness']) - - if roughness and current_node.distribution != 'SHARP': - params.update({ - 'type': 'roughdielectric', - 'alpha': roughness, - 'distribution': RoughnessMode[current_node.distribution], - }) - - else: - if ior == 1.0: - params['type'] = 'thindielectric' - else: - params['type'] = 'dielectric' - - params['int_ior'] = ior - - specular_transmittance = convert_color_texture_node(export_ctx, current_node.inputs['Color']) - - if specular_transmittance is not None: - params.update({ - 'specular_transmittance': specular_transmittance, - }) - - return params - -def convert_emitter_materials_cycles(export_ctx, current_node): - - if current_node.inputs["Strength"].is_linked: - raise NotImplementedError("Only default emitter strength value is supported.")#TODO: value input - - else: - radiance = current_node.inputs["Strength"].default_value - - if current_node.inputs['Color'].is_linked: - raise NotImplementedError("Only default emitter color is supported.")#TODO: rgb input - - else: - radiance = [x * radiance for x in current_node.inputs["Color"].default_value[:]] - if np.sum(radiance) == 0: - export_ctx.log("Emitter has zero emission, this will case mitsuba to fail! Ignoring it.", 'WARN') - return {'type':'diffuse', 'reflectance': export_ctx.spectrum(0)} - - params = { - 'type': 'area', - 'radiance': export_ctx.spectrum(radiance), - } - - return params - -def convert_add_materials_cycles(export_ctx, current_node): - if not current_node.inputs[0].is_linked or not current_node.inputs[1].is_linked: - raise NotImplementedError("Add shader is not linked to two materials.") - mat_I = current_node.inputs[0].links[0].from_node - mat_II = current_node.inputs[1].links[0].from_node - - if current_node.outputs[0].links[0].to_node.type != 'OUTPUT_MATERIAL': - raise NotImplementedError("Add Shader is supported only as the final node of the shader (right behind 'Material Output').") - #TODO: we could support it better to an extent, but it creates lots of degenerate cases, some of which won't work. Is it really worth it? - elif mat_I.type != 'EMISSION' and mat_II.type != 'EMISSION': - #Two bsdfs, this is not supported - raise NotImplementedError("Adding two BSDFs is not supported, consider using a mix shader instead.") - elif mat_I.type == 'EMISSION' and mat_II.type == 'EMISSION': - #weight radiances - #only RGB values for emitter colors are supported for now, so we can do this. It may be broken if we allow textures or spectra in blender - radiance_I = [float(f) for f in convert_emitter_materials_cycles(export_ctx, mat_I)['radiance']['value'].split(" ")] - radiance_II = [float(f) for f in convert_emitter_materials_cycles(export_ctx, mat_II)['radiance']['value'].split(" ")] - - sum_radiance = [radiance_I[i] + radiance_II[i] for i in range(3)] - params = { - 'type': 'area', - 'radiance': export_ctx.spectrum(sum_radiance), - } - return params - else: - #one emitter, one bsdf - return [cycles_material_to_dict(export_ctx, mat_I), - cycles_material_to_dict(export_ctx, mat_II)] - -def convert_mix_materials_cycles(export_ctx, current_node):#TODO: test and fix this - if not current_node.inputs[1].is_linked or not current_node.inputs[2].is_linked: - raise NotImplementedError("Mix shader is not linked to two materials.") - - mat_I = current_node.inputs[1].links[0].from_node - mat_II = current_node.inputs[2].links[0].from_node - - if mat_I.type == 'EMISSION' and mat_II.type == 'EMISSION': - #weight radiances - #only RGB values for emitter colors are supported for now, so we can do this. It may be broken if we allow textures or spectra in blender - if current_node.inputs['Fac'].is_linked:#texture weight - raise NotImplementedError("Only uniform weight is supported for mixing emitters.") - radiance_I = [float(f) for f in convert_emitter_materials_cycles(export_ctx, mat_I)['radiance']['value'].split(" ")] - radiance_II = [float(f) for f in convert_emitter_materials_cycles(export_ctx, mat_II)['radiance']['value'].split(" ")] - w = current_node.inputs['Fac'].default_value - weighted_radiance = [(1.0-w)*radiance_I[i] + w*radiance_II[i] for i in range(3)] - params = { - 'type': 'area', - 'radiance': export_ctx.spectrum(weighted_radiance), - } - return params - elif mat_I.type != 'EMISSION' and mat_II.type != 'EMISSION': - # TODO: When we can process textures in-memory, support exporting mask BSDFs - weight = convert_color_texture_node(export_ctx, current_node.inputs['Fac']) - params = { - 'type': 'blendbsdf', - 'weight': weight - } - # add first material - mat_A = cycles_material_to_dict(export_ctx, mat_I) - params.update([ - ('bsdf1', mat_A) - ]) - - # add second material - mat_B = cycles_material_to_dict(export_ctx, mat_II) - params.update([ - ('bsdf2', mat_B) - ]) - - return params - else:#one bsdf, one emitter - raise NotImplementedError("Mixing a BSDF and an emitter is not supported. Consider using an Add shader instead.") - -def convert_principled_materials_cycles(export_ctx, current_node): - params = {} - - if bpy.app.version >= (4, 0, 0): - specular_key = 'Specular IOR Level' - transmission_key = 'Transmission Weight' - sheen_key = 'Sheen Weight' - clearcoat_key = 'Coat Weight' - clearcoat_roughness_key = 'Coat Roughness' - else: - specular_key = 'Specular' - transmission_key = 'Transmission' - sheen_key = 'Sheen' - clearcoat_key = 'Clearcoat' - clearcoat_roughness_key = 'Clearcoat Roughness' - - base_color = convert_color_texture_node(export_ctx, current_node.inputs['Base Color']) - specular = current_node.inputs[specular_key].default_value - if bpy.app.version >= (4, 0, 0): - specular_tint = convert_color_texture_node(export_ctx, current_node.inputs['Specular Tint']) - else: - specular_tint = convert_float_texture_node(export_ctx, current_node.inputs['Specular Tint']) - specular_trans = convert_float_texture_node(export_ctx, current_node.inputs[transmission_key]) - ior = current_node.inputs['IOR'].default_value - roughness = convert_float_texture_node(export_ctx, current_node.inputs['Roughness']) - metallic = convert_float_texture_node(export_ctx, current_node.inputs['Metallic']) - anisotropic = convert_float_texture_node(export_ctx, current_node.inputs['Anisotropic']) - sheen = convert_float_texture_node(export_ctx, current_node.inputs[sheen_key]) - if bpy.app.version >= (4, 0, 0): - sheen_tint = convert_color_texture_node(export_ctx, current_node.inputs['Sheen Tint']) - else: - sheen_tint = convert_float_texture_node(export_ctx, current_node.inputs['Sheen Tint']) - clearcoat = convert_float_texture_node(export_ctx, current_node.inputs[clearcoat_key]) - clearcoat_roughness = convert_float_texture_node(export_ctx, current_node.inputs[clearcoat_roughness_key]) - - params.update({ - 'type': 'principled', - 'base_color': base_color, - 'spec_tint': specular_tint, - 'spec_trans': specular_trans, - 'metallic': metallic, - 'anisotropic': anisotropic, - 'roughness': roughness, - 'sheen': sheen, - 'sheen_tint': sheen_tint, - 'clearcoat': clearcoat, - 'clearcoat_gloss': clearcoat_roughness - }) - - # NOTE: Blender uses the 'specular' value for dielectric/metallic reflections and the - # 'IOR' value for transmission. Mitsuba only has one value for both which can either - # be defined by 'specular' or 'eta' ('specular' will be converted into the corresponding - # 'eta' value by Mitsuba). - if type(specular_trans) is not float or specular_trans > 0: - # Export 'eta' if the material has a transmission component - params.update({ - 'eta': max(ior, 1+1e-3), - }) - # Transmissive material should not be twosided - return params - else: - # Export 'specular' if the material is only reflective - params.update({ - 'specular': max(specular, 1e-3) - }) - return two_sided_bsdf(params) - -def convert_transparent_materials_cycles(export_ctx, current_node): - if current_node.inputs['Color'].is_linked: - #TODO: in order to support opacity textures, we need the ability to invert - # a texture. This will be doable once we convert textures in-memory instead - # of on-disk (cf PR #121). - export_ctx.log("Transparent BSDF: opacity textures are currently not supported. Consider using a Mix Shader instead.", 'WARN') - bl_opacity = current_node.inputs['Color'].default_value - if min(bl_opacity) == 1.0: - # Completely transparent, export a null material - return {'type': 'null'} - - # Invert the opacity value to match Mitsuba's convention - mi_opacity = [*[1.0 - x for x in bl_opacity[:3]], bl_opacity[3]] - params = { - 'type': 'mask', - 'opacity': export_ctx.spectrum(mi_opacity), - 'bsdf': { - 'type': 'diffuse', - 'reflectance': export_ctx.spectrum(0.0) - } - } - - return params - - - -#TODO: Add more support for other materials: refraction, transparent, translucent -cycles_converters = { - 'BSDF_PRINCIPLED': convert_principled_materials_cycles, - "BSDF_DIFFUSE": convert_diffuse_materials_cycles, - 'BSDF_GLOSSY': convert_glossy_materials_cycles, - 'BSDF_GLASS': convert_glass_materials_cycles, - 'BSDF_TRANSPARENT': convert_transparent_materials_cycles, - 'EMISSION': convert_emitter_materials_cycles, - 'MIX_SHADER': convert_mix_materials_cycles, - 'ADD_SHADER': convert_add_materials_cycles, -} - -def cycles_material_to_dict(export_ctx, node): - ''' Converting one material from Blender to Mitsuba dict''' - - if node.type in cycles_converters: - params = cycles_converters[node.type](export_ctx, node) - else: - raise NotImplementedError("Node type: %s is not supported in Mitsuba." % node.type) - - return params - -def get_dummy_material(export_ctx): - return { - 'type': 'diffuse', - 'reflectance': export_ctx.spectrum([1.0, 0.0, 0.3]), - } - -def b_material_to_dict(export_ctx, b_mat): - ''' Converting one material from Blender / Cycles to Mitsuba''' - - mat_params = {} - - if b_mat.use_nodes: - try: - output_node_id = 'Material Output' - if output_node_id in b_mat.node_tree.nodes: - output_node = b_mat.node_tree.nodes[output_node_id] - surface_node = output_node.inputs["Surface"].links[0].from_node - mat_params = cycles_material_to_dict(export_ctx, surface_node) - else: - export_ctx.log(f'Export of material {b_mat.name} failed: Cannot find material output node. Exporting a dummy material instead.', 'WARN') - mat_params = get_dummy_material(export_ctx) - except NotImplementedError as e: - export_ctx.log(f'Export of material \'{b_mat.name}\' failed: {e.args[0]}. Exporting a dummy material instead.', 'WARN') - mat_params = get_dummy_material(export_ctx) - else: - mat_params = {'type':'diffuse'} - mat_params['reflectance'] = export_ctx.spectrum(b_mat.diffuse_color) - - return mat_params - -def export_material(export_ctx, material): - mat_params = {} - - if material is None: - return mat_params - - mat_id = "mat-%s" % material.name - - mat_params = b_material_to_dict(export_ctx, material) - - #TODO: hide emitters - if export_ctx.data_get(mat_id) is not None: - #material was already exported - return - - if isinstance(mat_params, list): # Add/mix shader - mats = {} - for mat in mat_params: - if mat['type'] == 'area': # Emitter - mats['emitter'] = mat # Directly store the emitter, we don't reference emitters - else:#bsdf - mat['id'] = mat_id - mats['bsdf'] = mat_id - export_ctx.data_add(mat) - export_ctx.exported_mats.add_material(mats, mat_id) - else: - if mat_params['type'] == 'area': # Emitter with no bsdf - mats = {} - # We want the emitter object to be "shadeless", so we need to add it a dummy, empty bsdf, because all objects have a bsdf by default in mitsuba - if not export_ctx.data_get('empty-emitter-bsdf'): # We only need to add one of this, but we may have multiple emitter materials - empty_bsdf = { - 'type':'diffuse', - 'reflectance':export_ctx.spectrum(0.0), # No interaction with light - 'id':'empty-emitter-bsdf' - } - export_ctx.data_add(empty_bsdf) - mats['bsdf'] = 'empty-emitter-bsdf' - mats['emitter'] = mat_params - export_ctx.exported_mats.add_material(mats, mat_id) - - else: # Usual case - export_ctx.data_add(mat_params, mat_id) - -def convert_world(export_ctx, world, ignore_background): - """ - convert environment lighting. Constant emitter and envmaps are supported - - Params - ------ - - export_ctx: the export context - surface_node: the final node of the shader - ignore_background: whether we want to export blender's default background or not - """ - - params = {} - - if world is None: - export_ctx.log('No Blender world to export.', 'INFO') - return - - if world.use_nodes and world.node_tree is not None: - output_node_id = 'World Output' - if output_node_id not in world.node_tree.nodes: - export_ctx.log('Failed to export world: Cannot find world output node.', 'WARN') - return - output_node = world.node_tree.nodes[output_node_id] - if not output_node.inputs["Surface"].is_linked: - return - surface_node = output_node.inputs["Surface"].links[0].from_node - if surface_node.inputs['Strength'].is_linked: - raise NotImplementedError("Only default emitter strength value is supported.")#TODO: value input - strength = surface_node.inputs['Strength'].default_value - - if strength == 0: # Don't add an emitter if it emits nothing - export_ctx.log('Ignoring envmap with zero strength.', 'INFO') - return - - if surface_node.type in ['BACKGROUND', 'EMISSION']: - socket = surface_node.inputs["Color"] - if socket.is_linked: - color_node = socket.links[0].from_node - if color_node.type == 'TEX_ENVIRONMENT': - params.update({ - 'type': 'envmap', - 'filename': export_ctx.export_texture(color_node.image), - 'scale': strength - }) - coordinate_mat = Matrix(((0,0,1,0),(1,0,0,0),(0,1,0,0),(0,0,0,1))) - to_world = Matrix()#4x4 Identity - if color_node.inputs["Vector"].is_linked: - vector_node = color_node.inputs["Vector"].links[0].from_node - if vector_node.type != 'MAPPING': - raise NotImplementedError("Node: %s is not supported. Only a mapping node is supported" % vector_node.bl_idname) - if not vector_node.inputs["Vector"].is_linked: - raise NotImplementedError("The node %s should be linked with a Texture coordinate node." % vector_node.bl_idname) - coord_node = vector_node.inputs["Vector"].links[0].from_node - coord_socket = vector_node.inputs["Vector"].links[0].from_socket - if coord_node.type != 'TEX_COORD': - raise NotImplementedError("Unsupported node type: %s." % coord_node.bl_idname) - if coord_socket.name != 'Generated': - raise NotImplementedError("Link should come from 'Generated'.") - #only supported node setup for transform - if vector_node.vector_type != 'TEXTURE': - raise NotImplementedError("Only 'Texture' mapping mode is supported.") - if vector_node.inputs["Location"].is_linked or vector_node.inputs["Rotation"].is_linked or vector_node.inputs["Scale"].is_linked: - raise NotImplementedError("Transfrom inputs shouldn't be linked.") - - rotation = vector_node.inputs["Rotation"].default_value.to_matrix() - scale = vector_node.inputs["Scale"].default_value - location = vector_node.inputs["Location"].default_value - for i in range(3): - for j in range(3): - to_world[i][j] = rotation[i][j] - to_world[i][i] *= scale[i] - to_world[i][3] = location[i] - to_world = to_world - #TODO: support other types of mappings (vector, point...) - #change default position, apply transform and change coordinates - params['to_world'] = export_ctx.transform_matrix(to_world @ coordinate_mat) - elif color_node.type == 'RGB': - color = color_node.color - else: - raise NotImplementedError("Node type %s is not supported. Consider using an environment texture or RGB node instead." % color_node.bl_idname) - else: - color = socket.default_value - if 'type' not in params: # Not an envmap - radiance = [x * strength for x in color[:3]] - if ignore_background and radiance == [0.05087608844041824]*3: - export_ctx.log("Ignoring Blender's default background...", 'INFO') - return - if np.sum(radiance) == 0: - export_ctx.log("Ignoring background emitter with zero emission.", 'INFO') - return - params.update({ - 'type': 'constant', - 'radiance': export_ctx.spectrum(radiance) - }) - else: - raise NotImplementedError("Only Background and Emission nodes are supported as final nodes for World export, got '%s'" % surface_node.name) - else: - # Single color field for emission, no nodes - params.update({ - 'type': 'constant', - 'radiance': export_ctx.spectrum(world.color) - }) - - if export_ctx.export_ids: - export_ctx.data_add(params, "World") - else: - export_ctx.data_add(params) - -def export_world(export_ctx, world, ignore_background): - ''' - export_ctx: export context - world: blender 'world' object - ignore_background: whether we ignore blender's default grey background or not. - ''' - - try: - convert_world(export_ctx, world, ignore_background) - except NotImplementedError as err: - export_ctx.log("Error while exporting world: %s. Not exporting it." % err.args[0], 'WARN') diff --git a/mitsuba-blender/io/importer/bl_image_utils.py b/mitsuba-blender/io/importer/bl_image_utils.py deleted file mode 100644 index 1e90b0ef..00000000 --- a/mitsuba-blender/io/importer/bl_image_utils.py +++ /dev/null @@ -1,12 +0,0 @@ -import bpy - -def load_bl_image_from_filepath(mi_context, filepath, is_data): - try: - bl_image = bpy.data.images.load(filepath) - if is_data: - bl_image.colorspace_settings.is_data = True - bl_image.colorspace_settings.name = 'Non-Color' - return bl_image - except: - mi_context.log(f'Failed to load image "{filepath}".', 'ERROR') - return None diff --git a/mitsuba-blender/io/importer/bl_import_obj.py b/mitsuba-blender/io/importer/bl_import_obj.py deleted file mode 100644 index c86d636a..00000000 --- a/mitsuba-blender/io/importer/bl_import_obj.py +++ /dev/null @@ -1,649 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0-or-later - -# - -# Script copyright (C) Campbell Barton -# Contributors: Campbell Barton, Jiri Hnidek, Paolo Ciccone - -""" -This script is based on the OBJ importer for Blender - -http://wiki.blender.org/index.php/Scripts/Manual/Import/wavefront_obj -""" - -import array -import os -import bpy - -from bpy_extras.io_utils import unpack_list - - -def line_value(line_split): - """ - Returns 1 string representing the value for this line - None will be returned if there's only 1 word - """ - length = len(line_split) - if length == 1: - return None - - elif length == 2: - return line_split[1] - - elif length > 2: - return b' '.join(line_split[1:]) - - -def filenames_group_by_ext(line, ext): - """ - Splits material libraries supporting spaces, so: - b'foo bar.mtl baz spam.MTL' -> (b'foo bar.mtl', b'baz spam.MTL') - Also handle " chars (some software use those to protect filenames with spaces, see T67266... sic). - """ - # Note that we assume that if there are some " in that line, - # then all filenames are properly enclosed within those... - start = line.find(b'"') + 1 - if start != 0: - while start != 0: - end = line.find(b'"', start) - if end != -1: - yield line[start:end] - start = line.find(b'"', end + 1) + 1 - else: - break - return - - line_lower = line.lower() - i_prev = 0 - while i_prev != -1 and i_prev < len(line): - i = line_lower.find(ext, i_prev) - if i != -1: - i += len(ext) - yield line[i_prev:i].strip() - i_prev = i - -def face_is_edge(face): - """Simple check to test whether given (temp, working) data is an edge, and not a real face.""" - face_vert_loc_indices = face[0] - face_vert_nor_indices = face[1] - return len(face_vert_nor_indices) == 1 or len(face_vert_loc_indices) == 2 - - -def split_mesh(verts_loc, faces, filepath, split_meshes): - """ - Takes vert_loc and faces, and separates into multiple sets of - (verts_loc, faces, dataname) - """ - - filename = os.path.splitext((os.path.basename(filepath)))[0] - - if not split_meshes or not faces: - use_verts_nor = any(f[1] for f in faces) - use_verts_tex = any(f[2] for f in faces) - # use the filename for the object name since we aren't chopping up the mesh. - return [(verts_loc, faces, filename, use_verts_nor, use_verts_tex)] - - def key_to_name(key): - # if the key is a tuple, join it to make a string - if not key: - return filename # assume its a string. make sure this is true if the splitting code is changed - elif isinstance(key, bytes): - return key.decode('utf-8', 'replace') - else: - return "_".join(k.decode('utf-8', 'replace') for k in key) - - # Return a key that makes the faces unique. - face_split_dict = {} - - oldkey = -1 # initialize to a value that will never match the key - - for face in faces: - (face_vert_loc_indices, - face_vert_nor_indices, - face_vert_tex_indices, - _, - context_object_key, - _, - ) = face - key = context_object_key - - if oldkey != key: - # Check the key has changed. - (verts_split, faces_split, vert_remap, - use_verts_nor, use_verts_tex) = face_split_dict.setdefault(key, ([], [], {}, [], [])) - oldkey = key - - if not face_is_edge(face): - if not use_verts_nor and face_vert_nor_indices: - use_verts_nor.append(True) - - if not use_verts_tex and face_vert_tex_indices: - use_verts_tex.append(True) - - # Remap verts to new vert list and add where needed - for loop_idx, vert_idx in enumerate(face_vert_loc_indices): - map_index = vert_remap.get(vert_idx) - if map_index is None: - map_index = len(verts_split) - vert_remap[vert_idx] = map_index # set the new remapped index so we only add once and can reference next time. - verts_split.append(verts_loc[vert_idx]) # add the vert to the local verts - - face_vert_loc_indices[loop_idx] = map_index # remap to the local index - - faces_split.append(face) - - # remove one of the items and reorder - return [(verts_split, faces_split, key_to_name(key), bool(use_vnor), bool(use_vtex)) - for key, (verts_split, faces_split, _, use_vnor, use_vtex) - in face_split_dict.items()] - - -def create_mesh(new_meshes, - use_edges, - verts_loc, - verts_nor, - verts_tex, - faces, - unique_smooth_groups, - vertex_groups, - dataname, - ): - """ - Takes all the data gathered and generates a mesh, adding the new object to new_objects - deals with ngons, sharp edges and assigning materials - """ - - if unique_smooth_groups: - sharp_edges = set() - smooth_group_users = {context_smooth_group: {} for context_smooth_group in unique_smooth_groups.keys()} - context_smooth_group_old = -1 - - fgon_edges = set() # Used for storing fgon keys when we need to tessellate/untessellate them (ngons with hole). - edges = [] - tot_loops = 0 - - context_object_key = None - - # reverse loop through face indices - for f_idx in range(len(faces) - 1, -1, -1): - face = faces[f_idx] - - (face_vert_loc_indices, - face_vert_nor_indices, - face_vert_tex_indices, - context_smooth_group, - context_object_key, - face_invalid_blenpoly, - ) = face - - len_face_vert_loc_indices = len(face_vert_loc_indices) - - if len_face_vert_loc_indices == 1: - faces.pop(f_idx) # cant add single vert faces - - # Face with a single item in face_vert_nor_indices is actually a polyline! - elif face_is_edge(face): - if use_edges: - edges.extend((face_vert_loc_indices[i], face_vert_loc_indices[i + 1]) - for i in range(len_face_vert_loc_indices - 1)) - faces.pop(f_idx) - - else: - # Smooth Group - if unique_smooth_groups and context_smooth_group: - # Is a part of of a smooth group and is a face - if context_smooth_group_old is not context_smooth_group: - edge_dict = smooth_group_users[context_smooth_group] - context_smooth_group_old = context_smooth_group - - prev_vidx = face_vert_loc_indices[-1] - for vidx in face_vert_loc_indices: - edge_key = (prev_vidx, vidx) if (prev_vidx < vidx) else (vidx, prev_vidx) - prev_vidx = vidx - edge_dict[edge_key] = edge_dict.get(edge_key, 0) + 1 - - # NGons into triangles - if face_invalid_blenpoly: - # ignore triangles with invalid indices - if len(face_vert_loc_indices) > 3: - from bpy_extras.mesh_utils import ngon_tessellate - ngon_face_indices = ngon_tessellate(verts_loc, face_vert_loc_indices, debug_print=bpy.app.debug) - faces.extend([([face_vert_loc_indices[ngon[0]], - face_vert_loc_indices[ngon[1]], - face_vert_loc_indices[ngon[2]], - ], - [face_vert_nor_indices[ngon[0]], - face_vert_nor_indices[ngon[1]], - face_vert_nor_indices[ngon[2]], - ] if face_vert_nor_indices else [], - [face_vert_tex_indices[ngon[0]], - face_vert_tex_indices[ngon[1]], - face_vert_tex_indices[ngon[2]], - ] if face_vert_tex_indices else [], - context_smooth_group, - context_object_key, - [], - ) - for ngon in ngon_face_indices] - ) - tot_loops += 3 * len(ngon_face_indices) - - # edges to make ngons - if len(ngon_face_indices) > 1: - edge_users = set() - for ngon in ngon_face_indices: - prev_vidx = face_vert_loc_indices[ngon[-1]] - for ngidx in ngon: - vidx = face_vert_loc_indices[ngidx] - if vidx == prev_vidx: - continue # broken OBJ... Just skip. - edge_key = (prev_vidx, vidx) if (prev_vidx < vidx) else (vidx, prev_vidx) - prev_vidx = vidx - if edge_key in edge_users: - fgon_edges.add(edge_key) - else: - edge_users.add(edge_key) - - faces.pop(f_idx) - else: - tot_loops += len_face_vert_loc_indices - - # Build sharp edges - if unique_smooth_groups: - for edge_dict in smooth_group_users.values(): - for key, users in edge_dict.items(): - if users == 1: # This edge is on the boundary of a group - sharp_edges.add(key) - - me = bpy.data.meshes.new(dataname) - - me.vertices.add(len(verts_loc)) - me.loops.add(tot_loops) - me.polygons.add(len(faces)) - - # verts_loc is a list of (x, y, z) tuples - me.vertices.foreach_set("co", unpack_list(verts_loc)) - - loops_vert_idx = tuple(vidx for (face_vert_loc_indices, _, _, _, _, _) in faces for vidx in face_vert_loc_indices) - faces_loop_start = [] - lidx = 0 - for f in faces: - face_vert_loc_indices = f[0] - nbr_vidx = len(face_vert_loc_indices) - faces_loop_start.append(lidx) - lidx += nbr_vidx - faces_loop_total = tuple(len(face_vert_loc_indices) for (face_vert_loc_indices, _, _, _, _, _) in faces) - - me.loops.foreach_set("vertex_index", loops_vert_idx) - me.polygons.foreach_set("loop_start", faces_loop_start) - me.polygons.foreach_set("loop_total", faces_loop_total) - - faces_use_smooth = tuple(bool(context_smooth_group) for (_, _, _, context_smooth_group, _, _) in faces) - me.polygons.foreach_set("use_smooth", faces_use_smooth) - - if verts_nor and me.loops: - # Note: we store 'temp' normals in loops, since validate() may alter final mesh, - # we can only set custom lnors *after* calling it. - me.create_normals_split() - loops_nor = tuple(no for (_, face_vert_nor_indices, _, _, _, _) in faces - for face_noidx in face_vert_nor_indices - for no in verts_nor[face_noidx]) - me.loops.foreach_set("normal", loops_nor) - - if verts_tex and me.polygons: - # Some files Do not explicitly write the 'v' value when it's 0.0, see T68249... - verts_tex = [uv if len(uv) == 2 else uv + [0.0] for uv in verts_tex] - me.uv_layers.new(do_init=False) - loops_uv = tuple(uv for (_, _, face_vert_tex_indices, _, _, _) in faces - for face_uvidx in face_vert_tex_indices - for uv in verts_tex[face_uvidx]) - me.uv_layers[0].data.foreach_set("uv", loops_uv) - - use_edges = use_edges and bool(edges) - if use_edges: - me.edges.add(len(edges)) - # edges should be a list of (a, b) tuples - me.edges.foreach_set("vertices", unpack_list(edges)) - - me.validate(clean_customdata=False) # *Very* important to not remove lnors here! - me.update(calc_edges=use_edges, calc_edges_loose=use_edges) - - # Un-tessellate as much as possible, in case we had to triangulate some ngons... - if fgon_edges: - import bmesh - bm = bmesh.new() - bm.from_mesh(me) - verts = bm.verts[:] - get = bm.edges.get - edges = [get((verts[vidx1], verts[vidx2])) for vidx1, vidx2 in fgon_edges] - try: - bmesh.ops.dissolve_edges(bm, edges=edges, use_verts=False) - except: - # Possible dissolve fails for some edges, but don't fail silently in case this is a real bug. - import traceback - traceback.print_exc() - - bm.to_mesh(me) - bm.free() - - # XXX If validate changes the geometry, this is likely to be broken... - if unique_smooth_groups and sharp_edges: - for e in me.edges: - if e.key in sharp_edges: - e.use_edge_sharp = True - - if verts_nor: - clnors = array.array('f', [0.0] * (len(me.loops) * 3)) - me.loops.foreach_get("normal", clnors) - - if not unique_smooth_groups: - me.polygons.foreach_set("use_smooth", [True] * len(me.polygons)) - - me.normals_split_custom_set(tuple(zip(*(iter(clnors),) * 3))) - me.use_auto_smooth = True - - new_meshes.append(me) - -def strip_slash(line_split): - if line_split[-1][-1] == 92: # '\' char - if len(line_split[-1]) == 1: - line_split.pop() # remove the \ item - else: - line_split[-1] = line_split[-1][:-1] # remove the \ from the end last number - return True - return False - - -def get_float_func(filepath): - """ - find the float function for this obj file - - whether to replace commas or not - """ - file = open(filepath, 'rb') - for line in file: # .readlines(): - line = line.lstrip() - if line.startswith(b'v'): # vn vt v - if b',' in line: - file.close() - return lambda f: float(f.replace(b',', b'.')) - elif b'.' in line: - file.close() - return float - - file.close() - # in case all vert values were ints - return float - - -def any_number_as_int(svalue): - if b',' in svalue: - svalue = svalue.replace(b',', b'.') - return int(float(svalue)) - - -def load(filepath, - *, - use_smooth_groups=True, - use_edges=True, - use_split_objects=True, - use_split_groups=False, - use_groups_as_vgroups=False, - ): - """ - Called by the user interface or another script. - load_obj(path) - should give acceptable results. - This function passes the file and sends the data off - to be split into objects and then converted into mesh objects - """ - def unique_name(existing_names, name_orig): - i = 0 - if name_orig is None: - name_orig = b"ObjObject" - name = name_orig - while name in existing_names: - name = b"%s.%03d" % (name_orig, i) - i += 1 - existing_names.add(name) - return name - - def handle_vec(line_start, context_multi_line, line_split, tag, data, vec, vec_len): - ret_context_multi_line = tag if strip_slash(line_split) else b'' - if line_start == tag: - vec[:] = [float_func(v) for v in line_split[1:]] - elif context_multi_line == tag: - vec += [float_func(v) for v in line_split] - if not ret_context_multi_line: - data.append(tuple(vec[:vec_len])) - return ret_context_multi_line - - def create_face(context_smooth_group, context_object_key): - face_vert_loc_indices = [] - face_vert_nor_indices = [] - face_vert_tex_indices = [] - return ( - face_vert_loc_indices, - face_vert_nor_indices, - face_vert_tex_indices, - context_smooth_group, - context_object_key, - [], # If non-empty, that face is a Blender-invalid ngon (holes...), need a mutable object for that... - ) - - if use_split_objects or use_split_groups: - use_groups_as_vgroups = False - - verts_loc = [] - verts_nor = [] - verts_tex = [] - faces = [] # tuples of the faces - vertex_groups = {} # when use_groups_as_vgroups is true - - # Get the string to float conversion func for this file- is 'float' for almost all files. - float_func = get_float_func(filepath) - - # Context variables - context_smooth_group = None - context_object_key = None - context_object_obpart = None - context_vgroup = None - - objects_names = set() - - # Until we can use sets - unique_smooth_groups = {} - - # when there are faces that end with \ - # it means they are multiline- - # since we use xreadline we cant skip to the next line - # so we need to know whether - context_multi_line = b'' - - # Per-face handling data. - face_vert_loc_indices = None - face_vert_nor_indices = None - face_vert_tex_indices = None - verts_loc_len = verts_nor_len = verts_tex_len = 0 - face_items_usage = set() - face_invalid_blenpoly = None - prev_vidx = None - face = None - vec = [] - - quick_vert_failures = 0 - skip_quick_vert = False - - with open(filepath, 'rb') as f: - for line in f: - line_split = line.split() - - if not line_split: - continue - - line_start = line_split[0] # we compare with this a _lot_ - - if len(line_split) == 1 and not context_multi_line and line_start != b'end': - print("WARNING, skipping malformatted line: %s" % line.decode('UTF-8', 'replace').rstrip()) - continue - - # Handling vertex data are pretty similar, factorize that. - # Also, most OBJ files store all those on a single line, so try fast parsing for that first, - # and only fallback to full multi-line parsing when needed, this gives significant speed-up - # (~40% on affected code). - if line_start == b'v': - vdata, vdata_len, do_quick_vert = verts_loc, 3, not skip_quick_vert - elif line_start == b'vn': - vdata, vdata_len, do_quick_vert = verts_nor, 3, not skip_quick_vert - elif line_start == b'vt': - vdata, vdata_len, do_quick_vert = verts_tex, 2, not skip_quick_vert - elif context_multi_line == b'v': - vdata, vdata_len, do_quick_vert = verts_loc, 3, False - elif context_multi_line == b'vn': - vdata, vdata_len, do_quick_vert = verts_nor, 3, False - elif context_multi_line == b'vt': - vdata, vdata_len, do_quick_vert = verts_tex, 2, False - else: - vdata_len = 0 - - if vdata_len: - if do_quick_vert: - try: - vdata.append(list(map(float_func, line_split[1:vdata_len + 1]))) - except: - do_quick_vert = False - # In case we get too many failures on quick parsing, force fallback to full multi-line one. - # Exception handling can become costly... - quick_vert_failures += 1 - if quick_vert_failures > 10000: - skip_quick_vert = True - if not do_quick_vert: - context_multi_line = handle_vec(line_start, context_multi_line, line_split, - context_multi_line or line_start, - vdata, vec, vdata_len) - - elif line_start == b'f' or context_multi_line == b'f': - if not context_multi_line: - line_split = line_split[1:] - # Instantiate a face - face = create_face(context_smooth_group, context_object_key) - (face_vert_loc_indices, face_vert_nor_indices, face_vert_tex_indices, - _, _, face_invalid_blenpoly) = face - faces.append(face) - face_items_usage.clear() - verts_loc_len = len(verts_loc) - verts_nor_len = len(verts_nor) - verts_tex_len = len(verts_tex) - # Else, use face_vert_loc_indices and face_vert_tex_indices previously defined and used the obj_face - - context_multi_line = b'f' if strip_slash(line_split) else b'' - - for v in line_split: - obj_vert = v.split(b'/') - idx = int(obj_vert[0]) # Note that we assume here we cannot get OBJ invalid 0 index... - vert_loc_index = (idx + verts_loc_len) if (idx < 1) else idx - 1 - # Add the vertex to the current group - # *warning*, this wont work for files that have groups defined around verts - if use_groups_as_vgroups and context_vgroup: - vertex_groups[context_vgroup].append(vert_loc_index) - # This a first round to quick-detect ngons that *may* use a same edge more than once. - # Potential candidate will be re-checked once we have done parsing the whole face. - if not face_invalid_blenpoly: - # If we use more than once a same vertex, invalid ngon is suspected. - if vert_loc_index in face_items_usage: - face_invalid_blenpoly.append(True) - else: - face_items_usage.add(vert_loc_index) - face_vert_loc_indices.append(vert_loc_index) - - # formatting for faces with normals and textures is - # loc_index/tex_index/nor_index - if len(obj_vert) > 1 and obj_vert[1] and obj_vert[1] != b'0': - idx = int(obj_vert[1]) - face_vert_tex_indices.append((idx + verts_tex_len) if (idx < 1) else idx - 1) - else: - face_vert_tex_indices.append(0) - - if len(obj_vert) > 2 and obj_vert[2] and obj_vert[2] != b'0': - idx = int(obj_vert[2]) - face_vert_nor_indices.append((idx + verts_nor_len) if (idx < 1) else idx - 1) - else: - face_vert_nor_indices.append(0) - - if not context_multi_line: - # Means we have finished a face, we have to do final check if ngon is suspected to be blender-invalid... - if face_invalid_blenpoly: - face_invalid_blenpoly.clear() - face_items_usage.clear() - prev_vidx = face_vert_loc_indices[-1] - for vidx in face_vert_loc_indices: - edge_key = (prev_vidx, vidx) if (prev_vidx < vidx) else (vidx, prev_vidx) - if edge_key in face_items_usage: - face_invalid_blenpoly.append(True) - break - face_items_usage.add(edge_key) - prev_vidx = vidx - - elif use_edges and (line_start == b'l' or context_multi_line == b'l'): - # very similar to the face load function above with some parts removed - if not context_multi_line: - line_split = line_split[1:] - # Instantiate a face - face = create_face(context_smooth_group, context_object_key) - face_vert_loc_indices = face[0] - # XXX A bit hackish, we use special 'value' of face_vert_nor_indices (a single True item) to tag this - # as a polyline, and not a regular face... - face[1][:] = [True] - faces.append(face) - # Else, use face_vert_loc_indices previously defined and used the obj_face - - context_multi_line = b'l' if strip_slash(line_split) else b'' - - for v in line_split: - obj_vert = v.split(b'/') - idx = int(obj_vert[0]) - 1 - face_vert_loc_indices.append((idx + len(verts_loc) + 1) if (idx < 0) else idx) - - elif line_start == b's': - if use_smooth_groups: - context_smooth_group = line_value(line_split) - if context_smooth_group == b'off': - context_smooth_group = None - elif context_smooth_group: # is not None - unique_smooth_groups[context_smooth_group] = None - - elif line_start == b'o': - if use_split_objects: - context_object_key = unique_name(objects_names, line_value(line_split)) - context_object_obpart = context_object_key - - elif line_start == b'g': - if use_split_groups: - grppart = line_value(line_split) - context_object_key = (context_object_obpart, grppart) if context_object_obpart else grppart - elif use_groups_as_vgroups: - context_vgroup = line_value(line.split()) - if context_vgroup and context_vgroup != b'(null)': - vertex_groups.setdefault(context_vgroup, []) - else: - context_vgroup = None # dont assign a vgroup - - new_meshes = [] # put new objects here - - # Split the mesh by objects/materials, may - split_meshes = bool(use_split_objects or use_split_groups) - - for data in split_mesh(verts_loc, faces, filepath, split_meshes): - verts_loc_split, faces_split, dataname, use_vnor, use_vtex = data - # Create meshes from the data, warning 'vertex_groups' wont support splitting - #~ print(dataname, use_vnor, use_vtex) - create_mesh(new_meshes, - use_edges, - verts_loc_split, - verts_nor if use_vnor else [], - verts_tex if use_vtex else [], - faces_split, - unique_smooth_groups, - vertex_groups, - dataname, - ) - - return new_meshes diff --git a/mitsuba-blender/io/importer/bl_import_ply.py b/mitsuba-blender/io/importer/bl_import_ply.py deleted file mode 100644 index 18271d5d..00000000 --- a/mitsuba-blender/io/importer/bl_import_ply.py +++ /dev/null @@ -1,407 +0,0 @@ -# ##### BEGIN GPL LICENSE BLOCK ##### -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# ##### END GPL LICENSE BLOCK ##### - -# - - -class ElementSpec: - __slots__ = ( - "name", - "count", - "properties", - ) - - def __init__(self, name, count): - self.name = name - self.count = count - self.properties = [] - - def load(self, format, stream): - if format == b'ascii': - stream = stream.readline().split() - return [x.load(format, stream) for x in self.properties] - - def index(self, name): - for i, p in enumerate(self.properties): - if p.name == name: - return i - return -1 - - -class PropertySpec: - __slots__ = ( - "name", - "list_type", - "numeric_type", - ) - - def __init__(self, name, list_type, numeric_type): - self.name = name - self.list_type = list_type - self.numeric_type = numeric_type - - def read_format(self, format, count, num_type, stream): - import struct - - if format == b'ascii': - if num_type == 's': - ans = [] - for i in range(count): - s = stream[i] - if not (len(s) >= 2 and s.startswith(b'"') and s.endswith(b'"')): - print("Invalid string", s) - print("Note: ply_import.py does not handle whitespace in strings") - return None - ans.append(s[1:-1]) - stream[:count] = [] - return ans - if num_type == 'f' or num_type == 'd': - mapper = float - else: - mapper = int - ans = [mapper(x) for x in stream[:count]] - stream[:count] = [] - return ans - else: - if num_type == 's': - ans = [] - for i in range(count): - fmt = format + 'i' - data = stream.read(struct.calcsize(fmt)) - length = struct.unpack(fmt, data)[0] - fmt = '%s%is' % (format, length) - data = stream.read(struct.calcsize(fmt)) - s = struct.unpack(fmt, data)[0] - ans.append(s[:-1]) # strip the NULL - return ans - else: - fmt = '%s%i%s' % (format, count, num_type) - data = stream.read(struct.calcsize(fmt)) - return struct.unpack(fmt, data) - - def load(self, format, stream): - if self.list_type is not None: - count = int(self.read_format(format, 1, self.list_type, stream)[0]) - return self.read_format(format, count, self.numeric_type, stream) - else: - return self.read_format(format, 1, self.numeric_type, stream)[0] - - -class ObjectSpec: - __slots__ = ("specs",) - - def __init__(self): - # A list of element_specs - self.specs = [] - - def load(self, format, stream): - return { - i.name: [ - i.load(format, stream) for j in range(i.count) - ] - for i in self.specs - } - - -def read(filepath): - import re - - format = b'' - texture = b'' - version = b'1.0' - format_specs = { - b'binary_little_endian': '<', - b'binary_big_endian': '>', - b'ascii': b'ascii', - } - type_specs = { - b'char': 'b', - b'uchar': 'B', - b'int8': 'b', - b'uint8': 'B', - b'int16': 'h', - b'uint16': 'H', - b'short': 'h', - b'ushort': 'H', - b'int': 'i', - b'int32': 'i', - b'uint': 'I', - b'uint32': 'I', - b'float': 'f', - b'float32': 'f', - b'float64': 'd', - b'double': 'd', - b'string': 's', - } - obj_spec = ObjectSpec() - invalid_ply = (None, None, None) - - with open(filepath, 'rb') as plyf: - signature = plyf.peek(5) - - if not signature.startswith(b'ply') or not len(signature) >= 5: - print("Signature line was invalid") - return invalid_ply - - custom_line_sep = None - if signature[3] != ord(b'\n'): - if signature[3] != ord(b'\r'): - print("Unknown line separator") - return invalid_ply - if signature[4] == ord(b'\n'): - custom_line_sep = b"\r\n" - else: - custom_line_sep = b"\r" - - # Work around binary file reading only accepting "\n" as line separator. - plyf_header_line_iterator = lambda plyf: plyf - if custom_line_sep is not None: - def _plyf_header_line_iterator(plyf): - buff = plyf.peek(2**16) - while len(buff) != 0: - read_bytes = 0 - buff = buff.split(custom_line_sep) - for line in buff[:-1]: - read_bytes += len(line) + len(custom_line_sep) - if line.startswith(b'end_header'): - # Since reader code might (will) break iteration at this point, - # we have to ensure file is read up to here, yield, amd return... - plyf.read(read_bytes) - yield line - return - yield line - plyf.read(read_bytes) - buff = buff[-1] + plyf.peek(2**16) - plyf_header_line_iterator = _plyf_header_line_iterator - - valid_header = False - for line in plyf_header_line_iterator(plyf): - tokens = re.split(br'[ \r\n]+', line) - - if len(tokens) == 0: - continue - if tokens[0] == b'end_header': - valid_header = True - break - elif tokens[0] == b'comment': - if len(tokens) < 2: - continue - elif tokens[1] == b'TextureFile': - if len(tokens) < 4: - print("Invalid texture line") - else: - texture = tokens[2] - continue - - elif tokens[0] == b'obj_info': - continue - elif tokens[0] == b'format': - if len(tokens) < 3: - print("Invalid format line") - return invalid_ply - if tokens[1] not in format_specs: - print("Unknown format", tokens[1]) - return invalid_ply - try: - version_test = float(tokens[2]) - except Exception as ex: - print("Unknown version", ex) - version_test = None - if version_test != float(version): - print("Unknown version", tokens[2]) - return invalid_ply - del version_test - format = tokens[1] - elif tokens[0] == b'element': - if len(tokens) < 3: - print("Invalid element line") - return invalid_ply - obj_spec.specs.append(ElementSpec(tokens[1], int(tokens[2]))) - elif tokens[0] == b'property': - if not len(obj_spec.specs): - print("Property without element") - return invalid_ply - if tokens[1] == b'list': - obj_spec.specs[-1].properties.append(PropertySpec(tokens[4], type_specs[tokens[2]], type_specs[tokens[3]])) - else: - obj_spec.specs[-1].properties.append(PropertySpec(tokens[2], None, type_specs[tokens[1]])) - if not valid_header: - print("Invalid header ('end_header' line not found!)") - return invalid_ply - - obj = obj_spec.load(format_specs[format], plyf) - - return obj_spec, obj, texture - - -def load_ply_mesh(filepath, ply_name): - import bpy - - obj_spec, obj, texture = read(filepath) - # XXX28: use texture - if obj is None: - print("Invalid file") - return - - uvindices = colindices = None - colmultiply = None - - # TODO import normals - # noindices = None - - for el in obj_spec.specs: - if el.name == b'vertex': - vindices_x, vindices_y, vindices_z = el.index(b'x'), el.index(b'y'), el.index(b'z') - # noindices = (el.index('nx'), el.index('ny'), el.index('nz')) - # if -1 in noindices: noindices = None - uvindices = (el.index(b's'), el.index(b't')) - if -1 in uvindices: - uvindices = (el.index(b'u'), el.index(b'v')) - if -1 in uvindices: - uvindices = None - # ignore alpha if not present - if el.index(b'alpha') == -1: - colindices = el.index(b'red'), el.index(b'green'), el.index(b'blue') - else: - colindices = el.index(b'red'), el.index(b'green'), el.index(b'blue'), el.index(b'alpha') - if -1 in colindices: - if any(idx > -1 for idx in colindices): - print("Warning: At least one obligatory color channel is missing, ignoring vertex colors.") - colindices = None - else: # if not a float assume uchar - colmultiply = [1.0 if el.properties[i].numeric_type in {'f', 'd'} else (1.0 / 255.0) for i in colindices] - - elif el.name == b'face': - findex = el.index(b'vertex_indices') - elif el.name == b'tristrips': - trindex = el.index(b'vertex_indices') - elif el.name == b'edge': - eindex1, eindex2 = el.index(b'vertex1'), el.index(b'vertex2') - - mesh_faces = [] - mesh_uvs = [] - mesh_colors = [] - - def add_face(vertices, indices, uvindices, colindices): - mesh_faces.append(indices) - if uvindices: - # Mitsuba seems to flip the UVS on the y-axis when exporting. We account for this here by flipping them - # back to the original coordinates. - mesh_uvs.extend([(vertices[index][uvindices[0]], 1-vertices[index][uvindices[1]]) for index in indices]) - if colindices: - if len(colindices) == 3: - mesh_colors.extend([ - ( - vertices[index][colindices[0]] * colmultiply[0], - vertices[index][colindices[1]] * colmultiply[1], - vertices[index][colindices[2]] * colmultiply[2], - 1.0, - ) - for index in indices - ]) - elif len(colindices) == 4: - mesh_colors.extend([ - ( - vertices[index][colindices[0]] * colmultiply[0], - vertices[index][colindices[1]] * colmultiply[1], - vertices[index][colindices[2]] * colmultiply[2], - vertices[index][colindices[3]] * colmultiply[3], - ) - for index in indices - ]) - - if uvindices or colindices: - # If we have Cols or UVs then we need to check the face order. - add_face_simple = add_face - - # EVIL EEKADOODLE - face order annoyance. - def add_face(vertices, indices, uvindices, colindices): - if len(indices) == 4: - if indices[2] == 0 or indices[3] == 0: - indices = indices[2], indices[3], indices[0], indices[1] - elif len(indices) == 3: - if indices[2] == 0: - indices = indices[1], indices[2], indices[0] - - add_face_simple(vertices, indices, uvindices, colindices) - - verts = obj[b'vertex'] - - if b'face' in obj: - for f in obj[b'face']: - ind = f[findex] - add_face(verts, ind, uvindices, colindices) - - if b'tristrips' in obj: - for t in obj[b'tristrips']: - ind = t[trindex] - len_ind = len(ind) - for j in range(len_ind - 2): - add_face(verts, (ind[j], ind[j + 1], ind[j + 2]), uvindices, colindices) - - mesh = bpy.data.meshes.new(name=ply_name) - - mesh.vertices.add(len(obj[b'vertex'])) - - mesh.vertices.foreach_set("co", [a for v in obj[b'vertex'] for a in (v[vindices_x], v[vindices_y], v[vindices_z])]) - - if b'edge' in obj: - mesh.edges.add(len(obj[b'edge'])) - mesh.edges.foreach_set("vertices", [a for e in obj[b'edge'] for a in (e[eindex1], e[eindex2])]) - - if mesh_faces: - loops_vert_idx = [] - faces_loop_start = [] - faces_loop_total = [] - lidx = 0 - for f in mesh_faces: - nbr_vidx = len(f) - loops_vert_idx.extend(f) - faces_loop_start.append(lidx) - faces_loop_total.append(nbr_vidx) - lidx += nbr_vidx - - mesh.loops.add(len(loops_vert_idx)) - mesh.polygons.add(len(mesh_faces)) - - mesh.loops.foreach_set("vertex_index", loops_vert_idx) - mesh.polygons.foreach_set("loop_start", faces_loop_start) - mesh.polygons.foreach_set("loop_total", faces_loop_total) - - if uvindices: - uv_layer = mesh.uv_layers.new() - for i, uv in enumerate(uv_layer.data): - uv.uv = mesh_uvs[i] - - if colindices: - vcol_lay = mesh.vertex_colors.new() - - for i, col in enumerate(vcol_lay.data): - col.color[0] = mesh_colors[i][0] - col.color[1] = mesh_colors[i][1] - col.color[2] = mesh_colors[i][2] - col.color[3] = mesh_colors[i][3] - - mesh.update() - mesh.validate() - - if texture and uvindices: - # TODO add support for using texture. - pass - - return mesh diff --git a/mitsuba-blender/io/importer/bl_shader_utils.py b/mitsuba-blender/io/importer/bl_shader_utils.py deleted file mode 100644 index c7bf3d5b..00000000 --- a/mitsuba-blender/io/importer/bl_shader_utils.py +++ /dev/null @@ -1,243 +0,0 @@ -from collections import OrderedDict - -from mathutils import Color - -def rgb_to_rgba(color): - return color + [1.0] - -def rgba_to_rgb(color): - return Color(color[0], color[1], color[2]) - -class NodeShaderWrapper: - ''' Utility wrapper around a node-based Blender shader ''' - def __init__(self, bl_node_tree, init_empty=False, out_node=None): - ''' Construct a new NodeShaderWrapper - - Params - ------ - bl_node_tree : The wrapped Blender shader node tree - init_empty : bool, optional - If set, the material's node tree will be cleared - out_node : optional - Reference to the output (root) node of the material. - If not set, the default output material node is used. - If init_empty is set, this argument is ignored. - ''' - self.tree = bl_node_tree - # Clear the node tree if requested - if init_empty: - for node in self.tree.nodes: - self.tree.nodes.remove(node) - # Get the output node - self.out_node = self._ensure_out_node() - elif out_node is not None: - # Try to find the provided output node in the node tree - for node in self.tree.nodes: - if node == out_node: - self.out_node = node - assert self.out_node is not None - else: - # Ensure that an output node exists - self.out_node = self._ensure_out_node() - - def _delete_node_recursive(self, node): - for input in node.inputs: - if input.is_linked: - for link in input.links: - self._delete_node_recursive(link.from_node) - self.tree.nodes.remove(node) - - def _ensure_out_node(self): - raise NotImplementedError('To implement in subclasses') - - def _get_socket_with_id(self, socket_list, identifier): - for socket in socket_list: - if socket.identifier == identifier: - return socket - return None - - def ensure_node_type(self, path, bl_idname, output_socket_id): - ''' Ensures that a node of a certain type exists at the correct location - in the graph. If another node already exists at that location, then it is - removed. - - Params - ------ - path: list[str] - Path to the requested node. Each element of this list represent the identifier - of the input socket to follow starting from the output node. - bl_idname: str - Type of the node that should be connected to the last input in the path. - output_socket_id: str - Socket identifier of the newly created node that should be connected to the rest - of the path. - - Returns - ------- - The reference to the existing or newly created node. - ''' - current_node = self.out_node - next_socket = None - final_node = None - for i, next_id in enumerate(path): - # Ensure that a starting point for the path exists from the current node - next_socket = self._get_socket_with_id(current_node.inputs, next_id) - assert next_socket is not None - if i < len(path)-1: - # If this is not the last element of the path, follow the path if it exists - assert next_socket.is_linked - current_node = next_socket.links[0].from_node - elif next_socket.is_linked: - # If this is the last element of the path, check that the last node is of the - # correct type. If not, delete it recursively. - final_node = next_socket.links[0].from_node - if final_node.bl_idname != bl_idname: - self._delete_node_recursive(final_node) - final_node = None - # Create the new node only if it was not already present - if final_node is None: - final_node = self.tree.nodes.new(type=bl_idname) - output_socket = self._get_socket_with_id(final_node.outputs, output_socket_id) - assert output_socket is not None - self.tree.links.new(output_socket, next_socket) - return final_node - - def _get_node_depths(self): - def _traverse(node, graph=OrderedDict(), depth=0): - node_depth = depth - if node in graph: - current_node_depth = graph[node] - node_depth = depth if current_node_depth < depth else current_node_depth - graph[node] = node_depth - for input in node.inputs: - for link in input.links: - _traverse(link.from_node, graph, depth=depth+1) - return graph - - graph = _traverse(self.out_node) - depths = [] - for node, depth in graph.items(): - while len(depths) <= depth: - depths.append([]) - depths[depth].append(node) - return depths - - def _get_approximate_node_dimension(self, node): - ''' Get an approximation of a node's dimensions. - Nodes have dimensions attributes, however they are not updated until they are - displayed in the editor. Therefore, we cannot use them in this case as we create - and format the entire node tree in a script. - We use the number of inputs and outputs plus the header times a standard height - of 24 units as an approximation. POUET - ''' - # Hardcoded constant width - width = 240 - height = 24 * (len(node.inputs) + len(node.outputs) + 1) - return (width, height) - - def format_node_tree(self): - ''' Formats the placement of material nodes in the shader editor. ''' - margin_x = 100 - margin_y = 50 - - node_depths = self._get_node_depths() - tree_depth = len(node_depths) - - # 2D bbox, [min_x, min_y, max_x, max_y] - tree_bbox = [0.0, 0.0, 0.0, 0.0] - def expand_bbox(tree_bbox, other_bbox): - if other_bbox[0] < tree_bbox[0]: - tree_bbox[0] = other_bbox[0] - if other_bbox[1] < tree_bbox[1]: - tree_bbox[1] = other_bbox[1] - if other_bbox[2] > tree_bbox[2]: - tree_bbox[2] = other_bbox[2] - if other_bbox[3] > tree_bbox[3]: - tree_bbox[3] = other_bbox[3] - return tree_bbox - - current_x = 0.0 - for depth in range(tree_depth): - depth_width = 0.0 - depth_height = 0.0 - node_dims = [] - for node in node_depths[depth]: - node_width, node_height = self._get_approximate_node_dimension(node) - node_dims.append((node_width, node_height)) - if node_width > depth_width: - depth_width = node_width - depth_height += node_height + margin_y - - current_y = depth_height / 2.0 - for i, node in enumerate(node_depths[depth]): - node.location = (current_x, current_y) - node_width, node_height = node_dims[i] - tree_bbox = expand_bbox(tree_bbox, [current_x, current_y-node_height, current_x+node_width, current_y]) - current_y -= node_height + margin_y - - current_x -= depth_width + margin_x - - center = [(tree_bbox[0]+tree_bbox[2])/2.0, (tree_bbox[1]+tree_bbox[3])/2.0] - for node in self.tree.nodes: - current_location = node.location - node.location = (current_location[0]-center[0], current_location[1]-center[1]) - -class NodeMaterialWrapper(NodeShaderWrapper): - ''' Utility wrapper around a node-based Blender material ''' - def __init__(self, bl_mat, init_empty=False, out_node=None): - ''' Construct a new NodeMaterialWrapper - - Params - ------ - bl_mat : The wrapped Blender material - init_empty : bool, optional - If set, the material's node tree will be cleared - out_node : optional - Reference to the output (root) node of the material. - If not set, the default output material node is used. - If init_empty is set, this argument is ignored. - ''' - self.bl_mat = bl_mat - if not bl_mat.use_nodes: - bl_mat.use_nodes = True - super(NodeMaterialWrapper, self).__init__(bl_mat.node_tree, init_empty, out_node) - - def _ensure_out_node(self): - out_node = None - for node in self.tree.nodes: - if node.bl_idname == 'ShaderNodeOutputMaterial': - out_node = node - break - if out_node is None: - out_node = self.tree.nodes.new(type='ShaderNodeOutputMaterial') - return out_node - -class NodeWorldWrapper(NodeShaderWrapper): - ''' Utility wrapper around a node-based Blender world ''' - def __init__(self, bl_world, init_empty=False, out_node=None): - ''' Construct a new NodeWorldWrapper - - Params - ------ - bl_world : The wrapped Blender world - init_empty : bool, optional - If set, the material's node tree will be cleared - out_node : optional - Reference to the output (root) node of the material. - If not set, the default output material node is used. - If init_empty is set, this argument is ignored. - ''' - self.bl_world = bl_world - if not bl_world.use_nodes: - bl_world.use_nodes = True - super(NodeWorldWrapper, self).__init__(bl_world.node_tree, init_empty, out_node) - - def _ensure_out_node(self): - out_node = None - for node in self.tree.nodes: - if node.bl_idname == 'ShaderNodeOutputWorld': - out_node = node - break - if out_node is None: - out_node = self.tree.nodes.new(type='ShaderNodeOutputWorld') - return out_node diff --git a/mitsuba-blender/io/importer/common.py b/mitsuba-blender/io/importer/common.py deleted file mode 100644 index cfee018d..00000000 --- a/mitsuba-blender/io/importer/common.py +++ /dev/null @@ -1,52 +0,0 @@ -import os - -class MitsubaSceneImportContext: - ''' Define a context for the Mitsuba scene importer ''' - def __init__(self, bl_context, bl_scene, bl_collection, filepath, mi_state, axis_matrix): - self.bl_context = bl_context - self.bl_scene = bl_scene - self.bl_collection = bl_collection - self.filepath = filepath - self.directory, _ = os.path.split(self.filepath) - self.mi_state = mi_state - self.axis_matrix = axis_matrix - self.axis_matrix_inv = axis_matrix.inverted() - self.bl_material_cache = {} # Mapping of Mitsuba node IDs to Blender materials - self.bl_texture_cache = {} # Mapping of Mitsuba node IDs to Blender textures - self.processed_nodes = set() - - def log(self, message, level='INFO'): - ''' - Log something using mitsuba's logging API - - Params - ------ - - message: What to write - level: Level of logging - ''' - from mitsuba import Log, LogLevel - log_level = { - 'DEBUG': LogLevel.Debug, - 'INFO': LogLevel.Info, - 'WARN': LogLevel.Warn, - 'ERROR': LogLevel.Error, - 'TRACE': LogLevel.Trace - } - if level not in log_level: - raise ValueError("Invalid logging level '%s'!" % level) - Log(log_level[level], message) - - def bl_space_to_mi_space(self, matrix): - return self.axis_matrix @ matrix - - def mi_space_to_bl_space(self, matrix): - return self.axis_matrix_inv @ matrix - - def resolve_scene_relative_path(self, path): - abs_path = os.path.join(self.directory, path) - if not os.path.exists(abs_path): - self.log(f'Cannot resolve scene relative path "{path}".', 'ERROR') - return None - return abs_path - diff --git a/mitsuba-blender/io/importer/emitters.py b/mitsuba-blender/io/importer/emitters.py deleted file mode 100644 index 8c7a4b2b..00000000 --- a/mitsuba-blender/io/importer/emitters.py +++ /dev/null @@ -1,116 +0,0 @@ -import math - -if "bpy" in locals(): - import importlib - if "bl_transform_utils" in locals(): - importlib.reload(bl_transform_utils) - if "mi_spectra_utils" in locals(): - importlib.reload(mi_spectra_utils) - -import bpy -from mathutils import Matrix, Vector - -from . import bl_transform_utils -from . import mi_spectra_utils - -###################### -## Utilities ## -###################### - -def _get_matrix_from_direction(direction, up): - z = direction - x = up.cross(z) - # If `direction` is colinear with the `up` vector, we choose - # `x` as an arbitrary orthogonal vector to `up`. - if x.length_squared == 0: - x = up.orthogonal() - y = z.cross(x) - - x.normalize() - y.normalize() - z.normalize() - - rot = Matrix() - rot[0][0] = x[0] - rot[0][1] = y[0] - rot[0][2] = z[0] - rot[0][3] = 0 - rot[1][0] = x[1] - rot[1][1] = y[1] - rot[1][2] = z[1] - rot[1][3] = 0 - rot[2][0] = x[2] - rot[2][1] = y[2] - rot[2][2] = z[2] - rot[2][3] = 0 - - return rot - -def _get_radiance_value(mi_context, mi_emitter, mi_prop_name, default): - from mitsuba import Properties - if mi_prop_name in mi_emitter: - mi_prop_type = mi_emitter.type(mi_prop_name) - if mi_prop_type == Properties.Type.Color: - return mi_spectra_utils.convert_mi_srgb_emitter_spectrum(mi_emitter.get_emissive_texture(mi_prop_name), default) - else: - mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to float.', 'ERROR') - elif default is not None: - return mi_spectra_utils.get_color_strength_from_radiance(default) - else: - mi_context.log(f'Material "{mi_emitter.id()}" does not have property "{mi_prop_name}".', 'ERROR') - -###################### -## Converters ## -###################### - -def mi_point_to_bl_light(mi_context, mi_emitter): - bl_light = bpy.data.lights.new(name=mi_emitter.id(), type='POINT') - - color, strength = _get_radiance_value(mi_context, mi_emitter, 'intensity', [10/(math.pi*4)]*3) - bl_light.color = color - bl_light.energy = strength * math.pi * 4 - bl_light.shadow_soft_size = 0 - - if 'to_world' in mi_emitter: - world_matrix = mi_context.mi_space_to_bl_space(bl_transform_utils.mi_transform_to_bl_transform(mi_emitter.get('to_world', None))) - else: - world_matrix = Matrix.Translation(mi_context.mi_space_to_bl_space(Vector(mi_emitter.get('position', [0.0, 0.0, 0.0])))) - - return bl_light, world_matrix - -def mi_directional_to_bl_light(mi_context, mi_emitter): - bl_light = bpy.data.lights.new(name=mi_emitter.id(), type='SUN') - - color, strength = _get_radiance_value(mi_context, mi_emitter, 'irradiance', [1.0, 1.0, 1.0]) - bl_light.color = color - bl_light.energy = strength - - rot_mat = Matrix.Rotation(-math.pi, 4, 'X') - if 'to_world' in mi_emitter: - world_matrix = bl_transform_utils.mi_transform_to_bl_transform(mi_emitter.get('to_world', None)) - elif 'direction' in mi_emitter: - world_matrix = _get_matrix_from_direction(Vector(mi_emitter.get('direction', [0.0, 0.0, 1.0])), Vector([0.0, 1.0, 0.0])) - else: - world_matrix = Matrix() - - return bl_light, mi_context.mi_space_to_bl_space(world_matrix @ rot_mat) - -###################### -## Main import ## -###################### - -_emitter_converters = { - 'point': mi_point_to_bl_light, - 'directional': mi_directional_to_bl_light, -} - -def mi_emitter_to_bl_light(mi_context, mi_emitter): - emitter_type = mi_emitter.plugin_name() - if emitter_type not in _emitter_converters: - mi_context.log(f'Mitsuba Emitter type "{emitter_type}" not supported.', 'ERROR') - return None - - # Create the Blender object - bl_light, world_matrix = _emitter_converters[emitter_type](mi_context, mi_emitter) - - return bl_light, world_matrix diff --git a/mitsuba-blender/io/importer/materials.py b/mitsuba-blender/io/importer/materials.py deleted file mode 100644 index 2a16ae58..00000000 --- a/mitsuba-blender/io/importer/materials.py +++ /dev/null @@ -1,675 +0,0 @@ -import math - -if "bpy" in locals(): - import importlib - if "bl_material_utils" in locals(): - importlib.reload(bl_shader_utils) - if "mi_spectra_utils" in locals(): - importlib.reload(mi_spectra_utils) - if "mi_props_utils" in locals(): - importlib.reload(mi_props_utils) - if "textures" in locals(): - importlib.reload(textures) - -import bpy - -from . import bl_shader_utils -from . import mi_spectra_utils -from . import textures -from .mi_props_utils import get_references_by_type - -################# -## Utilities ## -################# - -def _eval_mi_bsdf_retro_reflection(mi_context, mi_mat, default): - ''' Evaluate the reflectance color of a BSDF for a perfect perpendicular reflection ''' - from mitsuba import load_dict, BSDFContext, SurfaceInteraction3f, Vector3f - # Generate the BSDF properties dictionary - bsdf_dict = { - 'type': mi_mat.plugin_name(), - } - for name in mi_mat.property_names(): - bsdf_dict[name] = mi_mat.get(name) - - bsdf = load_dict(bsdf_dict) - si = SurfaceInteraction3f() - si.wi = Vector3f(0.0, 0.0, 1.0) - wo = Vector3f(0.0, 0.0, 1.0) - color, pdf = bsdf.eval_pdf(BSDFContext(), si, wo) - if pdf == 0.0: - return default - return list(color / pdf) - -def get_bl_texture(mi_context, mi_tex_id): - if mi_tex_id in mi_context.bl_texture_cache: - return mi_context.bl_texture_cache[mi_tex_id] - mi_props = mi_context.mi_state.nodes[mi_tex_id].props - # We only parse bitmap textures - if mi_props.plugin_name() != 'bitmap': - mi_context.log(f'Mitsuba texture "{mi_props.id()}" of type "{mi_props.plugin_name()}" is not supported. Only "bitmap" textures are supported.', 'ERROR') - return None - - # Load the image - bl_image = textures.mi_texture_to_bl_image(mi_context, mi_props) - mi_context.bl_texture_cache[mi_tex_id] = bl_image - return bl_image - -################################ -## Misc property converters ## -################################ - -def mi_wrap_mode_to_bl_extension(mi_context, mi_wrap_mode): - if mi_wrap_mode == 'repeat': - return 'REPEAT' - elif mi_wrap_mode == 'mirror': - # NOTE: Blender does not support mirror wrap mode - return 'REPEAT' - elif mi_wrap_mode == 'clamp': - return 'CLIP' - else: - mi_context.log(f'Mitsuba wrap mode "{mi_wrap_mode}" is not supported.', 'ERROR') - return None - -def mi_filter_type_to_bl_interpolation(mi_context, mi_filter_type): - if mi_filter_type == 'bilinear': - return 'Cubic' - elif mi_filter_type == 'nearest': - return 'Closest' - else: - mi_context.log(f'Mitsuba filter type "{mi_filter_type}" is not supported.', 'ERROR') - return None - -_ior_string_values = { - 'acetone': 1.36, - 'acrylic glass': 1.49, - 'air': 1.00028, - 'amber': 1.55, - 'benzene': 1.501, - 'bk7': 1.5046, - 'bromine': 1.661, - 'carbon dioxide': 1.00045, - 'carbon tetrachloride': 1.461, - 'diamond': 2.419, - 'ethanol': 1.361, - 'fused quartz': 1.458, - 'glycerol': 1.4729, - 'helium': 1.00004, - 'hydrogen': 1.00013, - 'pet': 1.575, - 'polypropylene': 1.49, - 'pyrex': 1.470, - 'silicone oil': 1.52045, - 'sodium chloride': 1.544, - 'vacuum': 1.0, - 'water': 1.3330, - 'water ice': 1.31, -} - -def mi_ior_string_to_float(mi_context, mi_ior): - if mi_ior not in _ior_string_values: - mi_context.log(f'Mitsuba IOR name "{mi_ior}" is not supported.', 'ERROR') - return 1.0 - return _ior_string_values[mi_ior] - -_microfacet_distribution_values = { - 'beckmann': 'BECKMANN', - 'ggx': 'GGX' -} - -def mi_microfacet_to_bl_microfacet(mi_context, mi_microfacet_distribution): - if mi_microfacet_distribution not in _microfacet_distribution_values: - mi_context.log(f'Mitsuba microfacet distribution "{mi_microfacet_distribution}" not supported.', 'ERROR') - return 'BECKMANN' - return _microfacet_distribution_values[mi_microfacet_distribution] - -############################## -## Float property writers ## -############################## - -def write_mi_float_bitmap(mi_context, mi_tex_id, bl_mat_wrap, out_socket_id, default=None): - mi_texture = mi_context.mi_state.nodes[mi_tex_id].props - bl_image = get_bl_texture(mi_context, mi_tex_id) - - # FIXME: Support texture coordinate mapping - # FIXME: For float textures, it is not always clear if we should use the 'Alpha' output instead of the luminance value. - bl_teximage = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeTexImage', 'Color') - bl_teximage.image = bl_image - bl_teximage.extension = mi_wrap_mode_to_bl_extension(mi_context, mi_texture.get('wrap_mode', 'repeat')) - bl_teximage.interpolation = mi_filter_type_to_bl_interpolation(mi_context, mi_texture.get('filter_type', 'bilinear')) - -_float_texture_writers = { - 'bitmap': write_mi_float_bitmap -} - -def write_mi_float_texture(mi_context, mi_tex_id, bl_mat_wrap, out_socket_id, default=None): - mi_texture_type = mi_context.mi_state.nodes[mi_tex_id].props.plugin_name() - if mi_texture_type not in _float_texture_writers: - mi_context.log(f'Mitsuba Texture type "{mi_texture_type}" is not supported.', 'ERROR') - return - _float_texture_writers[mi_texture_type](mi_context, mi_tex_id, bl_mat_wrap, out_socket_id, default) - -def write_mi_float_srgb_reflectance_spectrum(mi_context, mi_obj, bl_mat_wrap, out_socket_id, default=None): - reflectance = mi_spectra_utils.convert_mi_srgb_reflectance_spectrum(mi_obj, default) - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = mi_spectra_utils.linear_rgb_to_luminance(reflectance) - -_float_spectrum_writers = { - 'SRGBReflectanceSpectrum': write_mi_float_srgb_reflectance_spectrum -} - -def write_mi_float_spectrum(mi_context, mi_obj, bl_mat_wrap, out_socket_id, default=None): - mi_obj_class_name = mi_obj.class_name() - if mi_obj_class_name not in _float_spectrum_writers: - mi_context.log(f'Mitsuba object type "{mi_obj_class_name}" is not supported.', 'ERROR') - return - _float_spectrum_writers[mi_obj_class_name](mi_context, mi_obj, bl_mat_wrap, out_socket_id, default) - -def write_mi_float_value(mi_context, float_value, bl_mat_wrap, out_socket_id, transformation=None): - if transformation is not None: - float_value = transformation(float_value) - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = float_value - -def write_mi_float_property(mi_context, mi_mat, mi_prop_name, bl_mat_wrap, out_socket_id, default=None, transformation=None, bounded=True): - from mitsuba import Properties - if mi_prop_name in mi_mat: - mi_prop_type = mi_mat.type(mi_prop_name) - if mi_prop_type == Properties.Type.Float: - mi_prop_value = mi_mat.get(mi_prop_name, default) - write_mi_float_value(mi_context, mi_prop_value, bl_mat_wrap, out_socket_id, transformation) - elif mi_prop_type == Properties.Type.Color: - if bounded: - mi_col = mi_mat.get_texture(mi_prop_name, default) - else: - mi_col = mi_mat.get_unbounded_texture(mi_prop_name, default) - write_mi_float_spectrum(mi_context, mi_col, bl_mat_wrap, out_socket_id, default) - elif mi_prop_type == Properties.Type.ResolvedReference: - mi_tex_id = mi_mat[mi_prop_name].index() - write_mi_float_texture(mi_context, mi_tex_id, bl_mat_wrap, out_socket_id, default) - else: - mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to float.', 'ERROR') - elif default is not None: - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = default - else: - mi_context.log(f'Material "{mi_mat.id()}" does not have property "{mi_prop_name}".', 'ERROR') - -############################ -## RGB property writers ## -############################ - -def write_mi_rgb_bitmap(mi_context, mi_tex_id, bl_mat_wrap, out_socket_id, default=None): - bl_image = get_bl_texture(mi_context, mi_tex_id) - #TODO: handle None case - mi_texture = mi_context.mi_state.nodes[mi_tex_id].props - - # FIXME: Support texture coordinate mapping - bl_teximage = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeTexImage', 'Color') - bl_teximage.image = bl_image - bl_teximage.extension = mi_wrap_mode_to_bl_extension(mi_context, mi_texture.get('wrap_mode', 'repeat')) - bl_teximage.interpolation = mi_filter_type_to_bl_interpolation(mi_context, mi_texture.get('filter_type', 'bilinear')) - -_rgb_texture_writers = { - 'bitmap': write_mi_rgb_bitmap -} - -def write_mi_rgb_texture(mi_context, mi_tex_id, bl_mat_wrap, out_socket_id, default=None): - mi_texture_type = mi_context.mi_state.nodes[mi_tex_id].props.plugin_name() - if mi_texture_type not in _rgb_texture_writers: - mi_context.log(f'Mitsuba Texture type "{mi_texture_type}" is not supported.', 'ERROR') - return - _rgb_texture_writers[mi_texture_type](mi_context, mi_tex_id, bl_mat_wrap, out_socket_id, default) - -def write_mi_rgb_srgb_reflectance_spectrum(mi_context, mi_obj, bl_mat_wrap, out_socket_id, default=None): - reflectance = mi_spectra_utils.convert_mi_srgb_reflectance_spectrum(mi_obj, default) - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = bl_shader_utils.rgb_to_rgba(reflectance) - -_rgb_spectrum_writers = { - 'SRGBReflectanceSpectrum': write_mi_rgb_srgb_reflectance_spectrum -} - -def write_mi_rgb_spectrum(mi_context, mi_obj, bl_mat_wrap, out_socket_id, default=None): - mi_obj_class_name = mi_obj.class_name() - if mi_obj_class_name not in _rgb_spectrum_writers: - mi_context.log(f'Mitsuba object type "{mi_obj_class_name}" is not supported.', 'ERROR') - return - _rgb_spectrum_writers[mi_obj_class_name](mi_context, mi_obj, bl_mat_wrap, out_socket_id, default) - -def write_mi_rgb_value(mi_context, rgb_value, bl_mat_wrap, out_socket_id): - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = bl_shader_utils.rgb_to_rgba(rgb_value) - -def write_mi_rgb_property(mi_context, mi_mat, mi_prop_name, bl_mat_wrap, out_socket_id, default=None): - from mitsuba import Properties - if mi_prop_name in mi_mat: - mi_prop_type = mi_mat.type(mi_prop_name) - if mi_prop_type == Properties.Type.Color: - mi_col = mi_mat.get_texture(mi_prop_name) - write_mi_rgb_spectrum(mi_context, mi_col, bl_mat_wrap, out_socket_id, default) - elif mi_prop_type == Properties.Type.Float: - if mi_prop_name in mi_mat: - col_val = mi_mat.get(mi_prop_name) - col = [col_val, col_val, col_val] - else: - col = default - write_mi_rgb_value(mi_context, list(col), bl_mat_wrap, out_socket_id) - elif mi_prop_type == Properties.Type.ResolvedReference: - mi_tex_id = mi_mat[mi_prop_name].index() - write_mi_rgb_texture(mi_context, mi_tex_id, bl_mat_wrap, out_socket_id, default) - else: - mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to rgb.', 'ERROR') - elif default is not None: - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = bl_shader_utils.rgb_to_rgba(default) - else: - mi_context.log(f'Material "{mi_mat.id()}" does not have property "{mi_prop_name}".', 'ERROR') - -############################ -## IOR property writers ## -############################ - -def write_mi_ior_property(mi_context, mi_mat, mi_prop_name, bl_mat_wrap, out_socket_id, default=None): - from mitsuba import Properties - if mi_prop_name in mi_mat: - mi_prop_type = mi_mat.type(mi_prop_name) - if mi_prop_type == Properties.Type.Float: - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = mi_mat.get(mi_prop_name, default) - elif mi_prop_type == Properties.Type.String: - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = mi_ior_string_to_float(mi_context, mi_mat.get(mi_prop_name, 'bk7')) - else: - mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to float.', 'ERROR') - elif default is not None: - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = default - else: - mi_context.log(f'Material "{mi_mat.id()}" does not have property "{mi_prop_name}".', 'ERROR') - -################################## -## Roughness property writers ## -################################## - -def write_mi_roughness_property(mi_context, mi_mat, mi_prop_name, bl_mat_wrap, out_socket_id, default=None): - # FIXME: Check that the roughness value transformation is actually correct. - # FIXME: Verify that roughness textures don't also need to take the transformation into account. - write_mi_float_property(mi_context, mi_mat, mi_prop_name, bl_mat_wrap, out_socket_id, default ** 2, lambda x: math.sqrt(x), bounded=False) - -############################# -## Normal & Bump writers ## -############################# - -def write_mi_bump_and_normal_maps(mi_context, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - from mitsuba import ObjectType - normal_mat_wrap = bl_mat_wrap - if mi_bump is not None: - bl_bump = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBump', 'Normal') - bl_bump_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_bump) - mi_tex_ids = get_references_by_type(mi_context, mi_bump, [ObjectType.Texture]) - assert len(mi_tex_ids) == 1 - write_mi_float_bitmap(mi_context, mi_tex_ids[0], bl_bump_wrap, 'Height', 0.0) - height = mi_bump.get('scale', 1.0) - write_mi_float_value(mi_context, height, bl_bump_wrap, 'Distance') - - normal_mat_wrap = bl_bump_wrap - - if mi_normal is not None: - bl_normal = normal_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeNormalMap', 'Normal') - bl_normal_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_normal) - write_mi_rgb_property(mi_context, mi_normal, 'normalmap', bl_normal_wrap, 'Color', [0.5, 0.5, 1.0]) - -########################### -## Area emitter writer ## -########################### - -def write_mi_emitter_bsdf(mi_context, bl_mat_wrap, out_socket_id, mi_emitter): - from mitsuba import Properties - bl_add = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeAddShader', 'Shader') - bl_add_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_add) - - bl_emissive = bl_add_wrap.ensure_node_type(['Shader'], 'ShaderNodeEmission', 'Emission') - if mi_emitter.type('radiance') == Properties.Type.Color: - radiance, strength = mi_spectra_utils.convert_mi_srgb_emitter_spectrum(mi_emitter.get_emissive_texture('radiance'), [1.0, 1.0, 1.0]) - - bl_emissive.inputs['Color'].default_value = bl_shader_utils.rgb_to_rgba(radiance) - bl_emissive.inputs['Strength'].default_value = strength - - return bl_add_wrap, 'Shader_001' - -###################### -## BSDF writers ## -###################### - -def write_mi_principled_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - if bpy.app.version >= (4, 0, 0): - specular_key = 'Specular IOR Level' - transmission_key = 'Transmission Weight' - sheen_key = 'Sheen Weight' - clearcoat_key = 'Coat Weight' - clearcoat_roughness_key = 'Coat Roughness' - else: - specular_key = 'Specular' - transmission_key = 'Transmission' - sheen_key = 'Sheen' - clearcoat_key = 'Clearcoat' - clearcoat_roughness_key = 'Clearcoat Roughness' - - - bl_principled = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfPrincipled', 'BSDF') - bl_principled_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_principled) - write_mi_rgb_property(mi_context, mi_mat, 'base_color', bl_principled_wrap, 'Base Color', [0.8, 0.8, 0.8]) - write_mi_float_property(mi_context, mi_mat, 'specular', bl_principled_wrap, specular_key, 0.5) - if bpy.app.version >= (4, 0, 0): - write_mi_rgb_property(mi_context, mi_mat, 'spec_tint', bl_principled_wrap, 'Specular Tint', [0.0, 0.0, 0.0]) - else: - write_mi_float_property(mi_context, mi_mat, 'spec_tint', bl_principled_wrap, 'Specular Tint', 0.0) - - write_mi_float_property(mi_context, mi_mat, 'spec_trans', bl_principled_wrap, transmission_key, 0.0) - write_mi_float_property(mi_context, mi_mat, 'metallic', bl_principled_wrap, 'Metallic', 0.0) - write_mi_float_property(mi_context, mi_mat, 'anisotropic', bl_principled_wrap, 'Anisotropic', 0.0) - write_mi_roughness_property(mi_context, mi_mat, 'roughness', bl_principled_wrap, 'Roughness', 0.4) - write_mi_float_property(mi_context, mi_mat, 'sheen', bl_principled_wrap, sheen_key, 0.0) - - if bpy.app.version >= (4, 0, 0): - write_mi_rgb_property(mi_context, mi_mat, 'sheen_tint', bl_principled_wrap, 'Sheen Tint', [0.5, 0.5, 0.5]) - else: - write_mi_float_property(mi_context, mi_mat, 'sheen_tint', bl_principled_wrap, 'Sheen Tint', 0.5) - - write_mi_float_property(mi_context, mi_mat, 'clearcoat', bl_principled_wrap, clearcoat_key, 0.0) - write_mi_roughness_property(mi_context, mi_mat, 'clearcoat_gloss', bl_principled_wrap, clearcoat_roughness_key, 0.03) - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_principled_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_diffuse_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_diffuse = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfDiffuse', 'BSDF') - bl_diffuse_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_diffuse) - write_mi_rgb_property(mi_context, mi_mat, 'reflectance', bl_diffuse_wrap, 'Color', [0.8, 0.8, 0.8]) - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_diffuse_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_twosided_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - from mitsuba import ObjectType - mi_child_ids = get_references_by_type(mi_context, mi_mat, [ObjectType.BSDF]) - mi_child_material_count = len(mi_child_ids) - if mi_child_material_count == 1: - # This case is handled by simply parsing the material. Blender materials are two-sided by default - # NOTE: We always parse the Mitsuba material; we don't use the material cache. - # This is because we have no way of reusing already created materials as a 'sub-material'. - mi_child_mats = mi_context.mi_state.nodes[mi_child_ids[0]].props - write_mi_material_to_node_graph(mi_context, mi_child_mats, bl_mat_wrap, out_socket_id, mi_bump=mi_bump, mi_normal=mi_normal) - # TODO: return values seem inconsistent - return True - elif mi_child_material_count == 2: - # This case is handled by creating a two-side material where the front face has the first - # material and the back face has the second one. - mi_front_mat = mi_context.mi_state.nodes[mi_child_ids[0]].props - mi_back_mat = mi_context.mi_state.nodes[mi_child_ids[1]].props - bl_mix = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeMixShader', 'Shader') - # Generate a geometry node that will select the correct BSDF based on face orientation - bl_mat_wrap.ensure_node_type([out_socket_id, 'Fac'], 'ShaderNodeNewGeometry', 'Backfacing') - # Create a new material wrapper with the mix shader as output node - bl_child_mat_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_mix) - # Write the children materials - write_mi_material_to_node_graph(mi_context, mi_front_mat, bl_child_mat_wrap, 'Shader', mi_bump=mi_bump, mi_normal=mi_normal) - write_mi_material_to_node_graph(mi_context, mi_back_mat, bl_child_mat_wrap, 'Shader_001', mi_bump=mi_bump, mi_normal=mi_normal) - return True - else: - mi_context.log(f'Mitsuba twosided material "{mi_mat.id()}" has {mi_child_material_count} child material(s). Expected 1 or 2.', 'ERROR') - #TODO error checking consistency pass - return False - -def write_mi_dielectric_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_glass = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfGlass', 'BSDF') - bl_glass_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_glass) - if bpy.app.version < (4, 0, 0): - bl_glass.distribution = 'SHARP' - else: - write_mi_roughness_property(mi_context, mi_mat, 'alpha', bl_glass_wrap, 'Roughness', 0.) - write_mi_ior_property(mi_context, mi_mat, 'int_ior', bl_glass_wrap, 'IOR', 1.5046) - write_mi_rgb_property(mi_context, mi_mat, 'specular_transmittance', bl_glass_wrap, 'Color', [1.0, 1.0, 1.0]) - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_glass_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_roughdielectric_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_glass = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfGlass', 'BSDF') - bl_glass_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_glass) - bl_glass.distribution = mi_microfacet_to_bl_microfacet(mi_context, mi_mat.get('distribution', 'beckmann')) - write_mi_ior_property(mi_context, mi_mat, 'int_ior', bl_glass_wrap, 'IOR', 1.5046) - write_mi_rgb_property(mi_context, mi_mat, 'specular_transmittance', bl_glass_wrap, 'Color', [1.0, 1.0, 1.0]) - write_mi_roughness_property(mi_context, mi_mat, 'alpha', bl_glass_wrap, 'Roughness', 0.1) - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_glass_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_thindielectric_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_glass = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfGlass', 'BSDF') - bl_glass_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_glass) - if bpy.app.version < (4, 0, 0): - bl_glass.distribution = 'SHARP' - else: - write_mi_roughness_property(mi_context, mi_mat, 'alpha', bl_glass_wrap, 'Roughness', 0.) - bl_glass.inputs['IOR'].default_value = 1.0 - write_mi_rgb_property(mi_context, mi_mat, 'specular_transmittance', bl_glass_wrap, 'Color', [1.0, 1.0, 1.0]) - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_glass_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_blend_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - from mitsuba import ObjectType - bl_mix = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeMixShader', 'Shader') - bl_mix_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_mix) - write_mi_float_property(mi_context, mi_mat, 'weight', bl_mix_wrap, 'Fac', 0.5) - # NOTE: We assume that the two BSDFs are ordered in the list of named references - mi_child_ids = get_references_by_type(mi_context, mi_mat, [ObjectType.BSDF]) - mi_child_mats_count = len(mi_child_ids) - if mi_child_mats_count != 2: - mi_context.log(f'Unexpected number of child BSDFs in blendbsdf. Expected 2 but got {mi_child_mats_count}.', 'ERROR') - return False - mi_child_mats = [mi_context.mi_state.nodes[i].props for i in mi_child_ids] - write_mi_material_to_node_graph(mi_context, mi_child_mats[0], bl_mix_wrap, 'Shader', mi_bump=mi_bump, mi_normal=mi_normal) - write_mi_material_to_node_graph(mi_context, mi_child_mats[1], bl_mix_wrap, 'Shader_001', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_conductor_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_glossy = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfGlossy', 'BSDF') - bl_glossy_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_glossy) - if bpy.app.version < (4, 0, 0): - bl_glossy.distribution = 'SHARP' - else: - write_mi_roughness_property(mi_context, mi_mat, 'alpha', bl_glossy_wrap, 'Roughness', 0.0) - reflectance = _eval_mi_bsdf_retro_reflection(mi_context, mi_mat, [1.0, 1.0, 1.0]) - write_mi_rgb_value(mi_context, reflectance, bl_glossy_wrap, 'Color') - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_glossy_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_roughconductor_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_glossy = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfGlossy', 'BSDF') - bl_glossy_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_glossy) - bl_glossy.distribution = mi_microfacet_to_bl_microfacet(mi_context, mi_mat.get('distribution', 'beckmann')) - #FIXME: this is completely broken for textured reflectance - reflectance = _eval_mi_bsdf_retro_reflection(mi_context, mi_mat, [1.0, 1.0, 1.0]) - write_mi_rgb_value(mi_context, reflectance, bl_glossy_wrap, 'Color') - write_mi_roughness_property(mi_context, mi_mat, 'alpha', bl_glossy_wrap, 'Roughness', 0.1) - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_glossy_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_mask_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - from mitsuba import ObjectType, Properties - # Parse the child BSDF - mi_child_ids = get_references_by_type(mi_context, mi_mat, [ObjectType.BSDF]) - mi_child_mats_count = len(mi_child_ids) - if mi_child_mats_count != 1: - mi_context.log(f'Unexpected number of child BSDFs in mask BSDF. Expected 1 but got {mi_child_mats_count}.', 'ERROR') - return False - - mi_child_mat = mi_context.mi_state.nodes[mi_child_ids[0]].props - if mi_child_mat.plugin_name() == 'diffuse' and mi_child_mat.get_texture('reflectance').max() == 0: - # This can simply be a transparent material. - bl_transparent = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfTransparent', 'BSDF') - bl_transparent_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_transparent) - # Invert the opacity to match Blender's convention - if 'opacity' in mi_mat: - mi_prop_type = mi_mat.type('opacity') - if mi_prop_type == Properties.Type.Color: - mi_mat['opacity'] = 1 - mi_mat['opacity'] - write_mi_rgb_spectrum(mi_context, mi_mat.get_texture('opacity'), bl_transparent_wrap, 'Color') - elif mi_prop_type == Properties.Type.Float: - col_val = 1 - mi_mat.get('opacity') - col = [col_val] * 3 - write_mi_rgb_value(mi_context, col, bl_transparent_wrap, 'Color') - elif mi_prop_type == Properties.Type.ResolvedReference: - mi_context.log("Mask BSDF: opacity textures are currently not supported.", 'ERROR') - else: - mi_context.log(f'Material property "opacity" of type "{mi_prop_type}" cannot be converted to rgb.', 'ERROR') - else: - bl_transparent_wrap.out_node.inputs['Color'].default_value = bl_shader_utils.rgb_to_rgba([0.5]*3) - else: - bl_mix = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeMixShader', 'Shader') - bl_mix_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_mix) - # Connect the opacity. A value of 0 is completely transparent and 1 is completely opaque. - write_mi_float_property(mi_context, mi_mat, 'opacity', bl_mix_wrap, 'Fac', 0.5) - # Add a transparent node to the top socket of the mix shader - bl_mix_wrap.ensure_node_type(['Shader'], 'ShaderNodeBsdfTransparent', 'BSDF') - write_mi_material_to_node_graph(mi_context, mi_child_mat, bl_mix_wrap, 'Shader_001', mi_bump=mi_bump, mi_normal=mi_normal) - - return True - -# FIXME: The plastic and roughplastic don't have simple equivalent in Blender. We rely on a -# crude approximation using a Disney principled shader. -def write_mi_plastic_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - mi_context.log('Mitsuba plastic BSDF is not supported in Blender. Using Principled BSDF as an approximation.', 'WARN') - bl_principled = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfPrincipled', 'BSDF') - bl_principled_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_principled) - write_mi_rgb_property(mi_context, mi_mat, 'diffuse_reflectance', bl_principled_wrap, 'Base Color', [0.5, 0.5, 0.5]) - write_mi_ior_property(mi_context, mi_mat, 'int_ior', bl_principled_wrap, 'IOR', 1.49) - bl_principled.inputs['Specular'].default_value = 0.2 - bl_principled.inputs['Specular Tint'].default_value = 1.0 - bl_principled.inputs['Roughness'].default_value = 0.0 - bl_principled.inputs['Clearcoat'].default_value = 0.8 - bl_principled.inputs['Clearcoat Roughness'].default_value = 0.0 - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_principled_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_roughplastic_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - mi_context.log('Mitsuba roughplastic BSDF is not supported in Blender. Using Principled BSDF as an approximation.', 'WARN') - bl_principled = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfPrincipled', 'BSDF') - bl_principled_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_principled) - write_mi_rgb_property(mi_context, mi_mat, 'diffuse_reflectance', bl_principled_wrap, 'Base Color', [0.5, 0.5, 0.5]) - write_mi_ior_property(mi_context, mi_mat, 'int_ior', bl_principled_wrap, 'IOR', 1.49) - write_mi_roughness_property(mi_context, mi_mat, 'alpha', bl_principled_wrap, 'Roughness', 0.1) - write_mi_roughness_property(mi_context, mi_mat, 'alpha', bl_principled_wrap, 'Clearcoat Roughness', 0.1) - bl_principled.distribution = mi_microfacet_to_bl_microfacet(mi_context, 'ggx') - bl_principled.inputs['Specular'].default_value = 0.2 - bl_principled.inputs['Specular Tint'].default_value = 1.0 - bl_principled.inputs['Clearcoat'].default_value = 0.8 - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_principled_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_bumpmap_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - from mitsuba import ObjectType - if mi_bump is not None: - mi_context.log('Cannot have nested bumpmap BSDFs', 'ERROR') - return False - mi_child_ids = get_references_by_type(mi_context, mi_mat, [ObjectType.BSDF]) - assert len(mi_child_ids) == 1 - - child_mat = mi_context.mi_state.nodes[mi_child_ids[0]].props - write_mi_material_to_node_graph(mi_context, child_mat, bl_mat_wrap, out_socket_id, mi_bump=mi_mat, mi_normal=mi_normal) - return True - -def write_mi_normalmap_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - from mitsuba import ObjectType - if mi_normal is not None: - mi_context.log('Cannot have nested normalmap BSDFs', 'ERROR') - return False - mi_child_ids = get_references_by_type(mi_context, mi_mat, [ObjectType.BSDF]) - assert len(mi_child_ids) == 1 - - child_mat = mi_context.mi_state.nodes[mi_child_ids[0]].props - write_mi_material_to_node_graph(mi_context, child_mat, bl_mat_wrap, out_socket_id, mi_bump=mi_bump, mi_normal=mi_mat) - return True - -def write_mi_null_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_transparent = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfTransparent', 'BSDF') - return True - -###################### -## Main import ## -###################### - -def write_bl_error_material(bl_mat_wrap, out_socket_id): - ''' Write a Blender error material that can be applied whenever - a Mitsuba material cannot be loaded. - ''' - bl_diffuse = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfDiffuse', 'BSDF') - bl_diffuse.inputs['Color'].default_value = [1.0, 0.0, 0.3, 1.0] - -_material_writers = { - 'principled': write_mi_principled_bsdf, - 'diffuse': write_mi_diffuse_bsdf, - 'twosided': write_mi_twosided_bsdf, - 'dielectric': write_mi_dielectric_bsdf, - 'roughdielectric': write_mi_roughdielectric_bsdf, - 'thindielectric': write_mi_thindielectric_bsdf, - 'blendbsdf': write_mi_blend_bsdf, - 'conductor': write_mi_conductor_bsdf, - 'roughconductor': write_mi_roughconductor_bsdf, - 'mask': write_mi_mask_bsdf, - 'plastic': write_mi_plastic_bsdf, - 'roughplastic': write_mi_roughplastic_bsdf, - 'bumpmap': write_mi_bumpmap_bsdf, - 'normalmap': write_mi_normalmap_bsdf, - 'null': write_mi_null_bsdf, -} - -def write_mi_material_to_node_graph(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - ''' Write a Mitsuba material in a node graph starting at a specific - node in the shader graph. This function is always guaranteed to succeed. - If a material cannot be converted, it will result in a distinctive error material. - ''' - mat_type = mi_mat.plugin_name() - if mat_type not in _material_writers: - mi_context.log(f'Mitsuba BSDF type "{mat_type}" not supported. Skipping.', 'WARN') - write_bl_error_material(bl_mat_wrap, out_socket_id) - return - - if not _material_writers[mat_type](mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=mi_bump, mi_normal=mi_normal): - mi_context.log(f'Failed to convert Mitsuba material "{mi_mat.id()}". Skipping.', 'WARN') - write_bl_error_material(bl_mat_wrap, out_socket_id) - -def mi_material_to_bl_material(mi_context, mi_mat, mi_emitter=None): - ''' Create a Blender node tree representing a given Mitsuba material - - Params - ------ - mi_context : Mitsuba import context - mi_mat : Mitsuba material properties - mi_emitter : optional, Mitsuba area emitter properties - - Returns - ------- - The newly created Blender material - ''' - # Check that the emitter is of the correct type - assert mi_emitter is None or mi_emitter.plugin_name() == 'area' - - bl_mat = bpy.data.materials.new(name=mi_mat.id()) - bl_mat_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat, init_empty=True) - out_socket_id = 'Surface' - - # If the material is emissive, write the emission shader - if mi_emitter is not None: - old_bl_mat_wrap = bl_mat_wrap - bl_mat_wrap, out_socket_id = write_mi_emitter_bsdf(mi_context, bl_mat_wrap, out_socket_id, mi_emitter) - - # Write the Mitsuba material to the surface output - write_mi_material_to_node_graph(mi_context, mi_mat, bl_mat_wrap, out_socket_id) - - # Restore the old material wrapper for formatting - if mi_emitter is not None: - bl_mat_wrap = old_bl_mat_wrap - - # Format the shader node graph - bl_mat_wrap.format_node_tree() - - return bl_mat diff --git a/mitsuba-blender/io/importer/mi_spectra_utils.py b/mitsuba-blender/io/importer/mi_spectra_utils.py deleted file mode 100644 index d1403895..00000000 --- a/mitsuba-blender/io/importer/mi_spectra_utils.py +++ /dev/null @@ -1,36 +0,0 @@ - -def _get_mi_obj_properties(mi_obj): - from mitsuba import traverse - props = {} - for prop_name, prop_value in traverse(mi_obj): - props[prop_name] = prop_value - return props - -def get_color_strength_from_radiance(radiance): - # FIXME: Find a proper way of converting radiance to color/energy - strength = max(radiance) - if strength < 1.0: - return radiance, 1.0 - return [c / strength for c in radiance], strength - -def linear_rgb_to_luminance(color): - return 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2] - -########################### -## Reflectance spectra ## -########################### - -def convert_mi_srgb_reflectance_spectrum(mi_obj, default): - assert mi_obj.class_name() == 'SRGBReflectanceSpectrum' - obj_props = _get_mi_obj_properties(mi_obj) - return list(obj_props.get('value', default)) - -####################### -## Emitter spectra ## -####################### - -def convert_mi_srgb_emitter_spectrum(mi_obj, default): - assert mi_obj.class_name() == 'SRGBReflectanceSpectrum' - obj_props = _get_mi_obj_properties(mi_obj) - radiance = list(obj_props.get('value', default)) - return get_color_strength_from_radiance(radiance) diff --git a/mitsuba-blender/io/importer/renderer.py b/mitsuba-blender/io/importer/renderer.py deleted file mode 100644 index 8092478a..00000000 --- a/mitsuba-blender/io/importer/renderer.py +++ /dev/null @@ -1,319 +0,0 @@ -if "bpy" in locals(): - import importlib - if "mi_props_utils" in locals(): - importlib.reload(mi_props_utils) - -import bpy - -from .mi_props_utils import get_references_by_type - -################# -## Utilities ## -################# - -_fileformat_values = { - 'openexr': 'OPEN_EXR', - 'exr': 'OPEN_EXR', - # FIXME: Support other file formats -} - -def mi_fileformat_to_bl_fileformat(mi_context, mi_file_format): - if mi_file_format not in _fileformat_values: - mi_context.log(f'Mitsuba Film image file format "{mi_file_format}" is not supported.', 'ERROR') - return None - return _fileformat_values[mi_file_format] - -_pixelformat_values = { - 'rgb': 'RGB', - 'rgba': 'RGBA', - # FIXME: Support other pixel formats -} - -def mi_pixelformat_to_bl_pixelformat(mi_context, mi_pixel_format): - if mi_pixel_format not in _pixelformat_values: - mi_context.log(f'Mitsuba Film image pixel format "{mi_pixel_format}" is not supported.', 'ERROR') - return None - return _pixelformat_values[mi_pixel_format] - -_componentformat_values = { - 'float16': '16', - 'float32': '32', - # FIXME: Support other component formats -} - -def mi_componentformat_to_bl_componentformat(mi_context, mi_component_format): - if mi_component_format not in _componentformat_values: - mi_context.log(f'Mitsuba Film image component format "{mi_component_format}" is not supported.', 'ERROR') - return None - return _componentformat_values[mi_component_format] - -############################# -## Integrator properties ## -############################# - -def apply_mi_path_properties(mi_context, mi_props, bl_props=None): - bl_integrator = mi_context.bl_scene.mitsuba if bl_props is None else bl_props - bl_path_props = getattr(bl_integrator.available_integrators, 'path', None) - if bl_path_props is None: - mi_context.log(f'Mitsuba Integrator "path" is not supported.', 'ERROR') - return False - bl_integrator.active_integrator = 'path' - bl_path_props.max_depth = mi_props.get('max_depth', -1) - bl_path_props.rr_depth = mi_props.get('rr_depth', 5) - bl_path_props.hide_emitters = mi_props.get('hide_emitters', False) - - # Cycles properties - if bl_props is None: - bl_renderer = mi_context.bl_scene.cycles - bl_renderer.progressive = 'PATH' - bl_max_bounces = mi_props.get('max_depth', 1024) - bl_renderer.max_bounces = bl_max_bounces - bl_renderer.diffuse_bounces = bl_max_bounces - bl_renderer.glossy_bounces = bl_max_bounces - bl_renderer.transparent_max_bounces = bl_max_bounces - bl_renderer.transmission_bounces = bl_max_bounces - bl_renderer.volume_bounces = bl_max_bounces - bl_renderer.min_light_bounces = mi_props.get('rr_depth', 5) - - return True - -def apply_mi_moment_properties(mi_context, mi_props, bl_props=None): - from mitsuba import ObjectType - if bl_props is not None: - # FIXME: support moment integrator nesting - mi_context.log('Mitsuba Integrator "moment" does not support being nested yet.', 'ERROR') - return False - - mi_renderer = mi_context.bl_scene.mitsuba - bl_moment_props = getattr(mi_renderer.available_integrators, 'moment', None) - if bl_moment_props is None: - mi_context.log(f'Mitsuba Integrator "moment" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_renderer.active_integrator = 'moment' - bl_child_integrator_list = bl_moment_props.integrators - - mi_integrators = get_references_by_type(mi_context, mi_props, [ObjectType.Integrator]) - for integrator_id in mi_integrators: - mi_integrator_props = mi_context.mi_state.nodes[integrator_id].props - bl_child_integrator_list.new(name=mi_integrator_props.id()) - bl_child_integrator = bl_child_integrator_list.collection[bl_child_integrator_list.count-1] - if not apply_mi_integrator_properties(mi_context, mi_integrator_props, bl_child_integrator): - return False - # Cycles properties - mi_context.log('Mitsuba Integrator "moment" is not supported in Blender Cycles', 'WARN') - - return True - -_mi_integrator_properties_converters = { - 'path': apply_mi_path_properties, - 'moment': apply_mi_moment_properties, -} - -def apply_mi_integrator_properties(mi_context, mi_props, bl_integrator_props=None): - mi_integrator_type = mi_props.plugin_name() - if mi_integrator_type not in _mi_integrator_properties_converters: - mi_context.log(f'Mitsuba Integrator "{mi_integrator_type}" is not supported.', 'ERROR') - return False - - return _mi_integrator_properties_converters[mi_integrator_type](mi_context, mi_props, bl_integrator_props) - -########################## -## RFilter properties ## -########################## - -def apply_mi_tent_properties(mi_context, mi_props): - mi_camera = mi_context.bl_scene.camera.data.mitsuba - bl_box_props = getattr(mi_camera.rfilters, 'tent', None) - if bl_box_props is None: - mi_context.log(f'Mitsuba Reconstruction Filter "tent" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_camera.active_rfilter = 'tent' - # Cycles properties - # NOTE: Cycles does not have any equivalent to the tent filter - - return True - -def apply_mi_box_properties(mi_context, mi_props): - mi_camera = mi_context.bl_scene.camera.data.mitsuba - bl_renderer = mi_context.bl_scene.cycles - bl_box_props = getattr(mi_camera.rfilters, 'box', None) - if bl_box_props is None: - mi_context.log(f'Mitsuba Reconstruction Filter "box" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_camera.active_rfilter = 'box' - # Cycles properties - bl_renderer.pixel_filter_type = 'BOX' - - return True - -def apply_mi_gaussian_properties(mi_context, mi_props): - mi_camera = mi_context.bl_scene.camera.data.mitsuba - bl_renderer = mi_context.bl_scene.cycles - bl_box_props = getattr(mi_camera.rfilters, 'gaussian', None) - if bl_box_props is None: - mi_context.log(f'Mitsuba Reconstruction Filter "gaussian" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_camera.active_rfilter = 'gaussian' - bl_box_props.stddev = mi_props.get('stddev', 0.5) - # Cycles properties - bl_renderer.pixel_filter_type = 'GAUSSIAN' - bl_renderer.filter_width = mi_props.get('stddev', 0.5) - return True - -_mi_rfilter_properties_converters = { - 'box': apply_mi_box_properties, - 'tent': apply_mi_tent_properties, - 'gaussian': apply_mi_gaussian_properties, -} - -def apply_mi_rfilter_properties(mi_context, mi_props): - mi_rfilter_type = mi_props.plugin_name() - if mi_rfilter_type not in _mi_rfilter_properties_converters: - mi_context.log(f'Mitsuba Reconstruction Filter "{mi_rfilter_type}" is not supported.', 'ERROR') - return False - - return _mi_rfilter_properties_converters[mi_rfilter_type](mi_context, mi_props) - -########################## -## Sampler properties ## -########################## - -def apply_mi_independent_properties(mi_context, mi_props): - mi_camera = mi_context.bl_scene.camera.data.mitsuba - bl_renderer = mi_context.bl_scene.cycles - bl_independent_props = getattr(mi_camera.samplers, 'independent', None) - if bl_independent_props is None: - mi_context.log(f'Mitsuba Sampler "independent" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_camera.active_sampler = 'independent' - bl_independent_props.sample_count = mi_props.get('sample_count', 4) - bl_independent_props.seed = mi_props.get('seed', 0) - # Cycles properties - if bpy.app.version < (3, 5, 0): - bl_renderer.sampling_pattern = 'SOBOL' - elif bpy.app.version < (4, 0, 0): - bl_renderer.sampling_pattern = 'SOBOL_BURLEY' - else: - bl_renderer.sampling_pattern = 'AUTOMATIC' - bl_renderer.samples = mi_props.get('sample_count', 4) - bl_renderer.preview_samples = mi_props.get('sample_count', 4) - bl_renderer.seed = mi_props.get('seed', 0) - return True - -def apply_mi_stratified_properties(mi_context, mi_props): - mi_camera = mi_context.bl_scene.camera.data.mitsuba - bl_renderer = mi_context.bl_scene.cycles - bl_stratified_props = getattr(mi_camera.samplers, 'stratified', None) - if bl_stratified_props is None: - mi_context.log(f'Mitsuba Sampler "stratified" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_camera.active_sampler = 'stratified' - bl_stratified_props.sample_count = mi_props.get('sample_count', 4) - bl_stratified_props.seed = mi_props.get('seed', 0) - bl_stratified_props.jitter = mi_props.get('jitter', True) - # Cycles properties - if bpy.app.version < (3, 5, 0): - bl_renderer.sampling_pattern = 'SOBOL' - elif bpy.app.version < (4, 0, 0): - bl_renderer.sampling_pattern = 'SOBOL_BURLEY' - else: - bl_renderer.sampling_pattern = 'AUTOMATIC' - bl_renderer.samples = mi_props.get('sample_count', 4) - bl_renderer.seed = mi_props.get('seed', 0) - return True - -def apply_mi_multijitter_properties(mi_context, mi_props): - mi_camera = mi_context.bl_scene.camera.data.mitsuba - bl_renderer = mi_context.bl_scene.cycles - bl_multijitter_props = getattr(mi_camera.samplers, 'multijitter', None) - if bl_multijitter_props is None: - mi_context.log(f'Mitsuba Sampler "multijitter" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_camera.active_sampler = 'multijitter' - bl_multijitter_props.sample_count = mi_props.get('sample_count', 4) - bl_multijitter_props.seed = mi_props.get('seed', 0) - bl_multijitter_props.jitter = mi_props.get('jitter', True) - # Cycles properties - if bpy.app.version < (3, 0, 0): - bl_renderer.sampling_pattern = 'CORRELATED_MUTI_JITTER' - elif bpy.app.version < (3, 5, 0): - bl_renderer.sampling_pattern = 'PROGRESSIVE_MULTI_JITTER' - else: - bl_renderer.sampling_pattern = 'TABULATED_SOBOL' - bl_renderer.samples = mi_props.get('sample_count', 4) - bl_renderer.seed = mi_props.get('seed', 0) - return True - -_mi_sampler_properties_converters = { - 'independent': apply_mi_independent_properties, - 'stratified': apply_mi_stratified_properties, - 'multijitter': apply_mi_multijitter_properties, -} - -def apply_mi_sampler_properties(mi_context, mi_props): - mi_sampler_type = mi_props.plugin_name() - if mi_sampler_type not in _mi_sampler_properties_converters: - mi_context.log(f'Mitsuba Sampler "{mi_sampler_type}" is not supported.', 'ERROR') - return False - - return _mi_sampler_properties_converters[mi_sampler_type](mi_context, mi_props) - -####################### -## Film properties ## -####################### - -def apply_mi_hdrfilm_properties(mi_context, mi_props): - mi_context.bl_scene.render.resolution_percentage = 100 - render_dims = (mi_props.get('width', 768), mi_props.get('height', 576)) - mi_context.bl_scene.render.resolution_x = render_dims[0] - mi_context.bl_scene.render.resolution_y = render_dims[1] - mi_context.bl_scene.render.image_settings.file_format = mi_fileformat_to_bl_fileformat(mi_context, mi_props.get('file_format', 'openexr')) - mi_context.bl_scene.render.image_settings.color_mode = mi_pixelformat_to_bl_pixelformat(mi_context, mi_props.get('pixel_format', 'rgba')) - mi_context.bl_scene.render.image_settings.color_depth = mi_componentformat_to_bl_componentformat(mi_context, mi_props.get('component_format', 'float16')) - - crop_keys = ['crop_offset_x', 'crop_offset_y', 'crop_width', 'crop_height'] - if any(key in mi_props for key in crop_keys): - mi_context.bl_scene.render.use_border = True - # FIXME: Do we want to crop the resulting image ? - mi_context.bl_scene.render.use_crop_to_border = True - offset_x = mi_props.get('crop_offset_x', 0) - offset_y = mi_props.get('crop_offset_y', 0) - width = mi_props.get('crop_width', render_dims[0]) - height = mi_props.get('crop_height', render_dims[1]) - mi_context.bl_scene.render.border_min_x = offset_x / render_dims[0] - mi_context.bl_scene.render.border_max_x = (offset_x + width) / render_dims[0] - mi_context.bl_scene.render.border_min_y = offset_y / render_dims[1] - mi_context.bl_scene.render.border_max_y = (offset_y + height) / render_dims[1] - return True - -_mi_film_properties_converters = { - 'hdrfilm': apply_mi_hdrfilm_properties -} - -def apply_mi_film_properties(mi_context, mi_props): - mi_film_type = mi_props.plugin_name() - if mi_film_type not in _mi_film_properties_converters: - mi_context.log(f'Mitsuba Film "{mi_film_type}" is not supported.', 'ERROR') - return False - - return _mi_film_properties_converters[mi_film_type](mi_context, mi_props) - -########################### -## Renderer properties ## -########################### - -def init_mitsuba_renderer(mi_context): - mi_context.bl_scene.render.engine = 'MITSUBA' - mi_renderer = mi_context.bl_scene.mitsuba - if 'scalar_rgb' not in mi_renderer.variants(): - mi_context.log('Mitsuba variant "scalar_rgb" not available.', 'ERROR') - return False - mi_renderer.variant = 'scalar_rgb' - return True diff --git a/mitsuba-blender/io/importer/sensors.py b/mitsuba-blender/io/importer/sensors.py deleted file mode 100644 index 0a3f7998..00000000 --- a/mitsuba-blender/io/importer/sensors.py +++ /dev/null @@ -1,63 +0,0 @@ -import math - -if "bpy" in locals(): - import importlib - if "bl_transform_utils" in locals(): - importlib.reload(bl_transform_utils) - -import bpy -from mathutils import Matrix - -from . import bl_transform_utils - -###################### -## Converters ## -###################### - -def mi_perspective_to_bl_camera(mi_context, mi_sensor): - bl_camera = bpy.data.cameras.new(name=mi_sensor.id()) - bl_camera.type = 'PERSP' - - bl_camera.clip_start = mi_sensor.get('near_clip', 1e-2) - bl_camera.clip_end = mi_sensor.get('far_clip', 1e4) - - bl_camera.shift_x = mi_sensor.get('principal_point_offset_x', 0.0) - bl_camera.shift_y = mi_sensor.get('principal_point_offset_y', 0.0) - - if 'focal_length' in mi_sensor: - mi_sensor.lens = mi_sensor['focal_length'] - else: - fov_axis = mi_sensor.get('fov_axis', 'x') - fov = math.radians(mi_sensor.get('fov', 80)) - if fov_axis == 'x': - bl_camera.angle_x = fov - elif fov_axis == 'y': - bl_camera.angle_y = fov - else: - mi_context.log(f'Camera fov axis "{fov_axis}" not supported.', 'ERROR') - - # NOTE: Cameras are exported with a 180° rotation on the Y axis to be compatible with Mitsuba. - # Therefore, we need to reverse this rotation - initial_rotation = Matrix.Rotation(-math.pi, 4, 'Y') - world_matrix = bl_transform_utils.mi_transform_to_bl_transform(mi_sensor.get('to_world', None)) - - return bl_camera, mi_context.mi_space_to_bl_space(world_matrix @ initial_rotation) - -###################### -## Main import ## -###################### - -_sensor_converters = { - 'perspective': mi_perspective_to_bl_camera, -} - -def mi_sensor_to_bl_camera(mi_context, mi_sensor): - sensor_type = mi_sensor.plugin_name() - if sensor_type not in _sensor_converters: - mi_context.log(f'Mitsuba Sensor type "{sensor_type}" not supported.', 'ERROR') - return None - - # Create the Blender object - bl_camera, world_matrix = _sensor_converters[sensor_type](mi_context, mi_sensor) - - return bl_camera, world_matrix diff --git a/mitsuba-blender/io/importer/shapes.py b/mitsuba-blender/io/importer/shapes.py deleted file mode 100644 index 85e10c4a..00000000 --- a/mitsuba-blender/io/importer/shapes.py +++ /dev/null @@ -1,218 +0,0 @@ -import time - -if "bpy" in locals(): - import importlib - if "bl_transform_utils" in locals(): - importlib.reload(bl_transform_utils) - if "bl_import_ply" in locals(): - importlib.reload(bl_import_ply) - if "bl_import_obj" in locals(): - importlib.reload(bl_import_obj) - -import bpy -import bmesh -from mathutils import Matrix, Vector - -from . import bl_transform_utils -from . import bl_import_ply -from . import bl_import_obj - -###################### -## Utilities ## -###################### - -def _set_bl_mesh_shading(bl_mesh, flat_shading=True, flip_normals=False): - ''' Set a Blender mesh shading mode. - - Params - ------ - bl_mesh : The Blender mesh to operate on. - flat_shading : boolean, optional - Should the face normals be used instead of the vertex normals? - flip_normals : boolean, optional - Should the normals be flipped from the current normal direction? - ''' - if flat_shading: - bl_mesh.polygons.foreach_set('use_smooth', [False] * len(bl_mesh.polygons)) - else: - if bpy.app.version < (4, 0, 0): - bl_mesh.calc_normals() - bl_mesh.polygons.foreach_set('use_smooth', [True] * len(bl_mesh.polygons)) - if flip_normals: - bl_mesh.flip_normals() - bl_mesh.update() - -###################### -## Converters ## -###################### - -def mi_ply_to_bl_shape(mi_context, mi_shape): - assert 'filename' in mi_shape - - filename = mi_shape['filename'] - abs_path = mi_context.resolve_scene_relative_path(filename) - - # Load .PLY mesh from file - bl_mesh = bl_import_ply.load_ply_mesh(abs_path, mi_shape.id()) - if not bl_mesh: - mi_context.log(f'Cannot load PLY mesh file "{filename}".', 'ERROR') - return None - - # Set face normals if requested - _set_bl_mesh_shading(bl_mesh, mi_shape.get('face_normals', False)) - - world_matrix = bl_transform_utils.mi_transform_to_bl_transform(mi_shape.get('to_world', None)) - - return bl_mesh, mi_context.mi_space_to_bl_space(world_matrix) - -def mi_serialized_to_bl_shape(mi_context, mi_shape): - from mitsuba import load_dict - import tempfile - import os - assert mi_shape.has_property('filename') - - filename = mi_shape['filename'] - abs_path = mi_context.resolve_scene_relative_path(filename) - - # Save as a temporary .PLY file - with tempfile.TemporaryDirectory() as tmp_dir: - tmp_mesh = load_dict({'type': 'serialized', 'filename': abs_path, 'shape_index': mi_shape.get('shape_index', 0)}) - tmp_filename = os.path.join(tmp_dir, 'shape.ply') - tmp_mesh.write_ply(tmp_filename) - # Load .PLY mesh from temporary file - bl_mesh = bl_import_ply.load_ply_mesh(tmp_filename, mi_shape.id()) - - if not bl_mesh: - mi_context.log(f'Cannot load serialized mesh file "{filename}".', 'ERROR') - return None - - # Set face normals if requested - _set_bl_mesh_shading(bl_mesh, mi_shape.get('face_normals', False)) - - world_matrix = bl_transform_utils.mi_transform_to_bl_transform(mi_shape.get('to_world', None)) - - return bl_mesh, mi_context.mi_space_to_bl_space(world_matrix) - -def mi_obj_to_bl_shape(mi_context, mi_shape): - start_time = time.time() - - assert 'filename' in mi_shape - - filename = mi_shape.get('filename') - abs_path = mi_context.resolve_scene_relative_path(filename) - - # Load the mesh from the file - bl_meshes = bl_import_obj.load(abs_path) - # FIXME: Handle multiple objects if supported by Mistuba. - if len(bl_meshes) > 1: - mi_context.log('OBJ file containing more than one mesh. Only the first one will be loaded.', 'WARN') - bl_mesh = bl_meshes[0] - bl_mesh.name = mi_shape.id() - - # FIXME: Support UV flipping. - - # Set face normals if requested - _set_bl_mesh_shading(bl_mesh, mi_shape.get('face_normals', False)) - - world_matrix = bl_transform_utils.mi_transform_to_bl_transform(mi_shape.get('to_world', None)) - - end_time = time.time() - mi_context.log(f'Loaded OBJ mesh "{mi_shape.id()}". Took {end_time-start_time:.2f}s.', 'INFO') - - return bl_mesh, mi_context.mi_space_to_bl_space(world_matrix) - -def mi_sphere_to_bl_shape(mi_context, mi_shape): - bl_mesh = bpy.data.meshes.new(mi_shape.id()) - bl_bmesh = bmesh.new() - - if 'to_world' in mi_shape: - world_matrix = mi_context.mi_space_to_bl_space(bl_transform_utils.mi_transform_to_bl_transform(mi_shape.get('to_world', None))) - radius = 1.0 - else: - # NOTE: We transform only the position vector to Blender space as the mesh is already correctly oriented. - world_matrix = Matrix.Translation(mi_context.mi_space_to_bl_space(Vector(mi_shape.get('center', [0.0, 0.0, 0.0])))) - radius = mi_shape.get('radius', 1.0) - - # Create a UV sphere mesh - # NOTE: The 'diameter' parameter seems to be missnamed as it results in sphere twice as big as expected - bmesh.ops.create_uvsphere(bl_bmesh, u_segments=32, v_segments=16, radius=radius, calc_uvs=True) - bl_bmesh.to_mesh(bl_mesh) - bl_bmesh.free() - - _set_bl_mesh_shading(bl_mesh, flat_shading=False, flip_normals=mi_shape.get('flip_normals', False)) - - # FIXME: Verify that the world matrix is correct - return bl_mesh, world_matrix - -def mi_disk_to_bl_shape(mi_context, mi_shape): - bl_mesh = bpy.data.meshes.new(mi_shape.id()) - bl_bmesh = bmesh.new() - - # Create a disk - bmesh.ops.create_circle(bl_bmesh, cap_ends=True, cap_tris=True, segments=32, radius=1.0, calc_uvs=True) - bl_bmesh.to_mesh(bl_mesh) - bl_bmesh.free() - - _set_bl_mesh_shading(bl_mesh, flip_normals=mi_shape.get('flip_normals', False)) - - # FIXME: The world matrix seems off - world_matrix = bl_transform_utils.mi_transform_to_bl_transform(mi_shape.get('to_world', None)) - - return bl_mesh, mi_context.mi_space_to_bl_space(world_matrix) - -def mi_rectangle_to_bl_shape(mi_context, mi_shape): - bl_mesh = bpy.data.meshes.new(mi_shape.id()) - bl_bmesh = bmesh.new() - - # Create a rectangle - bmesh.ops.create_grid(bl_bmesh, x_segments=1, y_segments=1, size=1.0, calc_uvs=True) - bl_bmesh.to_mesh(bl_mesh) - bl_bmesh.free() - - _set_bl_mesh_shading(bl_mesh, flip_normals=mi_shape.get('flip_normals', False)) - - # FIXME: The world matrix seems off - world_matrix = bl_transform_utils.mi_transform_to_bl_transform(mi_shape.get('to_world', None)) - - return bl_mesh, mi_context.mi_space_to_bl_space(world_matrix) - -def mi_cube_to_bl_shape(mi_context, mi_shape): - bl_mesh = bpy.data.meshes.new(mi_shape.id()) - bl_bmesh = bmesh.new() - - # Create a cube - bmesh.ops.create_cube(bl_bmesh, size=2.0, calc_uvs=True) - bl_bmesh.to_mesh(bl_mesh) - bl_bmesh.free() - - _set_bl_mesh_shading(bl_mesh, flip_normals=mi_shape.get('flip_normals', False)) - - # FIXME: The world matrix seems off - world_matrix = bl_transform_utils.mi_transform_to_bl_transform(mi_shape.get('to_world', None)) - - return bl_mesh, mi_context.mi_space_to_bl_space(world_matrix) - -###################### -## Main import ## -###################### - -_shape_converters = { - 'ply': mi_ply_to_bl_shape, - 'serialized': mi_serialized_to_bl_shape, - 'obj': mi_obj_to_bl_shape, - 'sphere': mi_sphere_to_bl_shape, - 'disk': mi_disk_to_bl_shape, - 'rectangle': mi_rectangle_to_bl_shape, - 'cube': mi_cube_to_bl_shape, -} - -def mi_shape_to_bl_shape(mi_context, mi_shape): - shape_type = mi_shape.plugin_name() - if shape_type not in _shape_converters: - mi_context.log(f'Mitsuba Shape type "{shape_type}" not supported.', 'ERROR') - return None - - # Create the Blender object - bl_mesh, world_matrix = _shape_converters[shape_type](mi_context, mi_shape) - - return bl_mesh, world_matrix diff --git a/mitsuba-blender/io/importer/textures.py b/mitsuba-blender/io/importer/textures.py deleted file mode 100644 index caa90161..00000000 --- a/mitsuba-blender/io/importer/textures.py +++ /dev/null @@ -1,42 +0,0 @@ -import os - -if "bpy" in locals(): - import importlib - if "bl_image_utils" in locals(): - importlib.reload(bl_image_utils) - -import bpy - -from . import bl_image_utils - -###################### -## Converters ## -###################### - -def mi_bitmap_to_bl_image(mi_context, mi_texture): - filepath = mi_context.resolve_scene_relative_path(mi_texture.get('filename')) - bl_image = bl_image_utils.load_bl_image_from_filepath(mi_context, filepath, mi_texture.get('raw', False)) - if bl_image is None: - mi_context.log(f'Failed to load image from path "{filepath}".', 'ERROR') - return None - bl_image.name = mi_texture.id() - return bl_image - -###################### -## Main import ## -###################### - -_texture_converters = { - 'bitmap': mi_bitmap_to_bl_image, -} - -def mi_texture_to_bl_image(mi_context, mi_texture): - texture_type = mi_texture.plugin_name() - if texture_type not in _texture_converters: - mi_context.log(f'Mitsuba Texture type "{texture_type}" not supported.', 'ERROR') - return None - - # Create the Blender object - bl_image = _texture_converters[texture_type](mi_context, mi_texture) - - return bl_image diff --git a/mitsuba-blender/io/importer/world.py b/mitsuba-blender/io/importer/world.py deleted file mode 100644 index 3e0281f5..00000000 --- a/mitsuba-blender/io/importer/world.py +++ /dev/null @@ -1,148 +0,0 @@ -if "bpy" in locals(): - import importlib - if "bl_material_utils" in locals(): - importlib.reload(bl_shader_utils) - if "mi_spectra_utils" in locals(): - importlib.reload(mi_spectra_utils) - if "bl_image_utils" in locals(): - importlib.reload(bl_image_utils) - -import bpy - -from mathutils import Color - -from . import bl_shader_utils -from . import mi_spectra_utils -from . import bl_image_utils - -############################## -## World property writers ## -############################## - -def write_mi_srgb_emitter_spectrum(mi_context, mi_obj, bl_world_wrap, radiance_socket_id, strength_socket_id, default=None): - color, strength = mi_spectra_utils.convert_mi_srgb_emitter_spectrum(mi_obj, default) - bl_world_wrap.out_node.inputs[radiance_socket_id].default_value = bl_shader_utils.rgb_to_rgba(color) - bl_world_wrap.out_node.inputs[strength_socket_id].default_value = strength - -_emitter_spectrum_object_writers = { - 'SRGBReflectanceSpectrum': write_mi_srgb_emitter_spectrum -} - -def write_mi_emitter_spectrum_object(mi_context, mi_obj, bl_world_wrap, radiance_socket_id, strength_socket_id, default=None): - mi_obj_class_name = mi_obj.class_name() - if mi_obj_class_name not in _emitter_spectrum_object_writers: - mi_context.log(f'Mitsuba object type "{mi_obj_class_name}" is not supported.', 'ERROR') - return - _emitter_spectrum_object_writers[mi_obj_class_name](mi_context, mi_obj, bl_world_wrap, radiance_socket_id, strength_socket_id, default) - -def write_mi_world_radiance_property(mi_context, mi_emitter, mi_prop_name, bl_world_wrap, radiance_socket_id, strength_socket_id, default=None): - from mitsuba import Properties - if mi_emitter.has_property(mi_prop_name): - mi_prop_type = mi_emitter.type(mi_prop_name) - if mi_prop_type == Properties.Type.Color: - mi_col = mi_emitter.get_emissive_texture(mi_prop_name) - write_mi_emitter_spectrum_object(mi_context, mi_col, bl_world_wrap, radiance_socket_id, strength_socket_id, default) - else: - mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to rgb.', 'ERROR') - elif default is not None: - color, strength = mi_spectra_utils.get_color_strength_from_radiance(default) - bl_world_wrap.out_node.inputs[radiance_socket_id].default_value = bl_shader_utils.rgb_to_rgba(color) - bl_world_wrap.out_node.inputs[strength_socket_id].default_value = strength - else: - mi_context.log(f'Emitter "{bl_world_wrap.id()}" does not have property "{mi_prop_name}".', 'ERROR') - -###################### -## Writers ## -###################### - -def write_mi_constant_emitter(mi_context, mi_emitter, bl_world_wrap, out_socket_id): - bl_background = bl_world_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBackground', 'Background') - bl_background_wrap = bl_shader_utils.NodeWorldWrapper(bl_world_wrap.bl_world, out_node=bl_background) - write_mi_world_radiance_property(mi_context, mi_emitter, 'radiance', bl_background_wrap, 'Color', 'Strength', [0.8, 0.8, 0.8]) - return True - -def write_mi_envmap_emitter(mi_context, mi_emitter, bl_world_wrap, out_socket_id): - # Load the environment texture - filepath = mi_context.resolve_scene_relative_path(mi_emitter.get('filename')) - bl_image = bl_image_utils.load_bl_image_from_filepath(mi_context, filepath, is_data=False) - if bl_image is None: - mi_context.log(f'Failed to load image file "{filepath}".', 'ERROR') - return False - bl_image.name = mi_emitter.id() - # Create the background shader node - bl_background = bl_world_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBackground', 'Background') - bl_background.inputs['Strength'].default_value = mi_emitter.get('scale', 1.0) - # Create the environment texture node - bl_environment = bl_world_wrap.ensure_node_type([out_socket_id, 'Color'], 'ShaderNodeTexEnvironment', 'Color') - bl_environment.projection = 'EQUIRECTANGULAR' - bl_environment.image = bl_image - # FIXME: Handle texture coordinate transforms - return True - -###################### -## Main import ## -###################### - -def write_bl_error_world(bl_world_wrap, out_socket_id): - ''' Write a Blender error world that can be applied whenever - a Mitsuba emitter cannot be loaded. - ''' - bl_background = bl_world_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBackground', 'Background') - bl_background.inputs['Color'].default_value = [1.0, 0.0, 0.3, 1.0] - -_world_writers = { - 'constant': write_mi_constant_emitter, - 'envmap': write_mi_envmap_emitter, -} - -def write_mi_emitter_to_node_graph(mi_context, mi_emitter, bl_world_wrap, out_socket_id): - ''' Write a Mitsuba emitter in a node graph starting at a specific - node in the shader graph. - ''' - emitter_type = mi_emitter.plugin_name() - if emitter_type not in _world_writers: - mi_context.log(f'Mitsuba Emitter type "{emitter_type}" not supported.', 'ERROR') - return - - if not _world_writers[emitter_type](mi_context, mi_emitter, bl_world_wrap, out_socket_id): - write_bl_error_world(bl_world_wrap, out_socket_id) - -def mi_emitter_to_bl_world(mi_context, mi_emitter): - ''' Create a Blender node tree representing a given Mitsuba emitter - - Params - ------ - mi_context : Mitsuba import context - mi_emitter : Mitsuba emitter properties - - Returns - ------- - The newly created Blender world - ''' - bl_world = bpy.data.worlds.new(name=mi_emitter.id()) - bl_world_wrap = bl_shader_utils.NodeWorldWrapper(bl_world, init_empty=True) - - # Write the Mitsuba emitter to the world output - write_mi_emitter_to_node_graph(mi_context, mi_emitter, bl_world_wrap, 'Surface') - - # Format the shader node graph - bl_world_wrap.format_node_tree() - - return bl_world - -def should_convert_mi_emitter_to_bl_world(mi_emitter): - ''' Return whether a Mitsuba emitter should be converted to - a Blender world. - ''' - return mi_emitter.plugin_name() in _world_writers - -def create_default_bl_world(): - ''' Create the default Blender world ''' - bl_world = bpy.data.worlds.new(name='World') - bl_world_wrap = bl_shader_utils.NodeWorldWrapper(bl_world, init_empty=True) - bl_background = bl_world_wrap.ensure_node_type(['Surface'], 'ShaderNodeBackground', 'Background') - # NOTE: This is the default Blender background color for worlds. This is required in order to be - # compatible with the exporter and the 'ignore_background' property - bl_background.inputs['Color'].default_value = [0.05087608844041824]*3 + [1.0] - bl_world_wrap.format_node_tree() - return bl_world diff --git a/mitsuba_blender/__init__.py b/mitsuba_blender/__init__.py new file mode 100644 index 00000000..43c6e176 --- /dev/null +++ b/mitsuba_blender/__init__.py @@ -0,0 +1,36 @@ +import os + +# Dr.Jit requires this to be set before mitsuba is first imported. +os.environ.setdefault('DRJIT_NO_RTLD_DEEPBIND', '1') + + +def register(): + from bpy.utils import register_class + from . import preferences + register_class(preferences.MitsubaPreferences) + if preferences.init_mitsuba(): + from . import properties, engine, ui, io + from .plugins import register_plugins + register_plugins() + properties.register() + engine.register() + ui.register() + io.register() + print(f'mitsuba_blender registered ' + f'(with mitsuba v{preferences.mitsuba_version})') + else: + print(f'mitsuba_blender: could not load mitsuba: ' + f'{preferences.init_error}') + + +def unregister(): + from bpy.utils import unregister_class + from . import preferences + if preferences.mitsuba_version is not None: + from . import properties, engine, ui, io + io.unregister() + ui.unregister() + engine.unregister() + properties.unregister() + preferences.mitsuba_version = None + unregister_class(preferences.MitsubaPreferences) diff --git a/mitsuba_blender/blender_manifest.toml b/mitsuba_blender/blender_manifest.toml new file mode 100644 index 00000000..80fd823f --- /dev/null +++ b/mitsuba_blender/blender_manifest.toml @@ -0,0 +1,20 @@ +schema_version = "1.0.0" + +id = "mitsuba_blender" +version = "0.5.0" +name = "Mitsuba Blender" +tagline = "Mitsuba renderer integration with scene import and export" +maintainer = "Baptiste Nicolet, Dorian Ros, Rami Tabbara" +type = "add-on" +website = "https://github.com/mitsuba-renderer/mitsuba-blender" +tags = ["Render", "Import-Export"] +blender_version_min = "4.2.0" +license = ["SPDX:BSD-3-Clause"] + +# release/build_extension.py fills this list with the mitsuba/drjit wheels +# for release builds. For local development, mitsuba comes from Blender's +# site-packages instead. +wheels = [] + +[build] +paths_exclude_pattern = ["__pycache__/", ".*"] diff --git a/mitsuba_blender/compat.py b/mitsuba_blender/compat.py new file mode 100644 index 00000000..e1f028a0 --- /dev/null +++ b/mitsuba_blender/compat.py @@ -0,0 +1,21 @@ +'''Blender version compatibility layer. + +This is the only module allowed to inspect bpy.app.version. Whenever a +Blender release renames an API that the addon uses, the branch belongs +here, with a fallback for the oldest supported release (4.2 LTS). +''' + + +def uses_nodes(data): + '''Whether a material or world shades with its node tree. use_nodes is + deprecated in Blender 5.0 (reads are always True, writes do nothing) + and slated for removal in 6.0, where node trees are always present.''' + return getattr(data, 'use_nodes', True) and data.node_tree is not None + + +def ensure_node_tree(data): + '''Enable node-based shading on a material or world and return its + node tree.''' + if hasattr(data, 'use_nodes'): + data.use_nodes = True + return data.node_tree diff --git a/mitsuba_blender/convert/__init__.py b/mitsuba_blender/convert/__init__.py new file mode 100644 index 00000000..e1f86bc4 --- /dev/null +++ b/mitsuba_blender/convert/__init__.py @@ -0,0 +1,4 @@ + +class ConversionError(Exception): + '''Raised when a converter cannot represent the given content.''' + diff --git a/mitsuba_blender/convert/export/__init__.py b/mitsuba_blender/convert/export/__init__.py new file mode 100644 index 00000000..580ade78 --- /dev/null +++ b/mitsuba_blender/convert/export/__init__.py @@ -0,0 +1,8 @@ +import re + + +def sanitize_attribute_name(name): + '''Mesh attribute names as exported to Mitsuba, shared between the + mesh exporter and the Color Attribute texture converter so that + mesh_attribute references match the exported attributes.''' + return re.sub(r'\W', '_', name) diff --git a/mitsuba_blender/convert/export/camera.py b/mitsuba_blender/convert/export/camera.py new file mode 100644 index 00000000..81ae11e8 --- /dev/null +++ b/mitsuba_blender/convert/export/camera.py @@ -0,0 +1,211 @@ +'''Blender camera to Mitsuba sensor conversion. + +The geometric mappings (field of view, lens shift, orthographic scale, +depth of field) live in this module; the importer in +convert.importer.camera applies their inverses. +''' + +import math + +from mathutils import Matrix + + +################### +## Geometry ## +################### + +def fit_dimension(sensor_fit, res_x, res_y): + '''The resolution of the axis that Blender's sensor fit locks. Lens + shift values are expressed relative to this dimension.''' + if sensor_fit == 'HORIZONTAL': + return res_x + if sensor_fit == 'VERTICAL': + return res_y + return max(res_x, res_y) + + +def shift_to_principal_point(shift_x, shift_y, sensor_fit, res_x, res_y): + '''Blender lens shift to Mitsuba principal point offset. Mitsuba + expresses the offset in fractions of the film width/height with the + y axis pointing down.''' + fit = fit_dimension(sensor_fit, res_x, res_y) + return shift_x * fit / res_x, -shift_y * fit / res_y + + +def principal_point_to_shift(offset_x, offset_y, res_x, res_y): + '''Inverse of shift_to_principal_point for an AUTO-fit camera.''' + fit = max(res_x, res_y) + return offset_x * res_x / fit, -offset_y * res_y / fit + + +def fstop_to_aperture_radius(fstop, lens): + '''Blender f-stop and focal length (mm) to an aperture radius in + scene units, following the Cycles thin lens model.''' + return lens / (2.0 * fstop) / 1000.0 + + +def aperture_radius_to_fstop(radius, lens): + return lens / (2.0 * radius) / 1000.0 + + +def ortho_scale_to_extent(ortho_scale, sensor_fit, res_x, res_y): + '''Blender orthographic scale to the half extent of the Mitsuba + orthographic sensor along its x axis.''' + horizontal = res_x >= res_y if sensor_fit == 'AUTO' \ + else sensor_fit == 'HORIZONTAL' + if horizontal: + return ortho_scale / 2.0 + return ortho_scale * res_x / (2.0 * res_y) + + +def extent_to_ortho_scale(extent, res_x, res_y): + '''Inverse of ortho_scale_to_extent for an AUTO-fit camera.''' + if res_x >= res_y: + return 2.0 * extent + return 2.0 * extent * res_y / res_x + + +def focus_distance(b_camera_data, matrix_world): + '''The focus distance in scene units, resolving a focus object to its + distance along the view axis like Cycles does.''' + focus_object = b_camera_data.dof.focus_object + if focus_object is None: + return b_camera_data.dof.focus_distance + local = matrix_world.inverted() @ focus_object.matrix_world.translation + return abs(local.z) + + +###################### +## Converters ## +###################### + +def _fov_params(b_camera_data, res_x, res_y): + sensor_fit = b_camera_data.sensor_fit + if sensor_fit == 'VERTICAL': + return {'fov_axis': 'y', 'fov': math.degrees(b_camera_data.angle_y)} + # With AUTO fit the sensor width applies to the larger image dimension, + # so angle_x is the field of view of the fit axis in both orientations. + axis = 'y' if sensor_fit == 'AUTO' and res_x < res_y else 'x' + return {'fov_axis': axis, 'fov': math.degrees(b_camera_data.angle_x)} + + +def _convert_perspective(export_ctx, b_camera, matrix_world, res_x, res_y): + data = b_camera.data + params = _fov_params(data, res_x, res_y) + offset_x, offset_y = shift_to_principal_point( + data.shift_x, data.shift_y, data.sensor_fit, res_x, res_y) + + if data.dof.use_dof: + params['type'] = 'thinlens' + params['aperture_radius'] = fstop_to_aperture_radius( + data.dof.aperture_fstop, data.lens) + params['focus_distance'] = focus_distance(data, matrix_world) + if offset_x != 0.0 or offset_y != 0.0: + export_ctx.log(f'Camera "{b_camera.name_full}": Mitsuba does not ' + 'support lens shift together with depth of field. ' + 'Ignoring the shift.', 'WARN') + else: + params['type'] = 'perspective' + params['principal_point_offset_x'] = offset_x + params['principal_point_offset_y'] = offset_y + + init_rot = Matrix.Rotation(math.pi, 4, 'Y') + params['to_world'] = export_ctx.transform_matrix(matrix_world @ init_rot) + return params + + +def _convert_orthographic(export_ctx, b_camera, matrix_world, res_x, res_y): + data = b_camera.data + params = {'type': 'orthographic'} + + if data.dof.use_dof: + export_ctx.log(f'Camera "{b_camera.name_full}": Mitsuba does not ' + 'support depth of field on orthographic cameras. ' + 'Ignoring it.', 'WARN') + + # A lens shift translates the imaged region in the camera plane + shift = Matrix.Translation((data.shift_x * data.ortho_scale, + data.shift_y * data.ortho_scale, 0.0)) + extent = ortho_scale_to_extent(data.ortho_scale, data.sensor_fit, + res_x, res_y) + scale = Matrix.Diagonal((extent, extent, 1.0, 1.0)) + init_rot = Matrix.Rotation(math.pi, 4, 'Y') + params['to_world'] = export_ctx.transform_matrix( + matrix_world @ shift @ init_rot @ scale) + return params + + +def convert_camera(export_ctx, b_camera, b_scene, matrix_world=None): + '''Convert a Blender camera object into a Mitsuba sensor dict.''' + if matrix_world is None: + matrix_world = b_camera.matrix_world + res_x = b_scene.render.resolution_x + res_y = b_scene.render.resolution_y + + data = b_camera.data + if data.type == 'ORTHO': + params = _convert_orthographic(export_ctx, b_camera, matrix_world, + res_x, res_y) + else: + if data.type != 'PERSP': + export_ctx.log(f'Camera "{b_camera.name_full}" of type ' + f'"{data.type}" is not supported. Exporting it ' + 'as a perspective camera.', 'WARN') + loc, rot, _ = matrix_world.decompose() + matrix_world = Matrix.LocRotScale(loc, rot, (1.0, 1.0, 1.0)) + params = _convert_perspective(export_ctx, b_camera, matrix_world, + res_x, res_y) + + params['near_clip'] = data.clip_start + params['far_clip'] = data.clip_end + params['sampler'] = _convert_sampler(b_camera, b_scene) + params['film'] = _convert_film(b_camera, b_scene) + return params + + +def _convert_sampler(b_camera, b_scene): + if b_scene.render.engine == 'MITSUBA': + return b_camera.data.mitsuba.sampler_to_dict() + # scene.cycles only exists while the Cycles addon is enabled + cycles = getattr(b_scene, 'cycles', None) + return { + 'type': 'independent', + 'sample_count': cycles.samples if cycles else 64, + } + + +def _convert_film(b_camera, b_scene): + scale = b_scene.render.resolution_percentage / 100.0 + film = { + 'type': 'hdrfilm', + 'width': int(b_scene.render.resolution_x * scale), + 'height': int(b_scene.render.resolution_y * scale), + } + if b_scene.render.engine == 'MITSUBA': + film['rfilter'] = b_camera.data.mitsuba.rfilter_to_dict() + elif b_scene.render.engine == 'CYCLES': + if b_scene.cycles.pixel_filter_type == 'GAUSSIAN': + film['rfilter'] = { + 'type': 'gaussian', + 'stddev': b_scene.cycles.filter_width, + } + elif b_scene.cycles.pixel_filter_type == 'BOX': + film['rfilter'] = {'type': 'box'} + return film + + +def export_camera(export_ctx, camera_instance, b_scene): + '''Convert a depsgraph camera instance and add it to the scene dict. + Never raises: failures produce a warning and the camera is skipped.''' + b_camera = camera_instance.object + try: + params = convert_camera(export_ctx, b_camera, b_scene, + camera_instance.matrix_world.copy()) + except Exception as e: + export_ctx.log(f'Failed to export camera "{b_camera.name_full}": ' + f'{e}. Skipping it.', 'WARN') + return + if export_ctx.export_ids: + export_ctx.data_add(params, name=b_camera.name_full) + else: + export_ctx.data_add(params) diff --git a/mitsuba_blender/convert/export/lights.py b/mitsuba_blender/convert/export/lights.py new file mode 100644 index 00000000..e1022c47 --- /dev/null +++ b/mitsuba_blender/convert/export/lights.py @@ -0,0 +1,234 @@ +'''Blender light to Mitsuba emitter conversion. + +The radiometric conversions between Blender lights (radiant power in +watts, following the Cycles conventions) and Mitsuba emitters (radiant +intensity, irradiance or radiance) live in this module; the importer in +convert.importer.lights applies their inverses. +''' + +import math + +from mathutils import Matrix + +from .. import ConversionError + + +################### +## Radiometry ## +################### + +def power_to_intensity(power): + '''Radiant power (W) of a Blender point or spot light to radiant + intensity (W/sr). Cycles normalizes by the full sphere regardless of + the spot cone angle.''' + return power / (4.0 * math.pi) + + +def intensity_to_power(intensity): + return intensity * 4.0 * math.pi + + +def power_to_radiance(power, area): + '''Radiant power (W) of a one-sided Lambertian area emitter to + radiance (W/(sr*m^2)).''' + return power / (math.pi * area) + + +def radiance_to_power(radiance, area): + return radiance * math.pi * area + + +def sphere_area(radius): + return 4.0 * math.pi * radius * radius + + +def spot_beam_width(spot_size, spot_blend): + '''The angle at which the falloff of a Blender spot light begins, + following the Cycles falloff curve. All angles are in radians.''' + alpha = spot_size / 2.0 + return math.acos(spot_blend + (1.0 - spot_blend) * math.cos(alpha)) + + +def spot_blend(spot_size, beam_width): + '''Inverse of spot_beam_width.''' + denom = 1.0 - math.cos(spot_size / 2.0) + if denom < 1e-9: + return 0.0 + blend = (math.cos(beam_width) - math.cos(spot_size / 2.0)) / denom + return min(max(blend, 0.0), 1.0) + + +#################### +## Converters ## +#################### + +def _colored(scalar, color): + return [scalar * c for c in color[:3]] + + +def _convert_point(export_ctx, b_light, matrix_world): + data = b_light.data + position = list(export_ctx.transform_matrix(matrix_world).translation()) + radius = data.shadow_soft_size + if radius > 0.0: + # Cycles gives the light the radiance below, but samples it as a + # disk of radius `radius` that always faces the shaded point, so it + # illuminates as P / (4 pi (d^2 + radius^2)). A sphere emitter has + # the same radiance and the same far-field power, but illuminates + # as P / (4 pi d^2) whatever its radius, so it stays brighter than + # Cycles by 1 + (radius / d)^2. No Mitsuba emitter reproduces the + # disk falloff; the sphere at least gets the soft shadows right. + radiance = _colored(power_to_radiance(data.energy, + sphere_area(radius)), + data.color) + return { + 'type': 'sphere', + 'center': position, + 'radius': radius, + 'emitter': { + 'type': 'area', + 'radiance': export_ctx.spectrum(radiance), + 'twosided' : True, + }, + 'bsdf': { + 'type': 'diffuse', + 'reflectance': export_ctx.spectrum(0.0), + }, + } + return { + 'type': 'point', + 'position': position, + 'intensity': export_ctx.spectrum( + _colored(power_to_intensity(data.energy), data.color)), + } + + +def _convert_spot(export_ctx, b_light, matrix_world): + data = b_light.data + if data.shadow_soft_size: + # Mitsuba spot emitters are delta lights, so unlike point lights the + # radius cannot be modelled at all: the shadows come out hard and the + # light stays brighter than Cycles by 1 + (radius / d)^2. + export_ctx.log(f'Light "{b_light.name_full}" has a non-zero radius. ' + 'It will be ignored: expect hard shadows and a ' + 'slightly brighter light than Cycles.', 'WARN') + # Blender spot lights point along -Z, Mitsuba's along +Z + flip = Matrix.Rotation(math.pi, 4, 'X') + return { + 'type': 'spot', + 'intensity': export_ctx.spectrum( + _colored(power_to_intensity(data.energy), data.color)), + 'cutoff_angle': math.degrees(data.spot_size / 2.0), + 'beam_width': math.degrees( + spot_beam_width(data.spot_size, data.spot_blend)), + 'to_world': export_ctx.transform_matrix(matrix_world @ flip), + } + + +def _convert_sun(export_ctx, b_light, matrix_world): + data = b_light.data + if data.angle > 0.0: + export_ctx.log(f'Light "{b_light.name_full}": Mitsuba directional ' + 'emitters have no angular diameter. Ignoring the sun ' + 'angle.', 'INFO') + # Blender sun lights shine along -Z, Mitsuba's along +Z + flip = Matrix.Rotation(math.pi, 4, 'X') + # Mitsuba's directional emitter does not normalize the direction it + # reads from `to_world`, so any object scale would scale the + # irradiance. Cycles only uses the orientation of a sun light. + orientation = (matrix_world @ flip).to_3x3().normalized().to_4x4() + return { + 'type': 'directional', + # The energy of a Blender sun light is its irradiance in W/m^2 + 'irradiance': export_ctx.spectrum(_colored(data.energy, data.color)), + 'to_world': export_ctx.transform_matrix(orientation), + } + + +def _convert_area(export_ctx, b_light, matrix_world): + data = b_light.data + obj_scale = matrix_world.to_scale() + sx, sy = abs(obj_scale.x), abs(obj_scale.y) + size_x = data.size + if data.shape in ('SQUARE', 'DISK'): + size_y = data.size + elif data.shape in ('RECTANGLE', 'ELLIPSE'): + size_y = data.size_y + else: + raise ConversionError(f'area light shape {data.shape} is not ' + 'supported') + if data.shape in ('SQUARE', 'RECTANGLE'): + shape = 'rectangle' + area = (size_x * sx) * (size_y * sy) + else: + shape = 'disk' + # The sizes are the diameters of the ellipse + area = math.pi / 4.0 * (size_x * sx) * (size_y * sy) + if area == 0.0: + raise ConversionError('the area light is degenerate') + if data.spread < math.pi - 1e-5: + export_ctx.log(f'Light "{b_light.name_full}" has a spread angle, ' + 'which Mitsuba does not support. Ignoring it.', 'WARN') + + # Mitsuba rectangles and disks span [-1, 1] locally + local = Matrix.Diagonal((size_x / 2.0, size_y / 2.0, 1.0)).to_4x4() + radiance = _colored(power_to_radiance(data.energy, area), data.color) + return { + 'type': shape, + # Blender area lights emit along -Z, Mitsuba shapes along +Z + 'flip_normals': True, + 'to_world': export_ctx.transform_matrix(matrix_world @ local), + 'emitter': { + 'type': 'area', + 'radiance': export_ctx.spectrum(radiance), + 'twosided' : True, + }, + 'bsdf': { + 'type': 'diffuse', + 'reflectance' : export_ctx.spectrum(0.0) + }, + } + + +_converters = { + 'POINT': _convert_point, + 'SPOT': _convert_spot, + 'SUN': _convert_sun, + 'AREA': _convert_area, +} + + +def convert_light(export_ctx, b_light, matrix_world=None): + '''Convert a Blender light object into a Mitsuba plugin dict. + Raises ConversionError for unsupported lights.''' + converter = _converters.get(b_light.data.type) + if converter is None: + raise ConversionError(f'light type {b_light.data.type} is not ' + 'supported') + if matrix_world is None: + matrix_world = b_light.matrix_world + emitter = converter(export_ctx, b_light, matrix_world) + + if not b_light.visible_camera: + if 'emitter' in emitter and isinstance(emitter['emitter'], dict): + emitter['emitter']['visible'] = False + else: + emitter['visible'] = False + return emitter + + +def export_light(export_ctx, light_instance): + '''Convert a depsgraph light instance and add it to the scene dict. + Never raises: failures produce a warning and the light is skipped.''' + b_light = light_instance.object + try: + params = convert_light(export_ctx, b_light, + light_instance.matrix_world.copy()) + except Exception as e: + export_ctx.log(f'Failed to export light "{b_light.name_full}": {e}. ' + 'Skipping it.', 'WARN') + return + if export_ctx.export_ids: + export_ctx.data_add(params, name=f'emit-{b_light.name_full}') + else: + export_ctx.data_add(params) diff --git a/mitsuba_blender/convert/export/materials/__init__.py b/mitsuba_blender/convert/export/materials/__init__.py new file mode 100644 index 00000000..4f0a85c0 --- /dev/null +++ b/mitsuba_blender/convert/export/materials/__init__.py @@ -0,0 +1,134 @@ +'''Registry of Blender shader node to Mitsuba material converters. + +Converter modules in this package register functions with +@node_converter(''). A converter receives (export_ctx, ref), +where `ref` is a resolve.NodeRef pairing the shader node with the group +instance path it was reached through, and returns either a Mitsuba BSDF +dict, or a {'bsdf': dict, 'emitter': dict|None} pair when the node +(also) emits light. Converters signal failure by raising ConversionError; +convert_material catches everything and substitutes an error BSDF, so a +broken material never aborts an export. + +Texture-producing nodes register with @texture_converter('') +instead and are picked up by the socket resolver in resolve. +''' + +import copy +import importlib +import pkgutil + +from ... import ConversionError +from ....compat import uses_nodes +from ._resolve import (FALLBACK_COLOR, ERROR_COLOR, Constant, NodeRef, + Texture, Unsupported, eval_color, eval_float, + resolve, texture_converter) + +_node_converters = {} + + +def node_converter(*node_types): + '''Register a converter for the given shader node types (node.type).''' + def decorator(func): + for node_type in node_types: + _node_converters[node_type] = func + return func + return decorator + +ERROR_BSDF = { + 'type': 'twosided', + 'bsdf': { + 'type': 'diffuse', + 'reflectance': {'type': 'rgb', 'value': ERROR_COLOR}, + }, +} +FALLBACK_BSDF = { + 'type': 'twosided', + 'bsdf': { + 'type': 'diffuse', + 'reflectance': {'type': 'rgb', 'value': FALLBACK_COLOR}, + }, +} + +def convert_shader_node(export_ctx, ref): + '''Convert one shader node into a {'bsdf', 'emitter'} pair.''' + node = ref.node + converter = _node_converters.get(node.type) + if converter is None: + raise ConversionError(f'shader node "{node.name}" of type ' + f'{node.type} is not supported') + result = converter(export_ctx, ref) + if 'type' in result: + return {'bsdf': result, 'emitter': None} + return {'bsdf': result.get('bsdf'), 'emitter': result.get('emitter')} + + +def surface_ref(b_mat): + '''A NodeRef for the node feeding the Surface input of the material + output, or None. The Surface node may live inside a node group, so the + group instance path the trace ended with is kept alongside it.''' + output = b_mat.node_tree.get_output_node('CYCLES') + if output is None: + return None + node, _, stack = _resolve.trace_source(output.inputs['Surface']) + return NodeRef(node, stack) if node is not None else None + + + +def convert_material(export_ctx, b_mat): + '''Convert a Blender material into {'bsdf': dict, + 'emitter': dict|None}. Never raises: failures produce a warning and a + gray diffuse fallback.''' + import os + try: + if not uses_nodes(b_mat): + return {'bsdf': { + 'type': 'diffuse', + 'reflectance': export_ctx.spectrum(b_mat.diffuse_color), + }, 'emitter': None} + ref = surface_ref(b_mat) + if ref is None: + raise ConversionError('no output node with a linked Surface ' + 'input') + return convert_shader_node(export_ctx, ref) + except Exception as e: + if export_ctx.strict: + if os.environ.get('MITSUBA_BLENDER_DEBUG'): + import traceback + traceback.print_exc() + + export_ctx.log(f'Failed to convert material "{b_mat.name}": {e}. ' + 'Exporting an ERROR diffuse fallback.', 'WARN') + return {'bsdf': copy.deepcopy(ERROR_BSDF), 'emitter': None} + else: + export_ctx.log(f'Failed to convert material "{b_mat.name}": {e}. ' + 'Exporting a diffuse fallback.', 'WARN') + return {'bsdf': copy.deepcopy(FALLBACK_BSDF), 'emitter': None} + +def add_material_to_dict(export_ctx, mat_id, bsdf, emitter): + '''Store a converted BSDF/emitter pair in the scene dict, in the layout + the shape exporter expects: the BSDF under mat_id, mixed pairs in the + exported materials cache.''' + export_ctx.data_add(bsdf, mat_id) + if emitter is None: + return + export_ctx.exported_mats[mat_id] = {'bsdf': mat_id, 'emitter': emitter} + + +def export_material(export_ctx, b_mat): + '''Convert a Blender material and store it in the scene dict, once per + material name.''' + if b_mat is None: + return + mat_id = f'mat-{b_mat.name}' + if export_ctx.data_get(mat_id) is not None \ + or mat_id in export_ctx.exported_mats: + return + result = convert_material(export_ctx, b_mat) + add_material_to_dict(export_ctx, mat_id, result['bsdf'], + result['emitter']) + + +# Converter modules register themselves when imported +for _module in pkgutil.iter_modules(__path__): + if not _module.name.startswith('_'): + importlib.import_module(f'.{_module.name}', __name__) diff --git a/mitsuba_blender/convert/export/materials/_resolve.py b/mitsuba_blender/convert/export/materials/_resolve.py new file mode 100644 index 00000000..6c250844 --- /dev/null +++ b/mitsuba_blender/convert/export/materials/_resolve.py @@ -0,0 +1,487 @@ +'''Socket resolution for material export. + +`resolve` follows node links through reroutes and muted nodes and classifies +what feeds an input socket: + +- `Constant(value)`: the socket is unlinked +- `Texture(params)`: the socket is fed by a node with a registered texture + converter, and `params` is the resulting Mitsuba texture dict. +- `Unsupported(reason)`: anything else, with a message naming the node that + could not be handled. +''' +import mitsuba as mi +import drjit as dr + +from typing import NamedTuple +from ....io.exporter.export_context import ExportContext +from ... import ConversionError + +ERROR_COLOR = [1.0, 0.0, 0.3] +FALLBACK_COLOR = [0.5, 0.5, 0.5] +_CONSTANT_FALLBACKS = { + 'ShaderNodeAmbientOcclusion' : { + 'Color' : 1.0, + 'AO': 1.0, + }, + 'ShaderNodeNewGeometry' : { + 'Backfacing' : 0.0, + 'Pointiness' : 0.5, + }, +} + +class Constant: + '''A constant: a float, or a tuple for vectors and colors.''' + + def __init__(self, value): + self.value = value + + def __repr__(self): + return f'Constant({self.value!r})' + + +class Texture: + '''A Mitsuba texture dict produced by a registered texture converter.''' + + def __init__(self, params): + self.params = params + + def __repr__(self): + return f'Texture({self.params!r})' + + +class Unsupported: + '''Marks a socket whose input cannot be converted, with the reason.''' + + def __init__(self, reason): + self.reason = reason + + def __repr__(self): + return f'Unsupported({self.reason!r})' + + +def _average(texture): + ''' Mean of a texture over a 5x5 grid of UV samples + + Some Mitsuba parameters are read as float at construction and cannot + take a texture, so a subgraph driving one has to collapse to a number. + Callers check is_spatially_varying() first, so this is only reached + when the value genuinely varies and the result is an approximation. + The grid size is arbitrary: 25 samples are cheap at export time and no + choice is correct, since the parameter is constant over the surface + either way. + ''' + n = 5 + if 'scalar' in mi.variant(): + total = 0.0 + for i in range(n): + for j in range(n): + si = dr.zeros(mi.SurfaceInteraction3f) + si.uv = mi.Point2f(i / (n - 1), j / (n - 1)) + total += float(texture.eval_1(si)) + return total / (n * n) + x = dr.linspace(mi.Float, 0.0, 1.0, n) + x, y = dr.meshgrid(x, x) + si = dr.zeros(mi.SurfaceInteraction3f, dr.width(x)) + si.uv = mi.Point2f(x, y) + colors = texture.eval(si) + + avg_color = dr.slice(dr.mean(colors, axis=None), 0) + return avg_color + + +def _average_vector(texture): + '''As _average, but per channel, for parameters that take three floats''' + n = 5 + if 'scalar' in mi.variant(): + total = [0.0, 0.0, 0.0 ] + for i in range(n): + for j in range(n): + si = dr.zeros(mi.SurfaceInteraction3f) + si.uv = mi.Point2f(i / (n - 1), j / (n - 1)) + c = texture.eval_3(si) + total[0] += float(c.x) + total[1] += float(c.y) + total[2] += float(c.z) + return [v / (n * n) for v in total] + x = dr.linspace(mi.Float, 0.0, 1.0, n) + x, y = dr.meshgrid(x, x) + si = dr.zeros(mi.SurfaceInteraction3f, dr.width(x)) + si.uv = mi.Point2f(x, y) + + colors = texture.eval_3(si) + avg_vector = dr.slice(dr.mean(colors, axis=1), 0) + return avg_vector + +def _absolutify_filenames(params, directory): + import os + params = dict(params) + for k, v in params.items(): + if k == 'filename' and isinstance(v, str) and not os.path.isabs(v): + params[k] = os.path.join(directory, v) + elif isinstance(v, dict): + params[k] = _absolutify_filenames(v, directory) + return params + + +def scalar_from_socket(export_ctx: ExportContext, socket, stack=()) -> float: + ''' + Average a subgraph down to a scalar for parameters Mitsuba reads as floats. + ''' + import numpy as np + result = resolve(export_ctx, socket, stack=stack) + if isinstance(result, Constant): + v = result.value + return float(v) if np.isscalar(v) else float(np.mean(v)) + if isinstance(result, Unsupported): + if export_ctx.strict: + raise ConversionError(result.reason) + return socket_default(socket) + # Texture: build it once and average over a UV grid + params = _absolutify_filenames(result.params, export_ctx.directory) + + texture = mi.load_dict(params) + + if not texture.is_spatially_varying(): + si = dr.zeros(mi.SurfaceInteraction3f) + return float(texture.eval_1(si)) + return _average(texture) + + +def vector_from_socket(export_ctx: ExportContext, ref, socket): + ''' + Average a subgraph down to a vector for parameters Mitsuba reads as vector + ''' + import os + result = resolve(export_ctx, socket, stack=ref.stack) + if isinstance(result, Constant): + return _to_vector(result.value) + if isinstance(result, Unsupported): + if export_ctx.strict: + raise ConversionError(result.reason) + return socket_default(socket) + params = _absolutify_filenames(result.params, export_ctx.directory) + + texture = mi.load_dict(params) + + if not texture.is_spatially_varying(): + si = dr.zeros(mi.SurfaceInteraction3f) + c = texture.eval_3(si) + return [float(c.x), float(c.y), float(c.z)] + return _average_vector(texture) + + +_texture_converters = {} + + +def texture_converter(*node_types): + '''Register a converter for a texture-producing shader node type (the + value of `node.type`, e.g. TEX_IMAGE). The function receives + (export_ctx, node, out_socket) and returns a Mitsuba texture dict, or + raises ConversionError.''' + def decorator(func): + for node_type in node_types: + _texture_converters[node_type] = func + return func + return decorator + + +def _active_output(tree): + '''The live NodeGroupOutput node of a node group tree, or None + + A tree may hold several Group output nodes, only the one flagged + is_active_output is evaluated, the rest are inert. If nothing is flagged, + it takes the first one''' + + outs = [n for n in tree.nodes if n.type == 'GROUP_OUTPUT'] + return next((n for n in outs if n.is_active_output), None) or (outs[0] if outs else None) + +def _index_of(sockets, socket): + '''The position of `socket` in a node's inputs or outputs collection + or None if it is not there''' + for i, s in enumerate(sockets): + if s == socket: + return i + return None + + +def trace_source(socket, stack=()): + '''Given an input socket and the current path, return where its value comes from + This gives us two cases: + - (node, out_socket, stack) a real node produces the value + - (node, terminal, stack) nothing does: `terminal` is where the walk stopped + + Raise a ConversionError when the links form a cycle (Blender permits cycles made + of reroutes or muted nodes).''' + + visited = set() + # The loop rewrites `socket` and `stack` in place and iterates. + # Every branch either moves or stops (return) + # + # At the top of each iteration + # - `socket` is an input socket which we want its feeding value + # - `stack` lists the group instances we are currently inside, outermost first + while socket.is_linked: + key = (socket.as_pointer(), stack) + if key in visited: + raise ConversionError(f'the links feeding socket ' + f'"{socket.name}" of node ' + f'"{socket.node.name}" form a cycle') + + visited.add(key) + + link = socket.links[0] + if link.is_muted or not link.is_valid: + return None, socket, stack + + node, source = link.from_node, link.from_socket + # pass through + if node.type == 'REROUTE': + socket = node.inputs[0] + + elif node.mute: + internal = [l for l in node.internal_links if l.to_socket == source] + if not internal: + return None, socket, stack + socket = internal[0].from_socket + + # We enter into a new group. + elif node.type == 'GROUP': + tree = node.node_tree + if tree is None: + return None, socket, stack + + active = _active_output(tree) + if active is None: + return None, socket, stack + + stack = stack + (node,) + i = _index_of(node.outputs, source) + if i is None or i >= len(active.inputs): + return None, socket, stack + + socket = active.inputs[i] + + # We are going outside the current group, hence we pop it, map the + # Group input node's output to the same position input on the group node + elif node.type == 'GROUP_INPUT': + if not stack: + return None, socket, stack + # Equivalent to stack = list(stack); outer = stack.pop() + outer, stack = stack[-1], stack[:-1] + + i = _index_of(node.outputs, source) + if i is None or i >= len(outer.inputs): + return None, socket, stack + + # outer group input socket for the node.outputs -> source link + socket = outer.inputs[i] + else: + return node, source, stack + return None, socket, stack + +def socket_default(socket): + '''The default value of a socket, as a float or tuple.''' + value = socket.default_value + if isinstance(value, (int, float)): + return float(value) + return tuple(value) + + +def resolve(export_ctx, socket, stack=()): + '''Classify the input of a socket as Constant, Texture or Unsupported.''' + try: + node, source, stack = trace_source(socket, stack=stack) + except ConversionError as e: + return Unsupported(str(e)) + + if node is None: + return Constant(socket_default(source)) + + ref = NodeRef(node, stack) + converter = _texture_converters.get(node.type) + if converter is not None: + try: + return Texture(converter(export_ctx, ref, source)) + except ConversionError as e: + return Unsupported(str(e)) + + if ref.node.type in _GETTERS: + getter = _GETTERS[ref.node.type] + else: + fallback = _CONSTANT_FALLBACKS.get(node.bl_idname) + if fallback is not None: + export_ctx.log(f'Node "{node.name}" of type {node.bl_idname} has no Mitsuba ' + 'equivalent; substituting a constant.', 'WARN') + return Constant(fallback[source.name]) + + return Unsupported(f'node "{node.name}" of type {node.type} is not ' + f'supported (feeding socket "{socket.name}" of node ' + f'"{socket.node.name}")') + try: + return Constant(_convert(getter(ref, source), socket.type)) + except _Uncastable as e: + return Unsupported(f'{e} (feeding socket "{socket.name}" of node ' + f'"{socket.node.name}")') + + +def eval_float(export_ctx, socket, default=None, stack=()): + '''Resolve a float socket to a float or a Mitsuba texture dict. On + unsupported input, a warning is logged and the default is used (the + socket default if none is given).''' + result = resolve(export_ctx, socket, stack=stack) + if isinstance(result, Constant): + return _to_float(result.value) + if isinstance(result, Texture): + return result.params + + if export_ctx.strict: + raise ConversionError(result.reason) + export_ctx.log(f'{result.reason}; using the default value', 'WARN') + return _to_float(default if default is not None else socket_default(socket)) + + +def eval_color(export_ctx, socket, default=None, stack=()): + '''Resolve a color socket to a Mitsuba spectrum or texture dict. On + unsupported input, a warning is logged and the default is used (the + socket default if none is given).''' + result = resolve(export_ctx, socket, stack=stack) + if isinstance(result, Constant): + return export_ctx.spectrum(list(_to_color(result.value))) + if isinstance(result, Texture): + return result.params + + if export_ctx.strict: + raise ConversionError(result.reason) + + export_ctx.log(f'{result.reason}; using the default color', 'WARN') + + return _to_color(default if default is not None else socket_default(socket)) + + +def eval_vector(export_ctx, socket, default=None, stack=()): + '''Resolve a vector socket to a 3-component value or a Mitsuba texture + dict. Unlike eval_color the result carries no colour semantics, so + constants are emitted as raw values and the fallback is the socket + default rather than the error colour.''' + + result = resolve(export_ctx, socket, stack=stack) + if isinstance(result, Constant): + return _to_vector(result.value) + if isinstance(result, Texture): + return result.params + + if export_ctx.strict: + raise ConversionError(result.reason) + + export_ctx.log(f'{result.reason}; using the default value', 'WARN') + return _to_vector(default if default is not None else socket_default(socket)) + + + +############################# +## Value representations ## +############################# + +class _Uncastable(Exception): + pass + + +def _luminance(rgb): + return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] + + +def _to_float(value): + if isinstance(value, (int, float)): + return float(value) + return _luminance(value) + + +def _to_vector(value): + if isinstance(value, (int, float)): + return (float(value),) * 3 + return tuple(value)[:3] + + +def _to_color(value): + if isinstance(value, (int, float)): + value = float(value) + return (value, value, value, 1.0) + value = tuple(value) + if len(value) == 3: + return value + (1.0,) + return value + + +_SOCKET_CONVERTERS = { + 'VALUE': _to_float, + 'VECTOR': _to_vector, + 'RGBA': _to_color, +} + + +def _convert(value, socket_type): + converter = _SOCKET_CONVERTERS.get(socket_type) + if converter is None: + raise _Uncastable(f'cannot cast into a socket of type {socket_type}') + return converter(value) + +#################### +## Nodes ## +#################### + +class NodeRef(NamedTuple): + node : object + stack: tuple + + +def _get_rgb(ref, out_socket): + # The value lives on the output socket; node.color is the header color. + node = ref.node + return tuple(node.outputs['Color'].default_value) + + +def _get_value(ref, out_socket): + node = ref.node + return float(node.outputs['Value'].default_value) + +def _get_object_info(ref, out_socket): + # TODO: every output here depends on which object (and which instance + # of it) is being shaded. A Mitsuba texture is evaluated from a + # SurfaceInteraction, which carries no per-instance identity, so none + # of these can be reproduced yet. Returning zero until instance data + # is available: https://github.com/mitsuba-renderer/mitsuba3/pull/1885 + if out_socket.type == 'VALUE': + return 0.0 + return (0.0, 0.0, 0.0) + + +_LIGHT_PATH_DEFAULTS = { + 'Is Camera Ray': 1.0, + 'Is Shadow Ray': 0.0, + 'Is Diffuse Ray': 0.0, + 'Is Glossy Ray': 0.0, + 'Is Singular Ray': 0.0, + 'Is Reflection Ray': 0.0, + 'Is Transmission Ray': 0.0, + 'Ray Depth': 0.0, + 'Diffuse Depth': 0.0, + 'Glossy Depth': 0.0, + 'Transparent Depth': 0.0, + 'Transmission Depth': 0.0, +} + + +def _get_light_path(ref, out_socket): + # TODO: these describe the path rather than the surface, and a Mitsuba + # texture is evaluated from a SurfaceInteraction which carries none of + # it. Defaulting to a camera ray at depth zero reproduces the primary + # appearance, which is roughly what EEVEE does for the outputs it + # cannot support either. + return _LIGHT_PATH_DEFAULTS.get(out_socket.name, 0.0) + +_GETTERS = { + 'RGB': _get_rgb, + 'VALUE': _get_value, + 'OBJECT_INFO' : _get_object_info, + 'LIGHT_PATH' : _get_light_path +} diff --git a/mitsuba_blender/convert/export/materials/basic.py b/mitsuba_blender/convert/export/materials/basic.py new file mode 100644 index 00000000..c5ca8c08 --- /dev/null +++ b/mitsuba_blender/convert/export/materials/basic.py @@ -0,0 +1,167 @@ +'''Converters for the basic Cycles BSDF nodes: Diffuse, Glossy, Glass, +Refraction, Transparent and Translucent.''' + +import math + +from . import node_converter +from ._resolve import Constant, eval_color, eval_float, resolve, scalar_from_socket +from .textures import convert_normal_input, _math + +# Mapping from MULTI_GGX is an approximation which diverges with high roughness +_DISTRIBUTIONS = { + 'BECKMANN': 'beckmann', + 'GGX': 'ggx', + 'ASHIKHMIN_SHIRLEY': 'beckmann', + 'MULTI_GGX': 'ggx', +} +_HANDLED_DISTRIBUTION = ('BECKMANN', 'GGX') + + +def _eval_roughness(export_ctx, socket, stack): + '''Blender roughness -> Mitsuba alpha. Cycles has used the square root + of the internal roughness as the UI value since 2.8, so the socket + value is squared; roughconductor and friends take alpha directly. + ''' + value = eval_float(export_ctx, socket, stack=stack) + if isinstance(value, dict): + return _math('in[0] >= 0 ? pow(in[0], in[1]) : 0.0', value, 2.0) + return value * value + + +@node_converter('BSDF_DIFFUSE') +def convert_diffuse(export_ctx, ref): + node = ref.node + roughness = node.inputs['Roughness'] + if roughness.is_linked or roughness.default_value > 0.0: + export_ctx.log(f'Mitsuba has no rough diffuse BSDF; ignoring the ' + f'roughness of node "{node.name}".', 'WARN') + bsdf = { + 'type': 'twosided', + 'bsdf': { + 'type': 'diffuse', + 'reflectance': eval_color(export_ctx, node.inputs['Color'], stack=ref.stack), + }, + } + return convert_normal_input(export_ctx, node.inputs['Normal'], bsdf, stack=ref.stack) + + +@node_converter('BSDF_GLOSSY') +def convert_glossy(export_ctx, ref): + node = ref.node + alpha = _eval_roughness(export_ctx, node.inputs['Roughness'], stack=ref.stack) + anisotropy = scalar_from_socket(export_ctx, node.inputs['Anisotropy'], stack=ref.stack) + anisotropy = min(max(anisotropy, -0.99), 0.99) + rotation = node.inputs['Rotation'] + if rotation.is_linked or rotation.default_value != 0.0: + export_ctx.log(f'Glossy node "{node.name}": anisotropy rotation is ' + 'not supported; ignoring it.', 'WARN') + + if isinstance(alpha, float) and alpha == 0.0: + params = {'type': 'conductor'} + else: + if node.distribution not in _HANDLED_DISTRIBUTION: + export_ctx.log( + f'Approximating distribution "{node.distribution}" with "{_DISTRIBUTIONS[node.distribution]}".', + 'WARN') + + params = { + 'type': 'roughconductor', + 'distribution': _DISTRIBUTIONS[node.distribution], + } + if abs(anisotropy) < 1e-4: + params['alpha'] = alpha + elif not isinstance(alpha, float): + export_ctx.log(f'Glossy node "{node.name}": anisotropy is not ' + 'supported with a textured roughness; ignoring ' + 'it.', 'WARN') + params['alpha'] = alpha + else: + # Cycles' anisotropy mapping: the aspect ratio stretches alpha + # along the tangent, a negative value swaps the axes + aspect = math.sqrt(1.0 - 0.9 * abs(anisotropy)) + alpha_u, alpha_v = alpha / aspect, alpha * aspect + if anisotropy < 0.0: + alpha_u, alpha_v = alpha_v, alpha_u + params['alpha_u'] = alpha_u + params['alpha_v'] = alpha_v + params['specular_reflectance'] = eval_color(export_ctx, + node.inputs['Color'], + stack=ref.stack) + return convert_normal_input(export_ctx, node.inputs['Normal'], + {'type': 'twosided', 'bsdf': params}, stack=ref.stack) + + +def _convert_dielectric(export_ctx, ref): + node = ref.node + ior = scalar_from_socket(export_ctx, node.inputs['IOR'], stack=ref.stack) + alpha = _eval_roughness(export_ctx, node.inputs['Roughness'], stack=ref.stack) + if isinstance(alpha, float) and alpha == 0.0: + params = {'type': 'thindielectric' if ior == 1.0 else 'dielectric'} + else: + if node.distribution not in _HANDLED_DISTRIBUTION: + export_ctx.log( + f'Approximating distribution "{node.distribution}" with "{_DISTRIBUTIONS[node.distribution]}".', + 'WARN') + + params = { + 'type': 'roughdielectric', + 'alpha': alpha, + 'distribution': _DISTRIBUTIONS[node.distribution], + } + params['int_ior'] = ior + params['specular_transmittance'] = eval_color(export_ctx, + node.inputs['Color'], stack=ref.stack) + return convert_normal_input(export_ctx, node.inputs['Normal'], params, stack=ref.stack) + + +@node_converter('BSDF_GLASS') +def convert_glass(export_ctx, ref): + return _convert_dielectric(export_ctx, ref) + + +@node_converter('BSDF_REFRACTION') +def convert_refraction(export_ctx, ref): + export_ctx.log(f'Mitsuba has no transmission-only BSDF; exporting ' + f'Refraction node "{ref.node.name}" as a dielectric that ' + 'also reflects.', 'WARN') + return _convert_dielectric(export_ctx, ref) + + +@node_converter('BSDF_TRANSPARENT') +def convert_transparent(export_ctx, ref): + node = ref.node + result = resolve(export_ctx, node.inputs['Color'], stack=ref.stack) + if isinstance(result, Constant): + color = result.value[:3] + else: + export_ctx.log(f'Transparent node "{node.name}": only constant ' + 'colors are supported; using the socket value. ' + 'Consider using a Mix Shader instead.', 'WARN') + color = tuple(node.inputs['Color'].default_value)[:3] + if min(color) == 1.0: + return {'type': 'null'} + # A tinted transparent BSDF transmits `color` and absorbs the rest: a + # mask over a black diffuse with the inverted color as opacity + return { + 'type': 'mask', + 'opacity': export_ctx.spectrum([1.0 - x for x in color]), + 'bsdf': { + 'type': 'diffuse', + 'reflectance': export_ctx.spectrum(0.0), + }, + } + + +@node_converter('BSDF_TRANSLUCENT') +def convert_translucent(export_ctx, ref): + # Mitsuba has no standalone diffuse transmitter; principledthin with + # full diffuse transmission is the closest match + node = ref.node + export_ctx.log(f'Approximating Translucent node "{node.name}" with a ' + 'thin principled BSDF.', 'WARN') + bsdf = { + 'type': 'principledthin', + 'base_color': eval_color(export_ctx, node.inputs['Color'], stack=ref.stack), + 'diff_trans': 2.0, + } + return convert_normal_input(export_ctx, node.inputs['Normal'], bsdf, stack=ref.stack) diff --git a/mitsuba_blender/convert/export/materials/combinators.py b/mitsuba_blender/convert/export/materials/combinators.py new file mode 100644 index 00000000..c333f7d3 --- /dev/null +++ b/mitsuba_blender/convert/export/materials/combinators.py @@ -0,0 +1,225 @@ +'''Converters for the shader combinator nodes: Mix Shader, Add Shader, +Emission and Holdout.''' + +import copy + +from ... import ConversionError +from . import convert_shader_node, node_converter +from .textures import _math +from ._resolve import Constant, NodeRef, Texture, resolve, eval_float, trace_source, scalar_from_socket + + +DEFAULT_BSDF = { + 'type' : 'diffuse', + 'reflectance' : {'type': 'rgb', 'value': 0.0} +} + +def _child_bsdf(export_ctx, ref): + '''Convert a nested shader node into a plain BSDF dict.''' + result = convert_shader_node(export_ctx, ref) + if result['emitter'] is not None: + export_ctx.log(f'Ignoring the emission of node "{ref.node.name}": ' + 'emitters cannot be nested inside a BSDF.', 'WARN') + if result['bsdf'] is None: + raise ConversionError(f'node "{ref.node.name}" does not produce a BSDF') + return result['bsdf'] + + +def _emission_radiance(export_ctx, ref): + '''Evaluate an Emission node into an RGB radiance list or a Mitsuba + texture dict (the color scaled by the strength).''' + node = ref.node + strength = scalar_from_socket(export_ctx, node.inputs['Strength'], stack=ref.stack) + + color = resolve(export_ctx, node.inputs['Color'], stack=ref.stack) + if isinstance(color, Texture): + params = color.params + if strength != 1.0: + params = _math('in[0] * in[1]', params, strength) + return params + if isinstance(color, Constant): + rgb = color.value + else: + export_ctx.log(f'{color.reason}; using the default value', 'WARN') + rgb = tuple(node.inputs['Color'].default_value) + return [c * strength for c in rgb[:3]] + + +def _emitter_result(export_ctx, radiance): + '''Wrap a radiance value in an area emitter pair. A zero radiance falls + back to a black diffuse BSDF since Mitsuba rejects black emitters.''' + if isinstance(radiance, dict): + return { + 'bsdf': copy.deepcopy(DEFAULT_BSDF), + 'emitter': { + 'type': 'area', + 'radiance': radiance, + 'twosided': True, + } + } + if sum(radiance) == 0: + export_ctx.log('Ignoring emitter with zero emission.', 'WARN') + return { + 'bsdf' : copy.deepcopy(DEFAULT_BSDF), + 'emitter': None + } + return { + 'bsdf': copy.deepcopy(DEFAULT_BSDF), + 'emitter': { + 'type': 'area', + 'radiance': export_ctx.spectrum(radiance), + 'twosided' : True, + } + } + + +@node_converter('EMISSION') +def convert_emission(export_ctx, ref): + return _emitter_result(export_ctx, _emission_radiance(export_ctx, ref)) + + +@node_converter('HOLDOUT') +def convert_holdout(export_ctx, ref): + export_ctx.log(f'Holdout node "{ref.node.name}" is approximated by a ' + 'null BSDF.', 'WARN') + return {'type': 'null'} + + +def _shader_input(socket, stack): + '''Trace a shader input to the node feeding it, as a NodeRef.''' + child, _, child_stack = trace_source(socket, stack) + return NodeRef(child, child_stack) if child is not None else None + + +@node_converter('ADD_SHADER') +def convert_add(export_ctx, ref): + node = ref.node + a = _shader_input(node.inputs[0], ref.stack) + b = _shader_input(node.inputs[1], ref.stack) + if a is None and b is None: + raise ConversionError(f'Add Shader node "{node.name}" needs both ' + 'shader inputs linked') + if a is None: + return _child_bsdf(export_ctx, a) + if b is None: + return _child_bsdf(export_ctx, b) + + a_emits = a.node.type == 'EMISSION' + b_emits = b.node.type == 'EMISSION' + if a_emits and b_emits: + radiance_a = _emission_radiance(export_ctx, a) + radiance_b = _emission_radiance(export_ctx, b) + if isinstance(radiance_a, dict) or isinstance(radiance_b, dict): + raise ConversionError(f'Add Shader node "{node.name}": adding ' + 'textured emitters is not supported') + return _emitter_result(export_ctx, + [x + y for x, y in zip(radiance_a, radiance_b)]) + if not a_emits and not b_emits: + raise ConversionError(f'Add Shader node "{node.name}": adding two ' + 'BSDFs is not supported; use a Mix Shader ' + 'instead') + + emission, other = (a, b) if a_emits else (b, a) + result = convert_shader_node(export_ctx, other) + if result['bsdf'] is None or result['emitter'] is not None: + raise ConversionError(f'Add Shader node "{node.name}": only one ' + 'Emission plus one BSDF is supported') + emitter = _emitter_result(export_ctx, + _emission_radiance(export_ctx, emission)) + return {'bsdf': result['bsdf'], 'emitter': emitter['emitter']} + + +@node_converter('MIX_SHADER') +def convert_mix(export_ctx, ref): + node = ref.node + a = _shader_input(node.inputs[1], ref.stack) + b = _shader_input(node.inputs[2], ref.stack) + if a is None and b is None: + raise ConversionError(f'Mix Shader node "{node.name}" needs both ' + 'shader inputs linked') + if a is None: + return { + 'type' : 'blendbsdf', + 'weight' : eval_float(export_ctx, node.inputs['Fac'], stack=ref.stack), + 'bsdf1': {'type' : 'null'}, + 'bsdf2': _child_bsdf(export_ctx, b) + } + if b is None: + return { + 'type' : 'blendbsdf', + 'weight' : eval_float(export_ctx, node.inputs['Fac'], stack=ref.stack), + 'bsdf1': _child_bsdf(export_ctx, a), + 'bsdf2': {'type' : 'null'} + } + + a_emits = a.node.type == 'EMISSION' + b_emits = b.node.type == 'EMISSION' + if a_emits and b_emits: + return _mix_emitters(export_ctx, ref, a, b) + if a_emits or b_emits: + raise ConversionError(f'Mix Shader node "{node.name}": mixing a ' + 'BSDF with an emitter is not supported; use ' + 'an Add Shader instead') + if a.node.type == 'BSDF_TRANSPARENT' or b.node.type == 'BSDF_TRANSPARENT': + return _mix_transparent(export_ctx, ref, a, b) + return { + 'type': 'blendbsdf', + 'weight': eval_float(export_ctx, node.inputs['Fac'], stack=ref.stack), + 'bsdf1': _child_bsdf(export_ctx, a), + 'bsdf2': _child_bsdf(export_ctx, b), + } + + +def _mix_emitters(export_ctx, ref, a, b): + node = ref.node + weight = scalar_from_socket(export_ctx, node.inputs['Fac'], stack=ref.stack) + + radiance_a = _emission_radiance(export_ctx, a) + radiance_b = _emission_radiance(export_ctx, b) + if isinstance(radiance_a, dict) or isinstance(radiance_b, dict): + raise ConversionError(f'Mix Shader node "{node.name}": mixing ' + 'textured emitters is not supported') + return _emitter_result( + export_ctx, [(1.0 - weight) * x + weight * y + for x, y in zip(radiance_a, radiance_b)]) + + +def _warn_transparent_tint(export_ctx, ref): + node = ref.node + color = resolve(export_ctx, node.inputs['Color'], stack=ref.stack) + if not isinstance(color, Constant) or \ + any(c != 1.0 for c in color.value[:3]): + export_ctx.log(f'Ignoring the tint of Transparent BSDF node ' + f'"{node.name}" inside a Mix Shader.', 'WARN') + + +def _mix_transparent(export_ctx, ref, a, b): + '''A Mix Shader with a Transparent BSDF on one side maps to a Mitsuba + mask, whose opacity gives the weight of the nested BSDF.''' + node = ref.node + if a.node.type == 'BSDF_TRANSPARENT' and b.node.type == 'BSDF_TRANSPARENT': + _warn_transparent_tint(export_ctx, a) + _warn_transparent_tint(export_ctx, b) + return {'type': 'null'} + if a.node.type == 'BSDF_TRANSPARENT': + transparent, opaque, invert = a, b, False + else: + transparent, opaque, invert = b, a, True + _warn_transparent_tint(export_ctx, transparent) + opacity = eval_float(export_ctx, node.inputs['Fac'], stack=ref.stack) + if invert: + if isinstance(opacity, dict): + # 1 - texture cannot be expressed; a blend against a null BSDF + # is equivalent to a mask with the inverted opacity + return { + 'type': 'blendbsdf', + 'weight': opacity, + 'bsdf1': _child_bsdf(export_ctx, opaque), + 'bsdf2': {'type': 'null'}, + } + opacity = 1.0 - opacity + return { + 'type': 'mask', + 'opacity': opacity, + 'bsdf': _child_bsdf(export_ctx, opaque), + } diff --git a/mitsuba_blender/convert/export/materials/principled.py b/mitsuba_blender/convert/export/materials/principled.py new file mode 100644 index 00000000..7fae2c5d --- /dev/null +++ b/mitsuba_blender/convert/export/materials/principled.py @@ -0,0 +1,108 @@ +'''Converter for the Principled BSDF shader node. + +The core parameters map onto Mitsuba's principled BSDF. Emission becomes a +separate area emitter dict, an Alpha below one wraps the BSDF in a mask, +and the Normal input wraps it in normalmap/bumpmap adapters. +''' + +from . import node_converter +from ._resolve import Constant, Texture, Unsupported, eval_color, eval_float, resolve, scalar_from_socket, vector_from_socket +from .textures import convert_normal_input, _math + + +def _spec_tint(export_ctx, ref): + '''Blender tints the specular highlight with a color (white means + untinted) while Mitsuba blends from white toward the base color hue, + so the distance from white becomes the tint amount.''' + node = ref.node + socket = node.inputs['Specular Tint'] + result = resolve(export_ctx, socket, stack=ref.stack) + if isinstance(result, Constant): + return 1.0 - min(result.value[:3]) + return 1.0 - min(vector_from_socket(export_ctx, ref, socket)[:3]) + + +def _emitter(export_ctx, ref): + '''Build an area emitter dict from the emission inputs, or None.''' + node = ref.node + strength = scalar_from_socket(export_ctx, node.inputs['Emission Strength'], + ref.stack) + if strength <= 0.0: + return None + result = resolve(export_ctx, node.inputs['Emission Color'], + stack=ref.stack) + if isinstance(result, Texture): + params = result.params + if strength != 1.0: + params = _math('in[0] * in[1]', params, strength) + return { + 'type': 'area', + 'radiance': params, + 'twosided' : True, + 'visible' : False + } + if isinstance(result, Unsupported): + export_ctx.log(f'{result.reason}; ignoring the emission of node ' + f'"{node.name}"', 'WARN') + return None + radiance = [c * strength for c in result.value[:3]] + if max(radiance) == 0.0: + return None + return { + 'type': 'area', + 'radiance': export_ctx.spectrum(radiance), + 'twosided': True, + 'visible' : False + } + + +@node_converter('BSDF_PRINCIPLED') +def convert_principled(export_ctx, ref): + node = ref.node + stack = ref.stack + params = { + 'type': 'principled', + 'base_color': eval_color(export_ctx, node.inputs['Base Color'], + stack=stack), + 'roughness': eval_float(export_ctx, node.inputs['Roughness'], + stack=stack), + 'metallic': eval_float(export_ctx, node.inputs['Metallic'], + stack=stack), + 'anisotropic': eval_float(export_ctx, node.inputs['Anisotropic'], + stack=stack), + 'spec_tint': _spec_tint(export_ctx, ref), + 'spec_trans': eval_float(export_ctx, + node.inputs['Transmission Weight'], + stack=stack), + 'sheen': eval_float(export_ctx, node.inputs['Sheen Weight'], + stack=stack), + 'sheen_tint': eval_float(export_ctx, node.inputs['Sheen Tint'], + stack=stack), + 'clearcoat': eval_float(export_ctx, node.inputs['Coat Weight'], + stack=stack), + 'clearcoat_gloss': + 1.0 - scalar_from_socket(export_ctx, node.inputs['Coat Roughness'], + stack), + } + + spec_trans = params['spec_trans'] + if isinstance(spec_trans, dict) or spec_trans > 0.0: + # Blender drives transmission with the IOR input; Mitsuba shares a + # single eta between reflection and refraction. Transmissive + # materials must stay one-sided. + ior = scalar_from_socket(export_ctx, node.inputs['IOR'], stack) + params['eta'] = max(ior, 1.0 + 1e-3) + bsdf = params + else: + specular = scalar_from_socket(export_ctx, + node.inputs['Specular IOR Level'], stack) + params['specular'] = max(specular, 1e-3) + bsdf = {'type': 'twosided', 'bsdf': params} + + bsdf = convert_normal_input(export_ctx, node.inputs['Normal'], bsdf, stack) + + alpha = eval_float(export_ctx, node.inputs['Alpha'], stack=stack) + if isinstance(alpha, dict) or alpha < 1.0: + bsdf = {'type': 'mask', 'opacity': alpha, 'bsdf': bsdf} + + return {'bsdf': bsdf, 'emitter': _emitter(export_ctx, ref)} diff --git a/mitsuba_blender/convert/export/materials/textures.py b/mitsuba_blender/convert/export/materials/textures.py new file mode 100644 index 00000000..b037482b --- /dev/null +++ b/mitsuba_blender/convert/export/materials/textures.py @@ -0,0 +1,935 @@ +'''Texture and vector-input converters for material export. + +Image data is referenced without side effects on the .blend file: images +that already exist on disk are copied verbatim, everything else (packed, +generated or edited images) is saved through a temporary copy of the +datablock. + +Besides the registered texture converters, this module provides two helpers +for other converters: BSDF converters call `convert_normal_input` on their +Normal input socket to wrap the produced BSDF in normalmap/bumpmap plugins, +and the world exporter calls `convert_environment_texture`. +''' + +import os +import shutil + +import bpy +from mathutils import Euler, Matrix + +from ....io.exporter.export_context import ExportContext + +from ... import ConversionError +from .. import sanitize_attribute_name +from . import texture_converter +from ._resolve import (Constant, Texture, NodeRef, eval_color, eval_float, + eval_vector, resolve, scalar_from_socket, + vector_from_socket, trace_source) + + +######################## +## Image references ## +######################## + +# File types Mitsuba can read directly; anything else is converted on export +_READABLE_EXTS = {'.bmp', '.exr', '.hdr', '.jpeg', '.jpg', '.pfm', '.png', + '.ppm', '.tga'} + +_SAVE_FORMATS = { + 'BMP': '.bmp', + 'HDR': '.hdr', + 'JPEG': '.jpg', + 'PNG': '.png', + 'OPEN_EXR': '.exr', + 'TARGA': '.tga', +} + +# Color spaces whose pixel values Mitsuba should use as-is +_DATA_COLORSPACES = {'Non-Color', 'Raw', 'Linear', 'Linear Rec.709'} + +def _unique_name(cache, name): + used = {os.path.basename(path) for path, _, _ in cache.values()} + if name not in used: + return name + stem, ext = os.path.splitext(name) + counter = 1 + while f'{stem}-{counter}{ext}' in used: + counter += 1 + return f'{stem}-{counter}{ext}' + + +def export_image(export_ctx, image): + '''Copy or save an image into the textures subfolder of the export + directory and return its path relative to the scene file. The Blender + image datablock is never modified.''' + cache = export_ctx.__dict__.setdefault('exported_images', {}) + key = image.name_full + if key in cache: + path, raw, was_dirty = cache[key] + if was_dirty == image.is_dirty and os.path.isfile(os.path.join(export_ctx.directory, path)): + return path, raw + + folder = os.path.join(export_ctx.directory, + export_ctx.TEXTURES_FOLDER) + os.makedirs(folder, exist_ok=True) + + source = '' + raw = False + if image.filepath_raw: + source = bpy.path.abspath(image.filepath_raw, library=image.library) + # Fallback: resolve by basename when the full path doesn't exist + # (common with scenes authored on Windows and opened on Linux) + if source and not os.path.isfile(source): + basename = os.path.basename(source) + blend_dir = os.path.dirname(bpy.data.filepath) + if blend_dir: + for root, dirs, files in os.walk(blend_dir): + if basename in files: + source = os.path.join(root, basename) + break + + if source and os.path.isfile(source) and not image.is_dirty \ + and os.path.splitext(source)[1].lower() in _READABLE_EXTS: + name = _unique_name(cache, os.path.basename(source)) + target = os.path.join(folder, name) + if os.path.abspath(source) != os.path.abspath(target): + shutil.copy2(source, target) + else: + + if not image.has_data: + image.buffers_free() + _ = image.pixels[0] # for lazy load + + if not image.has_data: + for sibling in bpy.data.images: + if sibling == image: + continue + # Strong match: same file on disk + if sibling.filepath and sibling.filepath == image.filepath: + pass + # Weak match: same stem, but only if same dimensions and colorspace + elif (sibling.name.split('.')[0] == image.name.split('.')[0] + and sibling.size[:] == image.size[:] + and sibling.size[0] > 0 + and sibling.colorspace_settings.name == image.colorspace_settings.name): + pass + else: + continue + # Try to load the candidate + if sibling.packed_file and not sibling.has_data: + sibling.buffers_free() + _ = sibling.pixels[0] + if sibling.has_data: + export_ctx.log(f'Image "{image.name}" has no data; using' + f'sibling "{sibling.name}" instead', + 'WARN') + image = sibling + break + + if not image.has_data: + raise RuntimeError(f'Failed to load packed image "{image.name}"') + + import numpy as np + file_format = image.file_format + if file_format not in _SAVE_FORMATS: + file_format = 'OPEN_EXR' if image.is_float else 'PNG' + ext = _SAVE_FORMATS[file_format] + base = image.name if not image.name.lower().endswith(ext) \ + else image.name[:-len(ext)] + name = _unique_name(cache, f'{base}{ext}') + copy = image.copy() + try: + if image.is_float: + copy.colorspace_settings.name = 'Non-Color' + raw = True + pixels = np.empty(len(image.pixels), dtype=np.float32) + image.pixels.foreach_get(pixels) + copy.pixels.foreach_set(pixels) + copy.filepath_raw = os.path.join(folder, name) + copy.file_format = file_format + copy.save() + finally: + bpy.data.images.remove(copy) + + + path = f'{export_ctx.TEXTURES_FOLDER}/{name}' + cache[key] = (path, raw, image.is_dirty) + return path, raw + + +###################### +## UV coordinates ## +###################### + +# Blender addresses the first image row at v = 1, Mitsuba at v = 0 +_FLIP = Matrix.Translation((0.0, 1.0, 0.0)) @ Matrix.Diagonal((1.0, -1.0, 1.0, 1.0)) + + +def _to_uv_param(matrix): + from mitsuba import ScalarTransform4f + return ScalarTransform4f([list(row) for row in matrix]) + + +def _mapping_matrix(export_ctx, ref): + node = ref.node + location = vector_from_socket(export_ctx, ref, node.inputs['Location']) + rotation = vector_from_socket(export_ctx, ref, node.inputs['Rotation']) + scale = vector_from_socket(export_ctx, ref, node.inputs['Scale']) + + if abs(rotation[0]) > 1e-6 or abs(rotation[1]) > 1e-6: + raise ConversionError(f'mapping node "{node.name}" rotates out of ' + 'the UV plane') + matrix = Euler(rotation).to_matrix().to_4x4() @ Matrix.Diagonal((*scale, 1.0)) + + if node.vector_type == 'POINT': + matrix = Matrix.Translation(location) @ matrix + elif node.vector_type == 'TEXTURE': + matrix = Matrix.Translation(location) @ matrix + if abs(matrix.determinant()) < 1e-12: + raise ConversionError(f'mapping node "{node.name}" is not ' + 'invertible') + matrix = matrix.inverted() + elif node.vector_type != 'VECTOR': + raise ConversionError(f'mapping type {node.vector_type} of node ' + f'"{node.name}" is not supported') + return matrix + + +def _uv_chain_matrix(export_ctx, socket, stack): + '''The Blender-UV-space transform of the chain feeding a texture Vector + input. Only UV sources are supported.''' + node, source, node_stack = trace_source(socket, stack) + if node is None: + return Matrix.Identity(4) + if node.type == 'TEX_COORD': + if source.name != 'UV': + raise ConversionError( + f'texture coordinate output "{source.name}" is not ' + 'supported; only UV coordinates map to Mitsuba') + return Matrix.Identity(4) + if node.type == 'UVMAP': + if node.uv_map: + export_ctx.log( + f'UV map node "{node.name}" selects layer "{node.uv_map}", ' + 'but only the active render UV layer is exported.', 'WARN') + return Matrix.Identity(4) + if node.type == 'MAPPING': + if node.vector_type == 'TEXTURE' or node.vector_type == 'POINT': + return _mapping_matrix(export_ctx, + NodeRef(node, node_stack)) @ _uv_chain_matrix(export_ctx, node.inputs['Vector'], + node_stack) + # TODO: allow other vector type + raise ConversionError(f'node "{node.name}" of type MAPPING of vector ' + f'type "{node.vector_type}" is not supported.' + f' Only texture is.') + + raise ConversionError(f'node "{node.name}" of type {node.type} feeding ' + 'a texture Vector input is not supported') + + +def _vector_to_uv(export_ctx, ref): + '''The to_uv parameter for a texture node, or None for identity. Chains + that cannot be converted produce a warning and are ignored.''' + node = ref.node + try: + matrix = _uv_chain_matrix(export_ctx, node.inputs['Vector'], ref.stack) + except ConversionError as e: + export_ctx.log(f'{e}; ignoring the texture mapping of node ' + f'"{node.name}"', 'WARN') + matrix = Matrix.Identity(4) + return _to_uv_param(_FLIP @ matrix) + + +########################## +## Texture converters ## +########################## + +_ONE_VECTOR_OPS = {'LENGTH', 'SCALE', 'NORMALIZE', + 'ABSOLUTE', 'FLOOR', 'CEIL', + 'FRACTION', 'SINE', 'COSINE', + 'TANGENT'} +_MATH_EXPRESSIONS = { + # Two-input arithmetic + 'ADD': 'in[0] + in[1]', + 'SUBTRACT': 'in[0] - in[1]', + 'MULTIPLY': 'in[0] * in[1]', + 'DIVIDE': 'in[1] != 0 ? in[0] / in[1] : 0.0', + 'POWER': 'in[0] >= 0 ? pow(in[0], in[1]) : 0.0', + 'LOGARITHM': 'in[0] > 0 && in[1] > 0 ? log(in[0]) / log(in[1]) : 0.0', + 'MODULO': 'in[1] != 0 ? fmod(in[0], in[1]) : 0.0', + 'FLOORED_MODULO': 'in[1] != 0 ? in[0] - floor(in[0] / in[1]) * in[1] : 0.0', + 'MINIMUM': 'min(in[0], in[1])', + 'MAXIMUM': 'max(in[0], in[1])', + 'LESS_THAN': 'in[0] < in[1] ? 1.0 : 0.0', + 'GREATER_THAN': 'in[0] > in[1] ? 1.0 : 0.0', + 'COMPARE': 'abs(in[0] - in[1]) <= in[2] ? 1.0 : 0.0', + 'ARCTAN2': 'atan2(in[0], in[1])', + 'SNAP': 'in[1] != 0 ? floor(in[0] / in[1]) * in[1] : in[0]', + + # One-input + 'SQRT': 'sqrt(max(in[0], 0))', + 'INVERSE_SQRT': 'in[0] > 0 ? rsqrt(in[0]) : 0.0', + 'ABSOLUTE': 'abs(in[0])', + 'NEGATE': '-in[0]', + 'SIGN': 'in[0] > 0 ? 1.0 : (in[0] < 0 ? -1.0 : 0.0)', + 'ROUND': 'floor(abs(in[0]) + 0.5) * sign(in[0])', + 'FLOOR': 'floor(in[0])', + 'CEIL': 'ceil(in[0])', + 'TRUNC': 'trunc(in[0])', + 'FRACT': 'in[0] - floor(in[0])', + 'SINE': 'sin(in[0])', + 'COSINE': 'cos(in[0])', + 'TANGENT': 'tan(in[0])', + 'ARCSINE': 'asin(clip(in[0], -1, 1))', + 'ARCCOSINE': 'acos(clip(in[0], -1, 1))', + 'ARCTANGENT': 'atan(in[0])', + 'EXPONENT': 'exp(in[0])', + 'RADIANS': 'in[0] * pi / 180.0', + 'DEGREES': 'in[0] * 180.0 / pi', + 'SINH': 'sinh(in[0])', + 'COSH': 'cosh(in[0])', + 'TANH': 'tanh(in[0])', + + # Three-input + 'MULTIPLY_ADD': 'fma(in[0], in[1], in[2])', + + # Complex — need verification + 'PINGPONG': 'tmp[0] = in[1] != 0 ? fmod(in[0], in[1] * 2.0) : 0.0; abs(tmp[0] - in[1])', + 'WRAP': 'tmp[0] = in[1] - in[2]; tmp[0] != 0 ? in[0] - tmp[0] * floor((in[0] - in[2]) / tmp[0]) : in[2]', + 'SMOOTH_MIN': None, + 'SMOOTH_MAX': None, +} + + +def _math(expr, *inputs): + params = {'type': 'math'} + tex_index = 0 + final_expr = expr + for i, value in enumerate(inputs): + if isinstance(value, dict): + if value.get('type') == 'rgb': + v = value['value'] + final_expr = final_expr.replace( + f'in[{i}]', f'rgb({v[0]}, {v[1]}, {v[2]})') + else: + final_expr = final_expr.replace(f'in[{i}]', f'in[{tex_index}]') + params[f'input_{tex_index}'] = value + tex_index += 1 + else: + final_expr = final_expr.replace(f'in[{i}]', f'({float(value)})') + params['expr'] = final_expr + return params + + +@texture_converter('TEX_IMAGE') +def convert_image_texture(export_ctx, ref, out_socket): + node = ref.node + image = node.image + if image is None: + raise ConversionError(f'image texture node "{node.name}" has no ' + 'image') + + params: dict[str, object] = {'type': 'bitmap'} + + if out_socket.name == 'Alpha': + if image.channels < 4: + raise ConversionError(f'image "{image.name}" has no alpha channel') + + import numpy as np + pixels = np.empty(len(image.pixels), dtype=np.float32) + image.pixels.foreach_get(pixels) + alpha = pixels.reshape(-1, image.channels)[:, 3] + + alpha_img = bpy.data.images.new( + f'{image.name}_alpha', image.size[0], image.size[1], + alpha=False, float_buffer=image.is_float) + try: + rgba = np.zeros((len(alpha), 4), dtype=np.float32) + rgba[:, :3] = alpha[:, np.newaxis] + rgba[:, 3] = 1.0 + alpha_img.pixels.foreach_set(rgba.ravel()) + alpha_img.colorspace_settings.name = 'Non-Color' + params['filename'], _ = export_image(export_ctx, alpha_img) + finally: + bpy.data.images.remove(alpha_img) + params['raw'] = True + + else: + colorspace = image.colorspace_settings.name + params['filename'], raw = export_image(export_ctx, image) + + if raw or colorspace in _DATA_COLORSPACES: + params['raw'] = True + elif colorspace != 'sRGB': + export_ctx.log( + f'Color space "{colorspace}" of image "{image.name}" ' + 'is not supported; Mitsuba will interpret the file ' + 'as sRGB.', 'WARN') + + if node.interpolation == 'Closest': + params['filter_type'] = 'nearest' + elif node.interpolation != 'Linear': + export_ctx.log(f'Interpolation {node.interpolation} of node ' + f'"{node.name}" is approximated by bilinear ' + 'filtering.', 'WARN') + + if node.extension in ('EXTEND', 'CLIP'): + params['wrap_mode'] = 'clamp' + if node.extension == 'CLIP': + export_ctx.log(f'Extension mode CLIP of node "{node.name}" ' + 'is approximated by clamping.', 'WARN') + + elif node.extension == 'MIRROR': + params['wrap_mode'] = 'mirror' + + to_uv = _vector_to_uv(export_ctx, ref) + if to_uv is not None: + params['to_uv'] = to_uv + return params + + +@texture_converter('TEX_CHECKER') +def convert_checker_texture(export_ctx, ref, out_socket): + node = ref.node + params = { + 'type': 'checkerboard', + # Once the UV flip is folded into to_uv below, Blender's Color1 + # cells land exactly on Mitsuba's color1 cells + 'color1': eval_color(export_ctx, node.inputs['Color1'], + stack=ref.stack), + 'color0': eval_color(export_ctx, node.inputs['Color2'], + stack=ref.stack), + } + + scale_input = resolve(export_ctx, node.inputs['Scale'], stack=ref.stack) + if isinstance(scale_input, Constant): + scale = float(scale_input.value) + else: + scale = node.inputs['Scale'].default_value + export_ctx.log(f'The scale of checker texture node "{node.name}" ' + 'must be a constant; using the socket value.', 'WARN') + + if not node.inputs['Vector'].is_linked: + export_ctx.log(f'Checker texture node "{node.name}" uses generated ' + 'coordinates, which are approximated by UV ' + 'coordinates.', 'WARN') + try: + matrix = _uv_chain_matrix(export_ctx, node.inputs['Vector'], ref.stack) + except ConversionError as e: + export_ctx.log(f'{e}; ignoring the texture mapping of node ' + f'"{node.name}"', 'WARN') + matrix = Matrix.Identity(4) + + # A Mitsuba checkerboard has 2x2 cells per to_uv period, a Blender one + # has scale x scale cells per UV unit + checker = Matrix.Diagonal((scale / 2.0, scale / 2.0, 1.0, 1.0)) @ matrix + params['to_uv'] = _to_uv_param(checker) + return params + + +@texture_converter('VERTEX_COLOR') +def convert_vertex_color(export_ctx, ref, out_socket): + node = ref.node + if out_socket.name == 'Alpha': + raise ConversionError(f'the Alpha output of color attribute node ' + f'"{node.name}" is not supported') + if not node.layer_name: + raise ConversionError(f'color attribute node "{node.name}" does not ' + 'name a color attribute') + return { + 'type': 'mesh_attribute', + # The mesh exporter sanitizes attribute names the same way, so the + # reference matches the exported attribute + 'name': f'vertex_{sanitize_attribute_name(node.layer_name)}', + } + + +@texture_converter('VALTORGB') +def convert_color_ramp(export_ctx, ref: NodeRef, out_socket): + node = ref.node + ramp = node.color_ramp + + params = { + 'type': 'color_ramp', + 'mode': ramp.interpolation.lower(), + 'num_bands': len(ramp.elements), + 'input': eval_float(export_ctx, node.inputs['Fac'], stack=ref.stack) + } + + for i, element in enumerate(ramp.elements): + params[f'pos{i}'] = element.position + params[f'color{i}'] = list(element.color[:3]) + + return params + +@texture_converter('MATH') +def convert_math(export_ctx, ref, out_socket): + node = ref.node + op = node.operation + expr = _MATH_EXPRESSIONS.get(op) + if expr is None: + raise ConversionError( + f'Math operation {op} of node "{node.name}" is not supported') + + input_sockets = [s for s in node.inputs if s.enabled] + + # First pass: resolve all inputs + resolved = [] + for socket in input_sockets: + result = resolve(export_ctx, socket, stack=ref.stack) + if isinstance(result, (Constant, Texture)): + resolved.append(result) + else: + raise ConversionError(result.reason) + + # Second pass: inline constants, renumber texture inputs + params = {'type': 'math', 'expr': expr} + tex_index = 0 + for i, result in enumerate(resolved): + if isinstance(result, Constant): + params['expr'] = params['expr'].replace( + f'in[{i}]', f'({float(result.value)})') + else: + if tex_index != i: + params['expr'] = params['expr'].replace( + f'in[{i}]', f'in[{tex_index}]') + params[f'input_{tex_index}'] = result.params + tex_index += 1 + + if node.use_clamp: + params['expr'] = f'clip({params["expr"]}, 0, 1)' + + return params + + +@texture_converter("HUE_SAT") +def convert_hue_saturation_value(export_ctx: ExportContext, ref: NodeRef, out_socket): + node = ref.node + + params = { + 'type': 'hue_saturation_value', + 'input': eval_color(export_ctx, node.inputs['Color'], stack=ref.stack), + 'hue': eval_float(export_ctx, node.inputs['Hue'], stack=ref.stack), + 'saturation': eval_float(export_ctx, node.inputs['Saturation'], stack=ref.stack), + 'value' : eval_float(export_ctx, node.inputs['Value'], stack=ref.stack), + 'mix' : eval_float(export_ctx, node.inputs['Fac'], stack=ref.stack) + } + return params + + +def _write_curve_table(export_ctx, arr): + '''Write a sampled curve table beside the scene and return its path. + + The XML writer cannot serialise an in-memory bitmap, so file export + stores the tables as images like any other texture. + ''' + import hashlib + import os + import mitsuba as mi + directory = os.path.join(export_ctx.directory, 'textures') + os.makedirs(directory, exist_ok=True) + digest = hashlib.sha1(arr.tobytes()).hexdigest()[:12] + filename = f'curve-{digest}.exr' + path = os.path.join(directory, filename) + if not os.path.exists(path): + mi.Bitmap(arr).write(path) + return f'textures/{filename}' + + + + +@texture_converter('CURVE_RGB') +def convert_rgb_curve(export_ctx: ExportContext, ref : NodeRef, out_socket): + import numpy as np + node = ref.node + + params = { + 'type' : 'rgb_curve', + 'fac' : eval_float(export_ctx, node.inputs['Fac'], stack=ref.stack), + 'color' : eval_color(export_ctx, node.inputs['Color'], stack=ref.stack) + } + N = 64 + mapping = node.mapping + mapping.update() + + + for i, c in enumerate(['curve_r', 'curve_g', 'curve_b', 'curve_c']): + curve = mapping.curves[i] + row = np.array([mapping.evaluate(curve, j / (N - 1)) for j in range(N)], dtype=np.float32) + arr = np.stack([row, row]).reshape(2, N, 1) # bitmaps need at least 2 x 2 + + table = { + 'type': 'bitmap', + 'raw': True, + 'wrap_mode': 'clamp', + 'filename' : _write_curve_table(export_ctx, arr) + } + params[c] = table + + return params + +def _socket(sockets, identifier): + return next(s for s in sockets if s.identifier == identifier) + +_SUPPORTED = ['MIX', 'ADD', 'MULTIPLY', 'SUBTRACT', 'SCREEN', + 'DIVIDE', 'DIFFERENCE', 'DARKEN', 'LIGHTEN', 'OVERLAY'] + +@texture_converter('MIX') +def convert_mix(export_ctx: ExportContext, ref : NodeRef, out_socket): + node = ref.node + + if node.blend_type not in _SUPPORTED: + raise ConversionError(f'Operation {node.blend_type} over a MIX node is not supported') + + data_type = node.data_type + params = { + 'type': 'mix', + 'clamp_factor': node.clamp_factor, + 'clamp_result': node.clamp_result, + 'factor': eval_float(export_ctx, _socket(node.inputs, 'Factor_Float'), stack=ref.stack), + } + + if data_type == 'FLOAT': + params['blend_type'] = 'MIX' + params['a'] = eval_float(export_ctx, _socket(node.inputs, 'A_Float'), stack=ref.stack) + params['b'] = eval_float(export_ctx, _socket(node.inputs, 'B_Float'), stack=ref.stack) + elif data_type == 'RGBA': + params['blend_type'] = node.blend_type + params['a'] = eval_color(export_ctx, _socket(node.inputs, 'A_Color'), stack=ref.stack) + params['b'] = eval_color(export_ctx, _socket(node.inputs, 'B_Color'), stack=ref.stack) + else: + raise ConversionError(f'Mix node "{node.name}": data type {data_type} is not supported') + + return params + + +@texture_converter('INVERT') +def convert_invert(export_ctx: ExportContext, ref : NodeRef, out_socket): + fac = eval_float(export_ctx, ref.node.inputs['Fac'], stack=ref.stack) + color = eval_color(export_ctx, ref.node.inputs['Color'], stack=ref.stack) + return _math('lerp(in[0], (1.0) - in[0], in[1])', color, fac) + + +@texture_converter('BRIGHTCONTRAST') +def convert_brightness_contrast(export_ctx: ExportContext, ref : NodeRef, out_socket): + return { + 'type' : 'brightness_contrast', + 'color' : eval_color(export_ctx, ref.node.inputs['Color'], stack=ref.stack), + 'brightness' : eval_float(export_ctx, ref.node.inputs['Bright'], stack=ref.stack), + 'contrast' : eval_float(export_ctx, ref.node.inputs['Contrast'], stack=ref.stack) + } + + +@texture_converter('RGBTOBW') +def convert_rgb_to_bw(export_ctx: ExportContext, ref: NodeRef, out_socket): + color = eval_color(export_ctx, ref.node.inputs['Color'], stack=ref.stack) + return _math('luminance(in[0])', color) + + +@texture_converter('GAMMA') +def convert_gamma(export_ctx: ExportContext, ref: NodeRef, out_socket): + color = eval_color(export_ctx, ref.node.inputs['Color'], stack=ref.stack) + gamma = eval_float(export_ctx, ref.node.inputs['Gamma'], stack=ref.stack) + return _math('in[0] >= 0 ? pow(in[0], in[1]) : 0.0', color, gamma) + +@texture_converter('CLAMP') +def convert_clamp(export_ctx: ExportContext, ref: NodeRef, out_socket): + node, stack = ref.node, ref.stack + value = eval_float(export_ctx, node.inputs['Value'], stack=stack) + lo = eval_float(export_ctx, node.inputs['Min'], stack=stack) + hi = eval_float(export_ctx, node.inputs['Max'], stack=stack) + + if node.clamp_type == 'RANGE': + lo, hi = (_math('min(in[0], in[1])', lo, hi), + _math('max(in[0], in[1])', lo, hi)) + + return _math('clip(in[0], in[1], in[2])', value, lo, hi) + + +@texture_converter('MAP_RANGE') +def convert_map_range(export_ctx: ExportContext, ref : NodeRef, out_socket): + node = ref.node + is_float = node.data_type == 'FLOAT' + params = { + 'type': 'map_range', + 'clamp': node.clamp, + 'vector': not is_float, + 'interpolation_type' : node.interpolation_type, + } + if is_float: + params['input'] = eval_float(export_ctx, node.inputs['Value'], stack=ref.stack) + params['from_min'] = eval_float(export_ctx, node.inputs['From Min'], stack=ref.stack) + params['from_max'] = eval_float(export_ctx, node.inputs['From Max'], stack=ref.stack) + params['to_min'] = eval_float(export_ctx, node.inputs['To Min'], stack=ref.stack) + params['to_max'] = eval_float(export_ctx, node.inputs['To Max'], stack=ref.stack) + steps = next((s for s in node.inputs if s.identifier == 'Steps'), None) + if steps is not None: + params['steps'] = eval_float(export_ctx, steps, stack=ref.stack) + else: + params['input'] = eval_vector(export_ctx, node.inputs['Vector'], stack=ref.stack) + params['from_min'] = eval_vector(export_ctx, node.inputs['From Min'], stack=ref.stack) + params['from_max'] = eval_vector(export_ctx, node.inputs['From Max'], stack=ref.stack) + params['to_min'] = eval_vector(export_ctx, node.inputs['To Min'], stack=ref.stack) + params['to_max'] = eval_vector(export_ctx, node.inputs['To Max'], stack=ref.stack) + steps = next((s for s in node.inputs if s.identifier == 'Steps'), None) + if steps is not None: + params['steps'] = eval_float(export_ctx, steps, stack=ref.stack) + return params + + +@texture_converter('COMBXYZ') +def convert_combine_xyz(export_ctx: ExportContext, ref: NodeRef, out_socket): + return { + 'type': 'combine_xyz', + 'x': eval_float(export_ctx, ref.node.inputs['X'], stack=ref.stack), + 'y': eval_float(export_ctx, ref.node.inputs['Y'], stack=ref.stack), + 'z': eval_float(export_ctx, ref.node.inputs['Z'], stack=ref.stack) + } + +@texture_converter('SEPXYZ') +def convert_separate_xyz(export_ctx: ExportContext, ref: NodeRef, out_socket): + index = {'X': 0, 'Y': 1, 'Z': 2}[out_socket.identifier] + return { + 'type': 'separate_xyz', + 'index': index, + 'vector': eval_vector(export_ctx, ref.node.inputs['Vector'], stack=ref.stack) + } + +@texture_converter('SEPARATE_COLOR') +def convert_separate_color(export_ctx: ExportContext, ref: NodeRef, out_socket): + node = ref.node + color = eval_color(export_ctx, ref.node.inputs['Color'], stack=ref.stack) + + if node.mode == 'RGB': + channel = {'Red': 'r', 'Green': 'g', 'Blue': 'b'}.get(out_socket.name) + if channel is None: + raise ConversionError( + f'output "{out_socket.name}" of Separate Color node ' + f'"{node.name}" is not supported') + return _math(f'in[0].{channel}', color) + + channel_map = {'Red': 0, 'Green': 1, 'Blue': 2} + idx = channel_map.get(out_socket.name) + if idx is None: + raise ConversionError( + f'output "{out_socket.name}" of Separate Color node ' + f'"{node.name}" is not supported') + + return { + 'type': 'separate_color', + 'mode': node.mode, + 'color': color, + 'index': idx, + } + + +@texture_converter('COMBINE_COLOR') +def convert_combine_color(export_ctx: ExportContext, ref: NodeRef, out_socket): + node = ref.node + if node.mode == 'RGB': + return _math('rgb(in[0], in[1], in[2])', + eval_float(export_ctx, node.inputs['Red'], stack=ref.stack), + eval_float(export_ctx, node.inputs['Green'], stack=ref.stack), + eval_float(export_ctx, node.inputs['Blue'], stack=ref.stack)) + return { + 'type': 'combine_color', + 'mode' : node.mode, + 'red' : eval_float(export_ctx, node.inputs['Red'], stack=ref.stack), + 'green' : eval_float(export_ctx, node.inputs['Green'], stack=ref.stack), + 'blue' : eval_float(export_ctx, node.inputs['Blue'], stack=ref.stack) + } + + +@texture_converter('VECT_MATH') +def convert_vect_math(export_ctx: ExportContext, ref: NodeRef, out_socket): + op = ref.node.operation + params = { + 'type': 'vect_math', + 'vec_0': eval_vector(export_ctx, ref.node.inputs['Vector'], stack=ref.stack), + 'op' : op + } + if op == 'SCALE': + params['scale'] = eval_float(export_ctx, ref.node.inputs['Scale'], stack=ref.stack) + elif op not in _ONE_VECTOR_OPS: + params['vec_1'] = eval_vector(export_ctx, ref.node.inputs['Vector_001'], stack=ref.stack) + + if op == 'MULTIPLY_ADD': + params['vec_2'] = eval_vector(export_ctx, ref.node.inputs['Vector_002'], stack=ref.stack) + + return params + + +@texture_converter('TEX_NOISE') +def convert_tex_noise(export_ctx: ExportContext, ref: NodeRef, out_socket): + node, stack = ref.node, ref.stack + + if node.noise_dimensions != '3D': + export_ctx.log(f'Noise node "{node.name}" is {node.noise_dimensions}; ' + 'approximating it with 3D noise.', 'WARN') + if out_socket.identifier != 'Fac': + raise ConversionError(f'the {out_socket.name} output of noise node ' + f'"{node.name}" is not supported') + params = { + 'type': 'tex_noise', + 'scale': eval_float(export_ctx, node.inputs['Scale'], stack=stack), + 'detail': scalar_from_socket(export_ctx, node.inputs['Detail'], stack=stack), + 'roughness': scalar_from_socket(export_ctx, node.inputs['Roughness'], stack=stack), + 'lacunarity': scalar_from_socket(export_ctx, node.inputs['Lacunarity'], stack=stack), + 'normalize': node.normalize, + } + + vector = node.inputs['Vector'] + if vector.is_linked: + params['vector'] = eval_vector(export_ctx, vector, stack=stack) + + distortion = scalar_from_socket(export_ctx, node.inputs['Distortion'], stack=stack) + if distortion != 0.0: + export_ctx.log(f'Noise node "{node.name}": distortion is not ' + 'supported; ignoring it.', 'WARN') + + return params + +########################### +## Normal and bump map ## +########################### + +def _texture_input(export_ctx, socket, stack): + result = resolve(export_ctx, socket, stack=stack) + if isinstance(result, Texture): + return result.params + if isinstance(result, Constant): + return None + raise ConversionError(result.reason) + + +def _wrap_normalmap(export_ctx, ref, bsdf): + import mitsuba as mi + + node = ref.node + if node.space != 'TANGENT': + raise ConversionError(f'normal map node "{node.name}" uses ' + f'{node.space} space; only tangent space is ' + 'supported') + + texture = _texture_input(export_ctx, node.inputs['Color'], ref.stack) + if texture is None: + export_ctx.log(f'The color of normal map node "{node.name}" is ' + 'constant and has no effect; ignoring it.', 'WARN') + return bsdf + + + if texture.get('type') == 'bitmap' and texture.get('filename'): + img_path = texture['filename'] + if not os.path.isabs(img_path): + img_path = os.path.join(export_ctx.directory, img_path) + + try: + bmp = mi.Bitmap(img_path) + if bmp.channel_count() < 3: + raise ConversionError( + f'normal map node "{node.name}" uses a grayscale image ' + f'("{os.path.basename(texture["filename"])}"); ' + 'a normal map requires an RGB image') + except ConversionError: + raise + except Exception: + pass + + strength = eval_float(export_ctx, node.inputs['Strength'], stack=ref.stack) + params = texture + + use_strength = False + if node.inputs['Strength'].is_linked \ + or (abs(node.inputs['Strength'].default_value - 1.0) > 1e-6): + use_strength = True + params = { + 'type': 'normal_map', + 'texture': texture, + 'strength': strength + } + + if texture.get('type') == 'bitmap' and not texture.get('raw'): + export_ctx.log(f'The image of normal map node "{node.name}" should ' + 'use a Non-Color space; interpreting it as raw ' + 'data.', 'WARN') + if use_strength: + params['texture']['raw'] = True + else: + params['raw'] = True + + return { + 'type': 'normalmap', + 'normalmap': params, + 'bsdf': bsdf, + } + + +def _wrap_bumpmap(export_ctx, ref, bsdf): + node = ref.node + # A chained perturbation on the Normal input applies before the bump + bsdf = convert_normal_input(export_ctx, node.inputs['Normal'], bsdf, + ref.stack) + texture = _texture_input(export_ctx, node.inputs['Height'], ref.stack) + if texture is None: + export_ctx.log(f'The height of bump node "{node.name}" is constant ' + 'and has no effect; ignoring it.', 'WARN') + return bsdf + strength = scalar_from_socket(export_ctx, node.inputs['Strength'], stack=ref.stack) + distance = scalar_from_socket(export_ctx, node.inputs['Distance'], stack=ref.stack) + scale = strength * distance + if node.invert: + scale = -scale + return { + 'type': 'bumpmap', + 'texture': texture, + 'scale': scale, + 'bsdf': bsdf, + } + + +def convert_normal_input(export_ctx, socket, bsdf, stack=()): + '''Wrap a converted BSDF dict in Mitsuba normalmap/bumpmap plugins + according to what feeds the given Normal input socket. Never raises: + unsupported input produces a warning and the unwrapped BSDF.''' + try: + node, _, node_stack = trace_source(socket, stack) + if node is None: + return bsdf + ref = NodeRef(node, node_stack) + if node.type == 'NORMAL_MAP': + return _wrap_normalmap(export_ctx, ref, bsdf) + if node.type == 'BUMP': + return _wrap_bumpmap(export_ctx, ref, bsdf) + raise ConversionError(f'node "{node.name}" of type {node.type} is ' + 'not supported as a normal input') + except ConversionError as e: + export_ctx.log(f'{e}; ignoring the normal input of node ' + f'"{socket.node.name}"', 'WARN') + return bsdf + + +########################### +## Environment texture ## +########################### + +def convert_environment_texture(export_ctx, ref): + '''Convert a TEX_ENVIRONMENT node into a partial envmap emitter dict + holding the image reference; the world exporter adds scale and + to_world.''' + node = ref.node + image = node.image + if image is None: + raise ConversionError(f'environment texture node "{node.name}" has ' + 'no image') + if node.projection != 'EQUIRECTANGULAR': + raise ConversionError(f'projection {node.projection} of environment ' + f'texture node "{node.name}" is not supported') + params = {'type': 'envmap'} + params['filename'], _ = export_image(export_ctx, image) + + colorspace = image.colorspace_settings.name + if colorspace != 'sRGB' and colorspace not in _DATA_COLORSPACES: + export_ctx.log( + f'Color space "{colorspace}" of image "{image.name}" is not ' + 'supported; Mitsuba will interpret the file as sRGB.', 'WARN') + return params diff --git a/mitsuba_blender/convert/export/mesh.py b/mitsuba_blender/convert/export/mesh.py new file mode 100644 index 00000000..793cc9bc --- /dev/null +++ b/mitsuba_blender/convert/export/mesh.py @@ -0,0 +1,461 @@ +'''Convert evaluated Blender meshes into Mitsuba meshes. + +The conversion reads the geometry into numpy arrays and builds `mitsuba.Mesh` +objects directly. When rendering inside Blender, the meshes are kept in memory +and inserted into the scene dict as instantiated objects, with the object +transform baked in. When exporting to a file, they are appended to one shared +`.serialized` file next to the XML, and each shape references its sub-mesh by +index and carries its own `to_world`. +''' +import bpy +import numpy as np + +from . import sanitize_attribute_name + + +def read_attribute(b_mesh, name, prop, dtype, size, legacy=None): + '''Bulk-read one of Blender's generic mesh attributes. + + These memcpy straight out of Blender's contiguous storage. The legacy RNA + collections expose the same values, but `foreach_get` walks them element + by element and is orders of magnitude slower, so they only serve as the + fallback `legacy=(collection, property)` for an attribute that a Blender + release might rename. Returns None when the attribute is absent and no + fallback was given. + ''' + out = np.empty(size, dtype=dtype) + attr = b_mesh.attributes.get(name) + if attr is not None: + attr.data.foreach_get(prop, out) + elif legacy is not None: + legacy[0].foreach_get(legacy[1], out) + else: + return None + return out + + +class MeshData: + '''Numpy snapshot of an evaluated Blender mesh, in Mitsuba conventions. + + The per-corner data stays in Blender's per-loop layout, with the faces + indexing into it. Mitsuba reads it through that map, so the export never + materializes a corner-aligned copy of an attribute. The geometry stays in + object space: the shapes carry their transform as 'to_world', which + Mitsuba bakes when the mesh is built or loaded. + + The mesh is described to Mitsuba as polygons, which it fans into + triangles itself. Reading Blender's own triangulation instead costs + more than everything else here put together, but handles the concave and + non-planar faces that a fan does not; `export_ctx.blender_triangulation` + selects it. Either way `prim_mats` holds one material index per + primitive, and the primitives are what a material slot selects. + ''' + + def __init__(self, export_ctx, b_mesh, name): + face_count = len(b_mesh.polygons) + loop_count = len(b_mesh.loops) + self.face_offsets = None + self.tri_loops = None + # Faces that already are triangles need no triangulation from either + # side: primitive i then owns the corners 3i..3i+2 + self.all_triangles = loop_count == 3 * face_count + + # Blender's triangulation is only worth its cost for faces that need + # splitting, and only when the user asked for it + split_in_blender = export_ctx.blender_triangulation \ + and not self.all_triangles + if split_in_blender: + b_mesh.calc_loop_triangles() + self.prim_count = len(b_mesh.loop_triangles) + else: + self.prim_count = face_count + if self.prim_count == 0: + export_ctx.log(f'Mesh "{name}" has no faces. Skipping.', 'WARN') + return + + materials = read_attribute(b_mesh, 'material_index', 'value', + np.int32, face_count) + if materials is not None: + # Out-of-range indices (deleted slots, imported data) would match + # no material slot and silently drop the faces; Blender clamps + # them when rendering, so do the same + np.clip(materials, 0, max(len(b_mesh.materials) - 1, 0), + out=materials) + + if split_in_blender: + # The primitives are Blender's triangles, and each one inherits + # the material of the polygon it was cut from + tri_loops = np.empty(self.prim_count * 3, dtype=np.int32) + b_mesh.loop_triangles.foreach_get('loops', tri_loops) + self.tri_loops = tri_loops.reshape(-1, 3) + if materials is not None: + tri_face = np.empty(self.prim_count, dtype=np.int32) + b_mesh.loop_triangle_polygons.foreach_get('value', tri_face) + materials = materials[tri_face] + elif not self.all_triangles: + # Mitsuba fans the polygons, so hand it their corner ranges + self.face_offsets = np.empty(face_count + 1, dtype=np.int32) + b_mesh.polygons.foreach_get('loop_start', + self.face_offsets[:face_count]) + self.face_offsets[face_count] = loop_count + + self.prim_mats = materials if materials is not None \ + else np.zeros(self.prim_count, dtype=np.int32) + + self.loop_vert = read_attribute( + b_mesh, '.corner_vert', 'value', np.int32, loop_count, + legacy=(b_mesh.loops, 'vertex_index')) + self.positions = read_attribute( + b_mesh, 'position', 'vector', np.float32, + len(b_mesh.vertices) * 3, + legacy=(b_mesh.vertices, 'co')).reshape(-1, 3) + + # Per-corner normals include the effect of smooth/sharp faces and + # custom split normals. + self.normals = np.empty(loop_count * 3, dtype=np.float32) + b_mesh.corner_normals.foreach_get('vector', self.normals) + self.normals = self.normals.reshape(-1, 3) + + self.uvs = None + if len(b_mesh.uv_layers) > 1: + export_ctx.log(f'Mesh "{name}" has multiple UV layers. Mitsuba ' + 'only supports one. Exporting the one set active ' + 'for render.', 'WARN') + for uv_layer in b_mesh.uv_layers: + if uv_layer.active_render: + uvs = np.empty(loop_count * 2, dtype=np.float32) + uv_layer.uv.foreach_get('vector', uvs) + # Exported verbatim: the differing image row convention is + # folded into each texture's 'to_uv', and flipping v here + # would reverse the bitangent of the MikkTSpace frame + self.uvs = uvs.reshape(-1, 2) + break + + # Color attributes, converted to one RGB value per loop + self.colors = [] + for attr in b_mesh.color_attributes: + values = np.empty(len(attr.data) * 4, dtype=np.float32) + attr.data.foreach_get('color', values) + values = np.ascontiguousarray(values.reshape(-1, 4)[:, :3]) + if attr.domain == 'POINT': + values = values[self.loop_vert] + self.colors.append((sanitize_attribute_name(attr.name), values)) + + def topology(self, prim_mask): + '''The (corner_index, face_offsets) pair describing the selected + primitives. Both are None when the corners already march through the + loops three at a time, which lets Mitsuba read the pools without any + index array at all.''' + if self.tri_loops is not None: + tris = self.tri_loops if prim_mask is None \ + else self.tri_loops[prim_mask] + return tris.ravel(), None + if prim_mask is None: + return None, self.face_offsets + + selected = np.flatnonzero(prim_mask).astype(np.int32) + if self.all_triangles: + return (selected[:, None] * 3 + + np.arange(3, dtype=np.int32)).ravel(), None + + # Concatenate the corner ranges of the selected polygons, rebasing + # the offsets onto the compacted result + starts = self.face_offsets[selected] + counts = self.face_offsets[selected + 1] - starts + offsets = np.empty(len(selected) + 1, dtype=np.int32) + offsets[0] = 0 + np.cumsum(counts, out=offsets[1:]) + corners = np.arange(offsets[-1], dtype=np.int32) \ + + np.repeat(starts - offsets[:-1], counts) + return corners, offsets + + +def make_mesh(mesh_data, name, prim_mask, props=None): + '''Build a mitsuba.Mesh from a subset of the primitives of a MeshData. + + Corners that share the same vertex, normal, UV and color values are + welded into a single Mitsuba vertex. Nothing here copies the per-corner + data: Blender's per-loop pools are handed over as they are, and the + topology arrays are None for a mesh whose faces are already triangles, + which lets Mitsuba read the pools without any index array at all. Any + 'to_world' on `props` is baked by the build itself. Returns None if the + selection contains no primitives. + ''' + import mitsuba as mi + + if mesh_data.prim_count == 0: + return None + if prim_mask is not None and not prim_mask.any(): + return None + if props is None: + props = mi.Properties() + + corners, face_offsets = mesh_data.topology(prim_mask) + props.set_id(name) + mesh = mi.Mesh(props) + mesh.from_corners( + positions=mesh_data.positions, + corner_vertex=mesh_data.loop_vert, + corner_index=corners, + face_offsets=face_offsets, + normals=mesh_data.normals, + texcoords=mesh_data.uvs, + attrs={f'vertex_{attr_name}': values + for attr_name, values in mesh_data.colors}) + return mesh + + +DEFAULT_BSDF_ID = 'default-bsdf' +DEFAULT_BSDF = { + 'type': 'twosided', + 'bsdf': {'type': 'diffuse'} +} +DEFAULT_BLACK_DIFFUSE_ID = 'default_black_diffuse' + +def material_refs(export_ctx, b_mat): + '''Export the material if needed; return (bsdf_id, emitter_dict or None).''' + from .materials import export_material + mat_id = f'mat-{b_mat.name}' + export_material(export_ctx, b_mat) + mixed = export_ctx.exported_mats.get(mat_id) + if mixed is not None: + return mixed['bsdf'], mixed['emitter'] + if export_ctx.data_get(mat_id) is None: + return default_bsdf_id(export_ctx), None + return mat_id, None + + +def default_bsdf_id(export_ctx): + '''Return the id of the fallback material, adding it to the dict once.''' + if export_ctx.data_get(DEFAULT_BSDF_ID) is None: + export_ctx.data_add(dict(DEFAULT_BSDF), name=DEFAULT_BSDF_ID) + return DEFAULT_BSDF_ID + +def default_black_diffuse_id(export_ctx): + if export_ctx.data_get(DEFAULT_BLACK_DIFFUSE_ID) is None: + export_ctx.data_add({ + 'type': 'diffuse', + 'reflectance': export_ctx.spectrum(0.0) + }, name=DEFAULT_BLACK_DIFFUSE_ID) + return DEFAULT_BLACK_DIFFUSE_ID + +class GeometryExporter: + '''Converts each distinct combination of mesh data and materials once. + Combinations that are rendered multiple times, including depsgraph + instances from particles and instanced collections, become a shapegroup + that is referenced by one Mitsuba instance per occurrence. + + The caller iterates the dependency graph twice: a counting pass over all + mesh-like instances (count_instance), then a conversion pass + (export_instance). Splitting the work this way avoids keeping evaluated + object references alive across iteration steps, which Blender forbids.''' + + # Marks combinations that cannot become a shapegroup because a part + # carries an emitter, which Mitsuba does not allow on group members + EMITTER_FALLBACK = object() + + def __init__(self, export_ctx): + self.export_ctx = export_ctx + self.occurrences = {} + self.group_ids = {} + self.fallback_counts = {} + + @staticmethod + def instance_key(deg_instance): + b_object = deg_instance.object + materials = tuple(slot.material.name if slot.material else None + for slot in b_object.material_slots) + return (b_object.data.session_uid, materials) + + @staticmethod + def is_prototype(deg_instance): + '''Blender does not render the prototype object of an instancer + (e.g. the child of a vertex instancer) at its own location.''' + b_object = deg_instance.object + return not deg_instance.is_instance and b_object.parent is not None \ + and b_object.parent.original.is_instancer + + def count_instance(self, deg_instance): + key = self.instance_key(deg_instance) + count = self.occurrences.get(key, 0) + if not self.is_prototype(deg_instance): + count += 1 + self.occurrences[key] = count + + def export_instance(self, deg_instance): + count = self.occurrences[self.instance_key(deg_instance)] + if count == 0: + return + if count == 1: + # Rendered exactly once: a plain shape with the transform baked in + if not self.is_prototype(deg_instance): + self.export_single(deg_instance) + else: + self.export_instanced(deg_instance) + + def export_single(self, deg_instance): + key = self.instance_key(deg_instance) + if key in self.group_ids: + return + self.group_ids[key] = None + name_clean = bpy.path.clean_name(deg_instance.object.name_full) + self.export_plain(deg_instance, name_clean) + + def export_plain(self, deg_instance, name_clean): + '''Export one occurrence as plain shapes carrying their own + object-to-world transform.''' + export_ctx = self.export_ctx + to_world = export_ctx.transform_matrix(deg_instance.matrix_world) + converted = self.convert_parts(deg_instance.object, name_clean) + for name, bsdf_id, emitter, mi_mesh in converted: + entry = self.make_entry(bsdf_id, emitter, mi_mesh, to_world) + if export_ctx.export_ids: + export_ctx.data_add(entry, name=f'mesh-{name}') + else: + export_ctx.data_add(entry) + + def uses_emitter(self, b_object): + '''Whether any material of the object exports an emitter.''' + for slot in b_object.material_slots: + if slot.material is not None and \ + material_refs(self.export_ctx, slot.material)[1] is not None: + return True + return False + + def export_instanced(self, deg_instance): + export_ctx = self.export_ctx + key = self.instance_key(deg_instance) + if key not in self.group_ids: + self.group_ids[key] = None + b_object = deg_instance.object + name_clean = bpy.path.clean_name(b_object.name_full) + if self.uses_emitter(b_object): + export_ctx.log( + f'Object "{b_object.name_full}" occurs multiple times ' + 'and has an emissive material, which Mitsuba does not ' + 'support on instanced shapes. Exporting each occurrence ' + 'as a separate shape.', 'WARN') + self.group_ids[key] = self.EMITTER_FALLBACK + else: + converted = self.convert_parts(b_object, name_clean) + group = {'type': 'shapegroup'} + for name, bsdf_id, emitter, mi_mesh in converted: + group[export_ctx.sanitize(name)] = \ + self.make_entry(bsdf_id, emitter, mi_mesh) + if len(group) > 1: + object_id = f'mesh-{name_clean}' + export_ctx.data_add(group, name=object_id) + self.group_ids[key] = object_id + + object_id = self.group_ids[key] + if object_id is self.EMITTER_FALLBACK: + if not self.is_prototype(deg_instance): + count = self.fallback_counts.get(key, 0) + self.fallback_counts[key] = count + 1 + name_clean = bpy.path.clean_name( + deg_instance.object.name_full) + self.export_plain(deg_instance, f'{name_clean}-{count:03d}') + return + if object_id is not None and not self.is_prototype(deg_instance): + export_ctx.data_add({ + 'type': 'instance', + 'shape': export_ctx.create_ref(object_id), + 'to_world': export_ctx.transform_matrix( + deg_instance.matrix_world) + }) + + def convert_parts(self, b_object, name_clean): + '''Convert the object into one (name, bsdf_id, emitter, mesh) tuple + per non-empty material slot.''' + export_ctx = self.export_ctx + if b_object.type == 'MESH': + b_mesh = b_object.data + else: # Metaballs, text, surfaces + b_mesh = b_object.to_mesh() + + mesh_data = MeshData(export_ctx, b_mesh, name_clean) + if mesh_data.prim_count == 0: + if b_object.type != 'MESH': + b_object.to_mesh_clear() + + return [] + + # One entry per material slot: (name, bsdf_id, emitter_dict, prim_mask) + parts = [] + slots = b_object.material_slots + if len(slots) == 0: + if not b_object.visible_camera: + return [] + + parts.append((name_clean, + default_bsdf_id(export_ctx), + None, + None)) + else: + refs_per_mat = {} + for mat_nr, slot in enumerate(slots): + prim_mask = mesh_data.prim_mats == mat_nr + if not prim_mask.any(): + continue + if slot.material is None: + # Blender renders faces assigned to an empty slot with + # its default material + if not b_object.visible_camera: + continue + + parts.append((f'{name_clean}-slot{mat_nr}', + default_bsdf_id(export_ctx), + None, + prim_mask)) + continue + # Ensure unique part names even if multiple slots refer to + # the same material + n_refs = refs_per_mat.get(slot.material.name, 0) + refs_per_mat[slot.material.name] = n_refs + 1 + # The part name doubles as a PLY filename, so the material + # name needs cleaning too + name = bpy.path.clean_name( + f'{name_clean}-{slot.material.name}') + if n_refs >= 1: + name += f'-{n_refs:03d}' + bsdf_id, emitter = material_refs(export_ctx, slot.material) + + if not b_object.visible_camera: + if emitter is None: + continue + emitter = dict(emitter) + emitter['visible'] = False + bsdf_id = default_black_diffuse_id(export_ctx) + + parts.append((name, bsdf_id, emitter, prim_mask)) + + # The material suffix only serves to tell several parts apart. An + # object that stays in one piece keeps its own name, and the bsdf + # reference of the shape already records the material. + if len(parts) == 1: + parts[0] = (name_clean, *parts[0][1:]) + + converted = [(name, bsdf_id, emitter, + make_mesh(mesh_data, name, prim_mask, None)) + for name, bsdf_id, emitter, prim_mask in parts] + + if b_object.type != 'MESH': + b_object.to_mesh_clear() + return converted + + def make_entry(self, bsdf_id, emitter, mi_mesh, to_world=None): + '''Return the scene dict entry of a converted mesh part.''' + export_ctx = self.export_ctx + # Every mesh goes into one shared .serialized file, addressed by the + # index it was appended at + entry = { + 'type': 'serialized', + 'filename': export_ctx.serialized_filename(), + 'shape_index': export_ctx.add_serialized_mesh(mi_mesh), + 'bsdf': export_ctx.create_ref(bsdf_id) + } + if to_world is not None: + entry['to_world'] = to_world + if emitter is not None: + entry['emitter'] = emitter + return entry diff --git a/mitsuba_blender/convert/export/world.py b/mitsuba_blender/convert/export/world.py new file mode 100644 index 00000000..609f230d --- /dev/null +++ b/mitsuba_blender/convert/export/world.py @@ -0,0 +1,154 @@ +'''Blender world to Mitsuba environment emitter conversion. + +Supports a Background or Emission surface node with either a constant +color (folded through the material evaluator, so RGB/Math/Mix chains +work) or an Environment Texture, optionally rotated through a Mapping +node driven by generated texture coordinates. +''' + +from mathutils import Euler, Matrix + +from .. import ConversionError +from ...compat import uses_nodes +from .materials import _resolve +from .materials.textures import convert_environment_texture + +# The color of Blender's default gray world background. +DEFAULT_BACKGROUND = [0.05087608844041824] * 3 + +# Aligns Mitsuba's equirectangular parametrization with Blender's. +ENVMAP_COORDINATE_MAT = Matrix(((0, 0, 1, 0), + (1, 0, 0, 0), + (0, 1, 0, 0), + (0, 0, 0, 1))) + + +def _envmap_world_transform(export_ctx, vector_socket, stack=()): + '''The world transform of an environment texture, from an optional + Mapping node feeding its Vector input.''' + node, _, stack = _resolve.trace_source(vector_socket, stack) + if node is None: + return Matrix() + if node.type != 'MAPPING': + raise ConversionError(f'node "{node.name}" of type {node.type} ' + 'cannot drive an environment texture; only ' + 'a Mapping node is supported') + + # checking the input of the Mapping node + coord_node, coord_socket, _ = _resolve.trace_source(node.inputs['Vector'], stack) + if coord_node is None or coord_node.type != 'TEX_COORD' \ + or coord_socket.name != 'Generated': + raise ConversionError('the Mapping node must be driven by the ' + '"Generated" output of a Texture Coordinate ' + 'node') + ref = _resolve.NodeRef(node, stack) + matrix = Matrix.LocRotScale( + _resolve.vector_from_socket(export_ctx, ref, node.inputs['Location']), + Euler(_resolve.vector_from_socket(export_ctx, ref, node.inputs['Rotation'])), + _resolve.vector_from_socket(export_ctx, ref, node.inputs['Scale'])) + if node.vector_type == 'TEXTURE': + # Texture mappings look up the texture at the inverse-transformed + # coordinate, matching Mitsuba's to_world convention directly + return matrix + if node.vector_type == 'POINT': + return matrix.inverted() + raise ConversionError(f'mapping type {node.vector_type} is not ' + 'supported; use "Point" or "Texture"') + + +def _convert_envmap(export_ctx, ref, strength): + '''Convert a `TEX_ENVIRONMENT` Blender node into an `envmap` emitter.''' + params = convert_environment_texture(export_ctx, ref=ref) + to_world = _envmap_world_transform(export_ctx, ref.node.inputs['Vector']) + params['scale'] = strength + params['to_world'] = export_ctx.transform_matrix( + to_world @ ENVMAP_COORDINATE_MAT) + return params + + +def _constant_color(result): + value = result.value + if isinstance(value, float): + return [value] * 3 + return list(value[:3]) + + +def convert_world(export_ctx, b_world, ignore_background=True): + '''Convert a Blender world into a Mitsuba emitter dict. Returns None + when there is nothing to export; raises ConversionError for + unsupported node setups.''' + if b_world is None: + export_ctx.log('No Blender world to export.', 'INFO') + return None + + if not uses_nodes(b_world): + return { + 'type': 'constant', + 'radiance': export_ctx.spectrum(list(b_world.color)), + } + + output = b_world.node_tree.get_output_node('CYCLES') + if output is None: + raise ConversionError('cannot find the world output node') + node, _, stack = _resolve.trace_source(output.inputs['Surface']) + if node is None: + return None + if node.type not in ('BACKGROUND', 'EMISSION'): + raise ConversionError(f'node "{node.name}" of type {node.type} is ' + 'not supported as the world surface; only ' + 'Background and Emission nodes are') + + strength = _resolve.scalar_from_socket(export_ctx, + node.inputs['Strength'], + stack) + + if strength == 0.0: + export_ctx.log('Ignoring a world with zero strength.', 'INFO') + return None + + color_socket = node.inputs['Color'] + env_node, _, env_stack = _resolve.trace_source(color_socket, stack) + if env_node is not None and env_node.type == 'TEX_ENVIRONMENT': + return _convert_envmap(export_ctx, + _resolve.NodeRef(env_node, env_stack), strength) + + # TODO: create a color_from_socket + result = _resolve.resolve(export_ctx, color_socket, stack) + + if not isinstance(result, _resolve.Constant): + reason = result.reason if isinstance(result, _resolve.Unsupported) \ + else 'only environment textures and constant colors are supported' + raise ConversionError(reason) + + color = _constant_color(result) + radiance = [c * strength for c in color] + # Test the color and the strength separately: comparing their product + # would also drop worlds the user did set up, such as twice the default + # color at half strength. + if ignore_background and strength == 1.0 and color == DEFAULT_BACKGROUND: + export_ctx.log("Ignoring Blender's default background.", 'INFO') + return None + if sum(radiance) == 0.0: + export_ctx.log('Ignoring a background with zero emission.', 'INFO') + return None + return { + 'type': 'constant', + 'radiance': export_ctx.spectrum(radiance), + } + + +def export_world(export_ctx, b_world, ignore_background=True): + '''Convert the world and add it to the scene dict. Never raises: + failures produce a warning and the world is skipped.''' + try: + params = convert_world(export_ctx, b_world, ignore_background) + except Exception as e: + export_ctx.log(f'Failed to export the world: {e}. Skipping it.', + 'WARN') + return + if params is None: + return + if export_ctx.export_ids: + export_ctx.data_add(params, 'World') + else: + export_ctx.data_add(params) diff --git a/release/README.md b/mitsuba_blender/convert/importer/__init__.py similarity index 100% rename from release/README.md rename to mitsuba_blender/convert/importer/__init__.py diff --git a/mitsuba_blender/convert/importer/camera.py b/mitsuba_blender/convert/importer/camera.py new file mode 100644 index 00000000..192aa625 --- /dev/null +++ b/mitsuba_blender/convert/importer/camera.py @@ -0,0 +1,150 @@ +'''Mitsuba sensor to Blender camera conversion. + +The geometric mappings are the inverses of the ones defined in +convert.export.camera. Imported cameras always use the AUTO sensor fit, +so the fit axis is the larger film dimension. +''' + +import math + +import bpy +from mathutils import Matrix + +from ..export.camera import (extent_to_ortho_scale, aperture_radius_to_fstop, + principal_point_to_shift) +from ...io.importer.bl_transform_utils import mi_transform_to_bl_transform + +# The diagonal of a full-frame 35mm sensor (36x24), which Mitsuba uses to +# interpret its focal_length parameter. +FULLFRAME_DIAGONAL = math.hypot(36.0, 24.0) + + +###################### +## Utilities ## +###################### + +def _film_size(mi_context, mi_sensor): + '''The resolution of the sensor's film, falling back to Mitsuba's + default film size.''' + from mitsuba import ObjectType, Properties + for _, value in mi_sensor.items(): + if isinstance(value, Properties.ResolvedReference): + mi_node = mi_context.mi_state.nodes[value.index()] + if mi_node.type == ObjectType.Film: + return (mi_node.props.get('width', 768), + mi_node.props.get('height', 576)) + return 768, 576 + + +def _angle_x_from_fov(mi_context, mi_sensor, res_x, res_y): + '''The horizontal field of view in radians, from either the fov/fov_axis + or the focal_length parametrization.''' + if 'focal_length' in mi_sensor: + # A focal length is relative to the diagonal of a full-frame sensor + focal_length = str(mi_sensor['focal_length']) + value = float(focal_length.replace('mm', '')) + tan_half = FULLFRAME_DIAGONAL / (2.0 * value) + axis = 'diagonal' + else: + tan_half = math.tan(math.radians(mi_sensor.get('fov', 34.0)) / 2.0) + axis = mi_sensor.get('fov_axis', 'x') + + if axis == 'smaller': + axis = 'x' if res_x <= res_y else 'y' + elif axis == 'larger': + axis = 'x' if res_x >= res_y else 'y' + + if axis == 'y': + tan_half *= res_x / res_y + elif axis == 'diagonal': + tan_half *= res_x / math.hypot(res_x, res_y) + elif axis != 'x': + mi_context.log(f'Camera fov axis "{axis}" is not supported. ' + 'Assuming "x".', 'WARN') + return 2.0 * math.atan(tan_half) + + +def _set_angle(bl_camera, angle_x, res_x, res_y): + '''Assign the field of view to the axis that the AUTO sensor fit locks.''' + if res_x >= res_y: + bl_camera.angle_x = angle_x + else: + # The 36mm sensor width applies to the vertical axis (see the + # exporter), so the vertical field of view lands in angle_x. + bl_camera.angle_x = 2.0 * math.atan( + math.tan(angle_x / 2.0) * res_y / res_x) + + +def _world_matrix(mi_context, mi_sensor): + '''Undo the 180 degree Y rotation that distinguishes a Mitsuba sensor + frame from a Blender camera frame.''' + init_rot = Matrix.Rotation(-math.pi, 4, 'Y') + world = mi_transform_to_bl_transform(mi_sensor.get('to_world', None)) + return mi_context.mi_space_to_bl_space(world @ init_rot) + + +###################### +## Converters ## +###################### + +def _perspective_to_bl_camera(mi_context, mi_sensor): + res_x, res_y = _film_size(mi_context, mi_sensor) + bl_camera = bpy.data.cameras.new(name=mi_sensor.id()) + bl_camera.type = 'PERSP' + _set_angle(bl_camera, _angle_x_from_fov(mi_context, mi_sensor, + res_x, res_y), res_x, res_y) + bl_camera.shift_x, bl_camera.shift_y = principal_point_to_shift( + mi_sensor.get('principal_point_offset_x', 0.0), + mi_sensor.get('principal_point_offset_y', 0.0), res_x, res_y) + return bl_camera, _world_matrix(mi_context, mi_sensor) + + +def _thinlens_to_bl_camera(mi_context, mi_sensor): + bl_camera, world_matrix = _perspective_to_bl_camera(mi_context, mi_sensor) + bl_camera.dof.focus_distance = mi_sensor.get('focus_distance', 0.0) + aperture_radius = mi_sensor.get('aperture_radius', 0.1) + # A zero aperture radius is a pinhole camera + if aperture_radius > 0.0: + bl_camera.dof.use_dof = True + bl_camera.dof.aperture_fstop = aperture_radius_to_fstop( + aperture_radius, bl_camera.lens) + return bl_camera, world_matrix + + +def _orthographic_to_bl_camera(mi_context, mi_sensor): + res_x, res_y = _film_size(mi_context, mi_sensor) + bl_camera = bpy.data.cameras.new(name=mi_sensor.id()) + bl_camera.type = 'ORTHO' + + world_matrix = _world_matrix(mi_context, mi_sensor) + # The view extent is a scale in to_world; it becomes the orthographic + # scale rather than part of the object transform. + location, rotation, scale = world_matrix.decompose() + bl_camera.ortho_scale = extent_to_ortho_scale(scale.x, res_x, res_y) + return bl_camera, Matrix.LocRotScale(location, rotation, None) + + +###################### +## Main import ## +###################### + +_converters = { + 'perspective': _perspective_to_bl_camera, + 'thinlens': _thinlens_to_bl_camera, + 'orthographic': _orthographic_to_bl_camera, +} + + +def mi_sensor_to_bl_camera(mi_context, mi_sensor): + '''Convert a Mitsuba sensor into a Blender camera data-block and its + world matrix. Returns None for unsupported sensor types.''' + converter = _converters.get(mi_sensor.plugin_name()) + if converter is None: + mi_context.log(f'Mitsuba sensor type "{mi_sensor.plugin_name()}" is ' + 'not supported.', 'WARN') + return None + + bl_camera, world_matrix = converter(mi_context, mi_sensor) + bl_camera.clip_start = mi_sensor.get('near_clip', 1e-2) + bl_camera.clip_end = mi_sensor.get('far_clip', 1e4) + return bl_camera, world_matrix diff --git a/mitsuba_blender/convert/importer/lights.py b/mitsuba_blender/convert/importer/lights.py new file mode 100644 index 00000000..fb6cf03d --- /dev/null +++ b/mitsuba_blender/convert/importer/lights.py @@ -0,0 +1,230 @@ +'''Mitsuba emitter to Blender light conversion. + +The radiometric conversions are the inverses of the ones defined in +convert.export.lights, which is the single source of truth for the +Blender/Mitsuba unit correspondence. +''' + +import math + +import bpy +from mathutils import Matrix, Vector + +from ..export.lights import (intensity_to_power, radiance_to_power, + sphere_area, spot_blend) +from ...io.importer import mi_spectra_utils +from ...io.importer.bl_transform_utils import mi_transform_to_bl_transform + + +###################### +## Utilities ## +###################### + +def _light_name(mi_props): + return mi_props.id() or f'Light-{mi_props.plugin_name()}' + + +def _direction_matrix(direction, up): + '''A rotation matrix whose +Z axis points along `direction`.''' + z = direction + x = up.cross(z) + # An arbitrary orthogonal vector when `direction` is collinear with `up` + if x.length_squared == 0: + x = up.orthogonal() + y = z.cross(x) + + x.normalize() + y.normalize() + z.normalize() + + rot = Matrix() + for i in range(3): + rot[i][0], rot[i][1], rot[i][2] = x[i], y[i], z[i] + return rot + + +# Blender point/spot/sun lights emit along -Z, Mitsuba emitters along +Z +_FLIP = Matrix.Rotation(math.pi, 4, 'X') + + +###################### +## Converters ## +###################### + +def _convert_point(mi_context, mi_props): + bl_light = bpy.data.lights.new(name=_light_name(mi_props), type='POINT') + color, strength = mi_spectra_utils.convert_radiance_property( + mi_context, mi_props, 'intensity', [1.0, 1.0, 1.0]) + bl_light.color = color + bl_light.energy = intensity_to_power(strength) + bl_light.shadow_soft_size = 0.0 + + if 'to_world' in mi_props: + matrix = mi_context.mi_space_to_bl_space( + mi_transform_to_bl_transform(mi_props.get('to_world'))) + else: + position = Vector(list(mi_props.get('position', [0.0, 0.0, 0.0]))) + matrix = Matrix.Translation(mi_context.mi_space_to_bl_space(position)) + return bl_light, matrix + + +def _convert_spot(mi_context, mi_props): + bl_light = bpy.data.lights.new(name=_light_name(mi_props), type='SPOT') + color, strength = mi_spectra_utils.convert_radiance_property( + mi_context, mi_props, 'intensity', [1.0, 1.0, 1.0]) + bl_light.color = color + bl_light.energy = intensity_to_power(strength) + bl_light.shadow_soft_size = 0.0 + + cutoff = math.radians(float(mi_props.get('cutoff_angle', 20.0))) + beam_width = math.radians( + float(mi_props.get('beam_width', math.degrees(cutoff) * 0.75))) + bl_light.spot_size = 2.0 * cutoff + bl_light.spot_blend = spot_blend(2.0 * cutoff, beam_width) + + matrix = mi_transform_to_bl_transform(mi_props.get('to_world', None)) + return bl_light, mi_context.mi_space_to_bl_space(matrix @ _FLIP) + + +def _convert_directional(mi_context, mi_props): + bl_light = bpy.data.lights.new(name=_light_name(mi_props), type='SUN') + color, strength = mi_spectra_utils.convert_radiance_property( + mi_context, mi_props, 'irradiance', [1.0, 1.0, 1.0]) + bl_light.color = color + # The energy of a Blender sun light is its irradiance in W/m^2 + bl_light.energy = strength + # Mitsuba directional emitters are delta lights + bl_light.angle = 0.0 + + if 'to_world' in mi_props: + matrix = mi_transform_to_bl_transform(mi_props.get('to_world')) + elif 'direction' in mi_props: + matrix = _direction_matrix( + Vector(list(mi_props.get('direction'))), Vector([0.0, 1.0, 0.0])) + else: + matrix = Matrix() + return bl_light, mi_context.mi_space_to_bl_space(matrix @ _FLIP) + + +_converters = { + 'point': _convert_point, + 'spot': _convert_spot, + 'directional': _convert_directional, +} + + +###################### +## Area emitters ## +###################### + +_area_shapes = ('rectangle', 'disk', 'sphere') + + +def can_convert_area_emitter(mi_shape_props): + '''Whether a shape carrying an area emitter can become a Blender + light instead of an emissive mesh.''' + return mi_shape_props.plugin_name() in _area_shapes + + +def is_placeholder_bsdf(mi_bsdf_props): + '''Whether a BSDF only exists to satisfy Mitsuba, which would otherwise + default a shape to a mid-gray diffuse. The exporter gives emitter-only + materials and Blender lights a black diffuse BSDF; such a shape is a + light source, not an emissive surface.''' + from mitsuba import Properties + plugin_name = mi_bsdf_props.plugin_name() + if plugin_name == 'null': + return True + if plugin_name != 'diffuse' or 'reflectance' not in mi_bsdf_props: + return False + prop_type = mi_bsdf_props.type('reflectance') + if prop_type == Properties.Type.Color: + return max(list(mi_bsdf_props['reflectance'])) == 0.0 + if prop_type == Properties.Type.Float: + return float(mi_bsdf_props['reflectance']) == 0.0 + return False + + +def mi_area_emitter_to_bl_light(mi_context, mi_emitter, mi_shape): + '''Convert an area emitter attached to a rectangle, disk or sphere + shape into a Blender area or point light. Returns (bl_light, + world_matrix), or None (with a warning) on failure.''' + try: + shape_type = mi_shape.plugin_name() + if shape_type not in _area_shapes: + raise ValueError(f'shape type "{shape_type}" cannot be ' + 'converted to a Blender light') + color, radiance = mi_spectra_utils.convert_radiance_property( + mi_context, mi_emitter, 'radiance', [1.0, 1.0, 1.0]) + matrix = mi_context.mi_space_to_bl_space( + mi_transform_to_bl_transform(mi_shape.get('to_world', None))) + name = _light_name(mi_emitter) + + if shape_type == 'sphere': + # Blender represents sphere emitters as point lights with a + # radius + scale = matrix.to_scale() + radius = float(mi_shape.get('radius', 1.0)) \ + * (abs(scale.x) + abs(scale.y) + abs(scale.z)) / 3.0 + if 'center' in mi_shape: + center = Vector(list(mi_shape.get('center'))) + matrix = matrix @ Matrix.Translation(center) + bl_light = bpy.data.lights.new(name=name, type='POINT') + bl_light.shadow_soft_size = radius + bl_light.color = color + bl_light.energy = radiance_to_power(radiance, sphere_area(radius)) + return bl_light, Matrix.Translation(matrix.to_translation()) + + # Mitsuba rectangles and disks span [-1, 1] locally; the world + # scale carries the actual dimensions + scale = matrix.to_scale() + sx, sy = abs(scale.x), abs(scale.y) + bl_light = bpy.data.lights.new(name=name, type='AREA') + if shape_type == 'rectangle': + bl_light.shape = 'RECTANGLE' + bl_light.size = 2.0 + bl_light.size_y = 2.0 + area = (2.0 * sx) * (2.0 * sy) + else: + if math.isclose(sx, sy, rel_tol=1e-5): + bl_light.shape = 'DISK' + bl_light.size = 2.0 + else: + bl_light.shape = 'ELLIPSE' + bl_light.size = 2.0 + bl_light.size_y = 2.0 + area = math.pi * sx * sy + bl_light.color = color + bl_light.energy = radiance_to_power(radiance, area) + # Blender area lights emit along -Z, Mitsuba shapes along +Z + # unless their normals are flipped + if not mi_shape.get('flip_normals', False): + matrix = matrix @ _FLIP + return bl_light, matrix + except Exception as e: + mi_context.log(f'Failed to convert area emitter ' + f'"{mi_emitter.id() or mi_emitter.plugin_name()}": ' + f'{e}.', 'WARN') + return None + + +###################### +## Main import ## +###################### + +def mi_emitter_to_bl_light(mi_context, mi_props): + '''Convert a Mitsuba emitter into a Blender light. Returns (bl_light, + world_matrix), or None (with a warning) when the emitter is not + supported or fails to convert.''' + emitter_type = mi_props.plugin_name() + converter = _converters.get(emitter_type) + if converter is None: + mi_context.log(f'Mitsuba emitter type "{emitter_type}" is not ' + 'supported.', 'WARN') + return None + try: + return converter(mi_context, mi_props) + except Exception as e: + mi_context.log(f'Failed to convert emitter ' + f'"{mi_props.id() or emitter_type}": {e}.', 'WARN') + return None diff --git a/mitsuba_blender/convert/importer/materials/__init__.py b/mitsuba_blender/convert/importer/materials/__init__.py new file mode 100644 index 00000000..fbf9e6f9 --- /dev/null +++ b/mitsuba_blender/convert/importer/materials/__init__.py @@ -0,0 +1,219 @@ +'''Registry of Mitsuba BSDF to Blender shader node converters. + +Converter modules in this package register functions with +@material_converter(''). A converter receives a +MaterialBuilder and the Mitsuba properties of the BSDF, creates shader +nodes through the builder, and returns the output socket that represents +the BSDF. Nested BSDFs are converted with builder.convert_bsdf, which +never raises: failures are logged and produce a distinctive pink error +BSDF instead. + +Texture plugins register with @texture_converter('') and +return the output socket of the node graph they build. +''' + +import importlib +import pkgutil + +import bpy + +from ... import ConversionError +from ....compat import ensure_node_tree + +_material_converters = {} +_texture_converters = {} + + +def material_converter(*plugin_names): + '''Register a converter for the given Mitsuba BSDF plugin names.''' + def decorator(func): + for plugin_name in plugin_names: + _material_converters[plugin_name] = func + return func + return decorator + + +def texture_converter(*plugin_names): + '''Register a converter for the given Mitsuba texture plugin names.''' + def decorator(func): + for plugin_name in plugin_names: + _texture_converters[plugin_name] = func + return func + return decorator + + +ERROR_COLOR = (1.0, 0.0, 0.3, 1.0) + + +class MaterialBuilder: + '''Builds a Blender shader node tree from Mitsuba properties.''' + + def __init__(self, mi_context, bl_mat): + self.mi_context = mi_context + self.bl_mat = bl_mat + self.tree = bl_mat.node_tree + + def node(self, bl_idname): + return self.tree.nodes.new(bl_idname) + + def link(self, from_socket, to_socket): + self.tree.links.new(from_socket, to_socket) + + def child_props(self, node_id): + '''The Mitsuba properties of a referenced scene node.''' + return self.mi_context.mi_state.nodes[node_id].props + + def error_bsdf(self): + '''A distinctive pink BSDF marking a failed conversion.''' + node = self.node('ShaderNodeBsdfDiffuse') + node.inputs['Color'].default_value = ERROR_COLOR + return node.outputs['BSDF'] + + def convert_bsdf(self, mi_props): + '''Convert a BSDF into nodes; returns its shader output socket. + Never raises: failures produce a warning and an error BSDF.''' + plugin_name = mi_props.plugin_name() + converter = _material_converters.get(plugin_name) + try: + if converter is None: + raise ConversionError(f'Mitsuba BSDF type "{plugin_name}" ' + 'is not supported') + return converter(self, mi_props) + except Exception as e: + self.mi_context.log( + f'Failed to convert Mitsuba BSDF ' + f'"{mi_props.id() or plugin_name}": {e}. ' + 'Using an error material.', 'WARN') + return self.error_bsdf() + + def convert_texture(self, node_id): + '''Build nodes for a referenced texture; returns its output socket, + or None (with a warning) if the texture type has no converter.''' + mi_props = self.child_props(node_id) + plugin_name = mi_props.plugin_name() + converter = _texture_converters.get(plugin_name) + if converter is None: + self.mi_context.log(f'Mitsuba texture type "{plugin_name}" is ' + 'not supported.', 'WARN') + return None + return converter(self, mi_props) + + def set_float(self, socket, mi_props, name, default=None, + transform=None): + '''Write a float property, a constant or a texture, into an input + socket. `transform` is applied to constant values (e.g. roughness + remapping).''' + from mitsuba import Properties + + def assign(value): + socket.default_value = transform(value) if transform else value + + if name not in mi_props: + if default is not None: + assign(default) + return + prop_type = mi_props.type(name) + if prop_type == Properties.Type.Float: + assign(mi_props[name]) + elif prop_type == Properties.Type.Color: + rgb = list(mi_props[name]) + assign(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) + elif prop_type == Properties.Type.ResolvedReference: + source = self.convert_texture(mi_props[name].index()) + if source is not None: + self.link(source, socket) + elif default is not None: + assign(default) + else: + self.mi_context.log(f'Property "{name}" of type {prop_type} ' + 'cannot be converted to a float.', 'WARN') + if default is not None: + assign(default) + + def set_color(self, socket, mi_props, name, default=None): + '''Write a color property, a constant or a texture, into an input + socket.''' + from mitsuba import Properties + if name not in mi_props: + if default is not None: + socket.default_value = _rgba(default) + return + prop_type = mi_props.type(name) + if prop_type == Properties.Type.Color: + socket.default_value = _rgba(list(mi_props[name])) + elif prop_type == Properties.Type.Float: + socket.default_value = _rgba([mi_props[name]] * 3) + elif prop_type == Properties.Type.ResolvedReference: + source = self.convert_texture(mi_props[name].index()) + if source is not None: + self.link(source, socket) + elif default is not None: + socket.default_value = _rgba(default) + else: + self.mi_context.log(f'Property "{name}" of type {prop_type} ' + 'cannot be converted to a color.', 'WARN') + if default is not None: + socket.default_value = _rgba(default) + + +def convert_material(mi_context, mi_props, mi_emitter=None): + '''Create a Blender material for a Mitsuba BSDF, wrapped in an emission + shader when an area emitter is given. Never raises.''' + name = mi_props.id() or f'Material-{mi_props.plugin_name()}' + bl_mat = bpy.data.materials.new(name=name) + tree = ensure_node_tree(bl_mat) + tree.nodes.clear() + output = tree.nodes.new('ShaderNodeOutputMaterial') + + builder = MaterialBuilder(mi_context, bl_mat) + surface = builder.convert_bsdf(mi_props) + if mi_emitter is not None: + surface = _wrap_emitter(builder, surface, mi_emitter) + builder.link(surface, output.inputs['Surface']) + _layout_tree(tree, output) + return bl_mat + + +def _rgba(color): + color = list(color) + return color + [1.0] * (4 - len(color)) + + +def _wrap_emitter(builder, bsdf_socket, mi_emitter): + '''Combine a BSDF output with an area emitter via an Add Shader.''' + from ....io.importer import mi_spectra_utils + radiance, strength = mi_spectra_utils.convert_radiance_property( + builder.mi_context, mi_emitter, 'radiance', [1.0, 1.0, 1.0]) + + emission = builder.node('ShaderNodeEmission') + emission.inputs['Color'].default_value = _rgba(radiance) + emission.inputs['Strength'].default_value = strength + add = builder.node('ShaderNodeAddShader') + builder.link(emission.outputs['Emission'], add.inputs[0]) + builder.link(bsdf_socket, add.inputs[1]) + return add.outputs['Shader'] + + +def _layout_tree(tree, output): + '''Arrange the nodes in columns by their link distance from the output.''' + depths = {output: 0} + stack = [output] + while stack: + node = stack.pop() + for socket in node.inputs: + for link in socket.links: + child = link.from_node + if depths.get(child, -1) < depths[node] + 1: + depths[child] = depths[node] + 1 + stack.append(child) + rows = {} + for node, depth in depths.items(): + row = rows.get(depth, 0) + node.location = (-300.0 * depth, -250.0 * row) + rows[depth] = row + 1 + + +# Converter modules register themselves when imported +for _module in pkgutil.iter_modules(__path__): + if not _module.name.startswith('_'): + importlib.import_module(f'.{_module.name}', __name__) diff --git a/mitsuba_blender/convert/importer/materials/bsdfs.py b/mitsuba_blender/convert/importer/materials/bsdfs.py new file mode 100644 index 00000000..042b90a6 --- /dev/null +++ b/mitsuba_blender/convert/importer/materials/bsdfs.py @@ -0,0 +1,223 @@ +'''Converters for Mitsuba's basic BSDF plugins.''' + +import math + +from . import material_converter, _rgba + + +@material_converter('diffuse') +def convert_diffuse(builder, mi_props): + node = builder.node('ShaderNodeBsdfDiffuse') + builder.set_color(node.inputs['Color'], mi_props, 'reflectance', + default=(0.8, 0.8, 0.8)) + return node.outputs['BSDF'] + + +################# +## Utilities ## +################# + +_IOR_VALUES = { + 'acetone': 1.36, + 'acrylic glass': 1.49, + 'air': 1.00028, + 'amber': 1.55, + 'benzene': 1.501, + 'bk7': 1.5046, + 'bromine': 1.661, + 'carbon dioxide': 1.00045, + 'carbon tetrachloride': 1.461, + 'diamond': 2.419, + 'ethanol': 1.361, + 'fused quartz': 1.458, + 'glycerol': 1.4729, + 'helium': 1.00004, + 'hydrogen': 1.00013, + 'pet': 1.575, + 'polypropylene': 1.49, + 'pyrex': 1.470, + 'silicone oil': 1.52045, + 'sodium chloride': 1.544, + 'vacuum': 1.0, + 'water': 1.3330, + 'water ice': 1.31, +} + +def _ior(builder, mi_props, name, default): + '''Read an IOR property, which is either a float or a material name.''' + from mitsuba import Properties + if name not in mi_props: + return default + if mi_props.type(name) == Properties.Type.String: + value = mi_props[name] + if value not in _IOR_VALUES: + builder.mi_context.log(f'Unknown IOR material "{value}"; using ' + 'the default value.', 'WARN') + return default + return _IOR_VALUES[value] + return float(mi_props[name]) + + +_DISTRIBUTIONS = { + 'beckmann': 'BECKMANN', + 'ggx': 'GGX', +} + + +def _set_distribution(builder, node, mi_props): + name = mi_props.get('distribution', 'beckmann') + if name not in _DISTRIBUTIONS: + builder.mi_context.log(f'Microfacet distribution "{name}" is not ' + 'supported; using Beckmann.', 'WARN') + name = 'beckmann' + node.distribution = _DISTRIBUTIONS[name] + + +def _retro_reflectance(builder, mi_props, default=(1.0, 1.0, 1.0)): + '''Estimate the reflection color of a BSDF by evaluating it head-on. + Smooth conductors are delta lobes that evaluate to zero, so they are + evaluated through an equivalent rough one.''' + from mitsuba import (BSDFContext, Properties, SurfaceInteraction3f, + Vector3f, load_dict) + try: + bsdf_dict = {'type': mi_props.plugin_name()} + for name in mi_props.keys(): + value = mi_props.get(name) + # References index into the parser state; following them from + # a standalone load_dict call would crash Mitsuba + if isinstance(value, Properties.ResolvedReference): + continue + bsdf_dict[name] = value + if bsdf_dict['type'] == 'conductor': + bsdf_dict['type'] = 'roughconductor' + bsdf_dict['alpha'] = 0.1 + bsdf = load_dict(bsdf_dict) + si = SurfaceInteraction3f() + si.wi = Vector3f(0.0, 0.0, 1.0) + color, pdf = bsdf.eval_pdf(BSDFContext(), si, + Vector3f(0.0, 0.0, 1.0)) + if pdf == 0.0: + return list(default) + return [min(float(c), 1.0) for c in color / pdf] + except Exception as e: + builder.mi_context.log('Could not evaluate the reflectance of ' + f'"{mi_props.id() or mi_props.plugin_name()}"' + f': {e}. Using the default value.', 'WARN') + return list(default) + + +def _set_anisotropy(builder, node, mi_props): + '''Convert alpha_u/alpha_v back into Cycles' roughness/anisotropy pair + (the inverse of the mapping in the exporter).''' + from mitsuba import Properties + + def constant(name): + if name in mi_props and \ + mi_props.type(name) == Properties.Type.Float: + return float(mi_props[name]) + return None + + alpha_u = constant('alpha_u') + alpha_v = constant('alpha_v') + if alpha_u is None or alpha_v is None: + builder.mi_context.log('Only constant alpha_u/alpha_v values are ' + 'supported; ignoring the anisotropy.', 'WARN') + node.inputs['Roughness'].default_value = math.sqrt(0.1) + return + sign = 1.0 + if alpha_u < alpha_v: + alpha_u, alpha_v = alpha_v, alpha_u + sign = -1.0 + aspect_sqr = alpha_v / alpha_u if alpha_u > 0.0 else 1.0 + node.inputs['Roughness'].default_value = (alpha_u * alpha_v) ** 0.25 + node.inputs['Anisotropy'].default_value = sign * (1.0 - aspect_sqr) / 0.9 + + +################## +## Conductors ## +################## + +@material_converter('conductor') +def convert_conductor(builder, mi_props): + node = builder.node('ShaderNodeBsdfAnisotropic') + node.inputs['Roughness'].default_value = 0.0 + node.inputs['Color'].default_value = \ + _rgba(_retro_reflectance(builder, mi_props)) + return node.outputs['BSDF'] + + +@material_converter('roughconductor') +def convert_roughconductor(builder, mi_props): + node = builder.node('ShaderNodeBsdfAnisotropic') + _set_distribution(builder, node, mi_props) + node.inputs['Color'].default_value = \ + _rgba(_retro_reflectance(builder, mi_props)) + if 'alpha_u' in mi_props or 'alpha_v' in mi_props: + _set_anisotropy(builder, node, mi_props) + else: + builder.set_float(node.inputs['Roughness'], mi_props, 'alpha', + default=0.1, transform=math.sqrt) + return node.outputs['BSDF'] + + +################### +## Dielectrics ## +################### + +@material_converter('dielectric') +def convert_dielectric(builder, mi_props): + node = builder.node('ShaderNodeBsdfGlass') + node.inputs['Roughness'].default_value = 0.0 + node.inputs['IOR'].default_value = _ior(builder, mi_props, 'int_ior', + 1.5046) + builder.set_color(node.inputs['Color'], mi_props, + 'specular_transmittance', default=(1.0, 1.0, 1.0)) + return node.outputs['BSDF'] + + +@material_converter('thindielectric') +def convert_thindielectric(builder, mi_props): + node = builder.node('ShaderNodeBsdfGlass') + node.inputs['Roughness'].default_value = 0.0 + node.inputs['IOR'].default_value = 1.0 + builder.set_color(node.inputs['Color'], mi_props, + 'specular_transmittance', default=(1.0, 1.0, 1.0)) + return node.outputs['BSDF'] + + +@material_converter('roughdielectric') +def convert_roughdielectric(builder, mi_props): + node = builder.node('ShaderNodeBsdfGlass') + _set_distribution(builder, node, mi_props) + node.inputs['IOR'].default_value = _ior(builder, mi_props, 'int_ior', + 1.5046) + builder.set_color(node.inputs['Color'], mi_props, + 'specular_transmittance', default=(1.0, 1.0, 1.0)) + builder.set_float(node.inputs['Roughness'], mi_props, 'alpha', + default=0.1, transform=math.sqrt) + return node.outputs['BSDF'] + + +################ +## Plastics ## +################ + +@material_converter('plastic', 'roughplastic') +def convert_plastic(builder, mi_props): + # Blender has no plastic BSDF; a Principled node with the plastic's + # diffuse base, IOR and roughness is a close approximation (the diffuse + # and specular lobes are not energy-coupled like in Mitsuba) + plugin_name = mi_props.plugin_name() + builder.mi_context.log(f'Approximating Mitsuba "{plugin_name}" with a ' + 'Principled BSDF.', 'WARN') + node = builder.node('ShaderNodeBsdfPrincipled') + builder.set_color(node.inputs['Base Color'], mi_props, + 'diffuse_reflectance', default=(0.5, 0.5, 0.5)) + node.inputs['IOR'].default_value = _ior(builder, mi_props, 'int_ior', + 1.49) + if plugin_name == 'roughplastic': + builder.set_float(node.inputs['Roughness'], mi_props, 'alpha', + default=0.1, transform=math.sqrt) + else: + node.inputs['Roughness'].default_value = 0.0 + return node.outputs['BSDF'] diff --git a/mitsuba_blender/convert/importer/materials/combinators.py b/mitsuba_blender/convert/importer/materials/combinators.py new file mode 100644 index 00000000..9f0a1d02 --- /dev/null +++ b/mitsuba_blender/convert/importer/materials/combinators.py @@ -0,0 +1,97 @@ +'''Converters for Mitsuba's BSDF combinator plugins: twosided, blendbsdf, +mask and null.''' + +from ... import ConversionError +from . import material_converter + + +def _child_bsdfs(builder, mi_props): + '''The Mitsuba properties of the BSDFs nested in this one, in order.''' + from mitsuba import ObjectType, Properties + children = [] + for _, value in mi_props.items(): + if isinstance(value, Properties.ResolvedReference): + node = builder.mi_context.mi_state.nodes[value.index()] + if node.type == ObjectType.BSDF: + children.append(node.props) + return children + + +@material_converter('twosided') +def convert_twosided(builder, mi_props): + children = _child_bsdfs(builder, mi_props) + if len(children) == 1: + # Blender materials are two-sided by default + return builder.convert_bsdf(children[0]) + if len(children) == 2: + # Select between the front and back BSDF by face orientation + mix = builder.node('ShaderNodeMixShader') + geometry = builder.node('ShaderNodeNewGeometry') + builder.link(geometry.outputs['Backfacing'], mix.inputs['Fac']) + builder.link(builder.convert_bsdf(children[0]), mix.inputs[1]) + builder.link(builder.convert_bsdf(children[1]), mix.inputs[2]) + return mix.outputs['Shader'] + raise ConversionError(f'twosided BSDF has {len(children)} nested ' + 'BSDFs, expected 1 or 2') + + +@material_converter('blendbsdf') +def convert_blendbsdf(builder, mi_props): + children = _child_bsdfs(builder, mi_props) + if len(children) != 2: + raise ConversionError(f'blendbsdf has {len(children)} nested ' + 'BSDFs, expected 2') + # A weight of zero selects the first BSDF, matching the Fac semantics + mix = builder.node('ShaderNodeMixShader') + builder.set_float(mix.inputs['Fac'], mi_props, 'weight', default=0.5) + builder.link(builder.convert_bsdf(children[0]), mix.inputs[1]) + builder.link(builder.convert_bsdf(children[1]), mix.inputs[2]) + return mix.outputs['Shader'] + + +def _constant_opacity(mi_props): + '''The opacity as an RGB list when it is a constant, else None.''' + from mitsuba import Properties + if 'opacity' not in mi_props: + return [0.5, 0.5, 0.5] + prop_type = mi_props.type('opacity') + if prop_type == Properties.Type.Color: + return list(mi_props['opacity']) + if prop_type == Properties.Type.Float: + return [mi_props['opacity']] * 3 + return None + + +def _is_black_diffuse(mi_props): + from mitsuba import Properties + return (mi_props.plugin_name() == 'diffuse' + and 'reflectance' in mi_props + and mi_props.type('reflectance') == Properties.Type.Color + and max(list(mi_props['reflectance'])) == 0) + + +@material_converter('mask') +def convert_mask(builder, mi_props): + children = _child_bsdfs(builder, mi_props) + if len(children) != 1: + raise ConversionError(f'mask BSDF has {len(children)} nested ' + 'BSDFs, expected 1') + opacity = _constant_opacity(mi_props) + if opacity is not None and _is_black_diffuse(children[0]): + # A masked black diffuse is plain transparency; Blender's + # Transparent BSDF color is the inverse of Mitsuba's opacity + node = builder.node('ShaderNodeBsdfTransparent') + node.inputs['Color'].default_value = \ + [1.0 - c for c in opacity] + [1.0] + return node.outputs['BSDF'] + mix = builder.node('ShaderNodeMixShader') + builder.set_float(mix.inputs['Fac'], mi_props, 'opacity', default=0.5) + transparent = builder.node('ShaderNodeBsdfTransparent') + builder.link(transparent.outputs['BSDF'], mix.inputs[1]) + builder.link(builder.convert_bsdf(children[0]), mix.inputs[2]) + return mix.outputs['Shader'] + + +@material_converter('null') +def convert_null(builder, mi_props): + return builder.node('ShaderNodeBsdfTransparent').outputs['BSDF'] diff --git a/mitsuba_blender/convert/importer/materials/principled.py b/mitsuba_blender/convert/importer/materials/principled.py new file mode 100644 index 00000000..5832f852 --- /dev/null +++ b/mitsuba_blender/convert/importer/materials/principled.py @@ -0,0 +1,69 @@ +'''Converter for Mitsuba's principled BSDF.''' + +from . import material_converter + + +def _set_spec_tint(builder, node, mi_props): + '''Mitsuba's spec_tint blends the specular color from white toward the + base color hue; Blender tints with a color where white means untinted.''' + from mitsuba import Properties + if 'spec_tint' not in mi_props: + return + prop_type = mi_props.type('spec_tint') + if prop_type == Properties.Type.Float: + tint = mi_props['spec_tint'] + elif prop_type == Properties.Type.Color: + rgb = list(mi_props['spec_tint']) + tint = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] + else: + builder.mi_context.log('Only constant spec_tint values are ' + 'supported; ignoring it.', 'WARN') + return + gray = 1.0 - tint + node.inputs['Specular Tint'].default_value = (gray, gray, gray, 1.0) + + +def _set_ior(builder, node, mi_props): + '''Fill the IOR and Specular IOR Level inputs from either of Mitsuba's + mutually exclusive eta/specular parameters, keeping both sockets + consistent (F0 = 0.08 * specular level).''' + import math + from mitsuba import Properties + for name in ('eta', 'specular'): + if name in mi_props and \ + mi_props.type(name) != Properties.Type.Float: + builder.mi_context.log(f'Only constant {name} values are ' + 'supported; ignoring it.', 'WARN') + return + if 'eta' in mi_props: + eta = mi_props['eta'] + specular = ((eta - 1.0) / (eta + 1.0)) ** 2 / 0.08 + elif 'specular' in mi_props: + specular = mi_props['specular'] + eta = 2.0 / (1.0 - math.sqrt(min(0.08 * specular, 0.9999))) - 1.0 + else: + return + node.inputs['IOR'].default_value = eta + node.inputs['Specular IOR Level'].default_value = min(specular, 1.0) + + +@material_converter('principled') +def convert_principled(builder, mi_props): + node = builder.node('ShaderNodeBsdfPrincipled') + builder.set_color(node.inputs['Base Color'], mi_props, 'base_color', + default=(0.5, 0.5, 0.5)) + builder.set_float(node.inputs['Roughness'], mi_props, 'roughness', + default=0.5) + builder.set_float(node.inputs['Metallic'], mi_props, 'metallic') + builder.set_float(node.inputs['Anisotropic'], mi_props, 'anisotropic') + builder.set_float(node.inputs['Transmission Weight'], mi_props, + 'spec_trans') + builder.set_float(node.inputs['Sheen Weight'], mi_props, 'sheen') + builder.set_color(node.inputs['Sheen Tint'], mi_props, 'sheen_tint') + builder.set_float(node.inputs['Coat Weight'], mi_props, 'clearcoat') + # Coat Roughness is the inverse of Mitsuba's clearcoat gloss + builder.set_float(node.inputs['Coat Roughness'], mi_props, + 'clearcoat_gloss', transform=lambda gloss: 1.0 - gloss) + _set_spec_tint(builder, node, mi_props) + _set_ior(builder, node, mi_props) + return node.outputs['BSDF'] diff --git a/mitsuba_blender/convert/importer/materials/textures.py b/mitsuba_blender/convert/importer/materials/textures.py new file mode 100644 index 00000000..03c591c9 --- /dev/null +++ b/mitsuba_blender/convert/importer/materials/textures.py @@ -0,0 +1,291 @@ +'''Converters for Mitsuba texture plugins and for the normalmap/bumpmap +BSDF wrappers, which become Normal Map and Bump nodes feeding the Normal +input of the wrapped BSDF.''' + +import os + +import bpy +import numpy as np +from mathutils import Matrix, Vector + +from ... import ConversionError +from . import material_converter, texture_converter + +# Mirrors the v axis about 0.5, the difference between the two image row +# conventions +_FLIP = Matrix.Translation((0.0, 1.0, 0.0)) \ + @ Matrix.Diagonal((1.0, -1.0, 1.0, 1.0)) + + +def _references(builder, mi_props, target_type): + '''Indices of the referenced scene nodes of the given ObjectType.''' + from mitsuba import Properties + refs = [] + for _, value in mi_props.items(): + if isinstance(value, Properties.ResolvedReference): + node = builder.mi_context.mi_state.nodes[value.index()] + if node.type == target_type: + refs.append(value.index()) + return refs + + +def _socket(sockets, identifier): + return next(s for s in sockets if s.identifier == identifier) + + +def _multiply(builder, source, value): + '''Multiply a color socket by a constant via a Mix node.''' + node = builder.node('ShaderNodeMix') + node.data_type = 'RGBA' + node.blend_type = 'MULTIPLY' + _socket(node.inputs, 'Factor_Float').default_value = 1.0 + builder.link(source, _socket(node.inputs, 'A_Color')) + if isinstance(value, (int, float)): + value = (value, value, value) + _socket(node.inputs, 'B_Color').default_value = (*value[:3], 1.0) + return _socket(node.outputs, 'Result_Color') + + +###################### +## UV coordinates ## +###################### + +def _is_identity(matrix): + return all(abs(matrix[i][j] - (i == j)) < 1e-8 + for i in range(4) for j in range(4)) + + +def _uv_matrix(mi_props): + '''The to_uv transform as a Blender UV mapping, or None for identity.''' + if 'to_uv' not in mi_props: + return None + matrix = Matrix(np.array(mi_props['to_uv'].matrix).tolist()) + return None if _is_identity(matrix) else matrix + + +def _decompose(matrix): + '''Split a UV-plane transform into (location, z rotation, scale), or + return None when it contains shear or out-of-plane components.''' + # A mirrored transform would otherwise decompose into a rotation out of + # the UV plane, which a Mapping node cannot express. Take the mirror out + # first and give it back as a negative v scale. + mirrored = (matrix[0][0] * matrix[1][1] - + matrix[0][1] * matrix[1][0]) < 0.0 + m = matrix @ Matrix.Diagonal((1.0, -1.0, 1.0, 1.0)) if mirrored else matrix + + location, rotation, scale = m.decompose() + euler = rotation.to_euler() + if abs(euler.x) > 1e-5 or abs(euler.y) > 1e-5: + return None + rebuilt = Matrix.Translation(location) @ euler.to_matrix().to_4x4() \ + @ Matrix.Diagonal((*scale, 1.0)) + if any(abs(rebuilt[i][j] - m[i][j]) > 1e-4 + for i in range(4) for j in range(4)): + return None + if mirrored: + scale = Vector((scale.x, -scale.y, scale.z)) + return location, euler.z, scale + + +def _link_mapping(builder, vector_socket, matrix): + '''Feed a texture Vector input with UV coordinates transformed by the + given Blender-space matrix.''' + parts = _decompose(matrix) + if parts is None: + builder.mi_context.log( + 'The to_uv transform of a texture cannot be represented by a ' + 'Mapping node; ignoring it.', 'WARN') + return + location, rotation_z, scale = parts + mapping = builder.node('ShaderNodeMapping') + mapping.vector_type = 'POINT' + mapping.inputs['Location'].default_value = location + mapping.inputs['Rotation'].default_value = (0.0, 0.0, rotation_z) + mapping.inputs['Scale'].default_value = scale + coords = builder.node('ShaderNodeTexCoord') + builder.link(coords.outputs['UV'], mapping.inputs['Vector']) + builder.link(mapping.outputs['Vector'], vector_socket) + + +########################## +## Texture converters ## +########################## + +def _load_image(mi_context, path, raw): + key = (os.path.normpath(path), raw) + if key in mi_context.bl_texture_cache: + return mi_context.bl_texture_cache[key] + try: + image = bpy.data.images.load(path) + except RuntimeError: + mi_context.log(f'Failed to load image "{path}".', 'WARN') + return None + if raw: + image.colorspace_settings.is_data = True + image.colorspace_settings.name = 'Non-Color' + mi_context.bl_texture_cache[key] = image + return image + + +@texture_converter('bitmap') +def convert_bitmap(builder, mi_props): + ctx = builder.mi_context + if 'filename' not in mi_props: + ctx.log('Bitmap textures without a filename are not supported.', + 'WARN') + return None + filename = mi_props['filename'] + path = ctx.resolve_scene_relative_path(filename) + if path is None: + ctx.log(f'Cannot find the texture file "{filename}".', 'WARN') + return None + image = _load_image(ctx, path, bool(mi_props.get('raw', False))) + if image is None: + return None + + node = builder.node('ShaderNodeTexImage') + node.image = image + wrap_mode = mi_props.get('wrap_mode', 'repeat') + if wrap_mode == 'clamp': + node.extension = 'EXTEND' + elif wrap_mode == 'mirror': + node.extension = 'MIRROR' + if mi_props.get('filter_type', 'bilinear') == 'nearest': + node.interpolation = 'Closest' + + # Blender addresses the first image row at v = 1, Mitsuba at v = 0 + matrix = _uv_matrix(mi_props) + matrix = _FLIP @ (matrix if matrix is not None else Matrix.Identity(4)) + if not _is_identity(matrix): + _link_mapping(builder, node.inputs['Vector'], matrix) + return node.outputs['Color'] + + +@texture_converter('checkerboard') +def convert_checkerboard(builder, mi_props): + node = builder.node('ShaderNodeTexChecker') + # Both patterns start their first cell at the UV origin + builder.set_color(node.inputs['Color1'], mi_props, 'color0', + default=(0.4, 0.4, 0.4)) + builder.set_color(node.inputs['Color2'], mi_props, 'color1', + default=(0.2, 0.2, 0.2)) + + # A Mitsuba checkerboard has 2x2 cells per to_uv period, a Blender one + # has scale x scale cells per UV unit + matrix = _uv_matrix(mi_props) or Matrix.Identity(4) + cells = Matrix.Diagonal((2.0, 2.0, 1.0, 1.0)) @ matrix + for i in range(2): + # Translations by an even number of cells do not alter the pattern + offset = 2.0 * round(cells[i][3] / 2.0) + if abs(cells[i][3] - offset) < 1e-5: + cells[i][3] -= offset + + parts = _decompose(cells) + if parts is not None: + location, rotation_z, scale = parts + if location.length < 1e-6 and abs(rotation_z) < 1e-6 \ + and abs(scale.x - scale.y) < 1e-6: + node.inputs['Scale'].default_value = scale.x + return node.outputs['Color'] + node.inputs['Scale'].default_value = 1.0 + _link_mapping(builder, node.inputs['Vector'], cells) + return node.outputs['Color'] + + +@texture_converter('scale') +def convert_scale(builder, mi_props): + from mitsuba import ObjectType + refs = _references(builder, mi_props, ObjectType.Texture) + if not refs: + builder.mi_context.log('Scale textures without a nested texture ' + 'are not supported.', 'WARN') + return None + source = builder.convert_texture(refs[0]) + if source is None: + return None + return _multiply(builder, source, mi_props.get('scale', 1.0)) + + +@texture_converter('mesh_attribute') +def convert_mesh_attribute(builder, mi_props): + name = mi_props.get('name', '') + if not name: + builder.mi_context.log('Mesh attribute textures without a name are ' + 'not supported.', 'WARN') + return None + if name.startswith('vertex_'): + node = builder.node('ShaderNodeVertexColor') + node.layer_name = name[len('vertex_'):] + else: + node = builder.node('ShaderNodeAttribute') + node.attribute_name = name + source = node.outputs['Color'] + scale = mi_props.get('scale', 1.0) + if isinstance(scale, float) and scale != 1.0: + source = _multiply(builder, source, scale) + return source + + +############################ +## Normal and bump maps ## +############################ + +def _attach_normal(builder, source, bsdf_socket, chain_input=None): + '''Link a Normal Map or Bump output into the Normal input of the shader + node behind bsdf_socket. An already linked Normal input is rerouted + through chain_input when one is available.''' + target = bsdf_socket.node.inputs.get('Normal') + if target is None: + builder.mi_context.log( + f'Shader node "{bsdf_socket.node.name}" has no Normal input; ' + 'dropping a normal/bump perturbation.', 'WARN') + return + if target.is_linked: + existing = target.links[0].from_socket + if chain_input is None: + builder.mi_context.log( + f'The Normal input of "{bsdf_socket.node.name}" is already ' + 'occupied; dropping a normal/bump perturbation.', 'WARN') + return + builder.tree.links.remove(target.links[0]) + builder.link(existing, chain_input) + builder.link(source, target) + + +def _child_bsdf(builder, mi_props): + from mitsuba import ObjectType + children = _references(builder, mi_props, ObjectType.BSDF) + if len(children) != 1: + raise ConversionError( + f'{mi_props.plugin_name()} BSDF expects one child BSDF, got ' + f'{len(children)}') + return builder.convert_bsdf(builder.child_props(children[0])) + + +@material_converter('normalmap') +def convert_normalmap(builder, mi_props): + bsdf_socket = _child_bsdf(builder, mi_props) + node = builder.node('ShaderNodeNormalMap') + builder.set_color(node.inputs['Color'], mi_props, 'normalmap', + default=(0.5, 0.5, 1.0)) + _attach_normal(builder, node.outputs['Normal'], bsdf_socket) + return bsdf_socket + + +@material_converter('bumpmap') +def convert_bumpmap(builder, mi_props): + from mitsuba import ObjectType + bsdf_socket = _child_bsdf(builder, mi_props) + node = builder.node('ShaderNodeBump') + node.inputs['Distance'].default_value = float(mi_props.get('scale', 1.0)) + refs = _references(builder, mi_props, ObjectType.Texture) + if refs: + source = builder.convert_texture(refs[0]) + if source is not None: + builder.link(source, node.inputs['Height']) + else: + builder.mi_context.log('Bumpmap BSDF without a height texture.', + 'WARN') + _attach_normal(builder, node.outputs['Normal'], bsdf_socket, + chain_input=node.inputs['Normal']) + return bsdf_socket diff --git a/mitsuba_blender/convert/importer/mesh.py b/mitsuba_blender/convert/importer/mesh.py new file mode 100644 index 00000000..30ba04aa --- /dev/null +++ b/mitsuba_blender/convert/importer/mesh.py @@ -0,0 +1,255 @@ +'''Convert Mitsuba shapes into Blender meshes. + +Mesh-backed shape plugins (ply, obj, serialized, ...) are instantiated by +Mitsuba itself and their vertex buffers are copied into a Blender mesh. +Analytic shapes that are not meshes in Mitsuba (sphere, rectangle, cube, +disk) map to native Blender primitives instead. +''' + +import bpy +import bmesh +import numpy as np +from mathutils import Matrix, Vector + +from ...io.importer.bl_transform_utils import mi_transform_to_bl_transform + + +def _set_bl_mesh_shading(bl_mesh, flat_shading=True, flip_normals=False): + bl_mesh.polygons.foreach_set( + 'use_smooth', [not flat_shading] * len(bl_mesh.polygons)) + if flip_normals: + bl_mesh.flip_normals() + bl_mesh.update() + + +###################### +## Mesh shapes ## +###################### + +def _shape_dict(mi_context, mi_props): + '''Rebuild the loadable dict of a shape from its parsed properties, + without child objects and without the to_world transform (which becomes + the Blender object transform instead).''' + from mitsuba import Properties + shape_dict = {'type': mi_props.plugin_name()} + for name, value in mi_props.items(): + if isinstance(value, Properties.ResolvedReference) or name == 'to_world': + continue + if name == 'filename': + resolved = mi_context.resolve_scene_relative_path(value) + if resolved is None: + mi_context.log(f'Cannot find the shape file "{value}".', + 'ERROR') + return None + value = resolved + if isinstance(value, (bool, int, float, str)): + shape_dict[name] = value + return shape_dict + + +def _buffers_to_bl_mesh(name, mi_mesh): + '''Copy the vertex buffers of a mitsuba.Mesh into a new Blender mesh.''' + import mitsuba as mi + params = mi.traverse(mi_mesh) + vertex_count = mi_mesh.vertex_count() + face_count = mi_mesh.face_count() + + def per_vertex(key): + '''Mitsuba stores each field at the coarsest level at which it is + constant, so an optional index map expands it back to one record + per vertex.''' + values = np.asarray(params[key], dtype=np.float32) + index_key = f'{key[:-1]}_index' + if index_key not in params: + return values + index = np.asarray(params[index_key], dtype=np.int64) + return values[index] if index.size else values + + positions = per_vertex('positions') + faces = np.array(params['faces'], dtype=np.int32) + + bl_mesh = bpy.data.meshes.new(name) + bl_mesh.vertices.add(vertex_count) + # The parameters are (n, dim) tensors, while foreach_set wants the flat + # buffer that ravel() produces + bl_mesh.vertices.foreach_set('co', positions.ravel()) + bl_mesh.loops.add(face_count * 3) + bl_mesh.loops.foreach_set('vertex_index', faces.ravel()) + bl_mesh.polygons.add(face_count) + bl_mesh.polygons.foreach_set( + 'loop_start', np.arange(0, 3 * face_count, 3, dtype=np.int32)) + bl_mesh.polygons.foreach_set( + 'loop_total', np.full(face_count, 3, dtype=np.int32)) + has_normals = mi_mesh.has_normals() + bl_mesh.polygons.foreach_set( + 'use_smooth', np.full(face_count, has_normals, dtype=bool)) + bl_mesh.update(calc_edges=True) + bl_mesh.validate() + + if has_normals: + bl_mesh.normals_split_custom_set_from_vertices(per_vertex('normals')) + + if mi_mesh.has_texcoords(): + uvs = np.asarray(params['texcoords'], dtype=np.float32) + uv_layer = bl_mesh.uv_layers.new(name='UVMap') + # validate() may have deleted invalid faces, so per-loop data + # cannot be indexed with the pre-validate face buffer + loop_vertices = np.empty(len(bl_mesh.loops), dtype=np.int32) + bl_mesh.loops.foreach_get('vertex_index', loop_vertices) + uv_layer.uv.foreach_set('vector', uvs[loop_vertices].ravel()) + + # Extra per-vertex attributes (e.g. vertex colors), which are the only + # parameters carrying the 'vertex_' prefix + for key in params.keys(): + if not key.startswith('vertex_'): + continue + values = np.array(params[key], dtype=np.float32).ravel() + dim = values.size // vertex_count + attr_name = key[len('vertex_'):] + if dim == 3: + attr = bl_mesh.color_attributes.new(attr_name, 'FLOAT_COLOR', + 'POINT') + rgba = np.ones((vertex_count, 4), dtype=np.float32) + rgba[:, :3] = values.reshape(-1, 3) + attr.data.foreach_set('color', rgba.ravel()) + elif dim == 1: + attr = bl_mesh.attributes.new(attr_name, 'FLOAT', 'POINT') + attr.data.foreach_set('value', values) + + return bl_mesh + + +def mi_mesh_to_bl_shape(mi_context, mi_shape): + import mitsuba as mi + shape_dict = _shape_dict(mi_context, mi_shape) + if shape_dict is None: + return None + try: + mi_mesh = mi.load_dict(shape_dict) + except Exception as e: + mi_context.log(f'Cannot load shape "{mi_shape.id()}": {e}', 'ERROR') + return None + if not isinstance(mi_mesh, mi.Mesh): + mi_context.log(f'Mitsuba shape type "{mi_shape.plugin_name()}" is ' + 'not a mesh.', 'ERROR') + return None + + bl_mesh = _buffers_to_bl_mesh(mi_shape.id(), mi_mesh) + world_matrix = mi_transform_to_bl_transform(mi_shape.get('to_world', None)) + return bl_mesh, mi_context.mi_space_to_bl_space(world_matrix) + + +###################### +## Analytic shapes ## +###################### + +def mi_sphere_to_bl_shape(mi_context, mi_shape): + bl_mesh = bpy.data.meshes.new(mi_shape.id()) + bl_bmesh = bmesh.new() + + # Mitsuba composes to_world with the center/radius parameters + to_world = mi_transform_to_bl_transform(mi_shape.get('to_world', None)) + center = Vector(list(mi_shape.get('center', [0.0, 0.0, 0.0]))) + radius = mi_shape.get('radius', 1.0) + world_matrix = mi_context.mi_space_to_bl_space( + to_world @ Matrix.Translation(center)) + + # create_uvsphere's calc_uvs produces no UV layer (measured), so UVs are built explicitly + bmesh.ops.create_uvsphere(bl_bmesh, u_segments=32, v_segments=16, + radius=radius) + + # bmesh has no foreach_get; UVs are built onto the MESH after to_mesh + bl_bmesh.to_mesh(bl_mesh) + bl_bmesh.free() + + co = _loop_coords(bl_mesh) + + co /= np.linalg.norm(co, axis=1, keepdims=True) + x, y, z = co[:, 0], co[:, 1], co[:, 2] + + phi = np.arctan2(y, x) + phi[phi < 0] += 2 * np.pi + _set_uvs(bl_mesh, phi / (2 * np.pi), 1.0 - np.arccos(np.clip(z, -1.0, 1.0)) / np.pi) + + _set_bl_mesh_shading(bl_mesh, flat_shading=False, + flip_normals=mi_shape.get('flip_normals', False)) + + return bl_mesh, world_matrix + + +# Analytic shapes that differ only in the bmesh primitive they create. +# The operators are looked up by name at call time: BMeshOpFunc objects +# must not outlive the bmesh.ops attribute access that created them. +_analytic_ops = { + 'disk': ('create_circle', + dict(cap_ends=True, cap_tris=True, segments=32, radius=1.0)), + 'rectangle': ('create_grid', + dict(x_segments=1, y_segments=1, size=1.0)), + 'cube': ('create_cube', dict(size=2.0)), + #TODO: create a new mapping for other shapes +} + +def _loop_coords(me): + """Per-loop vertex coordinates, shape (n_loops, 3).""" + n_loops = len(me.loops) + vidx = np.empty(n_loops, dtype=np.int64) + me.loops.foreach_get('vertex_index', vidx) + co = np.empty(len(me.vertices) * 3, dtype=np.float64) + me.vertices.foreach_get('co', co) + return co.reshape(-1, 3)[vidx] + +def _set_uvs(me, u, v): + uv = np.empty(len(me.loops) * 2, dtype=np.float64) + uv[0::2] = u + uv[1::2] = v + layer = me.uv_layers.active or me.uv_layers.new(name='UVMap') + layer.data.foreach_set('uv', uv) + +def _analytic_to_bl_shape(mi_context, mi_shape): + op_name, kwargs = _analytic_ops[mi_shape.plugin_name()] + op = getattr(bmesh.ops, op_name) + bl_mesh = bpy.data.meshes.new(mi_shape.id()) + bl_bmesh = bmesh.new() + + # bmesh's calc_uvs produces no UV layer for create_grid, create_circle or + # create_cube (measured), so UVs must be built explicitly. Only rectangle + # is handled here. Its parameterization is a planar XY projection of + # [-1,1] onto [0,1]. disk (polar) and cube (per-face) still import without + # UVs; their Mitsuba parameterizations need to be matched the way mi_sphere_to_bl_shape + #matches sphere.cpp. + op(bl_bmesh, **kwargs) + bl_bmesh.to_mesh(bl_mesh) + bl_bmesh.free() + + co = _loop_coords(bl_mesh) + name = mi_shape.plugin_name() + + if name == "rectangle": + _set_uvs(bl_mesh, (co[:, 0] + 1.0) / 2.0, (co[:, 1] + 1.0) / 2.0) + elif name == 'disk': + phi = np.arctan2(co[:, 1], co[:, 0]) + phi[phi < 0] += 2 * np.pi + _set_uvs(bl_mesh, phi / (2 * np.pi), np.hypot(co[:, 0], co[:, 1])) + + #TODO: create the uv mapping for other shapes + _set_bl_mesh_shading(bl_mesh, + flip_normals=mi_shape.get('flip_normals', False)) + + world_matrix = mi_transform_to_bl_transform(mi_shape.get('to_world', None)) + return bl_mesh, mi_context.mi_space_to_bl_space(world_matrix) + + +###################### +## Main import ## +###################### + +_analytic_converters = { + 'sphere': mi_sphere_to_bl_shape, + **{name: _analytic_to_bl_shape for name in _analytic_ops}, +} + + +def mi_shape_to_bl_shape(mi_context, mi_shape): + converter = _analytic_converters.get(mi_shape.plugin_name(), + mi_mesh_to_bl_shape) + return converter(mi_context, mi_shape) diff --git a/mitsuba_blender/convert/importer/world.py b/mitsuba_blender/convert/importer/world.py new file mode 100644 index 00000000..5c113f28 --- /dev/null +++ b/mitsuba_blender/convert/importer/world.py @@ -0,0 +1,136 @@ +'''Mitsuba environment emitter to Blender world conversion. + +Constant emitters become a Background node, envmaps become an +Environment Texture (with a Mapping node reconstructing the to_world +rotation), and sunsky produces a placeholder with a warning. +''' + +import math + +import bpy + +from ..export.world import ENVMAP_COORDINATE_MAT +from ...compat import ensure_node_tree +from ...io.importer import mi_spectra_utils +from ...io.importer.bl_transform_utils import mi_transform_to_bl_transform + +ERROR_COLOR = (1.0, 0.0, 0.3, 1.0) + + +###################### +## Utilities ## +###################### + +def _new_world(name): + '''A fresh node-based world with a Background node wired to its + output. Returns (bl_world, background node).''' + bl_world = bpy.data.worlds.new(name=name) + tree = ensure_node_tree(bl_world) + tree.nodes.clear() + output = tree.nodes.new('ShaderNodeOutputWorld') + output.location = (300, 0) + background = tree.nodes.new('ShaderNodeBackground') + tree.links.new(background.outputs['Background'], + output.inputs['Surface']) + return bl_world, background + + +###################### +## Converters ## +###################### + +def _convert_constant(mi_context, mi_props, background): + color, strength = mi_spectra_utils.convert_radiance_property( + mi_context, mi_props, 'radiance', [1.0] * 3) + background.inputs['Color'].default_value = list(color) + [1.0] + background.inputs['Strength'].default_value = strength + + +def _convert_envmap(mi_context, mi_props, background): + filename = mi_props.get('filename', '') + filepath = mi_context.resolve_scene_relative_path(filename) + if filepath is None: + raise ValueError(f'cannot find the environment map "{filename}"') + bl_image = bpy.data.images.load(filepath, check_existing=True) + + background.inputs['Strength'].default_value = \ + float(mi_props.get('scale', 1.0)) + tree = background.id_data + environment = tree.nodes.new('ShaderNodeTexEnvironment') + environment.location = (-300, 0) + environment.projection = 'EQUIRECTANGULAR' + environment.image = bl_image + tree.links.new(environment.outputs['Color'], + background.inputs['Color']) + + # Rebuild the to_world transform as a Mapping node, undoing the + # equirectangular convention change applied by the exporter + matrix = mi_context.mi_space_to_bl_space( + mi_transform_to_bl_transform(mi_props.get('to_world', None))) \ + @ ENVMAP_COORDINATE_MAT.inverted() + identity = all(math.isclose(matrix[i][j], float(i == j), abs_tol=1e-6) + for i in range(4) for j in range(4)) + if identity: + return + + location, rotation, scale = matrix.decompose() + mapping = tree.nodes.new('ShaderNodeMapping') + mapping.location = (-500, 0) + mapping.vector_type = 'TEXTURE' + mapping.inputs['Location'].default_value = location + mapping.inputs['Rotation'].default_value = rotation.to_euler() + mapping.inputs['Scale'].default_value = scale + coordinates = tree.nodes.new('ShaderNodeTexCoord') + coordinates.location = (-700, 0) + tree.links.new(coordinates.outputs['Generated'], + mapping.inputs['Vector']) + tree.links.new(mapping.outputs['Vector'], environment.inputs['Vector']) + + +def _convert_sunsky(mi_context, mi_props, background): + mi_context.log('Mitsuba sunsky emitters cannot be represented in ' + 'Blender. Using a plain sky-colored background.', 'WARN') + background.inputs['Color'].default_value = (0.35, 0.55, 0.8, 1.0) + + +_converters = { + 'constant': _convert_constant, + 'envmap': _convert_envmap, + 'sunsky': _convert_sunsky, +} + + +###################### +## Main import ## +###################### + +def should_convert_mi_emitter_to_bl_world(mi_props): + '''Whether a Mitsuba emitter maps to a Blender world rather than a + light object.''' + return mi_props.plugin_name() in _converters + + +def mi_emitter_to_bl_world(mi_context, mi_props): + '''Convert a Mitsuba environment emitter into a Blender world. Never + raises: failures produce a warning and a distinctive error-colored + background.''' + plugin_name = mi_props.plugin_name() + bl_world, background = _new_world(mi_props.id() or plugin_name) + try: + converter = _converters[plugin_name] + converter(mi_context, mi_props, background) + except Exception as e: + mi_context.log(f'Failed to convert the "{plugin_name}" emitter to ' + f'a world: {e}. Using an error background.', 'WARN') + background.inputs['Color'].default_value = ERROR_COLOR + background.inputs['Strength'].default_value = 1.0 + return bl_world + + +def create_default_bl_world(): + '''The default gray Blender world, recognized by the exporter's + ignore_background option.''' + bl_world, background = _new_world('World') + background.inputs['Color'].default_value = \ + [0.05087608844041824] * 3 + [1.0] + return bl_world diff --git a/mitsuba_blender/engine/__init__.py b/mitsuba_blender/engine/__init__.py new file mode 100644 index 00000000..382b3735 --- /dev/null +++ b/mitsuba_blender/engine/__init__.py @@ -0,0 +1,13 @@ +import bpy + +from .final import MitsubaRenderEngine, clear_texture_cache + + +def register(): + bpy.utils.register_class(MitsubaRenderEngine) + + +def unregister(): + bpy.utils.unregister_class(MitsubaRenderEngine) + clear_texture_cache() + diff --git a/mitsuba_blender/engine/final.py b/mitsuba_blender/engine/final.py new file mode 100644 index 00000000..4dd22d33 --- /dev/null +++ b/mitsuba_blender/engine/final.py @@ -0,0 +1,195 @@ +import tempfile +import threading + +import bpy +import numpy as np +import mitsuba as mi +import shutil +from ..io.exporter import SceneConverter + + +_texture_cache_dir = None +_texture_cache = {} + + +def get_texture_cache(): + """Directory and index of textures written for F12 renders, kept + across renders so repeated F12 does not rewrite every image. Not + used by XML export, which writes into the user's directory.""" + global _texture_cache_dir + if _texture_cache_dir is None: + _texture_cache_dir = tempfile.mkdtemp(prefix='mitsuba-blender-') + return _texture_cache_dir, _texture_cache + +def clear_texture_cache(): + global _texture_cache_dir + if _texture_cache_dir is not None: + shutil.rmtree(_texture_cache_dir, ignore_errors=True) + _texture_cache_dir = None + _texture_cache.clear() + + +def _make_progress_appender(): + '''Create a Mitsuba log appender that records the latest render + progress fraction. Mitsuba reports progress through its logging API.''' + + class ProgressAppender(mi.Appender): + def __init__(self): + super().__init__() + self.fraction = 0.0 + + def append(self, level, text): + pass + + def log_progress(self, progress, name, formatted, eta, ptr=None): + self.fraction = progress + + return ProgressAppender() + + +def run_render(integrator, scene, sensor, test_break, update_progress, + poll_interval=0.1): + '''Render a scene on a worker thread while the calling thread polls + test_break and forwards render progress to update_progress. + + Returns True when the render was canceled. Mitsuba stops at the next + block or iteration boundary after Integrator.cancel(), so the film + then holds the partial image accumulated up to that point. + ''' + logger = mi.logger() + appender = _make_progress_appender() + old_level = logger.log_level() + if old_level > mi.LogLevel.Info: + # Progress records only reach appenders at Info level and below + logger.set_log_level(mi.LogLevel.Info) + logger.add_appender(appender) + + errors = [] + + def work(): + try: + integrator.render(scene, sensor) + except Exception as e: + errors.append(e) + + worker = threading.Thread(target=work, name='mitsuba-render') + canceled = False + try: + worker.start() + while worker.is_alive(): + worker.join(poll_interval) + if not canceled and test_break(): + integrator.cancel() + canceled = True + update_progress(appender.fraction) + finally: + if worker.is_alive(): + integrator.cancel() + worker.join() + logger.remove_appender(appender) + logger.set_log_level(old_level) + + if errors: + raise errors[0] + return canceled + + +class MitsubaRenderEngine(bpy.types.RenderEngine): + + bl_idname = "MITSUBA" + bl_label = "Mitsuba" + bl_use_preview = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.converter = SceneConverter(render=True) + + # This is the method called by Blender for both final renders (F12) and + # small preview for materials, world and lights. + def render(self, depsgraph): + from mitsuba import set_variant + from bpy_extras.io_utils import axis_conversion + b_scene = depsgraph.scene + set_variant(b_scene.mitsuba.variant) + from ..plugins import register_plugins + register_plugins() + + scale = b_scene.render.resolution_percentage / 100.0 + self.size_x = int(b_scene.render.resolution_x * scale) + self.size_y = int(b_scene.render.resolution_y * scale) + + # Temporary files (meshes, textures) written during the export must + # survive until the scene has been loaded. + export_dir, cache = get_texture_cache() + self.converter.export_ctx.directory = export_dir + self.converter.export_ctx.exported_images = cache + self.converter.export_ctx.blender_triangulation = b_scene.mitsuba.blender_triangulation + self.converter.export_ctx.axis_mat = axis_conversion(to_forward='-Z', to_up='Y').to_4x4() + self.converter.export_ctx.strict = b_scene.mitsuba.export_mode == 'strict' + self.converter.scene_to_dict(depsgraph) + mts_scene = self.converter.dict_to_scene() + + sensor = mts_scene.sensors()[0] + self.update_stats('', 'Rendering with Mitsuba') + canceled = run_render(mts_scene.integrator(), mts_scene, sensor, + self.test_break, self.update_progress) + if canceled: + self.report({'WARNING'}, 'Render canceled') + + self._write_render_result(sensor.film()) + + warnings = self.converter.export_ctx.warnings + if warnings: + self.error_set(f'{len(warnings)} warning(s) during the scene ' + 'export (see the console)') + + def _write_render_result(self, film): + '''Split the film into its image and AOV components and write them + to the render result. Pixel values are passed through unchanged: + Blender expects linear data in its render passes.''' + results = [(name, _pass_channels(bitmap), np.atleast_3d(bitmap)) + for name, bitmap in film.bitmap().split()] + + # All passes must be declared before begin_result + for name, channels, pixels in results: + if name == '': + # The root image goes to the built-in Combined pass + continue + self.add_pass(name, len(channels), ''.join(channels)) + + blender_result = self.begin_result(0, 0, self.size_x, self.size_y) + layer = blender_result.layers[0] + + for name, channels, pixels in results: + if name == '': + name = 'Combined' + pixels = _to_rgba(pixels) + elif pixels.shape[2] < len(channels): + # Zero padding for channel counts Blender does not support + padding = np.zeros((*pixels.shape[:2], + len(channels) - pixels.shape[2])) + pixels = np.dstack((pixels, padding)) + layer.passes[name].rect = np.flip(pixels, 0).reshape((self.size_x * self.size_y, -1)) + self.end_result(blender_result) + + +def _to_rgba(pixels): + '''Blender's Combined pass is always RGBA. Monochrome films replicate + their single luminance channel; a missing alpha channel is filled with + ones. Writing a rect with fewer than four channels crashes Blender.''' + if pixels.shape[2] in (1, 2): + luminance = np.repeat(pixels[:, :, :1], 3, axis=2) + pixels = np.dstack((luminance, pixels[:, :, 1:])) + if pixels.shape[2] == 3: + pixels = np.dstack((pixels, np.ones((*pixels.shape[:2], 1)))) + return pixels + + +def _pass_channels(bitmap): + '''The per-channel identifiers to declare for a film component. + Two-channel outputs (e.g. UV coordinates) are padded to three since + Blender only supports 1, 3 or 4 channels per pass.''' + channels = [f.name.split('.')[-1] for f in bitmap.struct_()] + if len(channels) == 2: + channels.append(next(c for c in 'AZWQ' if c not in channels)) + return channels diff --git a/mitsuba-blender/io/__init__.py b/mitsuba_blender/io/__init__.py similarity index 60% rename from mitsuba-blender/io/__init__.py rename to mitsuba_blender/io/__init__.py index 73941d9c..32b7bb15 100644 --- a/mitsuba-blender/io/__init__.py +++ b/mitsuba_blender/io/__init__.py @@ -1,11 +1,4 @@ -if "bpy" in locals(): - import importlib - if "bl_utils" in locals(): - importlib.reload(bl_utils) - if "importer" in locals(): - importlib.reload(importer) - if "exporter" in locals(): - importlib.reload(exporter) +import os import bpy from bpy.props import ( @@ -24,7 +17,7 @@ from . import exporter @orientation_helper(axis_forward='-Z', axis_up='Y') -class ImportMistuba(bpy.types.Operator, ImportHelper): +class ImportMitsuba(bpy.types.Operator, ImportHelper): """Import a Mitsuba scene""" bl_idname = "import_scene.mitsuba" bl_label = "Mitsuba Import" @@ -51,6 +44,13 @@ class ImportMistuba(bpy.types.Operator, ImportHelper): default = True, ) + import_render_settings: BoolProperty( + name = 'Import Render Settings', + description = 'Apply the integrator, sampler, reconstruction filter and film ' + 'settings of the imported scene to the Mitsuba render properties.', + default = False, + ) + def execute(self, context): # Set blender to object mode if bpy.ops.object.mode_set.poll(): @@ -61,24 +61,37 @@ def execute(self, context): to_up=self.axis_up, ).to_4x4() + # Parse the XML before any destructive scene change so a malformed + # file cannot destroy the user's scene + try: + mi_state = importer.parse_mitsuba_scene(self.filepath, self.merge_shapes, self.merge_plugins) + except Exception as e: + print(e) + self.report({'ERROR'}, "Failed to load Mitsuba scene. See error log.") + return {'CANCELLED'} + if self.override_scene: # Clear the current scene - scene = bl_utils.init_empty_scene(context, name=bpy.context.scene.name) + scene = bl_utils.init_empty_scene(name=bpy.context.scene.name) else: - # Create a new scene for Mitsuba objects - scene = bl_utils.init_empty_scene(context, name='Mitsuba') + # Create a new scene for Mitsuba objects; Blender uniquifies + # the name, keeping earlier imports intact + scene = bpy.data.scenes.new('Mitsuba') collection = scene.collection try: - importer.load_mitsuba_scene(context, scene, collection, self.filepath, axis_mat, self.merge_shapes, self.merge_plugins) - except (RuntimeError, NotImplementedError) as e: + warnings = importer.load_mitsuba_scene(scene, collection, self.filepath, axis_mat, self.merge_shapes, self.merge_plugins, self.import_render_settings, mi_state=mi_state) + except Exception as e: print(e) self.report({'ERROR'}, "Failed to load Mitsuba scene. See error log.") return {'CANCELLED'} bpy.context.window.scene = scene - self.report({'INFO'}, "Scene imported successfully.") + if warnings: + self.report({'WARNING'}, f"Scene imported with {len(warnings)} warnings (see console).") + else: + self.report({'INFO'}, "Scene imported successfully.") return {'FINISHED'} @@ -93,10 +106,10 @@ class ExportMitsuba(bpy.types.Operator, ExportHelper): filter_glob: StringProperty(default="*.xml", options={'HIDDEN'}) use_selection: BoolProperty( - name = "Selection Only", - description="Export selected objects only", - default = False, - ) + name = "Selection Only", + description = "Export selected objects only", + default = False, + ) export_ids: BoolProperty( name = "Export IDs", @@ -110,6 +123,13 @@ class ExportMitsuba(bpy.types.Operator, ExportHelper): default = True ) + blender_triangulation: BoolProperty( + name = "Triangulate non-triangular faces in Blender", + description = "Mitsuba's triangulation algorithm is much faster " + "but does not handle all corner cases.", + default = False + ) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.reset() @@ -118,15 +138,24 @@ def reset(self): self.converter = exporter.SceneConverter() def execute(self, context): + # Leave edit mode so the exported meshes include pending edits. + # This must happen here: the render engine calls scene_to_dict from + # the render job thread, where operators are forbidden. + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode='OBJECT') + # Conversion matrix to shift the "Up" Vector. This can be useful when exporting single objects to an existing mitsuba scene. axis_mat = axis_conversion( - to_forward=self.axis_forward, - to_up=self.axis_up, - ).to_4x4() + to_forward=self.axis_forward, + to_up=self.axis_up, + ).to_4x4() self.converter.export_ctx.axis_mat = axis_mat # Add IDs to all base plugins (shape, emitter, sensor...) self.converter.export_ctx.export_ids = self.export_ids + self.converter.export_ctx.blender_triangulation = self.blender_triangulation + # Meshes and textures are written to subfolders of the target directory + self.converter.export_ctx.directory = os.path.dirname(self.filepath) window_manager = context.window_manager @@ -140,7 +169,11 @@ def execute(self, context): window_manager.progress_end() - self.report({'INFO'}, "Scene exported successfully!") + warnings = self.converter.export_ctx.warnings + if warnings: + self.report({'WARNING'}, f"Scene exported with {len(warnings)} warnings (see console).") + else: + self.report({'INFO'}, "Scene exported successfully!") # Reset the exporter self.reset() @@ -152,11 +185,11 @@ def menu_export_func(self, context): self.layout.operator(ExportMitsuba.bl_idname, text="Mitsuba (.xml)") def menu_import_func(self, context): - self.layout.operator(ImportMistuba.bl_idname, text="Mitsuba (.xml)") + self.layout.operator(ImportMitsuba.bl_idname, text="Mitsuba (.xml)") classes = ( - ImportMistuba, + ImportMitsuba, ExportMitsuba ) diff --git a/mitsuba_blender/io/bl_utils.py b/mitsuba_blender/io/bl_utils.py new file mode 100644 index 00000000..c2d16bcc --- /dev/null +++ b/mitsuba_blender/io/bl_utils.py @@ -0,0 +1,37 @@ + +import bpy + +def init_empty_scene(name='Scene'): + ''' Create an empty Blender scene with a specific name. + + If a scene already exists with the same name, it will be + cleared. + + Params + ------ + name : str, optional + Name of the newly created scene + + Returns + ------- + The newly created Blender scene + ''' + # Create a temporary scene to be able to delete others. + # This is required as Blender needs at least one scene + tmp_scene = bpy.data.scenes.new('mi-tmp') + + # Check if the scene already exists + bl_scene = bpy.data.scenes.get(name) + if bl_scene is not None: + # Delete the scene if it exists + bpy.data.scenes.remove(bl_scene) + + bl_scene = bpy.data.scenes.new(name) + + # Delete the temporary scene + bpy.data.scenes.remove(tmp_scene) + + # Clear all orphaned data + bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) + + return bl_scene diff --git a/mitsuba_blender/io/exporter/__init__.py b/mitsuba_blender/io/exporter/__init__.py new file mode 100644 index 00000000..d2c1d6ee --- /dev/null +++ b/mitsuba_blender/io/exporter/__init__.py @@ -0,0 +1,136 @@ +from . import export_context +from ...convert.export import camera, lights, mesh, world + +class SceneConverter: + ''' + Converts a blender scene to a Mitsuba-compatible dict. + Either save it as an XML or load it as a scene. + ''' + def __init__(self, render=False): + self.export_ctx = export_context.ExportContext() + self.export_ctx.render = render + + def scene_to_dict(self, depsgraph, window_manager=None, use_selection=False, ignore_background=True): + """ + Convert a Blender scene to a Mitsuba-compatible dict. + + Parameters + ---------- + + depsgraph : bpy.types.Depsgraph + The evaluated dependency graph of the scene to export. + window_manager : bpy.types.WindowManager, optional + The window manager to update the progress bar. No progress is + reported when omitted. + use_selection : bool, optional + Only export selected objects. Defaults to False. + ignore_background : bool, optional + Ignore the default background in Blender's world settings. Defaults to True. + """ + self.export_ctx.deg = depsgraph + + b_scene = depsgraph.scene #TODO: what if there are multiple scenes? + if b_scene.render.engine == 'MITSUBA': + integrator = b_scene.mitsuba.integrator_to_dict() + else: + # scene.cycles only exists while the Cycles addon is enabled + cycles = getattr(b_scene, 'cycles', None) + integrator = { + 'type': 'path', + 'max_depth': cycles.max_bounces if cycles else 12, + } + self.export_ctx.data_add(integrator) + + world.export_world(self.export_ctx, b_scene.world, ignore_background) + + geometry = mesh.GeometryExporter(self.export_ctx) + + # First pass: cameras, lights, and a count of how often each mesh + # data occurs (repeated data becomes a shapegroup with instances) + progress_counter = 0 + for object_instance in depsgraph.object_instances: + if window_manager is not None: + window_manager.progress_update(progress_counter) + progress_counter += 1 + + if self._skip_instance(object_instance, use_selection): + continue + + evaluated_obj = object_instance.object + object_type = evaluated_obj.type + if object_type in {'MESH', 'FONT', 'SURFACE', 'META'}: + geometry.count_instance(object_instance) + elif object_type == 'CAMERA': + # When rendering inside blender, export only the active camera + render = self.export_ctx.render + if not render or evaluated_obj.name_full == b_scene.camera.name_full: + camera.export_camera(self.export_ctx, object_instance, b_scene) + elif object_type == 'LIGHT': + lights.export_light(self.export_ctx, object_instance) + else: + self.export_ctx.log("Object: %s of type '%s' is not supported!" % (evaluated_obj.name_full, object_type), 'WARN') + + # Second pass: convert the geometry. Evaluated objects may not be + # kept alive across iteration steps, so each one is converted while + # the iterator points at it. + for object_instance in depsgraph.object_instances: + if self._skip_instance(object_instance, use_selection, log=False): + continue + if object_instance.object.type in {'MESH', 'FONT', 'SURFACE', 'META'}: + geometry.export_instance(object_instance) + + def _skip_instance(self, object_instance, use_selection, log=True): + if use_selection: + #skip if it's not selected or if it's an instance and the parent object is not selected + if not object_instance.is_instance and not object_instance.object.original.select_get(): + return True + if (object_instance.is_instance and object_instance.object.parent + and not object_instance.object.parent.original.select_get()): + return True + + evaluated_obj = object_instance.object + if evaluated_obj.hide_render or (object_instance.is_instance + and evaluated_obj.parent and evaluated_obj.parent.original.hide_render): + #ignore it since we don't want it rendered (TODO: hide_viewport) + if log: + self.export_ctx.log("Object: {} is hidden for render. Ignoring it.".format(evaluated_obj.name), 'INFO') + return True + return False + + + def _check_dict(self, d, path="root"): + if d is None: + raise ValueError(f"None at {path}") + if isinstance(d, dict): + for k, v in d.items(): + self._check_dict(v, f"{path}.{k}") + elif isinstance(d, (list, tuple)): + for i, v in enumerate(d): + self._check_dict(v, f"{path}[{i}]") + + def dict_to_xml(self, filename): + import os + if os.environ.get("MITSUBA_BLENDER_DEBUG"): + self._check_dict(self.export_ctx.scene_data) + import mitsuba as mi + # The shapes reference sub-meshes of the shared file, which is only + # readable once its end-of-file dictionary is in place + self.export_ctx.finalize_serialized() + config = mi.parser.ParserConfig(mi.variant()) + state = mi.parser.parse_dict(config, self.export_ctx.scene_data) + # Reorder the plugins so they are written in a legible order + mi.parser.transform_reorder(config, state) + # The exporter already placed meshes and textures in subfolders of the + # output directory, matching the relative references in the dict. + mi.parser.write_file(state, filename, True) + + def dict_to_scene(self): + import mitsuba as mi + self.export_ctx.finalize_serialized() + # A resources entry resolves the relative file references against + # the export directory; it must precede the entries that use them + data = {'type': 'scene', + 'resources': {'type': 'resources', + 'path': self.export_ctx.directory}} + data.update(self.export_ctx.scene_data) + return mi.load_dict(data) diff --git a/mitsuba_blender/io/exporter/export_context.py b/mitsuba_blender/io/exporter/export_context.py new file mode 100644 index 00000000..a0ca2e1d --- /dev/null +++ b/mitsuba_blender/io/exporter/export_context.py @@ -0,0 +1,165 @@ +import os +from collections import OrderedDict + +from mathutils import Matrix + +class ExportContext: + ''' + Export Context + ''' + + # Subfolders of the export directory receiving meshes and textures + MESHES_FOLDER = 'meshes' + TEXTURES_FOLDER = 'textures' + # Every exported mesh is appended to this one file, and the shapes + # reference their sub-mesh by index + SERIALIZED_NAME = 'meshes.serialized' + + def __init__(self): + self.scene_data = OrderedDict([('type','scene')]) + self.warnings = [] # Messages of WARN or ERROR level, for reporting after the export + self.counter = 0 # Counter to create unique IDs. + # Materials with both a BSDF and an emitter, as + # {mat_id: {'bsdf': bsdf_id, 'emitter': emitter_dict}}; shapes + # using them need two references + self.exported_mats = {} + self.export_ids = False # Export Object IDs in the XML file + # Let Blender split the polygons of a mesh instead of Mitsuba + self.blender_triangulation = False + # Shared .serialized output and the file offset of each sub-mesh + self.serialized_stream = None + self.serialized_offsets = [] + # All the args defined below are set in the Converter + self.directory = '' + self.axis_mat = Matrix() # Coordinate shift + self.deg = None # Dependency graph + self.strict = True + + def add_serialized_mesh(self, mi_mesh): + '''Append a mesh to the scene's shared .serialized file, returning + the sub-mesh index that a shape entry references it by.''' + import mitsuba as mi + if self.serialized_stream is None: + folder = os.path.join(self.directory, self.MESHES_FOLDER) + os.makedirs(folder, exist_ok=True) + self.serialized_stream = mi.FileStream( + os.path.join(folder, self.SERIALIZED_NAME), + mi.FileStream.ETruncReadWrite) + self.serialized_offsets.append(self.serialized_stream.tell()) + mi_mesh.write_serialized(self.serialized_stream) + return len(self.serialized_offsets) - 1 + + def finalize_serialized(self): + '''Close the shared file with the end-of-file dictionary that lets + the serialized plugin seek to a sub-mesh: one uint64 offset per + mesh, followed by their count.''' + if self.serialized_stream is None: + return + import mitsuba as mi + stream = self.serialized_stream + stream.set_byte_order(mi.FileStream.ELittleEndian) + for offset in self.serialized_offsets: + stream.write_uint64(offset) + stream.write_uint32(len(self.serialized_offsets)) + stream.close() + self.serialized_stream = None + + def serialized_filename(self): + '''Relative path that the shape entries reference.''' + return f'{self.MESHES_FOLDER}/{self.SERIALIZED_NAME}' + + def sanitize(self, name): + ''' + Sanitize a name to be used in the scene dict. + The parser class of Mitsuba does not accept dots in the names. + ''' + return name.replace('.', '_') + + def create_ref(self, name): + ''' + Create a reference dict pointing to the given name, + after sanitizing it. + ''' + return { + 'type': 'ref', + 'id': self.sanitize(name) + } + + def data_add(self, mts_dict, name=''): + ''' + Function to add new elements to the scene dict. The element is a + plugin dict. If a name is provided it will be used as the key of + the element. Otherwise the Id of the element is used if it exists + or a new key is generated incrementally. + ''' + if mts_dict is None or (isinstance(mts_dict, dict) + and 'type' not in mts_dict): + self.log('Skipping scene dict entry "%s" without a plugin ' + 'type: %r' % (name, mts_dict), 'WARN') + return + + if not name: + if isinstance(mts_dict, dict) and 'id' in mts_dict: + name = mts_dict['id'] + #remove the corresponding entry + del mts_dict['id'] + else: + name = 'elm__%i' % self.counter + + # Sanitize name + self.scene_data.update([(self.sanitize(name), mts_dict)]) + self.counter += 1 + + def data_get(self, name): + return self.scene_data.get(self.sanitize(name)) + + def log(self, message, level='INFO'): + ''' + Log something using mitsuba's logging API. Messages of WARN or + ERROR level are also collected in self.warnings so they can be + reported once the export has finished. + + Params + ------ + + message: What to write + level: Level of logging + ''' + from mitsuba import Log, LogLevel + if level in ('WARN', 'ERROR'): + self.warnings.append(message) + log_level = { + 'DEBUG': LogLevel.Debug, + 'INFO': LogLevel.Info, + 'WARN': LogLevel.Warn, + 'ERROR': LogLevel.Error, + 'TRACE': LogLevel.Trace + } + if level not in log_level: + raise ValueError("Invalid logging level '%s'!" % level) + Log(log_level[level], message) + + def spectrum(self, value): + ''' + Format a float or RGB(A) value as an rgb spectrum for the scene + dict. An alpha component is dropped. + ''' + if isinstance(value, (float, int)): + return {'type': 'rgb', 'value': float(value)} + value = list(value) + if len(value) in (3, 4) and \ + all(isinstance(x, (float, int)) for x in value): + return {'type': 'rgb', 'value': value[:3]} + raise ValueError('Expected a float or a 3/4-component sequence, ' + 'got: %s' % (value,)) + + def transform_matrix(self, matrix): + ''' + Apply coordinate shift and convert to a mitsuba Transform 4f + ''' + from mitsuba import ScalarTransform4f + if len(matrix) == 4: + mat = self.axis_mat @ matrix + else: #3x3 + mat = matrix.to_4x4() + return ScalarTransform4f(list([list(x) for x in mat])) diff --git a/mitsuba-blender/io/importer/__init__.py b/mitsuba_blender/io/importer/__init__.py similarity index 51% rename from mitsuba-blender/io/importer/__init__.py rename to mitsuba_blender/io/importer/__init__.py index 80f1cb04..8a9bb2ba 100644 --- a/mitsuba-blender/io/importer/__init__.py +++ b/mitsuba_blender/io/importer/__init__.py @@ -1,35 +1,13 @@ import time -if "bpy" in locals(): - import importlib - if "common" in locals(): - importlib.reload(common) - if "materials" in locals(): - importlib.reload(materials) - if "shapes" in locals(): - importlib.reload(shapes) - if "cameras" in locals(): - importlib.reload(sensors) - if "emitters" in locals(): - importlib.reload(emitters) - if "world" in locals(): - importlib.reload(world) - if "textures" in locals(): - importlib.reload(textures) - if "renderer" in locals(): - importlib.reload(renderer) - if "mi_props_utils" in locals(): - importlib.reload(mi_props_utils) - import bpy from . import common -from . import materials -from . import shapes -from . import emitters -from . import sensors -from . import world -from . import textures +from ...convert.importer import camera +from ...convert.importer import lights +from ...convert.importer import materials +from ...convert.importer import mesh as shapes +from ...convert.importer import world from . import renderer from .mi_props_utils import get_references_by_type @@ -40,29 +18,36 @@ def convert_mi_film(mi_context, node_id): from mitsuba import ObjectType + if not mi_context.import_render_settings: + return None mi_props = mi_context.mi_state.nodes[node_id].props if not renderer.apply_mi_film_properties(mi_context, mi_props): return None - film_id = get_references_by_type(mi_context, mi_props, [ObjectType.ReconstructionFilter]) - if len(film_id) > 1: - raise ValueError(f'Tried to import a film with multiple reconstruction filters. Mitsuba supports only one reconstruction filter per film.') - elif len(film_id) == 1: - convert_mi_node(mi_context, film_id[0]) - return True # ???? + rfilter_ids = get_references_by_type(mi_context, mi_props, [ObjectType.ReconstructionFilter]) + if len(rfilter_ids) > 1: + mi_context.log('Film has multiple reconstruction filters. Mitsuba ' + 'supports only one per film; using the first.', 'ERROR') + if rfilter_ids: + convert_mi_node(mi_context, rfilter_ids[0]) + return True def convert_mi_rfilter(mi_context, node_id): + if not mi_context.import_render_settings: + return mi_props = mi_context.mi_state.nodes[node_id].props - #TODO: return what ? renderer.apply_mi_rfilter_properties(mi_context, mi_props) def convert_mi_sampler(mi_context, node_id): + if not mi_context.import_render_settings: + return mi_props = mi_context.mi_state.nodes[node_id].props renderer.apply_mi_sampler_properties(mi_context, mi_props) def convert_mi_integrator(mi_context, node_id): + if not mi_context.import_render_settings: + return mi_props = mi_context.mi_state.nodes[node_id].props - # FIXME: Support nested integrators (AOVs) renderer.apply_mi_integrator_properties(mi_context, mi_props) def convert_mi_emitter(mi_context, node_id): @@ -71,12 +56,15 @@ def convert_mi_emitter(mi_context, node_id): if world.should_convert_mi_emitter_to_bl_world(mi_props): bl_data = world.mi_emitter_to_bl_world(mi_context, mi_props) if mi_context.bl_scene.world is not None: - mi_context.log(f'Multiple Blender worlds is not supported.', 'ERROR') + mi_context.log('Multiple Blender worlds are not supported.', 'ERROR') return None mi_context.bl_scene.world = bl_data else: em_name = mi_props.id() if mi_props.id() else f'Emitter_{node_id}' - bl_data, world_matrix = emitters.mi_emitter_to_bl_light(mi_context, mi_props) + result = lights.mi_emitter_to_bl_light(mi_context, mi_props) + if result is None: + return None + bl_data, world_matrix = result bl_obj = bpy.data.objects.new(em_name, bl_data) bl_obj.matrix_world = world_matrix @@ -91,13 +79,13 @@ def convert_mi_bsdf(mi_context, node_id, emitter_id=None): mi_props = mi_context.mi_state.nodes[node_id].props bsdf_name = mi_props.id() if mi_props.id() else f'Material_{node_id}' - + if emitter_id is None: em_props = None else: em_props = mi_context.mi_state.nodes[emitter_id].props - bl_material = materials.mi_material_to_bl_material(mi_context, mi_props, mi_emitter=em_props) + bl_material = materials.convert_material(mi_context, mi_props, mi_emitter=em_props) if bl_material is None: mi_context.log(f'Failed to convert material "{bsdf_name}".', 'ERROR') return None @@ -112,7 +100,10 @@ def convert_mi_sensor(mi_context, node_id): mi_props = mi_context.mi_state.nodes[node_id].props camera_name = mi_props.id() if mi_props.id() else f'Camera_{node_id}' # Convert the camera - bl_camera, world_matrix = sensors.mi_sensor_to_bl_camera(mi_context, mi_props) + result = camera.mi_sensor_to_bl_camera(mi_context, mi_props) + if result is None: + return None + bl_camera, world_matrix = result bl_obj = bpy.data.objects.new(camera_name, bl_camera) bl_obj.matrix_world = world_matrix @@ -131,38 +122,77 @@ def convert_mi_sensor(mi_context, node_id): def convert_mi_shape(mi_context, node_id): from mitsuba import ObjectType mi_props = mi_context.mi_state.nodes[node_id].props + + # The merge_meshes parser transform wraps the top-level shapes in a + # merge node; convert its children individually + if mi_props.plugin_name() == 'merge': + for child_id in get_references_by_type(mi_context, mi_props, + [ObjectType.Shape]): + convert_mi_node(mi_context, child_id) + return None + shape_name = mi_props.id() if mi_props.id() else f'Shape_{node_id}' mi_emitters = get_references_by_type(mi_context, mi_props, [ObjectType.Emitter]) if len(mi_emitters) > 1: - raise ValueError(f'Tried to import a shape with multiple emitters. Mitsuba supports only one emitter per shape.') + mi_context.log(f'Shape "{shape_name}" has multiple emitters. Mitsuba ' + 'supports only one per shape; using the first.', 'ERROR') + mi_emitters = mi_emitters[:1] + + # A supported shape that only carries an area emitter (no BSDF or a + # placeholder one) is a light source and comes back as a real Blender light + mi_mats = get_references_by_type(mi_context, mi_props, [ObjectType.BSDF]) + if mi_emitters and lights.can_convert_area_emitter(mi_props) \ + and (not mi_mats or (len(mi_mats) == 1 and + lights.is_placeholder_bsdf( + mi_context.mi_state.nodes[mi_mats[0]].props))): + em_props = mi_context.mi_state.nodes[mi_emitters[0]].props + result = lights.mi_area_emitter_to_bl_light(mi_context, em_props, mi_props) + if result is not None: + bl_light, world_matrix = result + bl_obj = bpy.data.objects.new(shape_name, bl_light) + bl_obj.matrix_world = world_matrix + mi_context.bl_collection.objects.link(bl_obj) + return bl_obj # Convert the shape - bl_shape, world_matrix = shapes.mi_shape_to_bl_shape(mi_context, mi_props) + result = shapes.mi_shape_to_bl_shape(mi_context, mi_props) + if result is None: + # An empty placeholder mesh keeps the object in the scene so the + # user can see what failed to import. + from mathutils import Matrix + result = bpy.data.meshes.new(shape_name), Matrix() + bl_shape, world_matrix = result bl_obj = bpy.data.objects.new(shape_name, bl_shape) bl_obj.matrix_world = world_matrix # Add a material bl_shape.materials.clear() - mi_mats = get_references_by_type(mi_context, mi_props, [ObjectType.BSDF]) - if len(mi_mats) == 0: - mi_context.log(f'Shape "{shape_name}" does not have a material. Using default diffuse.', 'WARN') - elif len(mi_mats) > 1: + em_id = mi_emitters[0] if mi_emitters else None + bl_mat = None + if len(mi_mats) > 1: mi_context.log(f'Shape "{shape_name}" has multiple materials. Only one is supported.', 'ERROR') - else: - # Make sure the material is converted - if mi_emitters: - em_id = mi_emitters[0] - else: - em_id = None + elif len(mi_mats) == 1: bl_mat = convert_mi_bsdf(mi_context, mi_mats[0], emitter_id=em_id) + elif em_id is not None: + # An emissive shape without a BSDF still needs a Blender material + # to hold its emission shader. + from mitsuba import Properties + mi_null = Properties('null') + mi_null.set_id(f'{shape_name}-emitter') + em_props = mi_context.mi_state.nodes[em_id].props + bl_mat = materials.convert_material(mi_context, mi_null, mi_emitter=em_props) + else: + mi_context.log(f'Shape "{shape_name}" does not have a material. Using default diffuse.', 'WARN') + + if bl_mat is not None: bl_shape.materials.append(bl_mat) bl_obj.active_material_index = 0 mi_context.bl_collection.objects.link(bl_obj) return bl_obj -def convert_mi_node(mi_context, node_id, extra_id=None): +def convert_mi_node(mi_context, node_id): from mitsuba import ObjectType #TODO: generate convenient default object names @@ -178,45 +208,78 @@ def convert_mi_node(mi_context, node_id, extra_id=None): } # TODO: maybe delay sensor instantiation ? if mi_node.type in converters: - converters[mi_node.type](mi_context, node_id) + # An unsupported or broken piece of content must never abort the + # whole import + try: + converters[mi_node.type](mi_context, node_id) + except Exception as e: + mi_props = mi_node.props + mi_context.log(f'Failed to convert Mitsuba object ' + f'"{mi_props.id() or mi_props.plugin_name()}": ' + f'{e}.', 'ERROR') def convert_mi_scene(mi_context): - from mitsuba import Properties + from mitsuba import ObjectType, Properties + # Samplers, films and rfilters are only converted as sensor children, + # where a camera exists to receive their settings + deferred = (ObjectType.Sampler, ObjectType.Film, + ObjectType.ReconstructionFilter) for key, val in mi_context.mi_state.root.props.items(): if isinstance(val, Properties.ResolvedReference): + if mi_context.mi_state.nodes[val.index()].type in deferred: + continue convert_mi_node(mi_context, val.index()) ######################### ## Main loading ## ######################### -def load_mitsuba_scene(bl_context, bl_scene, bl_collection, filepath, global_mat, merge_shapes, merge_plugins): +def parse_mitsuba_scene(filepath, merge_shapes, merge_plugins): + ''' Parse a Mitsuba XML file and resolve all references. Raises on + malformed input, so callers can parse before touching the Blender scene. + + The returned state carries a file resolver covering the scene directory + and any directories, which the converters use to locate files. + ''' + import mitsuba as mi + config = mi.parser.ParserConfig(mi.variant()) + config.merge_meshes = merge_shapes + config.merge_equivalent = merge_plugins + mi_state = mi.parser.parse_file(config, filepath) + # Resolve all references and merge equivalent plugins if enabled + mi.parser.transform_all(config, mi_state) + return mi_state + +def load_mitsuba_scene(bl_scene, bl_collection, filepath, global_mat, merge_shapes, merge_plugins, + import_render_settings=False, mi_state=None): ''' Load a Mitsuba scene from an XML file into a Blender scene. - + Params ------ - bl_context: Blender context bl_scene: Blender scene bl_collection: Blender collection filepath: Path to the Mitsuba XML scene file global_mat: Axis conversion matrix merge_shapes: Whether to merge similar shapes (same material) into a single one merge_plugins: Whether to merge identical plugins (e.g. materials) into a single one + import_render_settings: Whether to apply the scene's integrator, sampler, + reconstruction filter and film settings to the Mitsuba render properties + mi_state: Parser state from parse_mitsuba_scene; the file is parsed here + when omitted + + Returns + ------- + The list of warnings collected during the import. ''' #TODO: progress bar start_time = time.time() - # Load the Mitsuba XML and extract the objects' properties - import mitsuba as mi - config = mi.parser.ParserConfig(mi.variant()) - config.merge_meshes = merge_shapes - config.merge_equivalent = merge_plugins - mi_state = mi.parser.parse_file(config, filepath) - # Resolve all references and merge equivalent plugins if enabled - mi.parser.transform_all(config, mi_state) - mi_context = common.MitsubaSceneImportContext(bl_context, bl_scene, bl_collection, filepath, mi_state, global_mat) + if mi_state is None: + mi_state = parse_mitsuba_scene(filepath, merge_shapes, merge_plugins) + mi_context = common.MitsubaSceneImportContext(bl_scene, bl_collection, filepath, mi_state, global_mat, + import_render_settings) - # Initialize the Mitsuba renderer inside of Blender - renderer.init_mitsuba_renderer(mi_context) + # Select the Mitsuba variant used for rendering + renderer.init_mitsuba_variant(mi_context) # Convert the Mitsuba scene state to a Blender scene #TODO: error checking @@ -225,10 +288,10 @@ def load_mitsuba_scene(bl_context, bl_scene, bl_collection, filepath, global_mat # Instantiate a default Blender world if none was created if mi_context.bl_scene.world is None: mi_context.bl_scene.world = world.create_default_bl_world() - - # TODO: maybeCheck that every property was accessed at least once as a sanity check + + # TODO: check that every property was accessed at least once as a sanity check end_time = time.time() mi_context.log(f'Finished loading Mitsuba scene. Took {end_time-start_time:.2f}s.', 'INFO') - return + return mi_context.warnings diff --git a/mitsuba-blender/io/importer/bl_transform_utils.py b/mitsuba_blender/io/importer/bl_transform_utils.py similarity index 100% rename from mitsuba-blender/io/importer/bl_transform_utils.py rename to mitsuba_blender/io/importer/bl_transform_utils.py diff --git a/mitsuba_blender/io/importer/common.py b/mitsuba_blender/io/importer/common.py new file mode 100644 index 00000000..da10e7ca --- /dev/null +++ b/mitsuba_blender/io/importer/common.py @@ -0,0 +1,61 @@ +import os + +class MitsubaSceneImportContext: + ''' Define a context for the Mitsuba scene importer ''' + def __init__(self, bl_scene, bl_collection, filepath, mi_state, axis_matrix, + import_render_settings=False): + self.bl_scene = bl_scene + self.bl_collection = bl_collection + self.filepath = filepath + self.directory, _ = os.path.split(self.filepath) + self.mi_state = mi_state + # The parser's search paths, plus the scene directory as a last resort + # since states built from a dictionary do not know about it + import mitsuba as mi + self.resolver = mi.FileResolver(mi_state.resolver) + self.resolver.append(self.directory) + self.axis_matrix = axis_matrix + self.import_render_settings = import_render_settings + self.axis_matrix_inv = axis_matrix.inverted() + self.bl_material_cache = {} # Mapping of Mitsuba node IDs to Blender materials + self.bl_texture_cache = {} # Mapping of Mitsuba node IDs to Blender textures + self.warnings = [] # Messages of WARN or ERROR level, for the operator report + + def log(self, message, level='INFO'): + ''' + Log an importer message. WARN and ERROR messages are collected in + self.warnings so the import operator can report them. ERROR does + not route to Mitsuba's logger, which raises at that level: an + unsupported piece of content must never abort the whole import. + + Params + ------ + + message: What to write + level: Level of logging + ''' + from mitsuba import Log, LogLevel + log_level = { + 'DEBUG': LogLevel.Debug, + 'INFO': LogLevel.Info, + 'WARN': LogLevel.Warn, + 'TRACE': LogLevel.Trace + } + if level in ('WARN', 'ERROR'): + self.warnings.append(message) + if level == 'ERROR': + print(f'ERROR: {message}') + return + if level not in log_level: + raise ValueError("Invalid logging level '%s'!" % level) + Log(log_level[level], message) + + def mi_space_to_bl_space(self, matrix): + return self.axis_matrix_inv @ matrix + + def resolve_scene_relative_path(self, path): + '''Resolve a filename with the scene's search paths. Returns None when + the file does not exist; the caller reports the failure.''' + resolved = str(self.resolver.resolve(path)) + return resolved if os.path.exists(resolved) else None + diff --git a/mitsuba-blender/io/importer/mi_props_utils.py b/mitsuba_blender/io/importer/mi_props_utils.py similarity index 100% rename from mitsuba-blender/io/importer/mi_props_utils.py rename to mitsuba_blender/io/importer/mi_props_utils.py diff --git a/mitsuba_blender/io/importer/mi_spectra_utils.py b/mitsuba_blender/io/importer/mi_spectra_utils.py new file mode 100644 index 00000000..8a4ef125 --- /dev/null +++ b/mitsuba_blender/io/importer/mi_spectra_utils.py @@ -0,0 +1,23 @@ + +def get_color_strength_from_radiance(radiance): + # FIXME: Find a proper way of converting radiance to color/energy + strength = max(radiance) + if strength < 1.0: + return radiance, 1.0 + return [c / strength for c in radiance], strength + + +def convert_radiance_property(mi_context, mi_props, name, default): + '''Split a radiance-like property (a Color or a Float) into a Blender + color and a scalar strength.''' + from mitsuba import Properties + if name in mi_props: + prop_type = mi_props.type(name) + if prop_type == Properties.Type.Color: + return get_color_strength_from_radiance(list(mi_props[name])) + if prop_type == Properties.Type.Float: + return get_color_strength_from_radiance( + [float(mi_props[name])] * 3) + mi_context.log(f'Radiance property "{name}" of type {prop_type} is ' + 'not supported. Using the default value.', 'WARN') + return get_color_strength_from_radiance(list(default)) diff --git a/mitsuba_blender/io/importer/renderer.py b/mitsuba_blender/io/importer/renderer.py new file mode 100644 index 00000000..0ead53e7 --- /dev/null +++ b/mitsuba_blender/io/importer/renderer.py @@ -0,0 +1,195 @@ +################# +## Utilities ## +################# + +_fileformat_values = { + 'openexr': 'OPEN_EXR', + 'exr': 'OPEN_EXR', + # FIXME: Support other file formats +} + +def mi_fileformat_to_bl_fileformat(mi_context, mi_file_format): + if mi_file_format not in _fileformat_values: + mi_context.log(f'Mitsuba Film image file format "{mi_file_format}" is not supported.', 'ERROR') + return None + return _fileformat_values[mi_file_format] + +_pixelformat_values = { + 'rgb': 'RGB', + 'rgba': 'RGBA', + # FIXME: Support other pixel formats +} + +def mi_pixelformat_to_bl_pixelformat(mi_context, mi_pixel_format): + if mi_pixel_format not in _pixelformat_values: + mi_context.log(f'Mitsuba Film image pixel format "{mi_pixel_format}" is not supported.', 'ERROR') + return None + return _pixelformat_values[mi_pixel_format] + +_componentformat_values = { + 'float16': '16', + 'float32': '32', + # FIXME: Support other component formats +} + +def mi_componentformat_to_bl_componentformat(mi_context, mi_component_format): + if mi_component_format not in _componentformat_values: + mi_context.log(f'Mitsuba Film image component format "{mi_component_format}" is not supported.', 'ERROR') + return None + return _componentformat_values[mi_component_format] + +############################# +## Integrator properties ## +############################# + +# Integrators sharing the max_depth/rr_depth/hide_emitters parameter set +_mi_simple_integrators = ('path', 'volpath', 'volpathmis', 'ptracer') + +def _mi_integrator_to_dict(mi_context, mi_props): + '''Convert an integrator Properties tree to a plain dict literal.''' + from mitsuba import Properties + result = {'type': mi_props.plugin_name()} + for name, value in mi_props.items(): + if isinstance(value, Properties.ResolvedReference): + child_props = mi_context.mi_state.nodes[value.index()].props + result[name] = _mi_integrator_to_dict(mi_context, child_props) + elif isinstance(value, (bool, int, float, str)): + result[name] = value + else: + raise ValueError(f'Integrator property "{name}" of type ' + f'{type(value).__name__} cannot be stored as a dict literal.') + return result + +def apply_mi_integrator_properties(mi_context, mi_props): + mi_integrator_type = mi_props.plugin_name() + bl_renderer = mi_context.bl_scene.mitsuba + + if mi_integrator_type in _mi_simple_integrators: + bl_props = getattr(bl_renderer.available_integrators, mi_integrator_type) + bl_props.max_depth = mi_props.get('max_depth', -1) + bl_props.rr_depth = mi_props.get('rr_depth', 5) + bl_props.hide_emitters = mi_props.get('hide_emitters', False) + elif mi_integrator_type == 'direct': + bl_props = bl_renderer.available_integrators.direct + shading_samples = mi_props.get('shading_samples', 1) + bl_props.emitter_samples = mi_props.get('emitter_samples', shading_samples) + bl_props.bsdf_samples = mi_props.get('bsdf_samples', shading_samples) + bl_props.hide_emitters = mi_props.get('hide_emitters', False) + else: + # No UI representation: preserve the integrator verbatim through the + # custom dict escape hatch so it survives an import/export round trip. + try: + integrator = _mi_integrator_to_dict(mi_context, mi_props) + except ValueError: + mi_context.log(f'Mitsuba Integrator "{mi_integrator_type}" is not supported.', 'ERROR') + return False + bl_renderer.custom_integrator = repr(integrator) + mi_context.log(f'Mitsuba Integrator "{mi_integrator_type}" has no UI settings. ' + 'It was stored as a custom integrator dict.', 'WARN') + return True + bl_renderer.active_integrator = mi_integrator_type + bl_renderer.custom_integrator = '' + return True + +########################## +## RFilter properties ## +########################## + +def apply_mi_rfilter_properties(mi_context, mi_props): + mi_rfilter_type = mi_props.plugin_name() + mi_camera = mi_context.bl_scene.camera.data.mitsuba + bl_props = getattr(mi_camera.rfilters, mi_rfilter_type, None) + if bl_props is None: + mi_context.log(f'Mitsuba Reconstruction Filter "{mi_rfilter_type}" is not supported.', 'ERROR') + return False + mi_camera.active_rfilter = mi_rfilter_type + if mi_rfilter_type == 'gaussian': + bl_props.stddev = mi_props.get('stddev', 0.5) + elif mi_rfilter_type == 'mitchell': + bl_props.B = mi_props.get('B', 1.0 / 3.0) + bl_props.C = mi_props.get('C', 1.0 / 3.0) + elif mi_rfilter_type == 'lanczos': + bl_props.lobes = mi_props.get('lobes', 3) + return True + +########################## +## Sampler properties ## +########################## + +def apply_mi_sampler_properties(mi_context, mi_props): + mi_sampler_type = mi_props.plugin_name() + mi_camera = mi_context.bl_scene.camera.data.mitsuba + bl_props = getattr(mi_camera.samplers, mi_sampler_type, None) + if bl_props is None: + mi_context.log(f'Mitsuba Sampler "{mi_sampler_type}" is not supported.', 'ERROR') + return False + mi_camera.active_sampler = mi_sampler_type + bl_props.sample_count = mi_props.get('sample_count', 4) + bl_props.seed = mi_props.get('seed', 0) + if mi_sampler_type in ('stratified', 'multijitter', 'orthogonal'): + bl_props.jitter = mi_props.get('jitter', True) + if mi_sampler_type == 'orthogonal': + bl_props.strength = mi_props.get('strength', 2) + return True + +####################### +## Film properties ## +####################### + +def apply_mi_hdrfilm_properties(mi_context, mi_props): + mi_context.bl_scene.render.resolution_percentage = 100 + render_dims = (mi_props.get('width', 768), mi_props.get('height', 576)) + mi_context.bl_scene.render.resolution_x = render_dims[0] + mi_context.bl_scene.render.resolution_y = render_dims[1] + file_format = mi_fileformat_to_bl_fileformat(mi_context, mi_props.get('file_format', 'openexr')) + if file_format is not None: + mi_context.bl_scene.render.image_settings.file_format = file_format + color_mode = mi_pixelformat_to_bl_pixelformat(mi_context, mi_props.get('pixel_format', 'rgba')) + if color_mode is not None: + mi_context.bl_scene.render.image_settings.color_mode = color_mode + color_depth = mi_componentformat_to_bl_componentformat(mi_context, mi_props.get('component_format', 'float16')) + if color_depth is not None: + mi_context.bl_scene.render.image_settings.color_depth = color_depth + + crop_keys = ['crop_offset_x', 'crop_offset_y', 'crop_width', 'crop_height'] + if any(key in mi_props for key in crop_keys): + mi_context.bl_scene.render.use_border = True + # FIXME: Do we want to crop the resulting image ? + mi_context.bl_scene.render.use_crop_to_border = True + offset_x = mi_props.get('crop_offset_x', 0) + offset_y = mi_props.get('crop_offset_y', 0) + width = mi_props.get('crop_width', render_dims[0]) + height = mi_props.get('crop_height', render_dims[1]) + mi_context.bl_scene.render.border_min_x = offset_x / render_dims[0] + mi_context.bl_scene.render.border_max_x = (offset_x + width) / render_dims[0] + mi_context.bl_scene.render.border_min_y = offset_y / render_dims[1] + mi_context.bl_scene.render.border_max_y = (offset_y + height) / render_dims[1] + return True + +_mi_film_properties_converters = { + 'hdrfilm': apply_mi_hdrfilm_properties +} + +def apply_mi_film_properties(mi_context, mi_props): + mi_film_type = mi_props.plugin_name() + if mi_film_type not in _mi_film_properties_converters: + mi_context.log(f'Mitsuba Film "{mi_film_type}" is not supported.', 'ERROR') + return False + + return _mi_film_properties_converters[mi_film_type](mi_context, mi_props) + +########################### +## Renderer properties ## +########################### + +def init_mitsuba_variant(mi_context): + '''Select the Mitsuba variant on the render properties. The importer + never changes the active render engine.''' + import mitsuba + if not mi_context.import_render_settings: + return True + if 'scalar_rgb' not in mitsuba.variants(): + mi_context.log('Mitsuba variant "scalar_rgb" not available.', 'ERROR') + return False + mi_context.bl_scene.mitsuba.variant = 'scalar_rgb' + return True diff --git a/mitsuba_blender/plugins/__init__.py b/mitsuba_blender/plugins/__init__.py new file mode 100644 index 00000000..6cc0fb6c --- /dev/null +++ b/mitsuba_blender/plugins/__init__.py @@ -0,0 +1,36 @@ + +def _register_all(): + import mitsuba as mi + import drjit as dr + + from .textures import (color_ramp, hue_saturation, rgb_curve, mix, + brightness_contrast, map_range, combine_xyz, + separate_xyz, combine_color, vect_math, common, + normal_map, tex_noise, separate_color) + + color_ramp.register(mi, dr) + hue_saturation.register(mi, dr) + rgb_curve.register(mi, dr) + mix.register(mi, dr) + brightness_contrast.register(mi, dr) + map_range.register(mi, dr) + combine_xyz.register(mi, dr) + separate_xyz.register(mi, dr) + separate_color.register(mi, dr) + combine_color.register(mi, dr) + vect_math.register(mi, dr) + common.register(mi, dr) + normal_map.register(mi, dr) + tex_noise.register(mi, dr) + + +def register_plugins(): + '''Called by the Blender addon after set_variant.''' + _register_all() + + +# Entry-point auto-discovery: Mitsuba imports this module after +# set_variant and reloads it on every variant change. +import mitsuba as mi +if mi.variant() is not None: + _register_all() diff --git a/mitsuba_blender/plugins/textures/__init__.py b/mitsuba_blender/plugins/textures/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mitsuba_blender/plugins/textures/brightness_contrast.py b/mitsuba_blender/plugins/textures/brightness_contrast.py new file mode 100644 index 00000000..50829fe0 --- /dev/null +++ b/mitsuba_blender/plugins/textures/brightness_contrast.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from .common import get_texture + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + + + +def register(mi, dr): + ''' + Define and register the plugin for the active variant + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + class BrightnessContrast(mi.Texture): + ''' Bright/Contrast texture + + Follows node_shader_brightness.cc: gain is 1 + constant, offset is + brightness - contrast/2, and the result is clamped to zero from below + ''' + + def __init__(self, props : mi.Properties) -> None: + super().__init__(props) + self.input = get_texture(props, 'color', 1.0) + self.brightness = get_texture(props, 'brightness', 0.0) + self.contrast = get_texture(props, 'contrast', 0.0) + + def _process(self, value, si, active): + bright = self.brightness.eval_1(si, active) + contrast = self.contrast.eval_1(si, active) + + a = 1.0 + contrast + b = bright - contrast * 0.5 + return dr.maximum(a * value + b, 0.0) + + def eval(self, si, active=True): + return self.eval_3(si, active) + + def eval_1(self, si, active=True): + return self._process(self.input.eval_1(si, active), si, active) + + def eval_3(self, si, active=True): + return self._process(self.input.eval_3(si, active), si, active) + + def mean(self): + return 0.5 + + def traverse(self, cb): + cb.put('input', self.input, mi.ParamFlags.Differentiable) + cb.put('brightness', self.brightness, mi.ParamFlags.Differentiable) + cb.put('contrast', self.contrast, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return (self.input.is_spatially_varying() or + self.brightness.is_spatially_varying() or + self.contrast.is_spatially_varying()) + + def to_string(self): + return (f'BrightnessContrast[\n color = {self.input},\n brightness = {self.brightness},\n' + f' contrast = {self.contrast}\n]') + + mi.register_texture('brightness_contrast', BrightnessContrast) diff --git a/mitsuba_blender/plugins/textures/color_ramp.py b/mitsuba_blender/plugins/textures/color_ramp.py new file mode 100644 index 00000000..e236d79c --- /dev/null +++ b/mitsuba_blender/plugins/textures/color_ramp.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from enum import Enum +from .common import get_texture + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + + +def register(mi, dr): + ''' + Define and register the plugin for the active variant + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + + class ColorRamp(mi.Texture): + ''' ColorRamp texture plugin + + This has been taken from Sebastien Speierer and Baptiste Nicolet PR#121 + and adapted to match what is needed. + ''' + + class InterpolationMode(Enum): + Linear = 0, + Ease = 1, + Constant = 2, + Cardinal = 3, + + def __init__(self, props: mi.Properties) -> None: + super().__init__(props) + + + self.input = get_texture(props,'input') + + # Load interpolation mode + mode_str = props.get('mode', 'linear') + if mode_str == 'linear': + self.mode = ColorRamp.InterpolationMode.Linear + elif mode_str == 'ease': + self.mode = ColorRamp.InterpolationMode.Ease + elif mode_str == 'constant': + self.mode = ColorRamp.InterpolationMode.Constant + elif mode_str == 'cardinal': + self.mode = ColorRamp.InterpolationMode.Cardinal + else: + raise NotImplementedError(f'Interpolation mode {mode_str} is not supported') + self.mode_str = mode_str + + # Load colors and positions + num_bands = props.get('num_bands') + if num_bands <= 0: + raise Exception(f'Number of color bands {num_bands} has to be strictly positive') + + padding = 2 + self.band_pos = [0.0] * (num_bands + padding) + self.band_col = [0.0] * (3 * (num_bands + padding)) + + prev_pos = 0 + for i in range(num_bands): + pos = props.get(f'pos{i}') + col = props.get(f'color{i}') + + if pos < 0 or pos > 1: + raise Exception(f'Position at index {i} has value {pos} outside range [0,1]') + + if pos < prev_pos: + raise Exception(f'Position at index {i} has value {pos} less than' + + f'previous position {prev_pos} however sequence ' + + 'needs to be increasing') + + prev_pos = pos + self.band_pos[i+1] = pos + self.band_col[3*(i+1) ] = col[0] + self.band_col[3*(i+1)+1] = col[1] + self.band_col[3*(i+1)+2] = col[2] + + # Left-pad colors + self.band_col[0] = self.band_col[3] + self.band_col[1] = self.band_col[4] + self.band_col[2] = self.band_col[5] + + # Right-pad colors + self.band_pos[num_bands + padding - 1] = 1.0 + last_elem = num_bands + padding - 1 + self.band_col[3*last_elem ] = self.band_col[3*(last_elem-1)] + self.band_col[3*last_elem+1] = self.band_col[3*(last_elem-1)+1] + self.band_col[3*last_elem+2] = self.band_col[3*(last_elem-1)+2] + + def process(self, input_pos, active=True): + col = mi.Color3f(self.band_col[0], self.band_col[1], self.band_col[2]) + for i in range(1, len(self.band_pos)): + lo, hi = self.band_pos[i-1], self.band_pos[i] + t = dr.clip((input_pos - lo) / max(hi - lo, 1e-8), 0.0, 1.0) + c0 = mi.Color3f(*self.band_col[3*(i-1):3*i]) + c1 = mi.Color3f(*self.band_col[3*i:3*(i+1)]) + col = dr.select(input_pos >= lo, dr.lerp(c0, c1, t), col) + return col + + def eval(self, si, active=True): + return self.eval_3(si, active) + + def eval_1(self, si, active=True): + return mi.luminance(self.process(self.input.eval_1(si, active), active)) + + def eval_1_grad(self, si, active=True): + return mi.Vector2f(0.0) + + def eval_3(self, si, active=True): + return self.process(self.input.eval_1(si, active), active) + + def mean(self): + return 0.5 + + def traverse(self, callback): + callback.put('input', self.input, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return self.input.is_spatially_varying() + + def resolution(self): + return self.input.resolution() + + def wavelength_range(self): + return mi.ScalarVector2f(mi.MI_CIE_MIN, mi.MI_CIE_MAX) + + def to_string(self): + return f'ColorRamp[input={self.input}, mode={self.mode_str}]' + + mi.register_texture('color_ramp', ColorRamp) + diff --git a/mitsuba_blender/plugins/textures/combine_color.py b/mitsuba_blender/plugins/textures/combine_color.py new file mode 100644 index 00000000..cb8f2068 --- /dev/null +++ b/mitsuba_blender/plugins/textures/combine_color.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from .common import get_texture, hsv2rgb, hsl2rgb + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + + +def register(mi, dr): + ''' + Define and register the plugin for the active variant + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + + class CombineColor(mi.Texture): + ''' Texture combining inputs into a color + + Converts three values, red, green, blue into a color, either + in RGB, HSV or HSL. + ''' + def __init__(self, props: mi.Properties): + super().__init__(props) + + self.red = get_texture(props, 'red', 0.0) + self.green = get_texture(props, 'green', 0.0) + self.blue = get_texture(props, 'blue', 0.0) + self.mode = props.get('mode', 'RGB') + + + def eval(self, si, active=True): + return mi.UnpolarizedSpectrum(self.eval_3(si, active)) + + def eval_1(self, si, active=True): + c = self.eval_3(si, active) + return (c.x + c.y + c.z) * (1.0 / 3.0) + + def eval_3(self, si, active=True): + color = mi.Color3f(self.red.eval_1(si, active), + self.green.eval_1(si, active), + self.blue.eval_1(si, active)) + if self.mode == 'HSV': + return hsv2rgb(color) + elif self.mode == 'HSL': + return hsl2rgb(color) + return color + + def mean(self): + return 0.5 + + def traverse(self, cb: mi.TraversalCallback): + cb.put('red', self.red, mi.ParamFlags.Differentiable) + cb.put('green', self.green, mi.ParamFlags.Differentiable) + cb.put('blue', self.blue, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return (self.red.is_spatially_varying() or + self.green.is_spatially_varying() or + self.blue.is_spatially_varying()) + + def to_string(self): + return (f'CombineColor[mode = {self.mode},\n' + f'red = {self.red},\n' + f'green = {self.green},\n' + f'blue = {self.blue}\n]') + + + mi.register_texture('combine_color', CombineColor) diff --git a/mitsuba_blender/plugins/textures/combine_xyz.py b/mitsuba_blender/plugins/textures/combine_xyz.py new file mode 100644 index 00000000..2c69d192 --- /dev/null +++ b/mitsuba_blender/plugins/textures/combine_xyz.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from .common import get_texture + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + + +def register(mi, dr): + ''' + Define and register the plugin for the active variant + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + + class CombineXYZ(mi.Texture): + ''' Texture combining three values into a vector''' + def __init__(self, props: mi.Properties): + super().__init__(props) + self.x = get_texture(props, 'x', 0.0) + self.y = get_texture(props, 'y', 0.0) + self.z = get_texture(props, 'z', 0.0) + + + def eval(self, si, active=True): + return mi.UnpolarizedSpectrum(self.eval_3(si, active)) + + def eval_1(self, si, active=True): + c = self.eval_3(si, active) + return (c.x + c.y + c.z) * (1.0 / 3.0) + + + def eval_3(self, si, active=True): + return mi.Color3f(self.x.eval_1(si, active), + self.y.eval_1(si, active), + self.z.eval_1(si, active)) + + def mean(self): + return 0.5 + + def traverse(self, cb): + cb.put('x', self.x, mi.ParamFlags.Differentiable) + cb.put('y', self.y, mi.ParamFlags.Differentiable) + cb.put('z', self.z, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return (self.x.is_spatially_varying() or + self.y.is_spatially_varying() or + self.z.is_spatially_varying()) + + def to_string(self): + return (f'CombineXYZ[x = {self.x},\n y = {self.y},\n z = {self.z}]') + + mi.register_texture('combine_xyz', CombineXYZ) diff --git a/mitsuba_blender/plugins/textures/common.py b/mitsuba_blender/plugins/textures/common.py new file mode 100644 index 00000000..85b4c7e7 --- /dev/null +++ b/mitsuba_blender/plugins/textures/common.py @@ -0,0 +1,160 @@ +import mitsuba as mi +import drjit as dr + + + +def register(mi, dr): + class ConstantVector(mi.Texture): + ''' A fixed three-component value. Unlike Mitsuba's rgb texture this + carries no reflectance semantics, so components outside [0, 1] are + allowed.''' + + def __init__(self, props_or_value): + if isinstance(props_or_value, mi.Properties): + super().__init__(props_or_value) + value = props_or_value['value'] + else: + super().__init__(mi.Properties()) + value = props_or_value + + if isinstance(value, (int, float)): + self.value = mi.Color3f(value) + else: + self.value = mi.Color3f(value.x, value.y, value.z) + + def eval(self, si, active=True): + return mi.UnpolarizedSpectrum(self.value) + + def eval_1(self, si, active=True): + return (self.value.x + self.value.y + self.value.z) * (1.0 / 3.0) + + def eval_3(self, si, active=True): + return self.value + + def mean(self): + return 0.5 + + def traverse(self, cb): + cb.put('value', self.value, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return False + + def to_string(self): + return f'ConstantVector[value = {self.value}]' + + + mi.register_texture('constant_vector', ConstantVector) + +register(mi, dr) + +def get_texture(props: mi.Properties, + name: str, + value=None) -> mi.Texture: + ''' + Helper function to check when a node is active in `props` + Authors: Sebastien Speierer, Baptiste Nicolet + ''' + if name not in props: + if value is None: + raise Exception(f'Property {name} has not been specified!') + if isinstance(value, (float, int)): + return mi.load_dict({ 'type': 'uniform', 'value': value }, parallel=False) + return mi.load_dict({'type': 'rgb', 'value': value }, parallel=False) + texture = props.get(name) + if isinstance(texture, (float, int)): + return mi.load_dict({ 'type': 'uniform', 'value': texture }, parallel=False) + if not isinstance(texture, mi.Texture): + return mi.load_dict({ 'type': 'rgb', 'value': texture }, parallel=False) + return texture + + +def get_vector_texture(props, name, default=0.0): + '''As get_texture, but for vector-valued properties. Constants are kept + as raw values rather than wrapped in an rgb texture, whose reflectance + range would reject components outside [0, 1]. + ''' + if name not in props: + return mi.load_dict({'type' : 'constant_vector', 'value' : default}) + value = props.get(name) + if isinstance(value, mi.Texture): + return value + return mi.load_dict({'type' : 'constant_vector', 'value' : value}) + +# Those functions are taken directly from Blender implementation +# see: https://github.com/blender/blender/blob/main/source/blender/blenlib/intern/math_color.cc + +def rgb2hsv(rgb): + r, g, b = rgb.x, rgb.y, rgb.z + + swap_gb = g < b + g2 = dr.select(swap_gb, b, g) + b2 = dr.select(swap_gb, g, b) + k = dr.select(swap_gb, -1.0, 0.0) + + swap_rg = r < g2 + r3 = dr.select(swap_rg, g2, r) + g3 = dr.select(swap_rg, r, g2) + k = dr.select(swap_rg, -2.0 / 6.0 - k, k) + min_gb = dr.select(swap_rg, dr.minimum(g3, b2), b2) + + chroma = r3 - min_gb + return mi.Color3f(dr.abs(k + (g3 - b2) / (6.0 * chroma + 1e-20)), + chroma / (r3 + 1e-20), + r3) + +def _mod(a, b): + return a - b * dr.floor(a / b) + +def hsv2rgb(hsv): + h, s, v = hsv.x, hsv.y, hsv.z + nr = dr.clip(dr.abs(h * 6.0 - 3.0) - 1.0, 0.0, 1.0) + ng = dr.clip(2.0 - dr.abs(h * 6.0 - 2.0), 0.0, 1.0) + nb = dr.clip(2.0 - dr.abs(h * 6.0 - 4.0), 0.0, 1.0) + return mi.Color3f(((nr - 1.0) * s + 1.0) * v, + ((ng - 1.0) * s + 1.0) * v, + ((nb - 1.0) * s + 1.0) * v) + +def hsl2rgb(hsl): + h, s, l = hsl.x, hsl.y, hsl.z + nr = dr.clip(dr.abs(h * 6.0 - 3.0) - 1.0, 0.0, 1.0) + ng = dr.clip(2.0 - dr.abs(h * 6.0 - 2.0), 0.0, 1.0) + nb = dr.clip(2.0 - dr.abs(h * 6.0 - 4.0), 0.0, 1.0) + chroma = (1.0 - dr.abs(2.0 * l - 1.0)) * s + return mi.Color3f((nr - 0.5) * chroma + l, + (ng - 0.5) * chroma + l, + (nb - 0.5) * chroma + l) + + + +def rgb2hsl(rgb): + r, g, b = rgb.x, rgb.y, rgb.z + cmax = dr.maximum(r, dr.maximum(g, b)) + cmin = dr.minimum(r, dr.minimum(g, b)) + l = dr.minimum(1.0, (cmax + cmin) * 0.5) + + d = cmax - cmin + achromatic = d == 0.0 + d_safe = dr.select(achromatic, 1.0, d) + + s = dr.select(l > 0.5, + d_safe / dr.maximum(2.0 - cmax - cmin, 1e-20), + d_safe / dr.maximum(cmax + cmin, 1e-20)) + + h = dr.select(cmax == r, + (g - b) / d_safe + dr.select(g < b, 6.0, 0.0), + dr.select(cmax == g, + (b - r) / d_safe + 2.0, + (r - g) / d_safe + 4.0)) + h = h / 6.0 + + h = dr.select(achromatic, 0.0, h) + s = dr.select(achromatic, 0.0, s) + return mi.Color3f(h, s, l) + + + + diff --git a/mitsuba_blender/plugins/textures/hue_saturation.py b/mitsuba_blender/plugins/textures/hue_saturation.py new file mode 100644 index 00000000..430ec7df --- /dev/null +++ b/mitsuba_blender/plugins/textures/hue_saturation.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .common import get_texture, hsv2rgb, rgb2hsv + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + + +def register(mi, dr): + ''' Define and register the plugin for the active variant + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + + class HueSaturationValue(mi.Texture): + ''' Hue-Saturation-Value texture. + + Convert to HSV, offsets hue, scales saturation and value, converts + back, then lerps towards the result by Fac. The conversions follow + hsv_to_rgb and rgb_to_hsv in Blender's math_color.cc, which are branchless + and take hue in [0, 1] rather than degrees. + ''' + def __init__(self, props): + super().__init__(props) + self.hue = get_texture(props, 'hue', 0.5) + self.saturation = get_texture(props, 'saturation', 1.0) + self.value = get_texture(props, 'value', 1.0) + self.mix = get_texture(props, 'mix', 1.0) + self.input = get_texture(props, 'input', 1.0) + + + def eval(self, si, active=True): + return self.eval_3(si, active) + + def eval_1(self, si, active=True): + return self.input.eval_1(si, active) * self.value.eval_1(si, active) + + def eval_3(self, si, active=True): + hue = self.hue.eval_1(si, active) + saturation = self.saturation.eval_1(si, active) + value = self.value.eval_1(si, active) + mix = self.mix.eval_1(si, active) + color = self.input.eval_3(si, active) + + hsv = rgb2hsv(color) + hsv.x = hsv.x + hue + 0.5 + hsv.x = hsv.x - dr.floor(hsv.x) + hsv.y = dr.clip(hsv.y * saturation, 0.0, 1.0) + hsv.z = hsv.z * value + + return dr.maximum(dr.lerp(color, hsv2rgb(hsv), mix), 0.0) + + def mean(self): + return 0.5 + + def traverse(self, callback): + callback.put('hue', self.hue, mi.ParamFlags.Differentiable) + callback.put('saturation', self.saturation, mi.ParamFlags.Differentiable) + callback.put('value', self.value, mi.ParamFlags.Differentiable) + callback.put('mix', self.mix, mi.ParamFlags.Differentiable) + callback.put('input', self.input, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return (self.hue.is_spatially_varying() or + self.saturation.is_spatially_varying() or + self.value.is_spatially_varying() or + self.mix.is_spatially_varying() or + self.input.is_spatially_varying()) + + def to_string(self): + return (f'HueSaturationValue[\n input = {self.input},\n mix = {self.mix},\n' + f'value = {self.value},\n saturation = {self.saturation},\n hue = {self.hue}\n]') + + + mi.register_texture('hue_saturation_value', HueSaturationValue) diff --git a/mitsuba_blender/plugins/textures/map_range.py b/mitsuba_blender/plugins/textures/map_range.py new file mode 100644 index 00000000..0c34ec12 --- /dev/null +++ b/mitsuba_blender/plugins/textures/map_range.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .common import get_texture + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + +def register(mi, dr): + ''' + Define and register the plugin for the active variant + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + + class MapRange(mi.Texture): + ''' Blender's Map Range node. + + Follows node_shader_map_range.cc: the input is not clamped to the + from-range, the smoothstep variants clamp the factor to [0, 1] + regardless of the clamp setting, and clamp applies to the result only. + Float and vector modes differ only in which eval width the caller uses. + ''' + def __init__(self, props: mi.Properties): + super().__init__(props) + self.clamp = props.get('clamp', True) + self.vector = props.get('vector', True) + + + self.steps = get_texture(props, 'steps', 4.0) + self.input = get_texture(props, 'input', 1.0) + self.from_min = get_texture(props, 'from_min', 0.0) + self.from_max = get_texture(props, 'from_max', 1.0) + self.to_min = get_texture(props, 'to_min', 0.0) + self.to_max = get_texture(props, 'to_max', 1.0) + self.interpolation_type = props.get('interpolation_type', 'LINEAR') + + def _safe_divide(self, a, b): + return dr.select(b != 0.0, a / dr.select(b != 0.0, b, 1.0), 0.0) + + def _clamp_range(self, value, lo, hi): + return dr.select(hi > lo, + dr.clip(value, lo, hi), + dr.clip(value, hi, lo)) + + def process(self, si, active): + ev = (lambda t: t.eval_3(si, active)) if self.vector else (lambda t: t.eval_1(si, active)) + v = ev(self.input) + from_min, from_max = ev(self.from_min), ev(self.from_max) + to_min, to_max = ev(self.to_min), ev(self.to_max) + + factor = self._safe_divide(v - from_min, from_max - from_min) + + if self.interpolation_type == 'STEPPED': + steps = ev(self.steps) + factor = self._safe_divide(dr.floor(factor * (steps + 1.0)), steps) + elif self.interpolation_type == 'SMOOTHSTEP': + factor = dr.clip(factor, 0.0, 1.0) + factor = (3.0 - 2.0 * factor) * factor * factor + elif self.interpolation_type == 'SMOOTHERSTEP': + factor = dr.clip(factor, 0.0, 1.0) + factor = factor * factor * factor * (factor * (factor * 6.0 - 15.0) + 10.0) + + v = to_min + factor * (to_max - to_min) + + if self.clamp: + v = self._clamp_range(v, to_min, to_max) + return v + + def eval(self, si: mi.SurfaceInteraction3f, active=True): + return mi.UnpolarizedSpectrum(self.eval_3(si, active)) + + def eval_1(self, si, active=True): + return mi.Float(self.process(si, active)) + + def eval_3(self, si, active=True): + return mi.Color3f(self.process(si, active)) + + def mean(self): + return 0.5 + + def traverse(self, cb): + cb.put('steps', self.steps, mi.ParamFlags.Differentiable) + cb.put('input', self.input, mi.ParamFlags.Differentiable) + cb.put('from_min', self.from_min, mi.ParamFlags.Differentiable) + cb.put('from_max', self.from_max, mi.ParamFlags.Differentiable) + cb.put('to_min', self.to_min, mi.ParamFlags.Differentiable) + cb.put('to_max', self.to_max, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return any([t.is_spatially_varying() for t in [ + self.from_min, self.from_max, self.to_min, self.to_max, self.input + ]]) or self.steps.is_spatially_varying() + + def resolution(self): + return self.input.resolution() + + def to_string(self): + return (f'MapRange[interpolation_type={self.interpolation_type},\n' + f'clamp={self.clamp}, vector={self.vector},\n' + f'input = {self.input},\n' + f'from = [{self.from_min}, {self.from_max}],\n' + f'to = [{self.to_min}, {self.to_max}]\n]') + + mi.register_texture('map_range', MapRange) + + diff --git a/mitsuba_blender/plugins/textures/mix.py b/mitsuba_blender/plugins/textures/mix.py new file mode 100644 index 00000000..065bf8f6 --- /dev/null +++ b/mitsuba_blender/plugins/textures/mix.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from .common import get_texture + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + + + +def register(mi, dr): + ''' + Define and register the plugin for the active variant + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + + class Mix(mi.Texture): + ''' Blend two textures by a factor + + It mirrors Blender's Mix node. Blend modes other than a plain + interpolation apply only to colour inputs; a float mix is always + a lerp, so the converter emits 'MIX' for those. + ''' + + def __init__(self, props : mi.Properties) -> None: + super().__init__(props) + + self.blend_type = props.get('blend_type', 'MIX') + self.clamp_result = props.get('clamp_result', False) + self.clamp_factor = props.get('clamp_factor', False) + self.factor = get_texture(props, 'factor', 0.5) + self.a = get_texture(props, 'a', 1.0) + self.b = get_texture(props, 'b', 1.0) + + def _blend(self, blend_type, a, b, t): + facm = 1.0 - t + + if blend_type == 'MIX': + return dr.lerp(a, b, t) + if blend_type == 'ADD': + return a + t * b + if blend_type == 'MULTIPLY': + return a * (facm + t * b) + if blend_type == 'SUBTRACT': + return a - t * b + if blend_type == 'SCREEN': + return 1.0 - (facm + t * (1.0 - b)) * (1.0 - a) + if blend_type == 'DIVIDE': + return dr.select(b != 0.0, facm * a + t * a / dr.select(b != 0.0, b, 1.0), a) + if blend_type == 'DIFFERENCE': + return facm * a + t * dr.abs(a - b) + if blend_type == 'DARKEN': + return dr.lerp(a, dr.minimum(a, b), t) + if blend_type == 'LIGHTEN': + return dr.maximum(a, t * b) + if blend_type == 'OVERLAY': + lo = a * (facm + 2.0 * t * b) + hi = 1.0 - (facm + 2.0 * t * (1.0 - b)) * (1.0 - a) + return dr.select(a < 0.5, lo, hi) + raise Exception(f'Mix blend type "{blend_type}" is not supported') + + def _blend_grad(self, blend_type, a, b, t): + facm = 1.0 - t + + if blend_type == 'MIX': + # r = (1-t)a + tb + da = facm + db = t + dt = b - a + + elif blend_type == 'ADD': + # r = a + tb + da = 1.0 + db = t + dt = b + + elif blend_type == 'MULTIPLY': + # r = a(1-t+tb) + da = facm + t * b + db = t * a + dt = a * (b - 1.0) + + elif blend_type == 'SUBTRACT': + # r = a - tb + da = 1.0 + db = -t + dt = -b + + elif blend_type == 'SCREEN': + # r = 1 - (1-tb)(1-a) + da = 1.0 - t * b + db = t * (1.0 - a) + dt = b * (1.0 - a) + + elif blend_type == 'DIVIDE': + # r = a(1-t) + t*a/b, with r=a when b=0 + valid = b != 0.0 + bsafe = dr.select(valid, b, 1.0) + + da_valid = facm + t / bsafe + db_valid = -t * a / (bsafe * bsafe) + dt_valid = a / bsafe - a + + da = dr.select(valid, da_valid, 1.0) + db = dr.select(valid, db_valid, 0.0) + dt = dr.select(valid, dt_valid, 0.0) + + elif blend_type == 'DIFFERENCE': + # r = (1-t)a + t|a-b| + s = dr.sign(a - b) + + da = facm + t * s + db = -t * s + dt = dr.abs(a - b) + + elif blend_type == 'DARKEN': + # r = lerp(a, min(a,b), t) + m = dr.minimum(a, b) + + da_min = dr.select(a <= b, 1.0, 0.0) + db_min = dr.select(b < a, 1.0, 0.0) + + da = facm + t * da_min + db = t * db_min + dt = m - a + + elif blend_type == 'LIGHTEN': + # r = max(a, tb) + x = t * b + use_x = x > a + + da = dr.select(use_x, 0.0, 1.0) + db = dr.select(use_x, t, 0.0) + dt = dr.select(use_x, b, 0.0) + + elif blend_type == 'OVERLAY': + # lo = a * (1-t + 2tb) + # hi = 1 - (1-t + 2t(1-b)) * (1-a) + + lo_da = facm + 2.0 * t * b + lo_db = 2.0 * t * a + lo_dt = a * (2.0 * b - 1.0) + + hi_da = facm + 2.0 * t * (1.0 - b) + hi_db = 2.0 * t * (1.0 - a) + hi_dt = (1.0 - 2.0 * b) * (1.0 - a) + + use_lo = a < 0.5 + + da = dr.select(use_lo, lo_da, hi_da) + db = dr.select(use_lo, lo_db, hi_db) + dt = dr.select(use_lo, lo_dt, hi_dt) + + else: + raise Exception( + f'Mix blend type "{blend_type}" is not supported' + ) + + return da, db, dt + + def _process(self, si, val_a, val_b, active): + fac = self.factor.eval_1(si, active) + if self.clamp_factor: + fac = dr.clip(fac, 0.0, 1.0) + result = self._blend(self.blend_type, val_a, val_b, fac) + + if self.clamp_result: + return dr.clip(result, 0.0, 1.0) + return result + + def eval(self, si, active=True): + val_a = self.a.eval(si, active) + val_b = self.b.eval(si, active) + return mi.UnpolarizedSpectrum(self._process(si, val_a, val_b, active)) + + def eval_1(self, si, active=True): + val_a = self.a.eval_1(si, active) + val_b = self.b.eval_1(si, active) + return mi.Float(self._process(si, val_a, val_b, active)) + + def eval_1_grad(self, si, active=True): + a = self.a.eval_1(si, active) + b = self.b.eval_1(si, active) + t = self.factor.eval_1(si, active) + + # Spatial gradients (du, dv) + grad_a = self.a.eval_1_grad(si, active) + grad_b = self.b.eval_1_grad(si, active) + grad_t = self.factor.eval_1_grad(si, active) + + # Handle clamp_factor + if self.clamp_factor: + raw_t = t + t = dr.clip(raw_t, 0.0, 1.0) + + # d clip(t)/dt + t_mask = (raw_t > 0.0) & (raw_t < 1.0) + grad_t = dr.select(t_mask, grad_t, mi.Vector2f(0.0)) + + da, db, dt = self._blend_grad( + self.blend_type, a, b, t + ) + + # Chain rule + grad = da * grad_a + db * grad_b + dt * grad_t + + # Handle clamp_result + if self.clamp_result: + result = self._blend(self.blend_type, a, b, t) + + # d clip(result)/d result + result_mask = (result > 0.0) & (result < 1.0) + grad = dr.select( + result_mask, + grad, + mi.Vector2f(0.0) + ) + + return mi.Vector2f(grad) + + def eval_3(self, si, active=True): + val_a = self.a.eval_3(si, active) + val_b = self.b.eval_3(si, active) + + return mi.Color3f(self._process(si, val_a, val_b, active)) + + def mean(self): + return 0.5 + + def traverse(self, cb): + cb.put('a', self.a, mi.ParamFlags.Differentiable) + cb.put('b', self.b, mi.ParamFlags.Differentiable) + cb.put('factor', self.factor, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return (self.factor.is_spatially_varying() or + self.a.is_spatially_varying() or + self.b.is_spatially_varying()) + + def resolution(self): + return self.a.resolution() + + def to_string(self): + return (f'Mix[blend_type={self.blend_type},\n' + f' factor = {self.factor}, \n a = {self.a},\n' + f' b = {self.b}\n]') + + mi.register_texture('mix', Mix) + + + diff --git a/mitsuba_blender/plugins/textures/normal_map.py b/mitsuba_blender/plugins/textures/normal_map.py new file mode 100644 index 00000000..9d353e4b --- /dev/null +++ b/mitsuba_blender/plugins/textures/normal_map.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from .common import get_texture + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + + + +def register(mi, dr): + ''' + Define and register the plugin for the active variant + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + + class NormalMapWrapper(mi.Texture): + ''' Wrapper of the NormalMap BSDF + + It allows to add a `strength` factor to the normal map. + ''' + + def __init__(self, props : mi.Properties) -> None: + super().__init__(props) + + self.texture = get_texture(props, 'texture', 1.0) + self.strength = get_texture(props, 'strength', 1.0) + + def eval(self, si, active=True): + return mi.UnpolarizedSpectrum(self.eval_3(si, active)) + + def eval_1(self, si, active=True): + c = self.eval_3(si, active) + return (c.x + c.y + c.z) * (1.0/3.0) + + def eval_3(self, si, active=True): + n = self.texture.eval_3(si, active) * 2.0 - 1.0 + s = self.strength.eval_1(si, active) + n = mi.Color3f(n.x * s, n.y * s, dr.lerp(1.0, n.z, dr.clip(s, 0.0, 1.0))) + + return n * 0.5 + 0.5 + + def mean(self): + return 0.5 + + def traverse(self, cb: mi.TraversalCallback): + cb.put('texture', self.texture, mi.ParamFlags.Differentiable) + cb.put('strength', self.strength, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return (self.texture.is_spatially_varying() or + self.strength.is_spatially_varying()) + + def to_string(self): + return (f'NormalMapWrapper[\n texture = {self.texture},\n' + f' strength = {self.strength}\n]') + + mi.register_texture('normal_map', NormalMapWrapper) diff --git a/mitsuba_blender/plugins/textures/rgb_curve.py b/mitsuba_blender/plugins/textures/rgb_curve.py new file mode 100644 index 00000000..b5284a11 --- /dev/null +++ b/mitsuba_blender/plugins/textures/rgb_curve.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .common import get_texture + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + + +def register(mi, dr): + ''' + Define and register the plugin for the active variant + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + + + class RGBCurve(mi.Texture): + ''' Blender's RGB Curve node. + + The combined curve is applied first, then per channel curve. + Each curves arrive as a 2xN bitmap sampled with wrap mode `clamp`, + since Properties cannot hold a float array. + ''' + def __init__(self, props: mi.Properties) -> None: + super().__init__(props) + + + self.fac = get_texture(props, 'fac', 1.0) + self.color = get_texture(props, 'color') + + self.curve_c = get_texture(props, 'curve_c') + self.curve_r = get_texture(props, 'curve_r') + self.curve_g = get_texture(props, 'curve_g') + self.curve_b = get_texture(props, 'curve_b') + + def _sample(self, table, x, active): + lut_si = dr.zeros(mi.SurfaceInteraction3f) + lut_si.uv = mi.Point2f(x, 0.5) + return table.eval_1(lut_si, active) + + def eval(self, si, active=True): + return mi.UnpolarizedSpectrum(self.eval_3(si, active)) + + def eval_1(self, si, active=True): + return mi.luminance(self.eval_3(si, active)) + + def eval_1_grad(self, si, active=True): + return mi.Vector2f(0.0) + + def eval_3(self, si, active=True): + rgb = self.color.eval_3(si, active) + fac = self.fac.eval_1(si, active) + + r = self._sample(self.curve_r, self._sample(self.curve_c, rgb.x, active), active) + g = self._sample(self.curve_g, self._sample(self.curve_c, rgb.y, active), active) + b = self._sample(self.curve_b, self._sample(self.curve_c, rgb.z, active), active) + + return dr.lerp(rgb, mi.Color3f(r, g, b), fac) + + def mean(self): + return 0.5 + + def traverse(self, cb): + cb.put('color', self.color, mi.ParamFlags.Differentiable) + cb.put('fac', self.fac, mi.ParamFlags.Differentiable) + cb.put('curve_c', self.curve_c, mi.ParamFlags.NonDifferentiable) + cb.put('curve_r', self.curve_r, mi.ParamFlags.NonDifferentiable) + cb.put('curve_g', self.curve_g, mi.ParamFlags.NonDifferentiable) + cb.put('curve_b', self.curve_b, mi.ParamFlags.NonDifferentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return (self.color.is_spatially_varying() or + self.fac.is_spatially_varying() or + self.curve_c.is_spatially_varying() or + self.curve_r.is_spatially_varying() or + self.curve_g.is_spatially_varying() or + self.curve_b.is_spatially_varying()) + + def to_string(self): + return (f'RGBCurve[\n color = {self.color}, \n fac = {self.fac}\n]') + + + mi.register_texture('rgb_curve', RGBCurve) + + + + + + diff --git a/mitsuba_blender/plugins/textures/separate_color.py b/mitsuba_blender/plugins/textures/separate_color.py new file mode 100644 index 00000000..4772988c --- /dev/null +++ b/mitsuba_blender/plugins/textures/separate_color.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .common import get_texture, rgb2hsl, rgb2hsv + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + +def register(mi, dr): + ''' + Define and register the plugin for the active variant + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + + class SeparateColor(mi.Texture): + ''' One component of a color-valued texture + + Blender's Separate Color node has three outputs; each becomes + its own instance with a different index, since a Mitsuba texture + has one output. + ''' + def __init__(self, props: mi.Properties) -> None: + super().__init__(props) + + self.color = get_texture(props, 'color', 0.0) + self.index = int(props.get('index', 0)) + self.mode = props.get('mode', 'RGB') + + def eval(self, si, active=True): + return mi.UnpolarizedSpectrum(self.eval_1(si, active)) + + def eval_1(self, si, active=True): + c = self.color.eval_3(si, active) + if self.mode == 'HSV': + c = rgb2hsv(c) + elif self.mode == 'HSL': + c = rgb2hsl(c) + return [c.x, c.y, c.z][self.index] + + + def eval_3(self, si, active=True): + c = self.eval_1(si, active) + return mi.Color3f(c, c, c) + + + def mean(self): + return 0.5 + + def traverse(self, cb): + cb.put('color', self.color, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return self.color.is_spatially_varying() + + def to_string(self): + return f'SeparateColor[index={self.index}, mode={self.mode}]' + + mi.register_texture('separate_color', SeparateColor) + diff --git a/mitsuba_blender/plugins/textures/separate_xyz.py b/mitsuba_blender/plugins/textures/separate_xyz.py new file mode 100644 index 00000000..a3df21fc --- /dev/null +++ b/mitsuba_blender/plugins/textures/separate_xyz.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .common import get_texture + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + + +def register(mi, dr): + ''' + Define and register the plugin for the active variant + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + + class SeparateXYZ(mi.Texture): + ''' One component of a vector-valued texture + + Blender's Separate XYZ node has three outputs; each becomes + its own instance with a different index, since a Mitsuba texture + has one output. + ''' + + def __init__(self, props : mi.Properties): + super().__init__(props) + self.vector = get_texture(props, 'vector', 0.0) + self.index = int(props.get('index', 0.0)) + + def eval(self, si, active=True): + return mi.UnpolarizedSpectrum(self.eval_1(si, active)) + + def eval_1(self, si, active=True): + v = self.vector.eval_3(si, active) + return [v.x, v.y, v.z][self.index] + + def eval_3(self, si, active=True): + c = self.eval_1(si, active) + return mi.Color3f(c, c, c) + + + def mean(self): + return 0.5 + + def traverse(self, cb): + cb.put('vector', self.vector, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return self.vector.is_spatially_varying() + + def to_string(self): + return (f'SeparateXYZ[vector = {self.vector},\n index = {self.index}\n]') + + mi.register_texture('separate_xyz', SeparateXYZ) diff --git a/mitsuba_blender/plugins/textures/tex_noise.py b/mitsuba_blender/plugins/textures/tex_noise.py new file mode 100644 index 00000000..6a74fa97 --- /dev/null +++ b/mitsuba_blender/plugins/textures/tex_noise.py @@ -0,0 +1,194 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +from .common import get_texture, get_vector_texture + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + + +def register(mi, dr): + '''Define and register the plugin for the active variant. + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + + # Ported from Cycles: intern/cycles/kernel/svm/noise.h (perlin_3d, + # snoise_3d, noise_fbm) and util/hash.h (hash_uint3). The lattice + # gradients come from a hash of the integer coordinates, so nothing is + # stored and the same point always yields the same value. + + def _rot(x, k): + return (x << k) | (x >> (32 - k)) + + def _hash_uint3(kx, ky, kz): + '''Bob Jenkins' lookup3 finalisation, as Cycles uses it. The + rotation constants are load bearing: a different mixing function + gives structurally similar but visibly different noise.''' + init = 0xdeadbeef + (3 << 2) + 13 + a = mi.UInt32(init) + mi.UInt32(kx) + b = mi.UInt32(init) + mi.UInt32(ky) + c = mi.UInt32(init) + mi.UInt32(kz) + + c ^= b; c -= _rot(b, 14) + a ^= c; a -= _rot(c, 11) + b ^= a; b -= _rot(a, 25) + c ^= b; c -= _rot(b, 16) + a ^= c; a -= _rot(c, 4) + b ^= a; b -= _rot(a, 14) + c ^= b; c -= _rot(b, 24) + + return c + + def _fade(t): + '''6t^5 - 15t^4 + 10t^3: flat at both ends, so the interpolation + has a continuous slope across cell boundaries.''' + return t * t * t * (t * (t * 6.0 - 15.0) + 10.0) + + def _floorfrac(x): + '''Integer and fractional parts. The integer part is reinterpreted + as unsigned for the hash, matching C's implicit conversion, so + negative coordinates hash the same way they do in Cycles.''' + fl = dr.floor(x) + return mi.UInt32(mi.Int32(fl)), x - fl + + def _grad3(h, x, y, z): + '''Dot product of (x, y, z) with one of 16 fixed gradients chosen + by the low bits of the hash.''' + h = h & 15 + u = dr.select(h < 8, x, y) + vt = dr.select((h == 12) | (h == 14), x, z) + v = dr.select(h < 4, y, vt) + return (dr.select((h & 1) != 0, -u, u) + + dr.select((h & 2) != 0, -v, v)) + + def _tri_mix(v0, v1, v2, v3, v4, v5, v6, v7, x, y, z): + x1, y1, z1 = 1.0 - x, 1.0 - y, 1.0 - z + return (z1 * (y1 * (v0 * x1 + v1 * x) + y * (v2 * x1 + v3 * x)) + + z * (y1 * (v4 * x1 + v5 * x) + y * (v6 * x1 + v7 * x))) + + def _perlin_3d(x, y, z): + X, fx = _floorfrac(x) + Y, fy = _floorfrac(y) + Z, fz = _floorfrac(z) + + u, v, w = _fade(fx), _fade(fy), _fade(fz) + one = mi.UInt32(1) + + return _tri_mix( + _grad3(_hash_uint3(X, Y, Z), fx, fy, fz), + _grad3(_hash_uint3(X + one, Y, Z), fx - 1.0, fy, fz), + _grad3(_hash_uint3(X, Y + one, Z), fx, fy - 1.0, fz), + _grad3(_hash_uint3(X + one, Y + one, Z), fx - 1.0, fy - 1.0, fz), + _grad3(_hash_uint3(X, Y, Z + one), fx, fy, fz - 1.0), + _grad3(_hash_uint3(X + one, Y, Z + one), fx - 1.0, fy, fz - 1.0), + _grad3(_hash_uint3(X, Y + one, Z + one), fx, fy - 1.0, fz - 1.0), + _grad3(_hash_uint3(X + one, Y + one, Z + one), + fx - 1.0, fy - 1.0, fz - 1.0), + u, v, w) + + def _snoise_3d(p): + '''Signed noise in [-1, 1]. The 0.982 factor is Cycles' empirical + scale for the 3D case; the modulo repeats the field every 100000 + units to keep the lattice coordinates representable.''' + correction = 0.5 * dr.select(dr.abs(p) >= 1e6, 1.0, 0.0) + p = (p - 100000.0 * dr.floor(p * (1.0 / 100000.0))) + correction + return 0.982 * _perlin_3d(p.x, p.y, p.z) + + def _noise_fbm_3d(p, detail, roughness, lacunarity, normalize): + '''Sum of octaves at increasing frequency and falling amplitude. + + detail, roughness and lacunarity are Python floats: the octave + count sets the loop bound at trace time, so they cannot be driven + by a texture. The converter resolves them to constants. + ''' + fscale, amp = 1.0, 1.0 + maxamp = 0.0 + total = dr.zeros(mi.Float) + + for _ in range(int(detail) + 1): + total = total + _snoise_3d(p * fscale) * amp + maxamp += amp + amp *= roughness + fscale *= lacunarity + + rmd = detail - int(detail) + if rmd != 0.0: + total2 = total + _snoise_3d(p * fscale) * amp + if normalize: + return dr.lerp(0.5 * total / maxamp + 0.5, + 0.5 * total2 / (maxamp + amp) + 0.5, rmd) + return dr.lerp(total, total2, rmd) + + return 0.5 * total / maxamp + 0.5 if normalize else total + + class TexNoise(mi.Texture): + '''Blender's Noise Texture node, 3D fractal Brownian motion. + + Follows Cycles' noise.h. Two divergences from Blender: + + The coordinate. With the Vector input unconnected Blender uses the + generated texture coordinate, which is object space normalised to + the bounding box. A Mitsuba texture has the world position and the + UVs, so the converter passes the world position and the pattern is + anchored differently: it does not travel with the object and does + not match a Cycles render of the same scene. + + Only the Fac output and the FBM fractal type are implemented, and + Distortion is ignored. + ''' + + def __init__(self, props: mi.Properties) -> None: + super().__init__(props) + + self.vector = get_vector_texture(props, 'vector') if 'vector' in props else None + self.scale = get_texture(props, 'scale', 5.0) + + self.detail = float(props.get('detail', 2.0)) + self.roughness = float(props.get('roughness', 0.5)) + self.lacunarity = float(props.get('lacunarity', 2.0)) + self.normalize = bool(props.get('normalize', True)) + + def eval(self, si, active=True): + return mi.UnpolarizedSpectrum(self.eval_1(si, active)) + + def eval_1(self, si, active=True): + p = si.p if self.vector is None else self.vector.eval_3(si, active) + p = p * self.scale.eval_1(si, active) + return _noise_fbm_3d(p, self.detail, self.roughness, + self.lacunarity, self.normalize) + + def eval_3(self, si, active=True): + v = self.eval_1(si, active) + return mi.Color3f(v, v, v) + + def mean(self): + return 0.5 if self.normalize else 0.0 + + def traverse(self, cb): + # 'vector' is optional: with the input unconnected it is None, + # which is not a traversable value. + if self.vector is not None: + cb.put('vector', self.vector, mi.ParamFlags.Differentiable) + cb.put('scale', self.scale, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return True + + def to_string(self): + return (f'TexNoise[detail={self.detail}, ' + f'roughness={self.roughness}, ' + f'lacunarity={self.lacunarity}, ' + f'normalize={self.normalize},\n' + f' vector = {self.vector},\n' + f' scale = {self.scale}\n]') + + mi.register_texture('tex_noise', TexNoise) diff --git a/mitsuba_blender/plugins/textures/vect_math.py b/mitsuba_blender/plugins/textures/vect_math.py new file mode 100644 index 00000000..4381ba21 --- /dev/null +++ b/mitsuba_blender/plugins/textures/vect_math.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .common import get_vector_texture + +if TYPE_CHECKING: + import mitsuba as mi + import drjit as dr + + +def register(mi, dr): + ''' + Define and register the plugin for the active variant + + mi.Texture is a different class per variant, so a class defined at + module scope would bind to whichever variant was active on first + import and never rebind. Defining it inside this factory means + register_plugins() can be called again after every set_variant. + The class body should not be moved out of this function. + ''' + + ################## + ## Arithmetic ## + ################## + + def _safe_divide(a, b): + ok = b != 0.0 + return dr.select(ok, a / dr.select(ok, b, 1.0), 0.0) + + def _normalize(a): + n = dr.norm(a) + ok = n > 0.0 + return dr.select(ok, a / dr.select(ok, n, 1.0), mi.Color3f(0.0)) + + def _fract(a): + return a - dr.floor(a) + + def _modulo(a, b): + return _safe_divide(a, b) * 0.0 + dr.select(b != 0.0, + a - dr.trunc(a / dr.select(b != 0.0, b, 1.0)) * b, 0.0) + + + _VECTOR_OPS = { + 'ADD': lambda a, b, c, s: a + b, + 'SUBTRACT': lambda a, b, c, s: a - b, + 'MULTIPLY': lambda a, b, c, s: a * b, + 'DIVIDE': lambda a, b, c, s: _safe_divide(a, b), + 'MULTIPLY_ADD': lambda a, b, c, s: a * b + c, + 'CROSS_PRODUCT': lambda a, b, c, s: dr.cross(a, b), + 'DOT_PRODUCT': lambda a, b, c, s: dr.dot(a, b), + 'DISTANCE': lambda a, b, c, s: dr.norm(a - b), + 'LENGTH': lambda a, b, c, s: dr.norm(a), + 'SCALE': lambda a, b, c, s: a * s, + 'NORMALIZE': lambda a, b, c, s: _normalize(a), + 'ABSOLUTE': lambda a, b, c, s: dr.abs(a), + 'MINIMUM': lambda a, b, c, s: dr.minimum(a, b), + 'MAXIMUM': lambda a, b, c, s: dr.maximum(a, b), + 'FLOOR': lambda a, b, c, s: dr.floor(a), + 'CEIL': lambda a, b, c, s: dr.ceil(a), + 'FRACTION': lambda a, b, c, s: _fract(a), + 'MODULO': lambda a, b, c, s: _modulo(a, b), + 'SINE': lambda a, b, c, s: dr.sin(a), + 'COSINE': lambda a, b, c, s: dr.cos(a), + 'TANGENT': lambda a, b, c, s: dr.tan(a) + } + + _SCALAR_OPS = {'DOT_PRODUCT', 'DISTANCE', 'LENGTH'} + + + class VectMath(mi.Texture): + ''' Math operation on vector + + Equivalent to the Math texture but applied on vector, hence + new operations such as dot product, normalize, cross product, + etc. can be used on the inputs. + ''' + def __init__(self, props: mi.Properties): + super().__init__(props) + + self.vec_0 = get_vector_texture(props, 'vec_0', 0.0) + self.vec_1 = get_vector_texture(props, 'vec_1', 0.0) + self.vec_2 = get_vector_texture(props, 'vec_2', 0.0) + self.scale = get_vector_texture(props, 'scale', 1.0) + + self.op_fn = props.get('op') + if self.op_fn is None: + raise RuntimeError('vect_math plugin requires an "op" parameter') + + + def _process(self, si, active): + return _VECTOR_OPS[self.op_fn](self.vec_0.eval_3(si, active), + self.vec_1.eval_3(si, active), + self.vec_2.eval_3(si, active), + self.scale.eval_1(si, active)) + + + def eval(self, si, active=True): + return mi.UnpolarizedSpectrum(self.eval_3(si, active)) + + def eval_1(self, si, active=True): + result = self._process(si, active) + if self.op_fn in _SCALAR_OPS: + return result + return (result.x + result.y + result.z) * (1.0 / 3.0) + + def eval_3(self, si, active=True): + result = self._process(si, active) + if self.op_fn in _SCALAR_OPS: + return mi.Color3f(result, result, result) + + return result + + def mean(self): + return 0.5 + + def traverse(self, cb: mi.TraversalCallback): + cb.put('vec_0', self.vec_0, mi.ParamFlags.Differentiable) + cb.put('vec_1', self.vec_1, mi.ParamFlags.Differentiable) + cb.put('vec_2', self.vec_2, mi.ParamFlags.Differentiable) + cb.put('scale', self.scale, mi.ParamFlags.Differentiable) + + def parameters_changed(self, keys=None): + pass + + def is_spatially_varying(self): + return (self.vec_0.is_spatially_varying() or + self.vec_1.is_spatially_varying() or + self.vec_2.is_spatially_varying() or + self.scale.is_spatially_varying()) + + def to_string(self): + return (f'VectMath[ op = {self.op_fn},\n' + f'vec_0 = {self.vec_0},\n' + f'vec_1 = {self.vec_1},\n' + f'vec_2 = {self.vec_2},\n' + f'scale = {self.scale}\n]') + + + + + mi.register_texture('vect_math', VectMath) + diff --git a/mitsuba_blender/preferences.py b/mitsuba_blender/preferences.py new file mode 100644 index 00000000..d2cb0921 --- /dev/null +++ b/mitsuba_blender/preferences.py @@ -0,0 +1,62 @@ +import os +import sys + +# Dr.Jit requires this to be set before mitsuba is first imported. +os.environ.setdefault('DRJIT_NO_RTLD_DEEPBIND', '1') + +import bpy +from bpy.props import StringProperty +from bpy.types import AddonPreferences + +# Version string of the loaded mitsuba module, or None with the reason in +# init_error. Filled in by register(). +mitsuba_version = None +init_error = '' +_registered_path = None + + +class MitsubaPreferences(AddonPreferences): + bl_idname = __package__ + + custom_mitsuba_path: StringProperty( + name = 'Custom Mitsuba path', + description = 'Optional directory containing a custom mitsuba Python ' + 'package (e.g. /python), used instead of the ' + 'bundled one. Takes effect after restarting Blender', + default = '', + subtype = 'DIR_PATH', + ) + + def draw(self, context): + layout = self.layout + if mitsuba_version is not None: + layout.label(text=f'Using Mitsuba v{mitsuba_version}.', icon='CHECKMARK') + else: + row = layout.row() + row.alert = True + row.label(text=f'Failed to load Mitsuba: {init_error}', icon='ERROR') + layout.prop(self, 'custom_mitsuba_path') + if self.custom_mitsuba_path != _registered_path: + layout.label(text='Restart Blender to apply the new path.', icon='INFO') + + +def init_mitsuba(): + global mitsuba_version, init_error, _registered_path + addon = bpy.context.preferences.addons.get(__package__) + _registered_path = addon.preferences.custom_mitsuba_path if addon else '' + try: + if _registered_path: + path = bpy.path.abspath(_registered_path) + if path not in sys.path: + sys.path.insert(0, path) + import mitsuba + mitsuba.set_variant('scalar_rgb') + + mitsuba_version = mitsuba.__version__ + return True + except Exception as e: + mitsuba_version = None + init_error = str(e) + return False + + diff --git a/mitsuba_blender/properties.py b/mitsuba_blender/properties.py new file mode 100644 index 00000000..852ef532 --- /dev/null +++ b/mitsuba_blender/properties.py @@ -0,0 +1,476 @@ +import ast + +import bpy +from bpy.props import ( + BoolProperty, + EnumProperty, + FloatProperty, + IntProperty, + PointerProperty, + StringProperty, +) +from bpy.types import PropertyGroup + + +class MitsubaPluginProps: + '''Mixin for property groups that map directly to a Mitsuba plugin.''' + plugin_type = None + + @classmethod + def prop_names(cls): + return tuple(cls.__dict__.get('__annotations__', ())) + + def draw(self, layout): + for name in self.prop_names(): + layout.prop(self, name) + + def to_dict(self): + params = {'type': self.plugin_type} + for name in self.prop_names(): + params[name] = getattr(self, name) + return params + + +################### +## Integrators ## +################### + +class MitsubaPathProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'path' + max_depth: IntProperty( + name='Max Depth', + description='Longest path depth (-1 = infinite)', + default=-1, soft_min=-1) + rr_depth: IntProperty( + name='Russian Roulette Depth', + description='Minimum path depth at which russian roulette is used', + default=5, soft_min=0) + hide_emitters: BoolProperty( + name='Hide Emitters', + description='Hide directly visible emitters', + default=False) + + +class MitsubaDirectProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'direct' + emitter_samples: IntProperty( + name='Emitter Samples', + description='Number of samples generated with the direct illumination strategy', + default=1, soft_min=1) + bsdf_samples: IntProperty( + name='BSDF Samples', + description='Number of samples generated with the BSDF sampling strategy', + default=1, soft_min=1) + hide_emitters: BoolProperty( + name='Hide Emitters', + description='Hide directly visible emitters', + default=False) + + +class MitsubaVolPathProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'volpath' + max_depth: IntProperty( + name='Max Depth', + description='Longest path depth (-1 = infinite)', + default=-1, soft_min=-1) + rr_depth: IntProperty( + name='Russian Roulette Depth', + description='Minimum path depth at which russian roulette is used', + default=5, soft_min=0) + hide_emitters: BoolProperty( + name='Hide Emitters', + description='Hide directly visible emitters', + default=False) + + +class MitsubaVolPathMisProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'volpathmis' + max_depth: IntProperty( + name='Max Depth', + description='Longest path depth (-1 = infinite)', + default=-1, soft_min=-1) + rr_depth: IntProperty( + name='Russian Roulette Depth', + description='Minimum path depth at which russian roulette is used', + default=5, soft_min=0) + hide_emitters: BoolProperty( + name='Hide Emitters', + description='Hide directly visible emitters', + default=False) + + +class MitsubaPtracerProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'ptracer' + max_depth: IntProperty( + name='Max Depth', + description='Longest path depth (-1 = infinite)', + default=-1, soft_min=-1) + rr_depth: IntProperty( + name='Russian Roulette Depth', + description='Minimum path depth at which russian roulette is used', + default=5, soft_min=0) + hide_emitters: BoolProperty( + name='Hide Emitters', + description='Hide directly visible emitters', + default=False) + + +# AOV variables selectable in the UI. The output name doubles as the type. +AOV_NAMES = ('albedo', 'depth', 'position', 'uv', 'geo_normal', 'sh_normal', + 'dp_du', 'dp_dv', 'duv_dx', 'duv_dy') + +NESTED_INTEGRATOR_ITEMS = [ + ('path', 'Path Tracer', 'Standard path tracer'), + ('direct', 'Direct Illumination', 'Direct illumination integrator'), + ('volpath', 'Volumetric Path Tracer', 'Volumetric path tracer'), + ('volpathmis', 'Volumetric Path Tracer (MIS)', + 'Volumetric path tracer with spectral MIS'), + ('ptracer', 'Particle Tracer', 'Particle tracer starting from the emitters'), +] + + +class MitsubaAovProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'aov' + albedo: BoolProperty(name='Albedo', default=False) + depth: BoolProperty(name='Depth', default=False) + position: BoolProperty(name='Position', default=False) + uv: BoolProperty(name='UV Coordinates', default=False) + geo_normal: BoolProperty(name='Geometric Normals', default=False) + sh_normal: BoolProperty(name='Shading Normals', default=False) + dp_du: BoolProperty(name='Position Partial Derivative w.r.t. U', default=False) + dp_dv: BoolProperty(name='Position Partial Derivative w.r.t. V', default=False) + duv_dx: BoolProperty(name='UV Partial Derivative w.r.t. X', default=False) + duv_dy: BoolProperty(name='UV Partial Derivative w.r.t. Y', default=False) + nested_integrator: EnumProperty( + name='Image Integrator', + description='Integrator used to render the image alongside the AOVs', + items=NESTED_INTEGRATOR_ITEMS, + default='path') + path: PointerProperty(type=MitsubaPathProps) + direct: PointerProperty(type=MitsubaDirectProps) + volpath: PointerProperty(type=MitsubaVolPathProps) + volpathmis: PointerProperty(type=MitsubaVolPathMisProps) + ptracer: PointerProperty(type=MitsubaPtracerProps) + + def draw(self, layout): + col = layout.column(heading='Variables') + for name in AOV_NAMES: + col.prop(self, name) + layout.prop(self, 'nested_integrator') + getattr(self, self.nested_integrator).draw(layout.box()) + + def to_dict(self): + aovs = ['%s:%s' % (name, name) for name in AOV_NAMES + if getattr(self, name)] + return { + 'type': 'aov', + 'aovs': ','.join(aovs), + self.nested_integrator: getattr(self, self.nested_integrator).to_dict(), + } + + +class MitsubaAvailableIntegrators(PropertyGroup): + path: PointerProperty(type=MitsubaPathProps) + direct: PointerProperty(type=MitsubaDirectProps) + volpath: PointerProperty(type=MitsubaVolPathProps) + volpathmis: PointerProperty(type=MitsubaVolPathMisProps) + aov: PointerProperty(type=MitsubaAovProps) + ptracer: PointerProperty(type=MitsubaPtracerProps) + + +################ +## Samplers ## +################ + +class MitsubaIndependentProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'independent' + sample_count: IntProperty( + name='Sample Count', + description='Number of samples per pixel', + default=4, soft_min=1) + seed: IntProperty(name='Seed', description='Seed offset', default=0) + + +class MitsubaStratifiedProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'stratified' + sample_count: IntProperty( + name='Sample Count', + description='Number of samples per pixel', + default=4, soft_min=1) + seed: IntProperty(name='Seed', description='Seed offset', default=0) + jitter: BoolProperty( + name='Jitter', + description='Additional random jitter within the stratum', + default=True) + + +class MitsubaMultijitterProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'multijitter' + sample_count: IntProperty( + name='Sample Count', + description='Number of samples per pixel', + default=4, soft_min=1) + seed: IntProperty(name='Seed', description='Seed offset', default=0) + jitter: BoolProperty( + name='Jitter', + description='Additional random jitter within the substratum', + default=True) + + +class MitsubaOrthogonalProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'orthogonal' + sample_count: IntProperty( + name='Sample Count', + description='Number of samples per pixel', + default=4, soft_min=1) + seed: IntProperty(name='Seed', description='Seed offset', default=0) + strength: IntProperty( + name='Strength', + description='Strength of the orthogonal array', + default=2, soft_min=2) + jitter: BoolProperty( + name='Jitter', + description='Additional random jitter within the substratum', + default=True) + + +class MitsubaLdsamplerProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'ldsampler' + sample_count: IntProperty( + name='Sample Count', + description='Number of samples per pixel', + default=4, soft_min=1) + seed: IntProperty(name='Seed', description='Seed offset', default=0) + + +class MitsubaAvailableSamplers(PropertyGroup): + independent: PointerProperty(type=MitsubaIndependentProps) + stratified: PointerProperty(type=MitsubaStratifiedProps) + multijitter: PointerProperty(type=MitsubaMultijitterProps) + orthogonal: PointerProperty(type=MitsubaOrthogonalProps) + ldsampler: PointerProperty(type=MitsubaLdsamplerProps) + + +############################## +## Reconstruction filters ## +############################## + +class MitsubaBoxProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'box' + + +class MitsubaTentProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'tent' + + +class MitsubaGaussianProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'gaussian' + stddev: FloatProperty( + name='Standard Deviation', + description='Standard deviation of the gaussian', + default=0.5, soft_min=0.0) + + +class MitsubaMitchellProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'mitchell' + B: FloatProperty( + name='B', + description='B parameter from the original paper', + default=1.0 / 3.0) + C: FloatProperty( + name='C', + description='C parameter from the original paper', + default=1.0 / 3.0) + + +class MitsubaCatmullromProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'catmullrom' + + +class MitsubaLanczosProps(MitsubaPluginProps, PropertyGroup): + plugin_type = 'lanczos' + lobes: IntProperty( + name='Lobes', + description='Number of filter side-lobes', + default=3, soft_min=1) + + +class MitsubaAvailableRfilters(PropertyGroup): + box: PointerProperty(type=MitsubaBoxProps) + tent: PointerProperty(type=MitsubaTentProps) + gaussian: PointerProperty(type=MitsubaGaussianProps) + mitchell: PointerProperty(type=MitsubaMitchellProps) + catmullrom: PointerProperty(type=MitsubaCatmullromProps) + lanczos: PointerProperty(type=MitsubaLanczosProps) + + +######################## +## Top-level groups ## +######################## + +def _variant_items(): + import mitsuba + return [(v, v, '') for v in mitsuba.variants()] + + +def _default_variant(): + import mitsuba + variants = mitsuba.variants() + return 'scalar_rgb' if 'scalar_rgb' in variants else variants[0] + + +INTEGRATOR_ITEMS = [ + ('path', 'Path Tracer', 'Standard path tracer'), + ('direct', 'Direct Illumination', 'Direct illumination integrator with MIS'), + ('volpath', 'Volumetric Path Tracer', 'Volumetric path tracer'), + ('volpathmis', 'Volumetric Path Tracer (MIS)', + 'Volumetric path tracer with spectral MIS'), + ('aov', 'AOVs', 'Arbitrary output variables describing the visible surfaces'), + ('ptracer', 'Particle Tracer', 'Particle tracer starting from the emitters'), +] + +SAMPLER_ITEMS = [ + ('independent', 'Independent', 'Independent uniformly distributed samples'), + ('stratified', 'Stratified', 'Stratified samples'), + ('multijitter', 'Multijitter', 'Correlated multi-jittered samples'), + ('orthogonal', 'Orthogonal Array', 'Orthogonal array samples'), + ('ldsampler', 'Low Discrepancy', 'Low discrepancy samples'), +] + +RFILTER_ITEMS = [ + ('box', 'Box', 'Box filter'), + ('tent', 'Tent', 'Triangular filter'), + ('gaussian', 'Gaussian', 'Windowed gaussian filter'), + ('mitchell', 'Mitchell', 'Mitchell-Netravali cubic spline filter'), + ('catmullrom', 'Catmull-Rom', 'Catmull-Rom spline filter'), + ('lanczos', 'Lanczos', 'Windowed lanczos sinc filter'), +] + +MODE_EXP_ITEMS = [ + ('strict', 'Strict', 'Replace the whole material with the error BSDF when any node is unsupported'), + ('relaxed', 'Relaxed', 'Use the socket default for unsupported nodes and log a warning'), +] + +class MitsubaRenderSettings(PropertyGroup): + variant: EnumProperty( + name='Variant', + items=_variant_items(), + default=_default_variant()) + + active_integrator: EnumProperty( + name='Integrator', + items=INTEGRATOR_ITEMS, + default='path') + + available_integrators: PointerProperty(type=MitsubaAvailableIntegrators) + + export_mode: EnumProperty( + name='Exporting mode', + items=MODE_EXP_ITEMS, + default='strict' + ) + + blender_triangulation: BoolProperty( + name='Triangulate non-triangular faces in Blender', + description="Mitsuba's triangulation algorithm is much faster but " + 'does not handle all corner cases.', + default=False) + + custom_integrator: StringProperty( + name='Custom Integrator (dict)', + description='Python dict literal used verbatim as the integrator, ' + "e.g. {'type': 'stokes', 'nested': {'type': 'path'}}. " + 'Overrides the integrator selection above when non-empty', + default='') + + def integrator_to_dict(self): + custom = self.custom_integrator.strip() + if custom: + integrator = ast.literal_eval(custom) + if not isinstance(integrator, dict): + raise ValueError('The custom integrator must be a Python dict literal, ' + f'got: {self.custom_integrator!r}') + return integrator + return getattr(self.available_integrators, self.active_integrator).to_dict() + + @classmethod + def register(cls): + bpy.types.Scene.mitsuba = PointerProperty( + name='Mitsuba Render Settings', + description='Mitsuba render settings', + type=cls, + ) + + @classmethod + def unregister(cls): + del bpy.types.Scene.mitsuba + + +class MitsubaCameraSettings(PropertyGroup): + active_sampler: EnumProperty( + name='Sampler', + items=SAMPLER_ITEMS, + default='independent') + + samplers: PointerProperty(type=MitsubaAvailableSamplers) + + active_rfilter: EnumProperty( + name='Reconstruction Filter', + items=RFILTER_ITEMS, + default='box') + + rfilters: PointerProperty(type=MitsubaAvailableRfilters) + + def sampler_to_dict(self): + return getattr(self.samplers, self.active_sampler).to_dict() + + def rfilter_to_dict(self): + return getattr(self.rfilters, self.active_rfilter).to_dict() + + @classmethod + def register(cls): + bpy.types.Camera.mitsuba = PointerProperty( + name='Mitsuba Camera Settings', + description='Mitsuba camera settings', + type=cls, + ) + + @classmethod + def unregister(cls): + del bpy.types.Camera.mitsuba + + +classes = ( + MitsubaPathProps, + MitsubaDirectProps, + MitsubaVolPathProps, + MitsubaVolPathMisProps, + MitsubaPtracerProps, + MitsubaAovProps, + MitsubaAvailableIntegrators, + MitsubaIndependentProps, + MitsubaStratifiedProps, + MitsubaMultijitterProps, + MitsubaOrthogonalProps, + MitsubaLdsamplerProps, + MitsubaAvailableSamplers, + MitsubaBoxProps, + MitsubaTentProps, + MitsubaGaussianProps, + MitsubaMitchellProps, + MitsubaCatmullromProps, + MitsubaLanczosProps, + MitsubaAvailableRfilters, + MitsubaRenderSettings, + MitsubaCameraSettings, +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + + +def unregister(): + for cls in reversed(classes): + bpy.utils.unregister_class(cls) diff --git a/mitsuba_blender/ui.py b/mitsuba_blender/ui.py new file mode 100644 index 00000000..e0b9928d --- /dev/null +++ b/mitsuba_blender/ui.py @@ -0,0 +1,105 @@ +import bpy + + +class MitsubaPanel: + bl_space_type = 'PROPERTIES' + bl_region_type = 'WINDOW' + COMPAT_ENGINES = {'MITSUBA'} + + @classmethod + def poll(cls, context): + return context.engine in cls.COMPAT_ENGINES + + +class MITSUBA_RENDER_PT_integrator(MitsubaPanel, bpy.types.Panel): + bl_idname = 'MITSUBA_RENDER_PT_integrator' + bl_label = 'Integrator' + bl_context = 'render' + + def draw(self, context): + layout = self.layout + settings = context.scene.mitsuba + layout.prop(settings, 'active_integrator', text='Integrator') + getattr(settings.available_integrators, settings.active_integrator).draw(layout) + layout.prop(settings, 'custom_integrator') + + +class MITSUBA_CAMERA_PT_sampler(MitsubaPanel, bpy.types.Panel): + bl_idname = 'MITSUBA_CAMERA_PT_sampler' + bl_label = 'Sampler' + bl_context = 'render' + + def draw(self, context): + layout = self.layout + camera = context.scene.camera + if camera is None: + layout.label(text='No active camera') + return + settings = camera.data.mitsuba + layout.prop(settings, 'active_sampler', text='Sampler') + getattr(settings.samplers, settings.active_sampler).draw(layout) + + +class MITSUBA_CAMERA_PT_rfilter(MitsubaPanel, bpy.types.Panel): + bl_idname = 'MITSUBA_CAMERA_PT_rfilter' + bl_label = 'Reconstruction Filter' + bl_context = 'render' + + def draw(self, context): + layout = self.layout + camera = context.scene.camera + if camera is None: + layout.label(text='No active camera') + return + settings = camera.data.mitsuba + layout.prop(settings, 'active_rfilter', text='Filter') + getattr(settings.rfilters, settings.active_rfilter).draw(layout) + + +def draw_device(self, context): + if context.engine != 'MITSUBA': + return + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False + layout.column().prop(context.scene.mitsuba, 'variant') + layout.column().prop(context.scene.mitsuba, 'export_mode') + layout.column().prop(context.scene.mitsuba, 'blender_triangulation') + + +def get_compatible_panels(): + '''Built-in panels that should also show up for the Mitsuba engine.''' + exclude_panels = { + 'VIEWLAYER_PT_filter', + 'VIEWLAYER_PT_layer_passes', + 'RENDER_PT_simplify', + 'RENDER_PT_color_management', + 'RENDER_PT_freestyle', + 'RENDER_PT_gpencil', + } + return [panel for panel in bpy.types.Panel.__subclasses__() + if 'BLENDER_RENDER' in getattr(panel, 'COMPAT_ENGINES', ()) + and panel.__name__ not in exclude_panels] + + +classes = ( + MITSUBA_RENDER_PT_integrator, + MITSUBA_CAMERA_PT_sampler, + MITSUBA_CAMERA_PT_rfilter, +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.RENDER_PT_context.append(draw_device) + for panel in get_compatible_panels(): + panel.COMPAT_ENGINES.add('MITSUBA') + + +def unregister(): + for panel in get_compatible_panels(): + panel.COMPAT_ENGINES.discard('MITSUBA') + bpy.types.RENDER_PT_context.remove(draw_device) + for cls in reversed(classes): + bpy.utils.unregister_class(cls) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..d47195f7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "mitsuba-blender" +version = "0.1.0" +dependencies = [ + "mitsuba>=3.10,<4", +] + +[project.entry-points.mitsuba] +blender = "mitsuba_blender.plugins" + +[tool.setuptools.packages.find] +include = ["mitsuba_blender.plugins*"] diff --git a/release/build_extension.py b/release/build_extension.py new file mode 100644 index 00000000..70fde5a2 --- /dev/null +++ b/release/build_extension.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Builds distributable extension zips with bundled Mitsuba wheels. + +Copies the addon into a staging directory, downloads the mitsuba and drjit +wheels for every supported platform, lists them in the manifest, and invokes +Blender's extension builder with --split-platforms to produce one zip per +platform in the output directory. +""" + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +MITSUBA_VERSION = '3.9.0' + +# Blender bundles Python 3.11 up to release 5.0 and Python 3.13 from 5.1 +# on. Wheels for both are listed in the manifest; at install time Blender +# deploys the ones whose tag matches its own Python. +PYTHON_VERSIONS = ['3.11', '3.13'] + +# Blender extension platforms and the matching pip platform tags. Mitsuba +# ships no macos-x64 wheels, so that platform is not part of the release. +PLATFORMS = { + 'linux-x64': 'manylinux_2_28_x86_64', + 'windows-x64': 'win_amd64', + 'macos-arm64': 'macosx_11_0_arm64', +} + + +def download_wheels(wheels_dir): + wheels_dir.mkdir(parents=True, exist_ok=True) + for platform_tag in PLATFORMS.values(): + for python_version in PYTHON_VERSIONS: + subprocess.check_call([ + sys.executable, '-m', 'pip', 'download', + f'mitsuba=={MITSUBA_VERSION}', + '--dest', str(wheels_dir), + '--only-binary=:all:', + '--implementation', 'cp', + '--python-version', python_version, + '--platform', platform_tag, + ]) + return [path.name for path in wheels_dir.glob('*.whl')] + + +def patch_manifest(manifest_path, wheel_names): + wheels = ',\n'.join(f' "./wheels/{name}"' for name in sorted(wheel_names)) + platforms = ', '.join(f'"{name}"' for name in PLATFORMS) + replacement = (f'platforms = [{platforms}]\n' + f'wheels = [\n{wheels},\n]') + text, count = re.subn(r'wheels = \[\]', replacement, + manifest_path.read_text()) + if count != 1: + sys.exit('error: expected exactly one "wheels = []" entry in the manifest') + manifest_path.write_text(text) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--blender', default=os.environ.get('BLENDER'), + help='Blender executable used to build the extension ' + '(defaults to the BLENDER environment variable)') + parser.add_argument('--output-dir', default='dist', + help='directory receiving the built zips') + args = parser.parse_args() + if not args.blender: + sys.exit('error: pass --blender or set the BLENDER environment variable') + + repo_root = Path(__file__).resolve().parent.parent + output_dir = Path(args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as staging: + source_dir = Path(staging) / 'mitsuba_blender' + shutil.copytree(repo_root / 'mitsuba_blender', source_dir, + ignore=shutil.ignore_patterns('__pycache__')) + wheel_names = download_wheels(source_dir / 'wheels') + patch_manifest(source_dir / 'blender_manifest.toml', wheel_names) + subprocess.check_call([ + args.blender, '--command', 'extension', 'build', + '--source-dir', str(source_dir), + '--output-dir', str(output_dir), + '--split-platforms', + ]) + + print('Built:') + for zip_path in sorted(output_dir.glob('*.zip')): + print(f' {zip_path}') + + +if __name__ == '__main__': + main() diff --git a/release/package.py b/release/package.py deleted file mode 100644 index 301e2c38..00000000 --- a/release/package.py +++ /dev/null @@ -1,32 +0,0 @@ -import argparse -from zipfile import ZipFile -import os - -def main(args): - addon_name = 'mitsuba-blender' - base_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) - addon_dir = os.path.join(base_dir, addon_name) - - with ZipFile('mitsuba-blender.zip', 'w') as archive: - # Package miscellaneous files - miscellaneous_files = ['README.md', 'LICENSE'] - for filename in miscellaneous_files: - filepath = os.path.join(base_dir, filename) - archivepath = os.path.join(addon_name, filename) - archive.write(filepath, archivepath) - - # Package addon source files - for folder, _, filenames in os.walk(addon_dir): - # Filter out Python cache directories - if folder.endswith('__pycache__'): - continue - for filename in filenames: - if filename.endswith('.py') or filename.endswith('.json'): - filepath = os.path.join(folder, filename) - shortpath = os.path.relpath(filepath, base_dir) - archive.write(filepath, shortpath) - -if __name__ == '__main__': - parser = argparse.ArgumentParser() - args = parser.parse_args() - main(args) diff --git a/scripts/blender_downloader.py b/scripts/blender_downloader.py index ee912c80..d38dc2da 100644 --- a/scripts/blender_downloader.py +++ b/scripts/blender_downloader.py @@ -12,6 +12,8 @@ 'https://ftp.nluug.nl/pub/graphics/blender/release' ] +BLENDER_DAILY_BUILDS_URL = 'https://builder.blender.org/download/daily/?format=json&v=1' + def get_platform_suffix_pattern(): if sys.platform.startswith('linux'): return 'linux(-x64|64).tar.(xz|gz|bz2)' @@ -38,7 +40,11 @@ def feed(self, data: str): platform_blender_links.append(link) if len(self.blender_version_parts) == 2: - return platform_blender_links[-1] + # Pick the highest patch release; the directory listing is + # sorted lexicographically, so 4.2.9 would come after 4.2.22. + def link_version(link): + return tuple(int(p) for p in link.split('-')[1].split('.')) + return max(platform_blender_links, key=link_version, default=None) else: for link in platform_blender_links: link_blender_version = link.split('-')[1] @@ -55,7 +61,27 @@ def handle_starttag(self, tag, attrs): return super().handle_starttag(tag, attrs) +def get_daily_download_url(): + if sys.platform.startswith('linux'): + platform, architecture, extension = 'linux', 'x86_64', 'xz' + elif sys.platform.startswith('win64') or sys.platform.startswith('win32'): + platform, architecture, extension = 'windows', 'amd64', 'zip' + else: + raise RuntimeError(f'Unsupported platform: {sys.platform}') + + builds = requests.get(BLENDER_DAILY_BUILDS_URL).json() + for build in builds: + if (build['branch'] == 'main' + and build['platform'] == platform + and build['architecture'] == architecture + and build['file_extension'] == extension): + return build['url'] + return None + def get_download_url(blender_version): + if blender_version == 'daily': + return get_daily_download_url() + version_parts = blender_version.split('.') version_parts_count = len(version_parts) @@ -90,7 +116,10 @@ def main(args): blender_version_parts = archive_file_name.split('-') if len(blender_version_parts) < 2: raise RuntimeError(f'Invalid blender version: {archive_file_name}') - print(f'{blender_version_parts[0]}-{blender_version_parts[1]}') + # Daily archive names carry the branch and commit hash in the third + # part; keep it so consumers (e.g. CI caches) see a unique version. + part_count = 3 if args.version == 'daily' else 2 + print('-'.join(blender_version_parts[:part_count])) return if not os.path.exists(archive_file_name): @@ -132,7 +161,7 @@ def main(args): if __name__ == '__main__': parser = argparse.ArgumentParser(description='Blender archive downloader.') - parser.add_argument('version', help='Blender version') + parser.add_argument('version', help="Blender version (e.g. 4.2, 4.2.22, or 'daily')") parser.add_argument('-o', '--out', default='blender', help='output file name') parser.add_argument('--print-version', action='store_const', const=True, default=False, help='print the Blender version instead of downloading the archive') diff --git a/scripts/run_tests.py b/scripts/run_tests.py deleted file mode 100644 index a57bdb29..00000000 --- a/scripts/run_tests.py +++ /dev/null @@ -1,74 +0,0 @@ -import argparse -import sys -import os - -import bpy -import pytest - -class SetupPlugin: - def __init__(self, custom_mitsuba_path: str | None = None): - mi_addon_root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) - self.mi_addon_dir = os.path.join(mi_addon_root_dir, 'mitsuba-blender') - self.bl_addon_dir = bpy.utils.user_resource('SCRIPTS', path='addons', create=True) - bpy.utils.refresh_script_paths() - self.bl_mi_addon_dir = os.path.join(self.bl_addon_dir, 'mitsuba-blender') - self.custom_mitsuba_path = custom_mitsuba_path - - def pytest_configure(self, config): - if os.path.exists(self.bl_mi_addon_dir): - os.remove(self.bl_mi_addon_dir) - - # Create a symlink from the addon to the Blender script folder - if sys.platform == 'win32': - import _winapi - _winapi.CreateJunction(str(self.mi_addon_dir), str(self.bl_mi_addon_dir)) - else: - os.symlink(self.mi_addon_dir, self.bl_mi_addon_dir, target_is_directory=True) - - if bpy.ops.preferences.addon_enable(module='mitsuba-blender') != {'FINISHED'}: - raise RuntimeError('Cannot enable mitsuba-blender addon') - - if self.custom_mitsuba_path: - bpy.context.preferences.addons['mitsuba-blender'].preferences.using_mitsuba_custom_path = True - bpy.context.preferences.addons['mitsuba-blender'].preferences.mitsuba_custom_path = self.custom_mitsuba_path - - if not bpy.context.preferences.addons['mitsuba-blender'].preferences.is_mitsuba_initialized: - status = bpy.context.preferences.addons['mitsuba-blender'].preferences.mitsuba_dependencies_status_message - raise RuntimeError(f'Failed to initialize Mitsuba library: {status}') - - def pytest_unconfigure(self): - bpy.ops.preferences.addon_disable(module='mitsuba-blender') - # Remove the symlink - os.remove(self.bl_mi_addon_dir) - - def pytest_runtest_setup(self, item): - bpy.ops.wm.read_homefile(use_empty=True) - if 'mitsuba-blender' not in bpy.context.preferences.addons: - raise RuntimeError("Plugin was disabled by test reset") - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description="Runs mitsuba-blender pytest tests") - parser.add_argument('--mitsuba', - default=None, - help='Specify a custom path to the Mitsuba installation.') - - argv = sys.argv[1:] - pytest_args = ['tests'] - try: - index = argv.index('--') - script_args = argv[:index] - pytest_args += argv[index + 1:] - except ValueError: - script_args = argv - - args, _ = parser.parse_known_args(script_args) - if args.mitsuba: - print(f'Using custom Mitsuba path: {args.mitsuba}') - - try: - exit_code = pytest.main(pytest_args, plugins=[SetupPlugin(args.mitsuba)]) - except Exception as e: - print(e) - exit_code = 1 - - sys.exit(exit_code) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..5317dc4c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,75 @@ +# Test suite + +The tests run with pytest **inside headless Blender**, since they exercise +`bpy` and the addon itself. `tests/run.py` is a plain host-side Python script +that launches Blender, runs pytest in it, and propagates the result. + +## Requirements + +- Set the `BLENDER` environment variable to a Blender 4.2+ executable: + + ```sh + export BLENDER=/path/to/blender-4.2.22-linux-x64/blender + ``` + +- Blender's bundled Python must have `mitsuba` and `pytest` installed: + + ```sh + /path/to/blender-4.2.22-linux-x64/4.2/python/bin/python3.11 -m pip install mitsuba==3.9.0 pytest + ``` + +- To run the suite against a local Mitsuba build instead of the wheel, point + `MITSUBA_PYTHON` at a directory containing the `mitsuba` and `drjit` + packages (compiled for Blender's Python version): + + ```sh + export MITSUBA_PYTHON=/path/to/mitsuba3/build/python + ``` + +## Running + +```sh +python3 tests/run.py # all fast tests (unit/roundtrip/golden) +python3 tests/run.py tests/unit/test_smoke.py # one file +python3 tests/run.py tests/unit/test_smoke.py -k engine -v # any pytest args +python3 tests/run.py --all # include slow and packaging tests +python3 tests/run.py -m packaging # only packaging tests +``` + +Golden render tests compare against small EXR references committed under +`tests/golden/refs`. After an intentional change in rendering output, +regenerate them with: + +```sh +python3 tests/run.py tests/golden --update-refs +``` + +All arguments except `--all` are forwarded to pytest verbatim. The launcher +prints the tail of the pytest report plus a final `PASS`/`FAIL` line; the full +(noisy) Blender output is written to a log file whose path is printed at the +end. The exit code is pytest's exit code (non-zero on failure, also when +Blender crashes before pytest can report). + +## Markers + +- `@pytest.mark.slow`: takes more than a few seconds (e.g. golden renders at + higher spp). Skipped by default, included with `--all` or an explicit `-m`. +- `@pytest.mark.packaging`: builds the extension zip and installs it into a + factory Blender. Slow; same opt-in rules. + +CI runs the full set (`--all`). + +## Layout + +- `run.py`: host-side launcher (see above). +- `_bootstrap.py`: runs inside Blender; invokes `pytest.main()` and reports + the exit code back to `run.py`. +- `conftest.py`: shared fixtures: + - `mi_addon` (session): symlinks the in-repo extension into Blender's + `user_default` extension repository and enables it. + - `fresh_scene`: resets Blender to the factory startup scene. + - `render_dict`: renders a Mitsuba scene dict, returns a numpy array. + - `compare_images`: mean/RMSE image comparison with tolerances. +- `ztest.py`: statistical z-test comparison of two rendered scenes, used by + the full-scene round-trip test. +- `unit/`, `roundtrip/`, `golden/`: test directories discovered by default. diff --git a/tests/_bootstrap.py b/tests/_bootstrap.py new file mode 100644 index 00000000..24b48cee --- /dev/null +++ b/tests/_bootstrap.py @@ -0,0 +1,38 @@ +"""Runs inside headless Blender, launched by tests/run.py. + +Executes pytest with the arguments following '--', writes the exit code to +the file named by MI_BLENDER_TEST_RC_FILE, and exits Blender with it. +""" + +import os +import sys +from pathlib import Path + +# Must be set before mitsuba is first imported anywhere in the process. +os.environ.setdefault('DRJIT_NO_RTLD_DEEPBIND', '1') + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Optional override: run the suite against a local Mitsuba build (a directory +# containing the mitsuba and drjit packages, e.g. /python) instead of +# the wheel installed in Blender's Python. +_mitsuba_python = os.environ.get('MITSUBA_PYTHON') +if _mitsuba_python: + sys.path.insert(0, _mitsuba_python) + + +def main(): + argv = sys.argv + args = argv[argv.index('--') + 1:] if '--' in argv else [] + os.chdir(REPO_ROOT) + + import pytest + rc = int(pytest.main(args)) + + rc_file = os.environ.get('MI_BLENDER_TEST_RC_FILE') + if rc_file: + Path(rc_file).write_text(str(rc)) + sys.exit(rc) + + +main() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..fbfa4397 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,107 @@ +"""Shared fixtures for the pytest-in-Blender suite (see tests/README.md).""" + +import os +import sys + +import bpy +import numpy as np +import pytest + +ADDON_ID = 'mitsuba_blender' +ADDON_MODULE = f'bl_ext.user_default.{ADDON_ID}' +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + +def pytest_addoption(parser): + parser.addoption( + '--update-refs', action='store_true', default=False, + help='regenerate golden reference images instead of comparing') + + +def pytest_configure(config): + config.addinivalue_line( + 'markers', 'slow: takes more than a few seconds, skipped by default') + config.addinivalue_line( + 'markers', 'packaging: builds/installs the extension zip, skipped by default') + + +@pytest.fixture(scope='session') +def mi_addon(): + """Registers the in-repo extension inside Blender for the whole session.""" + src = os.path.join(REPO_ROOT, ADDON_ID) + repo_dir = os.path.join(bpy.utils.user_resource('EXTENSIONS'), 'user_default') + os.makedirs(repo_dir, exist_ok=True) + link = os.path.join(repo_dir, ADDON_ID) + + if os.path.lexists(link): + os.remove(link) + if sys.platform == 'win32': + import _winapi + _winapi.CreateJunction(src, link) + else: + os.symlink(src, link, target_is_directory=True) + bpy.ops.extensions.repo_refresh_all() + + try: + if bpy.ops.preferences.addon_enable(module=ADDON_MODULE) != {'FINISHED'}: + pytest.fail(f'Cannot enable the {ADDON_MODULE} extension') + addon_module = sys.modules[ADDON_MODULE] + prefs = sys.modules[f'{ADDON_MODULE}.preferences'] + if prefs.mitsuba_version is None: + pytest.fail('Extension failed to initialize Mitsuba: ' + f'{addon_module.init_error}') + yield ADDON_MODULE + if ADDON_MODULE in bpy.context.preferences.addons: + bpy.ops.preferences.addon_disable(module=ADDON_MODULE) + finally: + if os.path.lexists(link): + os.remove(link) + + +@pytest.fixture +def fresh_scene(): + """Resets Blender to the factory startup scene.""" + bpy.ops.wm.read_homefile() + return bpy.context.scene + + +@pytest.fixture(scope='session') +def render_dict(): + """Loads a Mitsuba scene dict and renders it, returning a numpy array.""" + import mitsuba as mi + + def _render(scene_dict, spp=4, seed=0): + mi.set_variant('scalar_rgb') + scene = mi.load_dict(scene_dict) + return np.array(mi.render(scene, spp=spp, seed=seed)) + + return _render + + +@pytest.fixture(scope='session') +def compare_images(): + """Asserts that two images match in mean value and RMSE.""" + + def _compare(img, ref, mean_tol=0.01, rmse_tol=0.05): + img = np.asarray(img, dtype=np.float64) + ref = np.asarray(ref, dtype=np.float64) + assert img.shape == ref.shape, \ + f'image shape {img.shape} != reference shape {ref.shape}' + diff = img - ref + mean_err = float(np.abs(diff.mean())) + rmse = float(np.sqrt(np.mean(diff ** 2))) + assert mean_err <= mean_tol, \ + f'mean error {mean_err:.6f} exceeds tolerance {mean_tol}' + assert rmse <= rmse_tol, \ + f'RMSE {rmse:.6f} exceeds tolerance {rmse_tol}' + + return _compare + + + +@pytest.fixture +def export_ctx(mi_addon, tmp_path): + import mitsuba as mi, sys + mi.set_variant('scalar_rgb') + converter = sys.modules[mi_addon].io.exporter.SceneConverter(render=False) + converter.export_ctx.directory = str(tmp_path) + return converter.export_ctx diff --git a/tests/golden/refs/suzanne.exr b/tests/golden/refs/suzanne.exr new file mode 100644 index 00000000..504d094b Binary files /dev/null and b/tests/golden/refs/suzanne.exr differ diff --git a/tests/golden/test_mesh_render.py b/tests/golden/test_mesh_render.py new file mode 100644 index 00000000..cf7d4c94 --- /dev/null +++ b/tests/golden/test_mesh_render.py @@ -0,0 +1,49 @@ +"""Golden render test: a mesh scene must match a committed EXR reference. + +Regenerate the references with: python3 tests/run.py tests/golden --update-refs +""" + +import os +import sys + +import bpy +import numpy as np +import pytest + +REFS_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'refs') + + +def render_current_scene(mi_addon, tmp_path, spp=16): + import mitsuba as mi + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + bpy.context.scene.render.resolution_x = 64 + bpy.context.scene.render.resolution_y = 64 + converter = sys.modules[mi_addon].io.exporter.SceneConverter(render=True) + converter.export_ctx.directory = str(tmp_path) + converter.scene_to_dict(bpy.context.evaluated_depsgraph_get()) + scene = converter.dict_to_scene() + return np.array(mi.render(scene, spp=spp, seed=0)) + + +def test_suzanne_golden(mi_addon, fresh_scene, compare_images, request, + tmp_path): + import mitsuba as mi + + # Smooth-shaded Suzanne in place of the default cube + bpy.data.objects.remove(bpy.data.objects['Cube']) + bpy.ops.mesh.primitive_monkey_add(size=3.0, rotation=(1.1, 0.0, 0.6)) + bpy.ops.object.shade_smooth() + bpy.data.objects['Light'].data.energy = 10000.0 + + image = render_current_scene(mi_addon, tmp_path) + assert image.shape == (64, 64, 3) + + ref_path = os.path.join(REFS_DIR, 'suzanne.exr') + if request.config.getoption('--update-refs'): + os.makedirs(REFS_DIR, exist_ok=True) + mi.Bitmap(image).write(ref_path) + pytest.skip(f'Updated reference {ref_path}') + + reference = np.array(mi.Bitmap(ref_path)) + compare_images(image, reference, mean_tol=0.005, rmse_tol=0.02) diff --git a/tests/golden/test_variants.py b/tests/golden/test_variants.py new file mode 100644 index 00000000..b747a86e --- /dev/null +++ b/tests/golden/test_variants.py @@ -0,0 +1,50 @@ +'''Render smoke test across every UI-selectable Mitsuba variant. + +The rest of the suite pins scalar_rgb; this exercises the same default-cube +F12 path once per variant offered by the variant dropdown, so a variant that +crashes or renders black is caught by CI. +''' + +import bpy +import numpy as np +import pytest + +import mitsuba + + +def _selectable_variants(): + return mitsuba.variants() + + +@pytest.fixture(autouse=True) +def _restore_variant(): + yield + mitsuba.set_variant('scalar_rgb') + + +@pytest.mark.slow +@pytest.mark.parametrize('variant', _selectable_variants()) +def test_f12_renders_in_variant(variant, mi_addon, fresh_scene, tmp_path): + if 'cuda' in variant: + pytest.skip('CUDA availability is machine-dependent') + try: + mitsuba.set_variant(variant) + except (ImportError, AttributeError) as e: + pytest.skip(f'variant {variant} unavailable: {e}') + + scene = fresh_scene + scene.render.engine = 'MITSUBA' + scene.mitsuba.variant = variant + scene.render.resolution_x = 32 + scene.render.resolution_y = 32 + scene.render.filepath = str(tmp_path / f'{variant}.exr') + scene.render.image_settings.file_format = 'OPEN_EXR' + + assert bpy.ops.render.render(write_still=True) == {'FINISHED'} + + render = bpy.data.images.load(scene.render.filepath) + pixels = np.array(render.pixels[:]).reshape(-1, 4) + assert np.all(np.isfinite(pixels)) + # The default cube under the default light is neither black nor uniform + assert pixels[:, :3].max() > 0.01 + assert pixels[:, :3].std() > 1e-4 diff --git a/tests/res/scenes/integrator_moment.xml b/tests/res/scenes/integrator_moment.xml deleted file mode 100644 index 4a3803af..00000000 --- a/tests/res/scenes/integrator_moment.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/tests/roundtrip/test_camera_roundtrip.py b/tests/roundtrip/test_camera_roundtrip.py new file mode 100644 index 00000000..ea4b1d38 --- /dev/null +++ b/tests/roundtrip/test_camera_roundtrip.py @@ -0,0 +1,99 @@ +"""Numeric round trips of camera parameters through export and import.""" + +import bpy +import pytest +from mathutils import Euler, Matrix + + +def _roundtrip(tmp_path): + scene_file = tmp_path / 'scene.xml' + bpy.context.scene.render.engine = 'MITSUBA' + assert bpy.ops.export_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + camera = bpy.context.scene.camera + assert camera is not None + return camera + + +def _pose_camera(): + camera = bpy.data.objects['Camera'] + camera.matrix_world = Matrix.Translation((1.0, -2.0, 3.0)) @ \ + Euler((1.1, 0.2, 0.7)).to_matrix().to_4x4() + bpy.context.view_layer.update() + return camera + + +def _assert_same_pose(matrix_a, matrix_b, tol=1e-4): + delta = [abs(a - b) for row_a, row_b in zip(matrix_a, matrix_b) + for a, b in zip(row_a, row_b)] + assert max(delta) < tol + + +def test_perspective_roundtrip(mi_addon, fresh_scene, tmp_path): + scene = bpy.context.scene + scene.render.resolution_x = 640 + scene.render.resolution_y = 480 + camera = _pose_camera() + camera.data.lens = 35.0 + camera.data.shift_x = 0.15 + camera.data.shift_y = -0.05 + camera.data.clip_start = 0.5 + camera.data.clip_end = 500.0 + original_matrix = camera.matrix_world.copy() + original_angle = camera.data.angle_x + + imported = _roundtrip(tmp_path) + + assert imported.data.type == 'PERSP' + assert imported.data.angle_x == pytest.approx(original_angle, rel=1e-5) + assert imported.data.shift_x == pytest.approx(0.15, rel=1e-5) + assert imported.data.shift_y == pytest.approx(-0.05, rel=1e-5) + assert imported.data.clip_start == pytest.approx(0.5) + assert imported.data.clip_end == pytest.approx(500.0) + _assert_same_pose(imported.matrix_world, original_matrix) + + +def test_portrait_fov_roundtrip(mi_addon, fresh_scene, tmp_path): + scene = bpy.context.scene + scene.render.resolution_x = 480 + scene.render.resolution_y = 640 + camera = bpy.data.objects['Camera'] + camera.data.lens = 42.0 + original_angle = camera.data.angle_x + + imported = _roundtrip(tmp_path) + assert imported.data.angle_x == pytest.approx(original_angle, rel=1e-5) + + +def test_dof_roundtrip(mi_addon, fresh_scene, tmp_path): + camera = bpy.data.objects['Camera'] + camera.data.lens = 80.0 + camera.data.dof.use_dof = True + camera.data.dof.aperture_fstop = 2.8 + camera.data.dof.focus_distance = 4.2 + original_angle = camera.data.angle_x + + imported = _roundtrip(tmp_path) + + assert imported.data.dof.use_dof + assert imported.data.angle_x == pytest.approx(original_angle, rel=1e-5) + assert imported.data.dof.aperture_fstop == pytest.approx(2.8, rel=1e-5) + assert imported.data.dof.focus_distance == pytest.approx(4.2, rel=1e-5) + + +def test_orthographic_roundtrip(mi_addon, fresh_scene, tmp_path): + scene = bpy.context.scene + scene.render.resolution_x = 640 + scene.render.resolution_y = 480 + camera = _pose_camera() + camera.data.type = 'ORTHO' + camera.data.ortho_scale = 5.0 + original_matrix = camera.matrix_world.copy() + + imported = _roundtrip(tmp_path) + + assert imported.data.type == 'ORTHO' + assert imported.data.ortho_scale == pytest.approx(5.0, rel=1e-5) + _assert_same_pose(imported.matrix_world, original_matrix) diff --git a/tests/roundtrip/test_light_roundtrip.py b/tests/roundtrip/test_light_roundtrip.py new file mode 100644 index 00000000..0e0f7a69 --- /dev/null +++ b/tests/roundtrip/test_light_roundtrip.py @@ -0,0 +1,320 @@ +"""Numeric round-trip tests for the light radiometry, both directions.""" + +import importlib +import math + +import bpy +import numpy as np +import pytest +from bpy_extras.io_utils import axis_conversion +from mathutils import Vector + +AXIS_MAT = axis_conversion(to_forward='-Z', to_up='Y').to_4x4() + + +@pytest.fixture(scope='session') +def export_lights(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.export.lights') + + +@pytest.fixture(scope='session') +def import_lights(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.importer.lights') + + +@pytest.fixture +def export_ctx(mi_addon, tmp_path): + import mitsuba as mi + mi.set_variant('scalar_rgb') + module = importlib.import_module(f'{mi_addon}.io.exporter.export_context') + ctx = module.ExportContext() + ctx.directory = str(tmp_path) + ctx.axis_mat = AXIS_MAT.copy() + return ctx + + +def parse_scene_dict(scene_dict): + """Parse a Mitsuba scene dict and return its parser state.""" + import mitsuba as mi + config = mi.parser.ParserConfig(mi.variant()) + config.merge_meshes = False + state = mi.parser.parse_dict(config, scene_dict) + mi.parser.transform_all(config, state) + return state + + +def collect_props(state, object_type): + """The properties of all nodes of a given type, found by walking the + reference graph from the root.""" + from mitsuba import Properties + found = [] + stack = [state.root] + while stack: + node = stack.pop() + for _, value in node.props.items(): + if isinstance(value, Properties.ResolvedReference): + child = state.nodes[value.index()] + stack.append(child) + # Skip container plugins introduced by the parser + if child.type == object_type \ + and child.props.plugin_name() != 'merge': + found.append(child.props) + return found + + +@pytest.fixture +def make_mi_context(mi_addon, tmp_path): + common = importlib.import_module(f'{mi_addon}.io.importer.common') + + def _make(state): + return common.MitsubaSceneImportContext( + bpy.context.scene, bpy.context.collection, + str(tmp_path / 'scene.xml'), state, AXIS_MAT.copy()) + + return _make + + +def make_light(light_type, name='TestLight', location=(0, 0, 0), + rotation=(0, 0, 0), scale=(1, 1, 1), **data_props): + data = bpy.data.lights.new(name, light_type) + for key, value in data_props.items(): + setattr(data, key, value) + obj = bpy.data.objects.new(name, data) + obj.location = location + obj.rotation_euler = rotation + obj.scale = scale + bpy.context.collection.objects.link(obj) + bpy.context.view_layer.update() + return obj + + +def roundtrip_emitter(export_lights, import_lights, export_ctx, + make_mi_context, obj): + """Export a Blender light and feed it back through the importer.""" + from mitsuba import ObjectType + params = export_lights.convert_light(export_ctx, obj) + state = parse_scene_dict({'type': 'scene', 'light': params}) + mi_context = make_mi_context(state) + emitters = collect_props(state, ObjectType.Emitter) + assert len(emitters) == 1 + if params['type'] in ('rectangle', 'disk', 'sphere'): + shapes = collect_props(state, ObjectType.Shape) + assert len(shapes) == 1 + result = import_lights.mi_area_emitter_to_bl_light( + mi_context, emitters[0], shapes[0]) + else: + result = import_lights.mi_emitter_to_bl_light(mi_context, + emitters[0]) + assert result is not None + return result + + +def minus_z(matrix): + direction = matrix.to_3x3() @ Vector((0.0, 0.0, -1.0)) + return np.array(direction.normalized()) + + +def test_point_roundtrip(fresh_scene, export_ctx, export_lights, + import_lights, make_mi_context): + obj = make_light('POINT', location=(1, 2, 3), energy=100.0, + color=(1.0, 0.5, 0.25)) + bl_light, matrix = roundtrip_emitter(export_lights, import_lights, + export_ctx, make_mi_context, obj) + assert bl_light.type == 'POINT' + assert bl_light.energy == pytest.approx(100.0, rel=1e-4) + assert tuple(bl_light.color) == pytest.approx((1.0, 0.5, 0.25), + rel=1e-4) + assert list(matrix.to_translation()) == pytest.approx([1, 2, 3], + abs=1e-5) + + +def test_dim_point_light_keeps_radiometry(fresh_scene, export_ctx, + export_lights, import_lights, + make_mi_context): + # A dim light imports with a different energy/color split, but the + # product (the radiant intensity) must be preserved + obj = make_light('POINT', energy=1.0, color=(0.8, 0.6, 0.4)) + bl_light, _ = roundtrip_emitter(export_lights, import_lights, + export_ctx, make_mi_context, obj) + original = [1.0 / (4 * math.pi) * c for c in (0.8, 0.6, 0.4)] + imported = [bl_light.energy / (4 * math.pi) * c for c in bl_light.color] + assert imported == pytest.approx(original, rel=1e-4) + + +def test_point_radius_roundtrip(fresh_scene, export_ctx, export_lights, + import_lights, make_mi_context): + obj = make_light('POINT', location=(0, 1, 2), energy=60.0, + shadow_soft_size=0.5) + bl_light, matrix = roundtrip_emitter(export_lights, import_lights, + export_ctx, make_mi_context, obj) + assert bl_light.type == 'POINT' + assert bl_light.shadow_soft_size == pytest.approx(0.5, rel=1e-5) + assert bl_light.energy == pytest.approx(60.0, rel=1e-4) + assert list(matrix.to_translation()) == pytest.approx([0, 1, 2], + abs=1e-5) + + +def test_spot_roundtrip(fresh_scene, export_ctx, export_lights, + import_lights, make_mi_context): + obj = make_light('SPOT', location=(2, -1, 4), + rotation=(0.4, 0.2, 0.1), energy=80.0, + spot_size=math.radians(65), spot_blend=0.3) + bl_light, matrix = roundtrip_emitter(export_lights, import_lights, + export_ctx, make_mi_context, obj) + assert bl_light.type == 'SPOT' + assert bl_light.energy == pytest.approx(80.0, rel=1e-4) + assert bl_light.spot_size == pytest.approx(math.radians(65), rel=1e-4) + assert bl_light.spot_blend == pytest.approx(0.3, abs=1e-4) + np.testing.assert_allclose(minus_z(matrix), + minus_z(obj.matrix_world), atol=1e-5) + assert list(matrix.to_translation()) == pytest.approx([2, -1, 4], + abs=1e-5) + + +def test_sun_roundtrip(fresh_scene, export_ctx, export_lights, + import_lights, make_mi_context): + obj = make_light('SUN', rotation=(0.5, -0.3, 0.2), energy=3.0, + color=(1.0, 0.9, 0.8), angle=0.05) + bl_light, matrix = roundtrip_emitter(export_lights, import_lights, + export_ctx, make_mi_context, obj) + assert bl_light.type == 'SUN' + # The energy of a sun light is its irradiance and survives unchanged + assert bl_light.energy == pytest.approx(3.0, rel=1e-4) + assert tuple(bl_light.color) == pytest.approx((1.0, 0.9, 0.8), rel=1e-4) + assert bl_light.angle == 0.0 + np.testing.assert_allclose(minus_z(matrix), + minus_z(obj.matrix_world), atol=1e-5) + + +@pytest.mark.parametrize('shape,size,size_y,scale,expected_shape', [ + ('RECTANGLE', 2.0, 3.0, (2, 1, 1), 'RECTANGLE'), + ('SQUARE', 1.5, None, (1, 1, 1), 'RECTANGLE'), + ('DISK', 3.0, None, (1, 1, 1), 'DISK'), + ('ELLIPSE', 2.0, 1.0, (1, 1, 1), 'ELLIPSE'), +]) +def test_area_roundtrip(fresh_scene, export_ctx, export_lights, + import_lights, make_mi_context, shape, size, size_y, + scale, expected_shape): + data_props = {'shape': shape, 'size': size, 'energy': 45.0} + if size_y is not None: + data_props['size_y'] = size_y + obj = make_light('AREA', location=(0, 0, 3), rotation=(0.3, 0.1, 0.7), + scale=scale, **data_props) + bl_light, matrix = roundtrip_emitter(export_lights, import_lights, + export_ctx, make_mi_context, obj) + assert bl_light.type == 'AREA' + assert bl_light.shape == expected_shape + assert bl_light.energy == pytest.approx(45.0, rel=1e-4) + # The effective world-space dimensions must match the original + size_y = size if size_y is None else size_y + imported_scale = matrix.to_scale() + assert bl_light.size * imported_scale.x == \ + pytest.approx(size * scale[0], rel=1e-4) + if expected_shape in ('RECTANGLE', 'ELLIPSE'): + assert bl_light.size_y * imported_scale.y == \ + pytest.approx(size_y * scale[1], rel=1e-4) + np.testing.assert_allclose(minus_z(matrix), + minus_z(obj.matrix_world), atol=1e-5) + assert list(matrix.to_translation()) == pytest.approx([0, 0, 3], + abs=1e-5) + + +def test_foreign_rectangle_emitter_orientation(fresh_scene, export_ctx, + import_lights, + make_mi_context): + # A Mitsuba rectangle without flipped normals emits along its +Z + # axis; the imported Blender area light must emit the same way + from mitsuba import ObjectType + state = parse_scene_dict({ + 'type': 'scene', + 'light': { + 'type': 'rectangle', + 'emitter': {'type': 'area', + 'radiance': {'type': 'rgb', 'value': [5, 5, 5]}}, + }, + }) + mi_context = make_mi_context(state) + emitter = collect_props(state, ObjectType.Emitter)[0] + shape = collect_props(state, ObjectType.Shape)[0] + bl_light, matrix = import_lights.mi_area_emitter_to_bl_light( + mi_context, emitter, shape) + assert bl_light.type == 'AREA' + # radiance 5 over the 2x2 rectangle: P = 5 * pi * 4 + assert bl_light.energy == pytest.approx(5 * math.pi * 4, rel=1e-4) + expected = np.array(AXIS_MAT.inverted().to_3x3() @ Vector((0, 0, 1))) + np.testing.assert_allclose(minus_z(matrix), expected, atol=1e-6) + + +def test_unsupported_emitter_returns_none(fresh_scene, import_lights, + make_mi_context): + from mitsuba import ObjectType + state = parse_scene_dict({ + 'type': 'scene', + 'light': {'type': 'projector'}, + }) + mi_context = make_mi_context(state) + logs = [] + mi_context.log = lambda msg, level='INFO': logs.append((level, msg)) + emitter = collect_props(state, ObjectType.Emitter)[0] + assert import_lights.mi_emitter_to_bl_light(mi_context, emitter) is None + assert any(level == 'WARN' for level, _ in logs) + + +def test_import_spot_defaults(fresh_scene, import_lights, make_mi_context): + # Mitsuba's spot defaults: cutoff_angle 20 deg, beam_width 3/4 of it + from mitsuba import ObjectType + state = parse_scene_dict({ + 'type': 'scene', + 'light': {'type': 'spot'}, + }) + mi_context = make_mi_context(state) + emitter = collect_props(state, ObjectType.Emitter)[0] + bl_light, _ = import_lights.mi_emitter_to_bl_light(mi_context, emitter) + assert bl_light.spot_size == pytest.approx(math.radians(40), rel=1e-5) + expected_blend = import_lights.spot_blend( + math.radians(40), math.radians(15)) + assert bl_light.spot_blend == pytest.approx(expected_blend, rel=1e-4) + + +def test_area_light_scene_roundtrip(mi_addon, fresh_scene, tmp_path): + """A Blender area light survives a full XML export-import cycle as a + real light, not an emissive mesh.""" + import sys + + import mitsuba as mi + + bpy.data.objects.remove(bpy.data.objects['Light']) + light = bpy.data.lights.new('Area', 'AREA') + light.shape = 'RECTANGLE' + light.size = 2.0 + light.size_y = 3.0 + light.energy = 60.0 + obj = bpy.data.objects.new('Area', light) + obj.location = (1.0, 2.0, 3.0) + bpy.context.collection.objects.link(obj) + bpy.context.view_layer.update() + + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + converter = sys.modules[mi_addon].io.exporter.SceneConverter(render=False) + converter.export_ctx.directory = str(tmp_path) + converter.export_ctx.axis_mat = AXIS_MAT.copy() + converter.scene_to_dict(bpy.context.evaluated_depsgraph_get()) + converter.dict_to_xml(str(tmp_path / 'scene.xml')) + + bpy.ops.wm.read_homefile() + assert bpy.ops.import_scene.mitsuba( + filepath=str(tmp_path / 'scene.xml')) == {'FINISHED'} + + bl_lights = [o for o in bpy.data.objects if o.type == 'LIGHT'] + assert len(bl_lights) == 1 + imported = bl_lights[0] + data = imported.data + assert data.type == 'AREA' + assert data.shape == 'RECTANGLE' + assert data.energy == pytest.approx(60.0, rel=1e-4) + scale = imported.matrix_world.to_scale() + assert data.size * scale.x == pytest.approx(2.0, rel=1e-4) + assert data.size_y * scale.y == pytest.approx(3.0, rel=1e-4) + assert list(imported.matrix_world.to_translation()) == \ + pytest.approx([1.0, 2.0, 3.0], abs=1e-5) diff --git a/tests/roundtrip/test_mesh_export.py b/tests/roundtrip/test_mesh_export.py new file mode 100644 index 00000000..3cdbe08a --- /dev/null +++ b/tests/roundtrip/test_mesh_export.py @@ -0,0 +1,401 @@ +"""Export-side geometry tests: Blender meshes -> mi.Mesh.""" + +import os +import re +import sys + +import bpy +import numpy as np +import pytest + + +@pytest.fixture +def exporter(mi_addon): + """Returns a function that exports the current scene to a Mitsuba scene.""" + import mitsuba as mi + + def _export(directory, render=True, blender_triangulation=False): + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + converter = sys.modules[mi_addon].io.exporter.SceneConverter(render=render) + converter.export_ctx.directory = str(directory) + converter.export_ctx.blender_triangulation = blender_triangulation + depsgraph = bpy.context.evaluated_depsgraph_get() + converter.scene_to_dict(depsgraph) + return converter + + return _export + + +def scene_meshes(mi_scene): + import mitsuba as mi + return [s for s in mi_scene.shapes() if isinstance(s, mi.Mesh)] + + +def triangle_loops(b_mesh, triangulate): + """The loop indices of each triangle corner, triangulated the way the + exporter asked for: Blender's own split, or the fan around each + polygon's first corner that Mitsuba applies.""" + if triangulate == 'blender': + b_mesh.calc_loop_triangles() + loops = np.empty(len(b_mesh.loop_triangles) * 3, dtype=np.int32) + b_mesh.loop_triangles.foreach_get('loops', loops) + return loops.reshape(-1, 3) + + starts = np.empty(len(b_mesh.polygons), dtype=np.int32) + b_mesh.polygons.foreach_get('loop_start', starts) + totals = np.empty(len(b_mesh.polygons), dtype=np.int32) + b_mesh.polygons.foreach_get('loop_total', totals) + return np.array([[s, s + i, s + i + 1] + for s, n in zip(starts, totals) + for i in range(1, n - 1)], dtype=np.int32) + + +def blender_corners(b_obj, axis_mat, triangulate): + """Blender's own per-triangle-corner data, in Mitsuba world space. + + This mirrors the exporter's conventions (axis conversion, baked object + transform, flipped V coordinate, reversed winding under a mirroring + transform) without sharing any of its code, so that the exported mesh + can be compared against it corner by corner. + """ + b_mesh = b_obj.data + n_loops = len(b_mesh.loops) + + tri_loops = triangle_loops(b_mesh, triangulate) + loop_vert = np.empty(n_loops, dtype=np.int32) + b_mesh.loops.foreach_get('vertex_index', loop_vert) + co = np.empty(len(b_mesh.vertices) * 3, dtype=np.float32) + b_mesh.vertices.foreach_get('co', co) + normals = np.empty(n_loops * 3, dtype=np.float32) + b_mesh.corner_normals.foreach_get('vector', normals) + + uvs = None + for layer in b_mesh.uv_layers: + if layer.active_render: + uvs = np.empty(n_loops * 2, dtype=np.float32) + layer.uv.foreach_get('vector', uvs) + uvs = uvs.reshape(-1, 2).astype(np.float64) + break + + colors = {} + for attr in b_mesh.color_attributes: + values = np.empty(len(attr.data) * 4, dtype=np.float32) + attr.data.foreach_get('color', values) + values = values.reshape(-1, 4)[:, :3].astype(np.float64) + # Attribute names reach Mitsuba with their non-word characters + # replaced, see sanitize_attribute_name() + colors[re.sub(r'\W', '_', attr.name)] = \ + values[loop_vert] if attr.domain == 'POINT' else values + + to_world = np.array(axis_mat @ b_obj.matrix_world, dtype=np.float64) + rot = to_world[:3, :3] + corners = tri_loops + if np.linalg.det(rot) < 0: + corners = corners[:, ::-1] + corners = corners.ravel() + + positions = co.reshape(-1, 3).astype(np.float64) @ rot.T + to_world[:3, 3] + n = normals.reshape(-1, 3).astype(np.float64) @ np.linalg.inv(rot) + n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-20) + + out = {'positions': positions[loop_vert[corners]], 'normals': n[corners]} + if uvs is not None: + out['texcoords'] = uvs[corners] + for name, values in colors.items(): + out[name] = values[corners] + return out + + +def mitsuba_corners(mesh, color_names=()): + """The same per-corner data, read back from the exported mi.Mesh.""" + faces = np.array(mesh.faces()).reshape(-1, 3).ravel() + attrs = {name: np.array(mesh.attribute(f'vertex_{name}')).reshape(-1, 3) + for name in color_names} + + out = {'positions': np.array([list(mesh.vertex_position(int(v))) + for v in faces]), + 'normals': np.array([list(mesh.vertex_normal(int(v))) + for v in faces])} + if mesh.has_texcoords(): + out['texcoords'] = np.array([list(mesh.vertex_texcoord(int(v))) + for v in faces]) + for name, values in attrs.items(): + out[name] = values[faces] + return out + + +def sole_mesh_object(build): + """Clears the scene's mesh objects, then runs `build` to add one.""" + for obj in [o for o in bpy.data.objects if o.type == 'MESH']: + bpy.data.objects.remove(obj, do_unlink=True) + build() + return bpy.context.active_object + + +def add_ngon_grid(): + b_mesh = bpy.data.meshes.new('Ngons') + verts = [(x, y, 0.1 * x * y) for y in range(3) for x in range(4)] + # A pentagon, a quad and a triangle, so the fan triangulation and the + # per-polygon corner counts both vary + faces = [(0, 1, 5, 6, 4), (1, 2, 6, 5), (2, 3, 7)] + b_mesh.from_pydata(verts, [], faces) + b_mesh.update() + uv = b_mesh.uv_layers.new(name='UVMap') + for i in range(len(b_mesh.loops)): + uv.uv[i].vector = (0.1 * i, 0.05 * i) + obj = bpy.data.objects.new('Ngons', b_mesh) + bpy.context.scene.collection.objects.link(obj) + bpy.context.view_layer.objects.active = obj + + +def add_colored_cube(): + bpy.ops.mesh.primitive_cube_add() + b_mesh = bpy.context.active_object.data + per_corner = b_mesh.color_attributes.new('Corner Col', 'FLOAT_COLOR', + 'CORNER') + rng = np.random.RandomState(3) + per_corner.data.foreach_set( + 'color', rng.rand(len(per_corner.data) * 4).astype(np.float32)) + per_point = b_mesh.color_attributes.new('PtCol', 'FLOAT_COLOR', 'POINT') + per_point.data.foreach_set( + 'color', rng.rand(len(per_point.data) * 4).astype(np.float32)) + + +MESH_CASES = { + 'flat_cube': (lambda: bpy.ops.mesh.primitive_cube_add(), None, ()), + 'smooth_sphere': (lambda: (bpy.ops.mesh.primitive_uv_sphere_add(), + bpy.ops.object.shade_smooth()), None, ()), + 'suzanne': (lambda: bpy.ops.mesh.primitive_monkey_add(), None, ()), + 'mirrored': (lambda: bpy.ops.mesh.primitive_cylinder_add(), + (-1.0, 1.0, 2.0), ()), + 'ngons': (add_ngon_grid, None, ()), + 'colors': (add_colored_cube, (0.5, -1.5, 1.0), ('Corner_Col', 'PtCol')), +} + + +@pytest.mark.parametrize('case', sorted(MESH_CASES)) +@pytest.mark.parametrize('triangulate', ['mitsuba', 'blender']) +def test_corner_data_matches_blender(fresh_scene, exporter, tmp_path, case, + triangulate): + """Every triangle corner of the exported mesh carries the values that + Blender holds for the loop it came from. This covers the corner-indexed + Mesh construction end to end: welding, vertex splitting at normal and + UV seams, the fan triangulation of n-gons, and the winding flip and + inverse-transpose normals of a mirroring transform.""" + build, scale, color_names = MESH_CASES[case] + b_obj = sole_mesh_object(build) + if scale is not None: + b_obj.scale = scale + + converter = exporter(tmp_path, render=True, + blender_triangulation=triangulate == 'blender') + scene = converter.dict_to_scene() + meshes = scene_meshes(scene) + assert len(meshes) == 1 + mesh = meshes[0] + + depsgraph = bpy.context.evaluated_depsgraph_get() + reference = blender_corners(b_obj.evaluated_get(depsgraph), + converter.export_ctx.axis_mat, triangulate) + assert mesh.face_count() == len(reference['positions']) // 3 + + exported = mitsuba_corners(mesh, color_names) + assert sorted(exported) == sorted(reference) + for key, expected in reference.items(): + assert np.allclose(exported[key], expected, atol=1e-5), \ + f'{case}: {key} differs' + + # Welding must not leave one vertex per corner behind + assert mesh.vertex_count() < len(reference['positions']) + + +def test_render_mode_writes_meshes(fresh_scene, exporter, tmp_path): + converter = exporter(tmp_path, render=True) + scene = converter.dict_to_scene() + assert os.path.isdir(os.path.join(str(tmp_path), 'meshes')) + assert len(scene.shapes()) > 0 + + +def test_file_export_writes_serialized(fresh_scene, exporter, tmp_path): + bpy.data.objects['Cube'].location = (2.0, 0.0, 0.0) + converter = exporter(tmp_path, render=False) + converter.dict_to_xml(str(tmp_path / 'scene.xml')) + + # Every mesh lives in one shared file, referenced by sub-mesh index + assert os.listdir(tmp_path / 'meshes') == ['meshes.serialized'] + + import mitsuba as mi + scene = mi.load_file(str(tmp_path / 'scene.xml')) + meshes = scene_meshes(scene) + assert len(meshes) == 1 + # The object transform is baked into the vertex data + center = 0.5 * (np.array(list(meshes[0].bbox().min)) + + np.array(list(meshes[0].bbox().max))) + assert np.allclose(center, [2, 0, 0], atol=1e-5) + + +def test_smooth_shading_welds_vertices(fresh_scene, exporter, tmp_path): + bpy.ops.mesh.primitive_monkey_add() + bpy.ops.object.shade_smooth() + b_mesh = bpy.context.active_object.data + b_mesh.calc_loop_triangles() + tri_count = len(b_mesh.loop_triangles) + vert_count = len(b_mesh.vertices) + + converter = exporter(tmp_path, render=True) + scene = converter.dict_to_scene() + suzanne = [m for m in scene_meshes(scene) if m.face_count() == tri_count] + assert len(suzanne) == 1 + # Smooth shading shares normals, so welding gets close to the original + # vertex count instead of one vertex per corner + assert suzanne[0].vertex_count() < 1.2 * vert_count + + +def vertex_arrays(mesh): + """Per-vertex positions and normals, which the mesh may store at a + coarser granularity internally.""" + n = mesh.vertex_count() + return (np.array([list(mesh.vertex_position(i)) for i in range(n)]), + np.array([list(mesh.vertex_normal(i)) for i in range(n)])) + + +def test_mirrored_object_keeps_orientation(fresh_scene, exporter, tmp_path): + bpy.data.objects['Cube'].scale = (-1.0, 1.0, 1.0) + converter = exporter(tmp_path, render=True) + scene = converter.dict_to_scene() + mesh = scene_meshes(scene)[0] + positions, normals = vertex_arrays(mesh) + # Normals still point away from the center after mirroring + assert np.all(np.sum(positions * normals, axis=1) > 0) + + +def distinct_material(name, color): + """A material that differs from the default one, so that Mitsuba does + not merge the shapes using them into a single mesh.""" + b_mat = bpy.data.materials.new(name) + if b_mat.node_tree is None: + # Blender releases before 5.0 start without a node tree + b_mat.use_nodes = True + b_mat.node_tree.nodes['Principled BSDF'] \ + .inputs['Base Color'].default_value = color + return b_mat + + +def test_multi_material_split(fresh_scene, exporter, tmp_path): + b_obj = bpy.data.objects['Cube'] + b_obj.data.materials.append(distinct_material('Second', (1, 0, 0, 1))) + for face in b_obj.data.polygons[:2]: + face.material_index = 1 + + converter = exporter(tmp_path, render=False) + parts = [v for v in converter.export_ctx.scene_data.values() + if isinstance(v, dict) and v.get('type') == 'serialized'] + assert len(parts) == 2 + assert parts[0]['bsdf']['id'] != parts[1]['bsdf']['id'] + assert {p['shape_index'] for p in parts} == {0, 1} + + scene = converter.dict_to_scene() + face_counts = sorted(m.face_count() for m in scene_meshes(scene)) + assert face_counts == [4, 8] + + +def test_emissive_material_render_mode(fresh_scene, exporter, tmp_path): + b_mat = bpy.data.materials.new('Emitter') + b_mat.use_nodes = True + tree = b_mat.node_tree + tree.nodes.remove(tree.nodes['Principled BSDF']) + emission = tree.nodes.new('ShaderNodeEmission') + emission.inputs['Strength'].default_value = 5.0 + tree.links.new(emission.outputs['Emission'], + tree.nodes['Material Output'].inputs['Surface']) + bpy.data.objects['Cube'].data.materials.clear() + bpy.data.objects['Cube'].data.materials.append(b_mat) + + converter = exporter(tmp_path, render=True) + scene = converter.dict_to_scene() + mesh = scene_meshes(scene)[0] + assert mesh.emitter() is not None + + +def test_vertex_colors_roundtrip(fresh_scene, exporter, tmp_path): + b_mesh = bpy.data.objects['Cube'].data + attr = b_mesh.color_attributes.new('Col', 'FLOAT_COLOR', 'POINT') + color = np.tile([0.25, 0.5, 0.75, 1.0], len(b_mesh.vertices)) + attr.data.foreach_set('color', color) + + converter = exporter(tmp_path, render=False) + converter.dict_to_xml(str(tmp_path / 'scene.xml')) + + import mitsuba as mi + mesh = mi.load_dict({ + 'type': 'serialized', + 'filename': str(tmp_path / 'meshes' / 'meshes.serialized'), + }) + params = mi.traverse(mesh) + values = np.array(params['vertex_Col']).reshape(-1, 3) + assert np.allclose(values, [0.25, 0.5, 0.75], atol=1e-5) + + +def test_faceless_mesh_does_not_abort_export(fresh_scene, exporter, tmp_path): + b_mesh = bpy.data.meshes.new('Wire') + b_mesh.from_pydata([(0, 0, 0), (1, 0, 0)], [(0, 1)], []) + b_mesh.materials.append(bpy.data.materials.new('WireMat')) + b_obj = bpy.data.objects.new('Wire', b_mesh) + bpy.context.scene.collection.objects.link(b_obj) + + converter = exporter(tmp_path, render=True) + scene = converter.dict_to_scene() + # Only the default cube survives; the faceless mesh is skipped + assert len(scene_meshes(scene)) == 1 + assert any('no faces' in w for w in converter.export_ctx.warnings) + + +def test_empty_material_slot_uses_default_bsdf(fresh_scene, exporter, + tmp_path): + b_obj = bpy.data.objects['Cube'] + b_obj.data.materials.clear() + b_obj.data.materials.append(None) + b_obj.data.materials.append(bpy.data.materials.new('Second')) + for face in b_obj.data.polygons[:2]: + face.material_index = 1 + + converter = exporter(tmp_path, render=False) + parts = [v for v in converter.export_ctx.scene_data.values() + if isinstance(v, dict) and v.get('type') == 'serialized'] + assert len(parts) == 2 + assert {p['bsdf']['id'] for p in parts} == {'default-bsdf', 'mat-Second'} + + scene = converter.dict_to_scene() + assert sorted(m.face_count() for m in scene_meshes(scene)) == [4, 8] + + +def test_material_name_with_path_separator(fresh_scene, exporter, tmp_path): + # Part filenames embed the material name; a separator in it must not + # make write_ply target a nonexistent subdirectory + b_obj = bpy.data.objects['Cube'] + b_obj.data.materials.append( + distinct_material('metal/rough', (0, 1, 0, 1))) + for face in b_obj.data.polygons[:2]: + face.material_index = 1 + + converter = exporter(tmp_path, render=False) + converter.dict_to_xml(str(tmp_path / 'scene.xml')) + assert os.listdir(tmp_path / 'meshes') == ['meshes.serialized'] + + import mitsuba as mi + scene = mi.load_file(str(tmp_path / 'scene.xml')) + assert len(scene_meshes(scene)) == 2 + + +def test_out_of_range_material_index_not_dropped(fresh_scene, exporter, + tmp_path): + # Indices beyond the slot count come from slot deletion via the API or + # imported files; Blender clamps them at render time + b_mesh = bpy.data.objects['Cube'].data + for face in b_mesh.polygons[:3]: + face.material_index = 7 + + converter = exporter(tmp_path, render=True) + scene = converter.dict_to_scene() + assert sum(m.face_count() for m in scene_meshes(scene)) == 12 diff --git a/tests/roundtrip/test_mesh_import.py b/tests/roundtrip/test_mesh_import.py new file mode 100644 index 00000000..442f1676 --- /dev/null +++ b/tests/roundtrip/test_mesh_import.py @@ -0,0 +1,216 @@ +"""Import-side geometry tests: mi shapes -> Blender meshes, and roundtrips.""" + +import glob +import os +import sys + +import bpy +import numpy as np +import pytest + +SCENES_DIR = os.path.join(os.path.dirname(os.path.dirname( + os.path.realpath(__file__))), 'res', 'scenes') +SCENE_FILES = sorted(glob.glob(os.path.join(SCENES_DIR, '*.xml'))) + + +def export_scene(mi_addon, filepath): + import mitsuba as mi + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + converter = sys.modules[mi_addon].io.exporter.SceneConverter(render=False) + converter.export_ctx.directory = os.path.dirname(filepath) + converter.scene_to_dict(bpy.context.evaluated_depsgraph_get()) + converter.dict_to_xml(filepath) + + +def imported_meshes(): + return [o for o in bpy.context.scene.objects if o.type == 'MESH'] + + +@pytest.mark.parametrize('scene_file', SCENE_FILES, + ids=[os.path.basename(f) for f in SCENE_FILES]) +def test_import_scene_files(mi_addon, fresh_scene, scene_file): + assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} + + +def test_import_ply_shape(mi_addon, fresh_scene): + scene_file = os.path.join(SCENES_DIR, 'test1.xml') + assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} + + meshes = imported_meshes() + assert len(meshes) == 1 + b_mesh = meshes[0].data + b_mesh.calc_loop_triangles() + assert len(b_mesh.loop_triangles) == 12 + assert b_mesh.materials[0] is not None + # The scene requests face normals, which map to flat shading + assert all(not p.use_smooth for p in b_mesh.polygons) + + +def test_roundtrip_smooth_suzanne(mi_addon, fresh_scene, tmp_path): + bpy.ops.mesh.primitive_monkey_add() + bpy.ops.object.shade_smooth() + b_mesh = bpy.context.active_object.data + b_mesh.calc_loop_triangles() + tri_count = len(b_mesh.loop_triangles) + bounds = np.array([v.co for v in b_mesh.vertices]) + + scene_file = str(tmp_path / 'scene.xml') + export_scene(mi_addon, scene_file) + assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} + + meshes = [o for o in imported_meshes() + if len(o.data.polygons) == tri_count] + assert len(meshes) == 1 + imported = meshes[0].data + assert imported.uv_layers + assert all(p.use_smooth for p in imported.polygons) + imported_bounds = np.array([v.co for v in imported.vertices]) + assert np.allclose(bounds.min(axis=0), imported_bounds.min(axis=0), + atol=1e-4) + assert np.allclose(bounds.max(axis=0), imported_bounds.max(axis=0), + atol=1e-4) + + +def test_roundtrip_flat_cube_normals(mi_addon, fresh_scene, tmp_path): + scene_file = str(tmp_path / 'scene.xml') + export_scene(mi_addon, scene_file) + assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} + + meshes = [o for o in imported_meshes() if len(o.data.polygons) == 12] + assert len(meshes) == 1 + b_mesh = meshes[0].data + normals = np.empty(len(b_mesh.loops) * 3, dtype=np.float32) + b_mesh.corner_normals.foreach_get('vector', normals) + normals = normals.reshape(-1, 3, 3) + # The default cube is flat shaded: every triangle has a uniform normal + assert np.allclose(normals[:, 0], normals[:, 1], atol=1e-5) + assert np.allclose(normals[:, 0], normals[:, 2], atol=1e-5) + + +def test_roundtrip_multi_material(mi_addon, fresh_scene, tmp_path): + # The default point light would come back as an emissive sphere mesh + bpy.data.objects.remove(bpy.data.objects['Light']) + b_obj = bpy.data.objects['Cube'] + # Identical materials are deduplicated when the scene is written, so + # the second one needs a distinct color to survive the roundtrip + second = bpy.data.materials.new('Second') + if second.node_tree is None: + # Blender releases before 5.0 start without a node tree + second.use_nodes = True + second.node_tree.nodes['Principled BSDF'] \ + .inputs['Base Color'].default_value = (1.0, 0.0, 0.0, 1.0) + b_obj.data.materials.append(second) + for face in b_obj.data.polygons[:2]: + face.material_index = 1 + + scene_file = str(tmp_path / 'scene.xml') + export_scene(mi_addon, scene_file) + assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} + + meshes = imported_meshes() + assert sorted(len(o.data.polygons) for o in meshes) == [4, 8] + mat_names = {o.data.materials[0].name for o in meshes} + assert len(mat_names) == 2 + + +def test_roundtrip_vertex_colors(mi_addon, fresh_scene, tmp_path): + b_mesh = bpy.data.objects['Cube'].data + attr = b_mesh.color_attributes.new('Col', 'FLOAT_COLOR', 'POINT') + color = np.tile([0.25, 0.5, 0.75, 1.0], len(b_mesh.vertices)) + attr.data.foreach_set('color', color) + + scene_file = str(tmp_path / 'scene.xml') + export_scene(mi_addon, scene_file) + assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} + + meshes = [o for o in imported_meshes() if len(o.data.polygons) == 12] + imported = meshes[0].data + assert 'Col' in imported.color_attributes + values = np.empty(len(imported.color_attributes['Col'].data) * 4, + dtype=np.float32) + imported.color_attributes['Col'].data.foreach_get('color', values) + assert np.allclose(values.reshape(-1, 4)[:, :3], [0.25, 0.5, 0.75], + atol=1e-5) + + +def test_import_degenerate_face_with_uvs(mi_addon, fresh_scene, tmp_path): + # bl_mesh.validate() silently deletes invalid faces (e.g. a repeated + # vertex index); UVs set from the pre-validate face buffer then no + # longer match the loop count and crashed the whole import + import mitsuba as mi + mi.set_variant('scalar_rgb') + mesh = mi.Mesh('degen') + mesh.from_fields( + faces=mi.TensorXu([[0, 1, 2], [1, 1, 3]]), + positions=mi.TensorXf([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]), + texcoords=mi.TensorXf([[0, 0], [1, 0], [1, 1], [0, 1]])) + ply_file = str(tmp_path / 'degen.ply') + mesh.write_ply(ply_file) + + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(''' + + + + ''') + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + + meshes = imported_meshes() + assert len(meshes) == 1 + imported = meshes[0].data + assert len(imported.polygons) == 1 + assert imported.uv_layers + uvs = np.empty(len(imported.loops) * 2, dtype=np.float32) + imported.uv_layers[0].uv.foreach_get('vector', uvs) + # Texture coordinates port over verbatim + assert np.allclose(sorted(uvs.reshape(-1, 2).tolist()), + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]) + + +def test_merge_shapes_import(mi_addon, fresh_scene, tmp_path): + # The merge_meshes parser transform moves all top-level shapes under + # one merge node; the importer used to drop its references and + # produce a single 0-vertex placeholder + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(''' + + + + + + + + + ''') + assert bpy.ops.import_scene.mitsuba( + filepath=str(scene_file), merge_shapes=True) == {'FINISHED'} + + meshes = imported_meshes() + assert meshes + assert all(len(o.data.vertices) > 0 for o in meshes) + assert sum(len(o.data.vertices) for o in meshes) == 8 + + +def test_sphere_center_radius_and_to_world(mi_addon, fresh_scene, tmp_path): + # Mitsuba composes to_world with center/radius; the importer treated + # them as mutually exclusive + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(''' + + + + + + ''') + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + + meshes = imported_meshes() + assert len(meshes) == 1 + obj = meshes[0] + # Mitsuba-space center (11, 2, 3) maps to Blender space (11, -3, 2) + assert np.allclose(obj.matrix_world.to_translation(), (11.0, -3.0, 2.0), + atol=1e-5) + assert np.allclose(obj.dimensions, (4.0, 4.0, 4.0), atol=1e-2) diff --git a/tests/roundtrip/test_mesh_instancing.py b/tests/roundtrip/test_mesh_instancing.py new file mode 100644 index 00000000..56d00afd --- /dev/null +++ b/tests/roundtrip/test_mesh_instancing.py @@ -0,0 +1,171 @@ +"""Instancing export tests: shared data becomes shapegroup + instances.""" + +import sys + +import bpy +import pytest + + +@pytest.fixture +def exporter(mi_addon): + """Returns a function that exports the current scene to a Mitsuba scene.""" + import mitsuba as mi + + def _export(directory, render=True): + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + converter = sys.modules[mi_addon].io.exporter.SceneConverter(render=render) + converter.export_ctx.directory = str(directory) + depsgraph = bpy.context.evaluated_depsgraph_get() + converter.scene_to_dict(depsgraph) + return converter + + return _export + + +def count_types(converter, type_name): + return sum(1 for v in converter.export_ctx.scene_data.values() + if isinstance(v, dict) and v.get('type') == type_name) + + +def remove_default_light(): + # Point lights export as sphere emitter shapes, which would skew the + # shape counts these tests assert on + bpy.data.objects.remove(bpy.data.objects['Light']) + + +def test_linked_duplicates_share_a_shapegroup(fresh_scene, exporter, tmp_path): + remove_default_light() + b_cube = bpy.data.objects['Cube'] + duplicate = bpy.data.objects.new('Cube2', b_cube.data) + duplicate.location = (4.0, 0.0, 0.0) + bpy.context.collection.objects.link(duplicate) + + converter = exporter(tmp_path, render=False) + assert count_types(converter, 'shapegroup') == 1 + assert count_types(converter, 'instance') == 2 + group = next(v for v in converter.export_ctx.scene_data.values() + if isinstance(v, dict) and v.get('type') == 'shapegroup') + parts = [v for v in group.values() + if isinstance(v, dict) and v.get('type') == 'serialized'] + assert len(parts) == 1 + + scene = converter.dict_to_scene() + assert len(scene.shapes()) == 2 + + +def test_material_override_prevents_instancing(fresh_scene, exporter, tmp_path): + b_cube = bpy.data.objects['Cube'] + duplicate = bpy.data.objects.new('Cube2', b_cube.data) + duplicate.location = (4.0, 0.0, 0.0) + bpy.context.collection.objects.link(duplicate) + override = bpy.data.materials.new('Override') + duplicate.material_slots[0].link = 'OBJECT' + duplicate.material_slots[0].material = override + + converter = exporter(tmp_path, render=False) + assert count_types(converter, 'shapegroup') == 0 + assert count_types(converter, 'instance') == 0 + plys = [v for v in converter.export_ctx.scene_data.values() + if isinstance(v, dict) and v.get('type') == 'serialized'] + assert sorted(p['bsdf']['id'] for p in plys) \ + == ['mat-Material', 'mat-Override'] + + +def test_collection_instances(fresh_scene, exporter, tmp_path): + remove_default_light() + proto = bpy.data.collections.new('Proto') + b_cube = bpy.data.objects['Cube'] + bpy.context.collection.objects.unlink(b_cube) + proto.objects.link(b_cube) + + for i in range(3): + empty = bpy.data.objects.new(f'Instancer{i}', None) + empty.instance_type = 'COLLECTION' + empty.instance_collection = proto + empty.location = (4.0 * i, 0.0, 0.0) + bpy.context.collection.objects.link(empty) + + converter = exporter(tmp_path, render=True) + assert count_types(converter, 'shapegroup') == 1 + assert count_types(converter, 'instance') == 3 + + scene = converter.dict_to_scene() + assert len(scene.shapes()) == 3 + # The instance transforms are distinct + centers = set() + for shape in scene.shapes(): + bbox = shape.bbox() + centers.add(round(0.5 * (bbox.min.x + bbox.max.x), 3)) + assert len(centers) == 3 + + +def test_vertex_instancer_prototype_is_hidden(fresh_scene, exporter, tmp_path): + b_cube = bpy.data.objects['Cube'] + bpy.ops.mesh.primitive_plane_add(size=10) + plane = bpy.context.active_object + b_cube.parent = plane + plane.instance_type = 'VERTS' + + converter = exporter(tmp_path, render=False) + # One instance per plane vertex; none at the prototype's own location + assert count_types(converter, 'shapegroup') == 1 + assert count_types(converter, 'instance') == 4 + # The plane itself is still a plain top-level shape + assert count_types(converter, 'serialized') == 1 + + +def test_particle_instances(fresh_scene, exporter, tmp_path): + remove_default_light() + emitter = bpy.data.objects['Cube'] + psys = emitter.modifiers.new('ps', 'PARTICLE_SYSTEM').particle_system + settings = psys.settings + settings.count = 5 + settings.render_type = 'OBJECT' + settings.frame_start = settings.frame_end = 1 + settings.physics_type = 'NO' + bpy.ops.mesh.primitive_ico_sphere_add(location=(20.0, 0.0, 0.0)) + settings.instance_object = bpy.context.active_object + bpy.context.view_layer.update() + + converter = exporter(tmp_path, render=False) + assert count_types(converter, 'shapegroup') == 1 + # 5 particles plus the prototype object itself + assert count_types(converter, 'instance') == 6 + + scene = converter.dict_to_scene() + assert len(scene.shapes()) == 7 + + +def test_shared_emissive_mesh_avoids_shapegroup(fresh_scene, exporter, + tmp_path): + # Mitsuba rejects emitters inside a shapegroup, so linked duplicates + # with an emissive material fall back to per-occurrence plain shapes + remove_default_light() + b_mat = bpy.data.materials.new('Emitter') + b_mat.use_nodes = True + tree = b_mat.node_tree + tree.nodes.remove(tree.nodes['Principled BSDF']) + emission = tree.nodes.new('ShaderNodeEmission') + emission.inputs['Strength'].default_value = 5.0 + tree.links.new(emission.outputs['Emission'], + tree.nodes['Material Output'].inputs['Surface']) + b_cube = bpy.data.objects['Cube'] + b_cube.data.materials.clear() + b_cube.data.materials.append(b_mat) + duplicate = bpy.data.objects.new('Cube2', b_cube.data) + duplicate.location = (4.0, 0.0, 0.0) + bpy.context.collection.objects.link(duplicate) + + converter = exporter(tmp_path, render=True) + assert count_types(converter, 'shapegroup') == 0 + assert count_types(converter, 'instance') == 0 + assert any('emissive' in w for w in converter.export_ctx.warnings) + + scene = converter.dict_to_scene() + assert len(scene.shapes()) == 2 + assert all(s.emitter() is not None for s in scene.shapes()) + # Each occurrence keeps its own transform + centers = {round(0.5 * (s.bbox().min.x + s.bbox().max.x), 3) + for s in scene.shapes()} + assert len(centers) == 2 diff --git a/tests/roundtrip/test_principled.py b/tests/roundtrip/test_principled.py new file mode 100644 index 00000000..9c1d5278 --- /dev/null +++ b/tests/roundtrip/test_principled.py @@ -0,0 +1,173 @@ +"""Roundtrip tests for Principled BSDF materials.""" + +import importlib +import sys + +import bpy +import pytest + + +@pytest.fixture +def exporter(mi_addon): + """Exports the current scene and returns the SceneConverter.""" + import mitsuba as mi + + def _export(directory, render=False): + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + converter = sys.modules[mi_addon].io.exporter.SceneConverter(render=render) + converter.export_ctx.directory = str(directory) + converter.scene_to_dict(bpy.context.evaluated_depsgraph_get()) + return converter + + return _export + + +def principled_node(name='Material'): + return bpy.data.materials[name].node_tree.nodes['Principled BSDF'] + + +def surface_node(b_mat): + surface = b_mat.node_tree.get_output_node('CYCLES').inputs['Surface'] + assert surface.is_linked + return surface.links[0].from_node + + +def test_roundtrip_transmissive(mi_addon, fresh_scene, exporter, tmp_path): + node = principled_node() + node.inputs['Base Color'].default_value = (0.2, 0.4, 0.6, 1.0) + node.inputs['Roughness'].default_value = 0.2 + node.inputs['Transmission Weight'].default_value = 1.0 + node.inputs['IOR'].default_value = 1.33 + + converter = exporter(tmp_path) + converter.dict_to_xml(str(tmp_path / 'scene.xml')) + bpy.ops.wm.read_homefile() + assert bpy.ops.import_scene.mitsuba( + filepath=str(tmp_path / 'scene.xml')) == {'FINISHED'} + + node = surface_node(bpy.data.materials['mat-Material']) + assert node.bl_idname == 'ShaderNodeBsdfPrincipled' + assert tuple(node.inputs['Base Color'].default_value) == \ + pytest.approx((0.2, 0.4, 0.6, 1.0)) + assert node.inputs['Roughness'].default_value == pytest.approx(0.2) + assert node.inputs['Transmission Weight'].default_value == \ + pytest.approx(1.0) + assert node.inputs['IOR'].default_value == pytest.approx(1.33) + + +def test_roundtrip_reflective_through_registries(mi_addon, fresh_scene, + exporter, tmp_path): + """Export dict -> mi parser -> import registry, without the legacy + twosided unwrapping in between.""" + import mitsuba as mi + + node = principled_node() + node.inputs['Base Color'].default_value = (0.6, 0.3, 0.1, 1.0) + node.inputs['Roughness'].default_value = 0.3 + node.inputs['Metallic'].default_value = 0.6 + node.inputs['Anisotropic'].default_value = 0.4 + node.inputs['Specular IOR Level'].default_value = 0.4 + node.inputs['Sheen Weight'].default_value = 0.3 + node.inputs['Coat Weight'].default_value = 0.5 + node.inputs['Coat Roughness'].default_value = 0.1 + + converter = exporter(tmp_path) + entry = converter.export_ctx.data_get('mat-Material') + assert entry['type'] == 'twosided' + + state = mi.parser.parse_dict(mi.parser.ParserConfig(mi.variant()), + {'type': 'scene', 'mat': entry}) + mi_props = next(n.props for n in state.nodes + if n.props.plugin_name() == 'principled') + + class StubContext: + mi_state = state + + def log(self, message, level='INFO'): + pass + + importer = importlib.import_module( + f'{mi_addon}.convert.importer.materials') + b_mat = importer.convert_material(StubContext(), mi_props) + + imported = surface_node(b_mat) + assert imported.bl_idname == 'ShaderNodeBsdfPrincipled' + for socket_name, expected in [ + ('Base Color', (0.6, 0.3, 0.1, 1.0)), + ('Roughness', 0.3), + ('Metallic', 0.6), + ('Anisotropic', 0.4), + ('Specular IOR Level', 0.4), + ('Sheen Weight', 0.3), + ('Coat Weight', 0.5), + ('Coat Roughness', 0.1)]: + value = imported.inputs[socket_name].default_value + if isinstance(expected, tuple): + value = tuple(value) + assert value == pytest.approx(expected, abs=1e-5), socket_name + + +def test_roundtrip_reflective_xml(mi_addon, fresh_scene, exporter, + tmp_path): + """Reflective materials pass through XML unchanged: the registry + importers unwrap twosided and use identity roughness mappings.""" + node = principled_node() + node.inputs['Base Color'].default_value = (0.6, 0.3, 0.1, 1.0) + node.inputs['Roughness'].default_value = 0.3 + node.inputs['Metallic'].default_value = 0.6 + node.inputs['Coat Weight'].default_value = 0.5 + node.inputs['Coat Roughness'].default_value = 0.1 + + converter = exporter(tmp_path) + converter.dict_to_xml(str(tmp_path / 'scene.xml')) + bpy.ops.wm.read_homefile() + assert bpy.ops.import_scene.mitsuba( + filepath=str(tmp_path / 'scene.xml')) == {'FINISHED'} + + node = surface_node(bpy.data.materials['mat-Material']) + assert node.bl_idname == 'ShaderNodeBsdfPrincipled' + assert tuple(node.inputs['Base Color'].default_value) == \ + pytest.approx((0.6, 0.3, 0.1, 1.0)) + assert node.inputs['Roughness'].default_value == pytest.approx(0.3) + assert node.inputs['Metallic'].default_value == pytest.approx(0.6) + assert node.inputs['Coat Weight'].default_value == pytest.approx(0.5) + assert node.inputs['Coat Roughness'].default_value == \ + pytest.approx(0.1) + + +def test_roundtrip_emission(mi_addon, fresh_scene, exporter, tmp_path): + node = principled_node() + node.inputs['Emission Strength'].default_value = 3.0 + node.inputs['Emission Color'].default_value = (1.0, 0.5, 0.25, 1.0) + + converter = exporter(tmp_path) + converter.dict_to_xml(str(tmp_path / 'scene.xml')) + bpy.ops.wm.read_homefile() + assert bpy.ops.import_scene.mitsuba( + filepath=str(tmp_path / 'scene.xml')) == {'FINISHED'} + + b_mat = bpy.data.materials['mat-Material'] + emission = [n for n in b_mat.node_tree.nodes + if n.bl_idname == 'ShaderNodeEmission'] + assert len(emission) == 1 + assert emission[0].inputs['Strength'].default_value == pytest.approx(3.0) + assert tuple(emission[0].inputs['Color'].default_value) == \ + pytest.approx((1.0, 0.5, 0.25, 1.0)) + principled = [n for n in b_mat.node_tree.nodes + if n.bl_idname == 'ShaderNodeBsdfPrincipled'] + assert len(principled) == 1 + + +def test_exported_scene_dict_loads(mi_addon, fresh_scene, exporter, + tmp_path): + """A full scene with a masked, transmissive and emissive Principled + material must load in Mitsuba.""" + node = principled_node() + node.inputs['Transmission Weight'].default_value = 0.5 + node.inputs['IOR'].default_value = 1.45 + node.inputs['Alpha'].default_value = 0.7 + node.inputs['Emission Strength'].default_value = 1.5 + + converter = exporter(tmp_path, render=True) + assert converter.dict_to_scene() is not None diff --git a/tests/roundtrip/test_texture_roundtrip.py b/tests/roundtrip/test_texture_roundtrip.py new file mode 100644 index 00000000..c9451c27 --- /dev/null +++ b/tests/roundtrip/test_texture_roundtrip.py @@ -0,0 +1,210 @@ +"""Round-trip tests: textured materials through XML export and import.""" + +import importlib +import math +import sys + +import bpy +import numpy as np +import pytest + + +@pytest.fixture +def exporter(mi_addon): + import mitsuba as mi + + def _export(directory): + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + converter = sys.modules[mi_addon].io.exporter.SceneConverter() + converter.export_ctx.directory = str(directory) + converter.scene_to_dict(bpy.context.evaluated_depsgraph_get()) + return converter + + return _export + + +def make_diffuse_with_texture(node_type, name='Textured'): + b_mat = bpy.data.materials.new(name) + b_mat.use_nodes = True + tree = b_mat.node_tree + tree.nodes.remove(tree.nodes['Principled BSDF']) + diffuse = tree.nodes.new('ShaderNodeBsdfDiffuse') + tree.links.new(diffuse.outputs['BSDF'], + tree.nodes['Material Output'].inputs['Surface']) + tex = tree.nodes.new(node_type) + tree.links.new(tex.outputs[0], diffuse.inputs['Color']) + return b_mat, tex + + +def assign_material(b_mat, object_name='Cube'): + b_obj = bpy.data.objects[object_name] + b_obj.data.materials.clear() + b_obj.data.materials.append(b_mat) + + +def roundtrip(exporter, tmp_path, mat_id='mat-Textured'): + converter = exporter(tmp_path) + # The exporter wraps diffuse BSDFs in twosided, whose importer belongs + # to the combinator converters; strip it so these tests only exercise + # the texture converters + entry = converter.export_ctx.data_get(mat_id) + if entry.get('type') == 'twosided': + converter.export_ctx.scene_data[mat_id] = entry['bsdf'] + converter.dict_to_xml(str(tmp_path / 'scene.xml')) + bpy.ops.wm.read_homefile() + assert bpy.ops.import_scene.mitsuba( + filepath=str(tmp_path / 'scene.xml')) == {'FINISHED'} + + +def find_node(b_mat, bl_idname): + nodes = [n for n in b_mat.node_tree.nodes if n.bl_idname == bl_idname] + assert len(nodes) == 1 + return nodes[0] + + +def test_image_texture_roundtrip(mi_addon, fresh_scene, exporter, tmp_path): + image = bpy.data.images.new('RoundTex', 4, 4, alpha=True) + # Changing the color space reloads generated images, so set it first + image.colorspace_settings.name = 'Non-Color' + values = np.linspace(0.0, 1.0, 4 * 4 * 4, dtype=np.float32) + image.pixels.foreach_set(values) + + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = image + tex.interpolation = 'Closest' + tex.extension = 'EXTEND' + assign_material(b_mat) + + roundtrip(exporter, tmp_path) + + b_mat = bpy.data.materials['mat-Textured'] + tex = find_node(b_mat, 'ShaderNodeTexImage') + assert tex.interpolation == 'Closest' + assert tex.extension == 'EXTEND' + assert tex.image.colorspace_settings.name == 'Non-Color' + assert tuple(tex.image.size) == (4, 4) + + loaded = np.zeros(4 * 4 * 4, dtype=np.float32) + tex.image.pixels.foreach_get(loaded) + # The PNG round trip quantizes the channels to 8 bits + assert np.allclose(loaded, values, atol=1.0 / 255.0) + + +def test_mapping_roundtrip(mi_addon, fresh_scene, exporter, tmp_path): + image = bpy.data.images.new('MapTex', 4, 4, alpha=True) + image.colorspace_settings.name = 'Non-Color' + + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = image + tree = b_mat.node_tree + mapping = tree.nodes.new('ShaderNodeMapping') + mapping.inputs['Location'].default_value = (0.25, -0.5, 0.0) + mapping.inputs['Rotation'].default_value = (0.0, 0.0, math.pi / 6) + mapping.inputs['Scale'].default_value = (2.0, 0.5, 1.0) + coords = tree.nodes.new('ShaderNodeTexCoord') + tree.links.new(coords.outputs['UV'], mapping.inputs['Vector']) + tree.links.new(mapping.outputs['Vector'], tex.inputs['Vector']) + assign_material(b_mat) + + roundtrip(exporter, tmp_path) + + b_mat = bpy.data.materials['mat-Textured'] + mapping = find_node(b_mat, 'ShaderNodeMapping') + assert tuple(mapping.inputs['Location'].default_value) == \ + pytest.approx((0.25, -0.5, 0.0), abs=1e-5) + assert mapping.inputs['Rotation'].default_value[2] == \ + pytest.approx(math.pi / 6, abs=1e-5) + assert tuple(mapping.inputs['Scale'].default_value) == \ + pytest.approx((2.0, 0.5, 1.0), abs=1e-5) + tex = find_node(b_mat, 'ShaderNodeTexImage') + assert tex.inputs['Vector'].links[0].from_node == mapping + + +def test_checker_roundtrip(mi_addon, fresh_scene, exporter, tmp_path): + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexChecker') + tex.inputs['Color1'].default_value = (1.0, 0.0, 0.0, 1.0) + tex.inputs['Color2'].default_value = (0.0, 0.0, 1.0, 1.0) + tex.inputs['Scale'].default_value = 5.0 + assign_material(b_mat) + + roundtrip(exporter, tmp_path) + + b_mat = bpy.data.materials['mat-Textured'] + checker = find_node(b_mat, 'ShaderNodeTexChecker') + assert tuple(checker.inputs['Color2'].default_value) == \ + pytest.approx((1.0, 0.0, 0.0, 1.0)) + assert tuple(checker.inputs['Color1'].default_value) == \ + pytest.approx((0.0, 0.0, 1.0, 1.0)) + assert checker.inputs['Scale'].default_value == pytest.approx(5.0) + assert not checker.inputs['Vector'].is_linked + + +def test_vertex_color_roundtrip(mi_addon, fresh_scene, exporter, tmp_path): + mesh = bpy.data.objects['Cube'].data + mesh.color_attributes.new('Col', 'FLOAT_COLOR', 'CORNER') + b_mat, tex = make_diffuse_with_texture('ShaderNodeVertexColor') + tex.layer_name = 'Col' + assign_material(b_mat) + + roundtrip(exporter, tmp_path) + + b_mat = bpy.data.materials['mat-Textured'] + color = find_node(b_mat, 'ShaderNodeVertexColor') + assert color.layer_name == 'Col' + + +def test_normal_and_bump_roundtrip(mi_addon, fresh_scene, tmp_path): + import mitsuba as mi + mi.set_variant('scalar_rgb') + + textures = importlib.import_module( + f'{mi_addon}.convert.export.materials.textures') + export_context = sys.modules[mi_addon].io.exporter.export_context + ctx = export_context.ExportContext() + ctx.directory = str(tmp_path) + + b_mat = bpy.data.materials.new('Chain') + b_mat.use_nodes = True + tree = b_mat.node_tree + diffuse = tree.nodes.new('ShaderNodeBsdfDiffuse') + bump = tree.nodes.new('ShaderNodeBump') + bump.inputs['Distance'].default_value = 0.4 + tree.links.new(bump.outputs['Normal'], diffuse.inputs['Normal']) + height = tree.nodes.new('ShaderNodeTexImage') + height.image = bpy.data.images.new('Height', 4, 4) + height.image.colorspace_settings.name = 'Non-Color' + tree.links.new(height.outputs['Color'], bump.inputs['Height']) + normal_map = tree.nodes.new('ShaderNodeNormalMap') + normal_tex = tree.nodes.new('ShaderNodeTexImage') + normal_tex.image = bpy.data.images.new('Normal', 4, 4) + normal_tex.image.colorspace_settings.name = 'Non-Color' + tree.links.new(normal_tex.outputs['Color'], normal_map.inputs['Color']) + tree.links.new(normal_map.outputs['Normal'], bump.inputs['Normal']) + + wrapped = textures.convert_normal_input( + ctx, diffuse.inputs['Normal'], {'type': 'diffuse'}) + assert wrapped['type'] == 'bumpmap' + assert wrapped['bsdf']['type'] == 'normalmap' + + scene_dict = { + 'type': 'scene', + 'shape': {'type': 'sphere', 'surface': wrapped}, + } + config = mi.parser.ParserConfig(mi.variant()) + state = mi.parser.parse_dict(config, scene_dict) + mi.parser.write_file(state, str(tmp_path / 'scene.xml'), True) + + bpy.ops.wm.read_homefile() + assert bpy.ops.import_scene.mitsuba( + filepath=str(tmp_path / 'scene.xml')) == {'FINISHED'} + + b_mat = next(m for m in bpy.data.materials + if m.node_tree and any(n.bl_idname == 'ShaderNodeBump' + for n in m.node_tree.nodes)) + diffuse = find_node(b_mat, 'ShaderNodeBsdfDiffuse') + bump = find_node(b_mat, 'ShaderNodeBump') + normal_map = find_node(b_mat, 'ShaderNodeNormalMap') + assert diffuse.inputs['Normal'].links[0].from_node == bump + assert bump.inputs['Normal'].links[0].from_node == normal_map + assert bump.inputs['Distance'].default_value == pytest.approx(0.4) diff --git a/tests/roundtrip/test_world_roundtrip.py b/tests/roundtrip/test_world_roundtrip.py new file mode 100644 index 00000000..f1fadc8a --- /dev/null +++ b/tests/roundtrip/test_world_roundtrip.py @@ -0,0 +1,225 @@ +"""Round-trip and import tests for world/environment emitters.""" + +import importlib + +import bpy +import numpy as np +import pytest +from bpy_extras.io_utils import axis_conversion + +AXIS_MAT = axis_conversion(to_forward='-Z', to_up='Y').to_4x4() + + +@pytest.fixture(scope='session') +def export_world(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.export.world') + + +@pytest.fixture(scope='session') +def import_world(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.importer.world') + + +@pytest.fixture +def export_ctx(mi_addon, tmp_path): + import mitsuba as mi + mi.set_variant('scalar_rgb') + module = importlib.import_module(f'{mi_addon}.io.exporter.export_context') + ctx = module.ExportContext() + ctx.directory = str(tmp_path) + ctx.axis_mat = AXIS_MAT.copy() + return ctx + + +@pytest.fixture +def make_mi_context(mi_addon, tmp_path): + common = importlib.import_module(f'{mi_addon}.io.importer.common') + + def _make(state): + return common.MitsubaSceneImportContext( + bpy.context.scene, bpy.context.collection, + str(tmp_path / 'scene.xml'), state, AXIS_MAT.copy()) + + return _make + + +def emitter_props(scene_dict): + """Parse a scene dict and return (state, props of the only emitter).""" + import mitsuba as mi + from mitsuba import ObjectType + config = mi.parser.ParserConfig(mi.variant()) + state = mi.parser.parse_dict(config, scene_dict) + mi.parser.transform_all(config, state) + emitters = [state.nodes[i].props for i in range(len(state.nodes)) + if state.nodes[i].type == ObjectType.Emitter] + assert len(emitters) == 1 + return state, emitters[0] + + +def find_nodes(bl_world, bl_idname): + return [node for node in bl_world.node_tree.nodes + if node.bl_idname == bl_idname] + + +def background_node(bl_world): + nodes = find_nodes(bl_world, 'ShaderNodeBackground') + assert len(nodes) == 1 + return nodes[0] + + +def make_env_world(export_world_module, tmp_path, rotation=None, + strength=1.0): + image = bpy.data.images.new('env', 8, 4, float_buffer=True) + image.filepath_raw = str(tmp_path / 'env.exr') + image.file_format = 'OPEN_EXR' + image.save() + b_world = bpy.data.worlds.new('EnvWorld') + b_world.use_nodes = True + tree = b_world.node_tree + background = tree.nodes['Background'] + background.inputs['Strength'].default_value = strength + environment = tree.nodes.new('ShaderNodeTexEnvironment') + environment.image = image + tree.links.new(environment.outputs['Color'], + background.inputs['Color']) + if rotation is not None: + mapping = tree.nodes.new('ShaderNodeMapping') + mapping.vector_type = 'TEXTURE' + mapping.inputs['Rotation'].default_value = rotation + coordinates = tree.nodes.new('ShaderNodeTexCoord') + tree.links.new(coordinates.outputs['Generated'], + mapping.inputs['Vector']) + tree.links.new(mapping.outputs['Vector'], + environment.inputs['Vector']) + return b_world + + +def test_constant_roundtrip(fresh_scene, export_ctx, export_world, + import_world, make_mi_context): + b_world = bpy.data.worlds.new('Const') + b_world.use_nodes = True + background = b_world.node_tree.nodes['Background'] + background.inputs['Color'].default_value = (0.2, 0.4, 0.6, 1.0) + background.inputs['Strength'].default_value = 2.0 + + params = export_world.convert_world(export_ctx, b_world) + state, mi_props = emitter_props({'type': 'scene', 'world': params}) + bl_world = import_world.mi_emitter_to_bl_world(make_mi_context(state), + mi_props) + imported = background_node(bl_world) + color = list(imported.inputs['Color'].default_value)[:3] + strength = imported.inputs['Strength'].default_value + # The color/strength split may differ; the radiance must not + radiance = [c * strength for c in color] + assert radiance == pytest.approx([0.4, 0.8, 1.2], rel=1e-4) + + # Exporting the imported world reproduces the radiance + params2 = export_world.convert_world(export_ctx, bl_world) + assert params2['radiance']['value'] == \ + pytest.approx([0.4, 0.8, 1.2], rel=1e-4) + + +def test_constant_float_radiance_import(fresh_scene, import_world, + make_mi_context): + state, mi_props = emitter_props({ + 'type': 'scene', + 'world': {'type': 'constant', 'radiance': 5.0}, + }) + bl_world = import_world.mi_emitter_to_bl_world(make_mi_context(state), + mi_props) + imported = background_node(bl_world) + assert list(imported.inputs['Color'].default_value)[:3] == \ + pytest.approx([1.0] * 3) + assert imported.inputs['Strength'].default_value == pytest.approx(5.0) + + +def test_envmap_roundtrip_with_rotation(fresh_scene, export_ctx, + export_world, import_world, + make_mi_context, tmp_path): + rotation = (0.3, 0.0, 1.1) + b_world = make_env_world(export_world, tmp_path, rotation=rotation, + strength=1.5) + params = export_world.convert_world(export_ctx, b_world) + state, mi_props = emitter_props({'type': 'scene', 'world': params}) + bl_world = import_world.mi_emitter_to_bl_world(make_mi_context(state), + mi_props) + + imported = background_node(bl_world) + assert imported.inputs['Strength'].default_value == pytest.approx(1.5) + environment = find_nodes(bl_world, 'ShaderNodeTexEnvironment') + assert len(environment) == 1 + assert environment[0].image is not None + mapping = find_nodes(bl_world, 'ShaderNodeMapping') + assert len(mapping) == 1 + assert mapping[0].vector_type == 'TEXTURE' + assert tuple(mapping[0].inputs['Rotation'].default_value) == \ + pytest.approx(rotation, abs=1e-5) + + # A second export reproduces the same transform and scale + params2 = export_world.convert_world(export_ctx, bl_world) + assert params2['scale'] == pytest.approx(1.5) + np.testing.assert_allclose(np.array(params2['to_world'].matrix), + np.array(params['to_world'].matrix), + atol=1e-5) + + +def test_envmap_import_without_rotation(fresh_scene, export_ctx, + export_world, import_world, + make_mi_context, tmp_path): + b_world = make_env_world(export_world, tmp_path) + params = export_world.convert_world(export_ctx, b_world) + state, mi_props = emitter_props({'type': 'scene', 'world': params}) + bl_world = import_world.mi_emitter_to_bl_world(make_mi_context(state), + mi_props) + assert len(find_nodes(bl_world, 'ShaderNodeTexEnvironment')) == 1 + # An identity transform must not create a mapping node + assert len(find_nodes(bl_world, 'ShaderNodeMapping')) == 0 + + +def test_envmap_missing_file_yields_error_world(fresh_scene, import_world, + make_mi_context): + state, mi_props = emitter_props({ + 'type': 'scene', + 'world': {'type': 'envmap', 'filename': 'missing.exr'}, + }) + mi_context = make_mi_context(state) + logs = [] + mi_context.log = lambda msg, level='INFO': logs.append((level, msg)) + bl_world = import_world.mi_emitter_to_bl_world(mi_context, mi_props) + imported = background_node(bl_world) + assert tuple(imported.inputs['Color'].default_value) == \ + pytest.approx(import_world.ERROR_COLOR) + assert any(level == 'WARN' for level, _ in logs) + + +def test_sunsky_imports_placeholder_with_warning(fresh_scene, import_world, + make_mi_context): + state, mi_props = emitter_props({ + 'type': 'scene', + 'world': {'type': 'sunsky'}, + }) + mi_context = make_mi_context(state) + logs = [] + mi_context.log = lambda msg, level='INFO': logs.append((level, msg)) + assert import_world.should_convert_mi_emitter_to_bl_world(mi_props) + bl_world = import_world.mi_emitter_to_bl_world(mi_context, mi_props) + assert background_node(bl_world) is not None + assert any(level == 'WARN' and 'sunsky' in msg for level, msg in logs) + + +def test_should_convert_dispatch(fresh_scene, import_world, + make_mi_context): + state, mi_props = emitter_props({ + 'type': 'scene', + 'world': {'type': 'point'}, + }) + assert not import_world.should_convert_mi_emitter_to_bl_world(mi_props) + + +def test_default_world_roundtrips_to_nothing(fresh_scene, export_ctx, + export_world, import_world): + # The default world created on import is recognized and skipped by + # the exporter's ignore_background option + bl_world = import_world.create_default_bl_world() + assert export_world.convert_world(export_ctx, bl_world, + ignore_background=True) is None diff --git a/tests/roundtrip/test_xml_roundtrip.py b/tests/roundtrip/test_xml_roundtrip.py new file mode 100644 index 00000000..85b024c6 --- /dev/null +++ b/tests/roundtrip/test_xml_roundtrip.py @@ -0,0 +1,29 @@ +"""Full-scene round trip: import an XML scene, export it again and compare +renders of the two files with a statistical z-test.""" + +import os + +import bpy + +from ztest import MitsubaRenderTester, MitsubaSceneRenderer + +SCENES = os.path.join(os.path.dirname(__file__), '..', 'res', 'scenes') + + +def test_xml_scene_ztest_roundtrip(mi_addon, fresh_scene, tmp_path): + resolution = (1280, 720) + sample_budget = int(2e6) + spp = sample_budget // (resolution[0] * resolution[1]) + + ref_scene_file = os.path.join(SCENES, 'test1.xml') + output_scene_file = str(tmp_path / 'test1_out.xml') + + assert bpy.ops.import_scene.mitsuba( + filepath=ref_scene_file, import_render_settings=True) == {'FINISHED'} + bpy.context.scene.render.engine = 'MITSUBA' + assert bpy.ops.export_scene.mitsuba( + filepath=output_scene_file, ignore_background=True) == {'FINISHED'} + + tester = MitsubaRenderTester(MitsubaSceneRenderer()) + assert tester.compare_scenes(ref_scene_file, output_scene_file, spp, + resolution, str(tmp_path)) diff --git a/tests/run.py b/tests/run.py new file mode 100644 index 00000000..95604439 --- /dev/null +++ b/tests/run.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Host-side test launcher. + +Runs the pytest suite inside a headless Blender found via the BLENDER +environment variable and propagates pytest's exit code. See tests/README.md. +""" + +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +DEFAULT_TEST_DIRS = ('tests/unit', 'tests/roundtrip', 'tests/golden') + + +def find_blender(): + blender = os.environ.get('BLENDER') + if not blender: + sys.exit('error: set the BLENDER environment variable to the Blender ' + 'executable, e.g. BLENDER=/path/to/blender-4.2/blender') + if not (os.path.isfile(blender) and os.access(blender, os.X_OK)): + sys.exit(f'error: BLENDER={blender} is not an executable file') + return blender + + +def build_pytest_args(argv): + args = list(argv) + run_all = '--all' in args + if run_all: + args.remove('--all') + + # Positional arguments select test files; without any, run every test + # directory that exists. + if not any(not a.startswith('-') for a in args): + args += [d for d in DEFAULT_TEST_DIRS if (REPO_ROOT / d).is_dir()] + + # Skip slow and packaging tests unless the caller opts in. + if not run_all and '-m' not in args: + args += ['-m', 'not slow and not packaging'] + return args + + +def pytest_tail(output, max_lines=40): + """Extracts the tail of the pytest report from Blender's noisy output.""" + lines = output.splitlines() + start = 0 + for i, line in enumerate(lines): + if 'test session starts' in line: + start = i + block = lines[start:] + + # Prefer the part from the failure/summary sections onwards. + for marker in ('short test summary info', 'FAILURES', 'ERRORS'): + for i, line in enumerate(block): + if marker in line: + return block[i:][-max_lines:] + return block[-max_lines:] + + +def main(argv): + blender = find_blender() + pytest_args = build_pytest_args(argv) + + env = os.environ.copy() + env['DRJIT_NO_RTLD_DEEPBIND'] = '1' + # Point Blender's user config/scripts/extensions at a throwaway directory + # so the conftest addon install cannot touch the developer's real profile. + user_resources = tempfile.mkdtemp(prefix='mi-blender-test-home-') + env['BLENDER_USER_RESOURCES'] = user_resources + rc_fd, rc_path = tempfile.mkstemp(prefix='mi-blender-test-rc-') + os.close(rc_fd) + os.remove(rc_path) + env['MI_BLENDER_TEST_RC_FILE'] = rc_path + + cmd = [blender, '-b', '--factory-startup', + '--python', str(REPO_ROOT / 'tests' / '_bootstrap.py'), + '--', *pytest_args] + print('Running:', ' '.join(cmd), flush=True) + + proc = subprocess.run(cmd, cwd=REPO_ROOT, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, errors='replace') + + log_fd, log_path = tempfile.mkstemp(prefix='mi-blender-test-', + suffix='.log', text=True) + with os.fdopen(log_fd, 'w') as f: + f.write(proc.stdout) + + try: + rc = int(Path(rc_path).read_text().strip()) + crashed = False + except (FileNotFoundError, ValueError): + rc = proc.returncode if proc.returncode != 0 else 1 + crashed = True + finally: + Path(rc_path).unlink(missing_ok=True) + shutil.rmtree(user_resources, ignore_errors=True) + + print() + for line in pytest_tail(proc.stdout): + print(line) + print() + print(f'Full Blender output: {log_path}') + if crashed: + print(f'FAIL: Blender exited (code {proc.returncode}) before pytest ' + 'could report a result') + elif rc == 0: + print('PASS') + else: + print(f'FAIL (pytest exit code {rc})') + return rc + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_addon.py b/tests/test_addon.py deleted file mode 100644 index 83b19a3e..00000000 --- a/tests/test_addon.py +++ /dev/null @@ -1,17 +0,0 @@ -import bpy - -from mathutils import Matrix - -def test_prespective_sensor(): - import importlib - sensors = importlib.import_module("mitsuba-blender.io.importer.sensors") - assert sensors - common = importlib.import_module("mitsuba-blender.io.importer.common") - assert common - - from mitsuba import Properties - mi_sensor_props = Properties('perspective') - mi_context = common.MitsubaSceneImportContext(bpy.context, bpy.context.scene, bpy.context.scene.collection, '', mi_sensor_props, Matrix()) - - bl_camera, world_matrix = sensors.mi_perspective_to_bl_camera(mi_context, mi_sensor_props) - assert bl_camera.type == 'PERSP' diff --git a/tests/test_compare.py b/tests/test_compare.py deleted file mode 100644 index 036eac98..00000000 --- a/tests/test_compare.py +++ /dev/null @@ -1,24 +0,0 @@ -import os - -import bpy - -import pytest - -from fixtures import * - -@pytest.mark.parametrize("xml_scene", ["scenes/test1.xml"]) -def test_round_trip(xml_scene, resource_resolver, mitsuba_scene_ztest): - resolution = (1280, 720) - sample_budget = int(2e6) - pixel_count = resolution[0] * resolution[1] - spp = sample_budget // pixel_count - - ref_scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - ref_scene_name, _ = os.path.splitext(os.path.basename(ref_scene_file)) - test_output_dir = resource_resolver.ensure_resource_dir(f'out/{ref_scene_name}') - output_scene_file = os.path.join(test_output_dir, f'{ref_scene_name}_out.xml') - - assert bpy.ops.import_scene.mitsuba(filepath=ref_scene_file) == {'FINISHED'} - assert bpy.ops.export_scene.mitsuba(filepath=output_scene_file, ignore_background=True) == {'FINISHED'} - - assert mitsuba_scene_ztest.compare_scenes(ref_scene_file, output_scene_file, spp, resolution, test_output_dir) diff --git a/tests/test_importer.py b/tests/test_importer.py deleted file mode 100644 index 77cc2df0..00000000 --- a/tests/test_importer.py +++ /dev/null @@ -1,229 +0,0 @@ -import bpy - -import pytest - -from fixtures import * - -@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) -def test_importer_override_current_scene_conserves_name(resource_resolver, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - - assert len(bpy.data.scenes) == 1 - scene_name_before = bpy.data.scenes[0].name - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file, override_scene=True) == {'FINISHED'} - - assert len(bpy.data.scenes) == 1 - assert bpy.data.scenes[0].name == scene_name_before - -@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) -def test_importer_multiple_scene_imports(resource_resolver, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - - assert len(bpy.data.scenes) == 1 - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file, override_scene=False) == {'FINISHED'} - object_count_before = len(bpy.context.scene.objects) - - assert len(bpy.data.scenes) == 2 - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file, override_scene=False) == {'FINISHED'} - assert len(bpy.context.scene.objects) == object_count_before - - assert len(bpy.data.scenes) == 2 - -@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) -def test_importer_set_new_scene_as_active(resource_resolver, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - - assert len(bpy.data.scenes) == 1 - scene_name_before = bpy.data.scenes[0].name - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file, override_scene=False) == {'FINISHED'} - - assert len(bpy.data.scenes) == 2 - assert bpy.context.scene.name != scene_name_before - -@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) -def test_importer_initializes_mitsuba_renderer(resource_resolver, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - assert bpy.context.scene.render.engine == 'MITSUBA' - assert bpy.context.scene.mitsuba.variant == 'scalar_rgb' - -@pytest.mark.parametrize("xml_scene", ["scenes/integrator_path.xml"]) -def test_importer_path_integrator(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_integrator = mitsuba_scene_parser.get_props_by_name('path') - assert mi_integrator - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_scene = bpy.context.scene - assert bl_scene.mitsuba.active_integrator == 'path' - assert bl_scene.mitsuba.available_integrators.path.max_depth == mi_integrator.get('max_depth') - assert bl_scene.mitsuba.available_integrators.path.rr_depth == mi_integrator.get('rr_depth') - assert bl_scene.mitsuba.available_integrators.path.hide_emitters == mi_integrator.get('hide_emitters') - - # assert len(mi_integrator.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/integrator_moment.xml"]) -def test_importer_moment_integrator(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_integrator = mitsuba_scene_parser.get_props_by_name('moment') - assert mi_integrator - - mi_child_integrator = mitsuba_scene_parser.get_props_by_name('path') - assert mi_child_integrator - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_scene = bpy.context.scene - assert bl_scene.mitsuba.active_integrator == 'moment' - assert bl_scene.mitsuba.available_integrators.moment.integrators.count == 1 - bl_child_integrator = bl_scene.mitsuba.available_integrators.moment.integrators.collection[0] - assert bl_child_integrator.active_integrator == 'path' - assert bl_child_integrator.available_integrators.path.max_depth == mi_child_integrator.get('max_depth') - assert bl_child_integrator.available_integrators.path.rr_depth == mi_child_integrator.get('rr_depth') - assert bl_child_integrator.available_integrators.path.hide_emitters == mi_child_integrator.get('hide_emitters') - - # assert len(mi_integrator.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/sampler_independent.xml"]) -def test_importer_independent_sampler(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_sampler = mitsuba_scene_parser.get_props_by_name('independent') - assert mi_sampler - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_camera = bpy.context.scene.camera.data.mitsuba - assert bl_camera.active_sampler == 'independent' - assert bl_camera.samplers.independent.sample_count == mi_sampler.get('sample_count') - assert bl_camera.samplers.independent.seed == mi_sampler.get('seed') - - # assert len(mi_sampler.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/sampler_stratified.xml"]) -def test_importer_stratified_sampler(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_sampler = mitsuba_scene_parser.get_props_by_name('stratified') - assert mi_sampler - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_camera = bpy.context.scene.camera.data.mitsuba - assert bl_camera.active_sampler == 'stratified' - assert bl_camera.samplers.stratified.sample_count == mi_sampler.get('sample_count') - assert bl_camera.samplers.stratified.seed == mi_sampler.get('seed') - assert bl_camera.samplers.stratified.jitter == mi_sampler.get('jitter') - - # assert len(mi_sampler.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/sampler_multijitter.xml"]) -def test_importer_multijitter_sampler(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_sampler = mitsuba_scene_parser.get_props_by_name('multijitter') - assert mi_sampler - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_camera = bpy.context.scene.camera.data.mitsuba - assert bl_camera.active_sampler == 'multijitter' - assert bl_camera.samplers.multijitter.sample_count == mi_sampler.get('sample_count') - assert bl_camera.samplers.multijitter.seed == mi_sampler.get('seed') - assert bl_camera.samplers.multijitter.jitter == mi_sampler.get('jitter') - - # assert len(mi_sampler.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/rfilter_box.xml"]) -def test_importer_box_rfilter(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_rfilter = mitsuba_scene_parser.get_props_by_name('box') - assert mi_rfilter - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_camera = bpy.context.scene.camera.data.mitsuba - assert bl_camera.active_rfilter == 'box' - - bl_cycles = bpy.context.scene.cycles - assert bl_cycles.pixel_filter_type == 'BOX' - - # assert len(mi_rfilter.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/rfilter_tent.xml"]) -def test_importer_tent_rfilter(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_rfilter = mitsuba_scene_parser.get_props_by_name('tent') - assert mi_rfilter - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_camera = bpy.context.scene.camera.data.mitsuba - assert bl_camera.active_rfilter == 'tent' - - # assert len(mi_rfilter.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/rfilter_gaussian.xml"]) -def test_importer_gaussian_rfilter(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_rfilter = mitsuba_scene_parser.get_props_by_name('gaussian') - assert mi_rfilter - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_camera = bpy.context.scene.camera.data.mitsuba - assert bl_camera.active_rfilter == 'gaussian' - assert bl_camera.rfilters.gaussian.stddev == mi_rfilter.get('stddev') - - bl_cycles = bpy.context.scene.cycles - assert bl_cycles.pixel_filter_type == 'GAUSSIAN' - - # assert len(mi_rfilter.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/film_hdrfilm.xml", "scenes/film_hdrfilm_crop.xml"]) -def test_importer_hdrfilm_film(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_film = mitsuba_scene_parser.get_props_by_name('hdrfilm') - assert mi_film - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_render = bpy.context.scene.render - assert bl_render.resolution_percentage == 100 - assert bl_render.resolution_x == mi_film.get('width') - assert bl_render.resolution_y == mi_film.get('height') - assert bl_render.image_settings.file_format == 'OPEN_EXR' - assert bl_render.image_settings.color_mode == 'RGBA' - assert bl_render.image_settings.color_depth == '32' - if 'crop' in xml_scene: - assert bl_render.use_border == True - assert bl_render.border_min_x == 0.0 - assert bl_render.border_min_y == 0.0 - assert bl_render.border_max_x == 0.5 - assert bl_render.border_max_y == 0.5 - else: - assert bl_render.use_border == False - - # assert len(mi_film.unqueried()) == 0 diff --git a/tests/test_mitsuba.py b/tests/test_mitsuba.py deleted file mode 100644 index 670d7171..00000000 --- a/tests/test_mitsuba.py +++ /dev/null @@ -1,4 +0,0 @@ - -def test_mitsuba_has_correct_variant(): - import mitsuba - assert mitsuba.variant() == 'scalar_rgb' diff --git a/tests/unit/test_axis_conversion.py b/tests/unit/test_axis_conversion.py new file mode 100644 index 00000000..f06dfe27 --- /dev/null +++ b/tests/unit/test_axis_conversion.py @@ -0,0 +1,110 @@ +"""Both export paths must place geometry in the same world coordinates. + +Blender is Z-up and Mitsuba is Y-up. `export_ctx.axis_mat` carries that +conversion, and it was once set only by the export operator, so F12 +rendered every scene rotated 90 degrees about X while the same scene +exported to XML was correct. Nothing in the suite noticed. + +These tests pin the conversion itself and the agreement between the two +paths, so a caller that forgets to set `axis_mat` fails here rather than +in a render. +""" + +import math + +import bpy +import numpy as np +import sys +import pytest + +@pytest.fixture(scope='session') +def registry(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.export.materials') + +@pytest.fixture +def offset_cube(fresh_scene): + bpy.data.objects.remove(bpy.data.objects['Cube']) + bpy.ops.mesh.primitive_cube_add(size=2.0, location=(1.0, 5.0, 3.0)) + return bpy.context.object + + +@pytest.fixture +def exporter(mi_addon): + """Exports the current scene and returns the SceneConverter.""" + import mitsuba as mi + + def _export(directory, render=False): + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + converter = sys.modules[mi_addon].io.exporter.SceneConverter(render=render) + converter.export_ctx.directory = str(directory) + converter.scene_to_dict(bpy.context.evaluated_depsgraph_get()) + return converter + + return _export + + + +def _blender_to_mitsuba(v): + """Blender (x, y, z) -> Mitsuba (x, z, -y), the -Z forward / Y up shift.""" + x, y, z = v + return (x, z, -y) + + +def _bboxes(scene): + """Shape bounding boxes, rounded and sorted so the order does not matter.""" + out = [] + for shape in scene.shapes(): + bbox = shape.bbox() + out.append((tuple(round(c, 4) for c in bbox.min), + tuple(round(c, 4) for c in bbox.max))) + return sorted(out) + + +@pytest.fixture +def offset_cube(fresh_scene): + """A cube away from the origin, so a rotation about X is visible.""" + bpy.ops.mesh.primitive_cube_add(size=2.0, location=(1.0, 5.0, 3.0)) + return bpy.context.object + + +def test_render_and_file_paths_agree_on_geometry(offset_cube, exporter, + tmp_path): + """The dict built for F12 and the one written to XML must place every + shape identically. This is the check that a missing axis_mat fails.""" + import mitsuba as mi + + render_dir = tmp_path / 'render' + file_dir = tmp_path / 'file' + render_dir.mkdir() + file_dir.mkdir() + + render_scene = exporter(render_dir, render=True).dict_to_scene() + + xml_path = file_dir / 'scene.xml' + exporter(file_dir).dict_to_xml(str(xml_path)) + file_scene = mi.load_file(str(xml_path)) + + assert _bboxes(render_scene) == _bboxes(file_scene) + + +def test_camera_orientation_matches_between_paths(offset_cube, exporter, + tmp_path): + """A rotation about X leaves shape counts and sizes intact, so the + sensor is worth checking on its own.""" + import mitsuba as mi + + render_dir = tmp_path / 'render' + file_dir = tmp_path / 'file' + render_dir.mkdir() + file_dir.mkdir() + + render_scene = exporter(render_dir, render=True).dict_to_scene() + + xml_path = file_dir / 'scene.xml' + exporter(file_dir).dict_to_xml(str(xml_path)) + file_scene = mi.load_file(str(xml_path)) + + a = np.array(render_scene.sensors()[0].world_transform().matrix) + b = np.array(file_scene.sensors()[0].world_transform().matrix) + assert a == pytest.approx(b, abs=1e-5) diff --git a/tests/unit/test_camera_export.py b/tests/unit/test_camera_export.py new file mode 100644 index 00000000..5820d2ec --- /dev/null +++ b/tests/unit/test_camera_export.py @@ -0,0 +1,220 @@ +"""Camera export through convert.export.camera. + +The numeric tests compare the exported Mitsuba sensor against Blender's own +view frame (camera.view_frame), so they verify the actual imaging geometry +rather than just the dict layout. +""" + +import math + +import bpy +import pytest +from mathutils import Vector + + +def _export_scene_dict(): + from bl_ext.user_default.mitsuba_blender.io.exporter import SceneConverter + bpy.context.scene.render.engine = 'MITSUBA' + converter = SceneConverter(render=True) + depsgraph = bpy.context.evaluated_depsgraph_get() + converter.scene_to_dict(depsgraph) + return converter.export_ctx.scene_data + + +def _sensor_dict(scene_data): + sensors = [v for v in scene_data.values() + if isinstance(v, dict) and v.get('type') in + ('perspective', 'thinlens', 'orthographic')] + assert len(sensors) == 1 + return sensors[0] + + +def _camera(): + return bpy.data.objects['Camera'] + + +def _load_sensor(sensor_dict): + import mitsuba as mi + mi.set_variant('scalar_rgb') + return mi.load_dict(sensor_dict) + + +def _center_ray(sensor_dict): + """World-space origin and direction of the film-center ray.""" + sensor = _load_sensor(sensor_dict) + ray, _ = sensor.sample_ray(0, 0, (0.5, 0.5), (0.5, 0.5)) + return Vector(list(ray.o)), Vector(list(ray.d)) + + +def _view_frame_center_world(b_camera): + """World-space center of Blender's camera view frame.""" + frame = b_camera.data.view_frame(scene=bpy.context.scene) + center = sum(frame, Vector()) / 4 + return b_camera.matrix_world @ center + + +def test_perspective_defaults(mi_addon, fresh_scene): + camera = _camera() + camera.data.clip_start = 0.25 + camera.data.clip_end = 250.0 + sensor = _sensor_dict(_export_scene_dict()) + + assert sensor['type'] == 'perspective' + assert sensor['fov_axis'] == 'x' + assert sensor['fov'] == pytest.approx(math.degrees(camera.data.angle_x)) + assert sensor['near_clip'] == pytest.approx(0.25) + assert sensor['far_clip'] == pytest.approx(250.0) + assert sensor['principal_point_offset_x'] == 0.0 + assert sensor['principal_point_offset_y'] == 0.0 + assert _load_sensor(sensor) is not None + + +def test_vertical_sensor_fit_uses_y_axis(mi_addon, fresh_scene): + camera = _camera() + camera.data.sensor_fit = 'VERTICAL' + sensor = _sensor_dict(_export_scene_dict()) + + assert sensor['fov_axis'] == 'y' + assert sensor['fov'] == pytest.approx(math.degrees(camera.data.angle_y)) + + +def test_portrait_resolution_uses_y_axis(mi_addon, fresh_scene): + scene = bpy.context.scene + scene.render.resolution_x = 480 + scene.render.resolution_y = 640 + camera = _camera() + sensor = _sensor_dict(_export_scene_dict()) + + # With AUTO fit the 36mm sensor width applies to the larger (vertical) + # dimension, so the vertical field of view is angle_x. + assert sensor['fov_axis'] == 'y' + assert sensor['fov'] == pytest.approx(math.degrees(camera.data.angle_x)) + + +@pytest.mark.parametrize('res,sensor_fit', [ + ((640, 480), 'AUTO'), + ((480, 640), 'AUTO'), + ((640, 480), 'HORIZONTAL'), + ((640, 480), 'VERTICAL'), +]) +def test_perspective_corners_match_view_frame(mi_addon, fresh_scene, res, + sensor_fit): + scene = bpy.context.scene + scene.render.resolution_x, scene.render.resolution_y = res + camera = _camera() + camera.data.sensor_fit = sensor_fit + bpy.context.view_layer.update() + + mi_sensor = _load_sensor(_sensor_dict(_export_scene_dict())) + frame = [camera.matrix_world @ v + for v in camera.data.view_frame(scene=scene)] + origin = camera.matrix_world.translation + + # Mitsuba film (0, 0) is the top-left corner (frame[3]) + corner_pairs = [((0, 0), frame[3]), ((1, 0), frame[0]), + ((0, 1), frame[2]), ((1, 1), frame[1])] + for film_pos, corner in corner_pairs: + ray, _ = mi_sensor.sample_ray(0, 0, film_pos, (0.5, 0.5)) + direction = Vector(list(ray.d)) + expected = (corner - origin).normalized() + assert max(abs(a - b) for a, b in zip(direction, expected)) < 1e-4, \ + (film_pos, tuple(direction), tuple(expected)) + + +def test_film_resolution_and_percentage(mi_addon, fresh_scene): + scene = bpy.context.scene + scene.render.resolution_x = 640 + scene.render.resolution_y = 360 + scene.render.resolution_percentage = 50 + sensor = _sensor_dict(_export_scene_dict()) + + assert sensor['film']['width'] == 320 + assert sensor['film']['height'] == 180 + + +@pytest.mark.parametrize('sensor_fit', ['AUTO', 'VERTICAL']) +def test_shift_matches_blender_view_frame(mi_addon, fresh_scene, sensor_fit): + scene = bpy.context.scene + scene.render.resolution_x = 640 + scene.render.resolution_y = 480 + camera = _camera() + camera.data.sensor_fit = sensor_fit + camera.data.shift_x = 0.2 + camera.data.shift_y = 0.1 + bpy.context.view_layer.update() + + sensor = _sensor_dict(_export_scene_dict()) + _, direction = _center_ray(sensor) + + center = _view_frame_center_world(camera) + expected = (center - camera.matrix_world.translation).normalized() + assert max(abs(a - b) for a, b in zip(direction, expected)) < 1e-4 + + +def test_dof_exports_thinlens(mi_addon, fresh_scene): + camera = _camera() + camera.data.lens = 80.0 + camera.data.dof.use_dof = True + camera.data.dof.aperture_fstop = 2.0 + camera.data.dof.focus_distance = 3.5 + sensor = _sensor_dict(_export_scene_dict()) + + assert sensor['type'] == 'thinlens' + # Cycles thin lens: radius = focal_length / (2 fstop), in meters + assert sensor['aperture_radius'] == pytest.approx(0.08 / (2.0 * 2.0)) + assert sensor['focus_distance'] == pytest.approx(3.5) + assert 'principal_point_offset_x' not in sensor + assert _load_sensor(sensor) is not None + + +def test_dof_focus_object_distance(mi_addon, fresh_scene): + camera = _camera() + camera.data.dof.use_dof = True + camera.data.dof.focus_object = bpy.data.objects['Cube'] + bpy.context.view_layer.update() + sensor = _sensor_dict(_export_scene_dict()) + + local = camera.matrix_world.inverted() @ \ + bpy.data.objects['Cube'].matrix_world.translation + assert sensor['focus_distance'] == pytest.approx(abs(local.z), rel=1e-5) + + +def test_orthographic_extents_match_view_frame(mi_addon, fresh_scene): + scene = bpy.context.scene + scene.render.resolution_x = 640 + scene.render.resolution_y = 480 + camera = _camera() + camera.data.type = 'ORTHO' + camera.data.ortho_scale = 4.0 + camera.data.shift_x = 0.25 + camera.data.shift_y = -0.125 + bpy.context.view_layer.update() + + sensor = _sensor_dict(_export_scene_dict()) + assert sensor['type'] == 'orthographic' + mi_sensor = _load_sensor(sensor) + + # The origins of the corner rays must land on Blender's view frame + # corners (projected onto the camera plane). + frame = [camera.matrix_world @ v + for v in camera.data.view_frame(scene=scene)] + view_dir = camera.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0)) + + # Mitsuba film (0, 0) is the top-left corner: max x, max y in the + # Blender camera frame (frame[3] is the top-left corner). + corner_pairs = [((0, 0), frame[3]), ((1, 0), frame[0]), + ((0, 1), frame[2]), ((1, 1), frame[1])] + for film_pos, expected in corner_pairs: + ray, _ = mi_sensor.sample_ray(0, 0, film_pos, (0.5, 0.5)) + origin = Vector(list(ray.o)) + # Compare in the camera plane (the ray starts at the near clip) + delta = origin - expected + delta -= delta.dot(view_dir) * view_dir + assert delta.length < 1e-4, (film_pos, tuple(origin), tuple(expected)) + + +def test_panoramic_falls_back_to_perspective(mi_addon, fresh_scene): + camera = _camera() + camera.data.type = 'PANO' + sensor = _sensor_dict(_export_scene_dict()) + assert sensor['type'] == 'perspective' diff --git a/tests/unit/test_camera_import.py b/tests/unit/test_camera_import.py new file mode 100644 index 00000000..f0cd7342 --- /dev/null +++ b/tests/unit/test_camera_import.py @@ -0,0 +1,139 @@ +"""Camera import through convert.importer.camera.""" + +import math + +import bpy +import pytest + + +def _import_sensor_xml(tmp_path, sensor_body, width=640, height=480): + xml = f''' + {sensor_body} + ''' + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(xml) + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + return bpy.context.scene.camera + + +FILM = '' \ + '' \ + '' + + +def test_perspective_fov_x(mi_addon, fresh_scene, tmp_path): + camera = _import_sensor_xml(tmp_path, f''' + + + + + + {FILM} + ''') + + assert camera is not None + assert camera.data.type == 'PERSP' + assert camera.data.angle_x == pytest.approx(math.radians(40.0), rel=1e-5) + assert camera.data.clip_start == pytest.approx(0.5) + assert camera.data.clip_end == pytest.approx(500.0) + + +def test_perspective_fov_y(mi_addon, fresh_scene, tmp_path): + camera = _import_sensor_xml(tmp_path, f''' + + + + {FILM} + ''') + + # Horizontal fov for a 4:3 film with a 30 degree vertical fov + expected = 2.0 * math.atan(math.tan(math.radians(15.0)) * 640.0 / 480.0) + assert camera.data.angle_x == pytest.approx(expected, rel=1e-5) + + +def test_perspective_focal_length(mi_addon, fresh_scene, tmp_path): + # The focal_length parameter did not reach the Blender camera at all + # in the old importer (it was assigned to the Mitsuba properties). + camera = _import_sensor_xml(tmp_path, f''' + + + {FILM} + ''') + + # Mitsuba interprets the focal length on the diagonal of a full-frame + # 35mm sensor. + tan_half_diag = math.hypot(36.0, 24.0) / (2.0 * 50.0) + expected = 2.0 * math.atan( + tan_half_diag * 640.0 / math.hypot(640.0, 480.0)) + assert camera.data.angle_x == pytest.approx(expected, rel=1e-5) + + +def test_perspective_shift(mi_addon, fresh_scene, tmp_path): + # The old importer copied the principal point offset verbatim, without + # the resolution scaling or the y sign flip. + camera = _import_sensor_xml(tmp_path, f''' + + + + + {FILM} + ''') + + assert camera.data.shift_x == pytest.approx(0.2, rel=1e-5) + assert camera.data.shift_y == pytest.approx(0.1, rel=1e-5) + + +def test_thinlens(mi_addon, fresh_scene, tmp_path): + camera = _import_sensor_xml(tmp_path, f''' + + + + + {FILM} + ''') + + assert camera.data.dof.use_dof + assert camera.data.dof.focus_distance == pytest.approx(3.5) + expected_fstop = camera.data.lens / (2.0 * 0.02) / 1000.0 + assert camera.data.dof.aperture_fstop == pytest.approx(expected_fstop, + rel=1e-5) + + +def test_thinlens_zero_aperture(mi_addon, fresh_scene, tmp_path): + # A zero aperture radius is a pinhole; it used to abort the whole + # import with a ZeroDivisionError in the f-stop conversion + camera = _import_sensor_xml(tmp_path, f''' + + + + {FILM} + ''') + + assert camera is not None + assert not camera.data.dof.use_dof + + +def test_orthographic(mi_addon, fresh_scene, tmp_path): + camera = _import_sensor_xml(tmp_path, f''' + + + + + + {FILM} + ''') + + assert camera.data.type == 'ORTHO' + assert camera.data.ortho_scale == pytest.approx(4.0, rel=1e-5) + # The view scale must not leak into the object transform + scale = camera.matrix_world.decompose()[2] + assert max(abs(s - 1.0) for s in scale) < 1e-5 + + +def test_unsupported_sensor_is_skipped(mi_addon, fresh_scene, tmp_path): + camera = _import_sensor_xml(tmp_path, f''' + + {FILM} + ''') + assert camera is None diff --git a/tests/unit/test_compat.py b/tests/unit/test_compat.py new file mode 100644 index 00000000..65f161b1 --- /dev/null +++ b/tests/unit/test_compat.py @@ -0,0 +1,32 @@ +"""compat.py is the only module allowed to touch version-dependent API.""" + +import os + +REPO_ROOT = os.path.dirname(os.path.dirname( + os.path.dirname(os.path.realpath(__file__)))) +ADDON_DIR = os.path.join(REPO_ROOT, 'mitsuba_blender') + + +def _offenders(needle): + offenders = [] + for dirpath, _, filenames in os.walk(ADDON_DIR): + for filename in filenames: + if not filename.endswith('.py'): + continue + path = os.path.join(dirpath, filename) + if os.path.relpath(path, ADDON_DIR) == 'compat.py': + continue + with open(path) as f: + if needle in f.read(): + offenders.append(os.path.relpath(path, ADDON_DIR)) + return offenders + + +def test_version_checks_only_in_compat(): + assert _offenders('bpy.app.version') == [] + + +def test_use_nodes_only_in_compat(): + # use_nodes is deprecated in Blender 5.0 and removed in 6.0; go + # through compat.uses_nodes / compat.ensure_node_tree instead + assert _offenders('use_nodes') == [] diff --git a/tests/unit/test_export_paths.py b/tests/unit/test_export_paths.py new file mode 100644 index 00000000..6678f181 --- /dev/null +++ b/tests/unit/test_export_paths.py @@ -0,0 +1,60 @@ +"""File placement of the scene exporter and the render engine. + +Meshes and textures must land in subfolders of the export target directory +(or a temporary directory during rendering), never in the current working +directory. +""" + +import os +from contextlib import contextmanager + +import bpy + + +@contextmanager +def chdir(path): + old_cwd = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(old_cwd) + + +def test_export_writes_into_target_dir(mi_addon, fresh_scene, tmp_path): + target = tmp_path / 'export' + target.mkdir() + cwd = tmp_path / 'cwd' + cwd.mkdir() + + xml_file = target / 'scene.xml' + with chdir(cwd): + result = bpy.ops.export_scene.mitsuba(filepath=str(xml_file)) + assert result == {'FINISHED'} + + assert xml_file.is_file() + mesh_files = list((target / 'meshes').glob('*.serialized')) + assert len(mesh_files) == 1 + # Nothing may leak into the current working directory + assert list(cwd.iterdir()) == [] + + # The exported XML must be loadable, i.e. its file references resolve + import mitsuba as mi + mi.set_variant('scalar_rgb') + scene = mi.load_file(str(xml_file)) + assert scene.shapes() + + +def test_render_leaves_cwd_clean(mi_addon, fresh_scene, tmp_path): + scene = fresh_scene + scene.render.engine = 'MITSUBA' + scene.mitsuba.variant = 'scalar_rgb' + scene.render.resolution_x = 32 + scene.render.resolution_y = 32 + scene.render.resolution_percentage = 100 + + cwd = tmp_path / 'cwd' + cwd.mkdir() + with chdir(cwd): + assert bpy.ops.render.render() == {'FINISHED'} + assert list(cwd.iterdir()) == [] diff --git a/tests/unit/test_export_warnings.py b/tests/unit/test_export_warnings.py new file mode 100644 index 00000000..af9ab711 --- /dev/null +++ b/tests/unit/test_export_warnings.py @@ -0,0 +1,97 @@ +"""Export warnings are collected and reported after the export.""" + +import bpy + + +def _scene_converter(mi_addon, render=False): + from bl_ext.user_default.mitsuba_blender.io.exporter import SceneConverter + return SceneConverter(render=render) + + +def test_export_collects_warnings(mi_addon, fresh_scene, tmp_path): + # Armatures are not exportable and must produce a warning + bpy.ops.object.armature_add() + armature_name = bpy.context.object.name_full + + converter = _scene_converter(mi_addon) + converter.export_ctx.directory = str(tmp_path) + depsgraph = bpy.context.evaluated_depsgraph_get() + converter.scene_to_dict(depsgraph) + + warnings = converter.export_ctx.warnings + assert warnings + assert any(armature_name in message for message in warnings) + + +def test_clean_export_has_no_warnings(mi_addon, fresh_scene, tmp_path): + converter = _scene_converter(mi_addon) + converter.export_ctx.directory = str(tmp_path) + depsgraph = bpy.context.evaluated_depsgraph_get() + converter.scene_to_dict(depsgraph) + assert converter.export_ctx.warnings == [] + + +def test_f12_with_warnings_still_renders(mi_addon, fresh_scene, tmp_path): + scene = fresh_scene + bpy.ops.object.armature_add() + scene.render.engine = 'MITSUBA' + scene.mitsuba.variant = 'scalar_rgb' + scene.render.resolution_x = 32 + scene.render.resolution_y = 32 + scene.render.resolution_percentage = 100 + scene.render.filepath = str(tmp_path / 'render.exr') + scene.render.image_settings.file_format = 'OPEN_EXR' + + assert bpy.ops.render.render(write_still=True) == {'FINISHED'} + assert (tmp_path / 'render.exr').exists() + + +def test_export_operator_reports_warnings(mi_addon, fresh_scene, tmp_path, + capfd): + bpy.ops.object.armature_add() + filepath = str(tmp_path / 'scene.xml') + assert bpy.ops.export_scene.mitsuba(filepath=filepath) == {'FINISHED'} + + # In background mode operator reports go to stdout + captured = capfd.readouterr() + assert 'exported with' in captured.out + assert 'warnings' in captured.out + + +def test_export_survives_disabled_cycles_addon(mi_addon, fresh_scene, + tmp_path): + # scene.cycles is registered by the Cycles addon; with it disabled the + # non-Mitsuba fallbacks must use defaults instead of crashing + bpy.ops.preferences.addon_disable(module='cycles') + try: + assert not hasattr(bpy.context.scene, 'cycles') + converter = _scene_converter(mi_addon) + converter.export_ctx.directory = str(tmp_path) + depsgraph = bpy.context.evaluated_depsgraph_get() + converter.scene_to_dict(depsgraph) + finally: + bpy.ops.preferences.addon_enable(module='cycles') + + entries = [v for v in converter.export_ctx.scene_data.values() + if isinstance(v, dict)] + integrator = next(v for v in entries if v.get('type') == 'path') + assert integrator['max_depth'] > 0 + sensor = next(v for v in entries if v.get('type') == 'perspective') + assert sensor['sampler']['sample_count'] > 0 + + +def test_scene_to_dict_leaves_edit_mode_alone(mi_addon, fresh_scene, + tmp_path): + # The render engine calls scene_to_dict on the render job thread, + # where operators like mode_set are forbidden; the converter must not + # switch modes behind the user's back + bpy.context.view_layer.objects.active = bpy.data.objects['Cube'] + bpy.ops.object.mode_set(mode='EDIT') + try: + converter = _scene_converter(mi_addon) + converter.export_ctx.directory = str(tmp_path) + depsgraph = bpy.context.evaluated_depsgraph_get() + converter.scene_to_dict(depsgraph) + assert bpy.data.objects['Cube'].mode == 'EDIT' + finally: + bpy.ops.object.mode_set(mode='OBJECT') diff --git a/tests/unit/test_final_render.py b/tests/unit/test_final_render.py new file mode 100644 index 00000000..6f2c6f6f --- /dev/null +++ b/tests/unit/test_final_render.py @@ -0,0 +1,104 @@ +"""Final render (F12) through the MITSUBA render engine.""" + +import bpy +import numpy as np + + +def test_f12_default_cube(mi_addon, fresh_scene, tmp_path): + scene = fresh_scene + scene.render.engine = 'MITSUBA' + scene.mitsuba.variant = 'scalar_rgb' + scene.render.resolution_x = 64 + scene.render.resolution_y = 48 + scene.render.resolution_percentage = 100 + scene.render.filepath = str(tmp_path / 'render.exr') + scene.render.image_settings.file_format = 'OPEN_EXR' + + assert bpy.ops.render.render(write_still=True) == {'FINISHED'} + + import mitsuba as mi + img = np.array(mi.Bitmap(str(tmp_path / 'render.exr'))) + assert img.shape[:2] == (48, 64) + # The default cube lit by a point light renders to a non-uniform image. + rgb = img[:, :, :3] + assert rgb.max() > 0.0 + assert rgb.std() > 0.01 + + +def test_f12_aov_passes(mi_addon, fresh_scene, tmp_path): + scene = fresh_scene + scene.render.engine = 'MITSUBA' + scene.mitsuba.variant = 'scalar_rgb' + scene.render.resolution_x = 32 + scene.render.resolution_y = 32 + scene.render.resolution_percentage = 100 + scene.render.filepath = str(tmp_path / 'passes.exr') + scene.render.image_settings.file_format = 'OPEN_EXR_MULTILAYER' + + scene.mitsuba.active_integrator = 'aov' + aov = scene.mitsuba.available_integrators.aov + aov.depth = True + aov.sh_normal = True + aov.uv = True # Two channels: exercises the padding to three + + assert bpy.ops.render.render(write_still=True) == {'FINISHED'} + + import mitsuba as mi + bitmap = mi.Bitmap(str(tmp_path / 'passes.exr')) + pixels = np.array(bitmap) + layers = {} + channels = {} + # Channels are named .. + for index, field in enumerate(bitmap.struct_()): + _, pass_name, channel = field.name.rsplit('.', 2) + layers.setdefault(pass_name, []).append(pixels[:, :, index]) + channels.setdefault(pass_name, []).append(channel) + layers = {name: np.dstack(planes) for name, planes in layers.items()} + + # 'path' is the image rendered by the nested path integrator + assert {'depth', 'sh_normal', 'uv', 'path'} <= layers.keys() + depth = layers['depth'] + assert depth.shape[2] == 1 + assert depth.max() > 0.0, 'the cube must be visible in the depth pass' + normal = layers['sh_normal'] + assert normal.shape[2] == 3 + hit = depth[:, :, 0] > 0 + norms = np.linalg.norm(normal[hit], axis=-1) + # AOVs average over samples, so only pixels fully covered by the cube + # carry a unit shading normal; silhouette pixels mix in zeros. + unit = np.isclose(norms, 1.0, atol=1e-2) + assert unit.mean() > 0.5 + # The UV AOV has two channels and is padded with an empty third one + uv = layers['uv'] + assert uv.shape[2] == 3 + assert set(channels['uv']) == {'U', 'V', 'A'} + assert np.all(uv[:, :, channels['uv'].index('A')] == 0.0) + assert uv.max() > 0.0 + + +def test_f12_combined_is_linear(mi_addon, fresh_scene, tmp_path): + scene = fresh_scene + for b_object in list(scene.collection.all_objects): + if b_object.type != 'CAMERA': + bpy.data.objects.remove(b_object, do_unlink=True) + background = scene.world.node_tree.nodes['Background'] + background.inputs['Color'].default_value = (0.5, 0.5, 0.5, 1.0) + background.inputs['Strength'].default_value = 1.0 + + scene.render.engine = 'MITSUBA' + scene.mitsuba.variant = 'scalar_rgb' + scene.render.resolution_x = 32 + scene.render.resolution_y = 32 + scene.render.resolution_percentage = 100 + scene.render.filepath = str(tmp_path / 'gray.exr') + scene.render.image_settings.file_format = 'OPEN_EXR' + + assert bpy.ops.render.render(write_still=True) == {'FINISHED'} + + import mitsuba as mi + img = np.array(mi.Bitmap(str(tmp_path / 'gray.exr'))) + rgb = img[:, :, :3] + # A mid-gray emitter must come out at 0.5 in the linear EXR: no view + # transform or gamma may be baked into the Combined pass. + assert np.allclose(rgb, 0.5, atol=5e-3), \ + f'expected linear 0.5, got mean {rgb.mean():.4f}' diff --git a/tests/unit/test_import_warnings.py b/tests/unit/test_import_warnings.py new file mode 100644 index 00000000..d8386fb5 --- /dev/null +++ b/tests/unit/test_import_warnings.py @@ -0,0 +1,159 @@ +"""The importer must never raise for unsupported content: it produces +placeholders and collects warnings that the operator reports.""" + +import bpy + + +def _write_scene(tmp_path, xml_body): + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(f'\n{xml_body}\n') + return scene_file + + +UNKNOWN_SHAPE = ''' + + + ''' + + +def test_unknown_shape_yields_placeholder(mi_addon, fresh_scene, tmp_path): + scene_file = _write_scene(tmp_path, UNKNOWN_SHAPE) + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + + # The failed shape is kept as an empty placeholder mesh object + meshes = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH'] + assert len(meshes) == 1 + assert len(meshes[0].data.vertices) == 0 + + +def test_load_scene_collects_warnings(mi_addon, fresh_scene, tmp_path): + from bl_ext.user_default.mitsuba_blender.io import importer, bl_utils + from bpy_extras.io_utils import axis_conversion + + scene_file = _write_scene(tmp_path, UNKNOWN_SHAPE) + axis_mat = axis_conversion(to_forward='-Z', to_up='Y').to_4x4() + scene = bl_utils.init_empty_scene(name='warn-test') + + warnings = importer.load_mitsuba_scene( + scene, scene.collection, str(scene_file), axis_mat, False, True) + assert any('frobnicator' in w for w in warnings) + + +def test_missing_mesh_file_yields_placeholder(mi_addon, fresh_scene, + tmp_path): + scene_file = _write_scene(tmp_path, ''' + + + ''') + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + meshes = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH'] + assert len(meshes) == 1 + assert len(meshes[0].data.vertices) == 0 + + +def test_multiple_rfilters_use_first(mi_addon, fresh_scene, tmp_path): + scene_file = _write_scene(tmp_path, ''' + + + + + + ''') + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + + +def test_unknown_film_does_not_abort(mi_addon, fresh_scene, tmp_path): + scene_file = _write_scene(tmp_path, ''' + + + ''') + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + assert bpy.context.scene.camera is not None + + +def test_converter_exception_does_not_abort(mi_addon, fresh_scene, tmp_path, + monkeypatch): + # Shape and sensor converters run under the same never-crash contract + # as materials: an exception must degrade to a warning + import sys + importer = sys.modules[mi_addon].io.importer + from bl_ext.user_default.mitsuba_blender.io import bl_utils + from bpy_extras.io_utils import axis_conversion + + def boom(mi_context, node_id): + raise RuntimeError('boom') + monkeypatch.setattr(importer, 'convert_mi_shape', boom) + + scene_file = _write_scene(tmp_path, ''' + + + ''') + axis_mat = axis_conversion(to_forward='-Z', to_up='Y').to_4x4() + scene = bl_utils.init_empty_scene(name='guard-test') + + warnings = importer.load_mitsuba_scene( + scene, scene.collection, str(scene_file), axis_mat, False, True) + assert any('boom' in w for w in warnings) + + +def test_failed_parse_preserves_scene(mi_addon, fresh_scene, tmp_path): + # A malformed XML file must not destroy the user's scene: the file is + # parsed before any destructive scene change happens. + scene_file = tmp_path / 'broken.xml' + scene_file.write_text('\n + + ''') + axis_mat = axis_conversion(to_forward='-Z', to_up='Y').to_4x4() + scene = bl_utils.init_empty_scene(name='clean-test') + + warnings = importer.load_mitsuba_scene( + scene, scene.collection, str(scene_file), axis_mat, False, True) + assert warnings == [] + + +def test_second_import_keeps_previous_import(mi_addon, fresh_scene, + tmp_path): + # Importing into a new scene used to delete an existing 'Mitsuba' + # scene and purge its data, silently destroying the previous import + scene_file = _write_scene(tmp_path, ''' + + + ''') + + assert bpy.ops.import_scene.mitsuba( + filepath=str(scene_file), override_scene=False) == {'FINISHED'} + first_name = bpy.context.window.scene.name + first_objs = {o.name for o in bpy.context.window.scene.objects} + assert first_objs + + assert bpy.ops.import_scene.mitsuba( + filepath=str(scene_file), override_scene=False) == {'FINISHED'} + second_name = bpy.context.window.scene.name + + assert second_name != first_name + assert first_name in bpy.data.scenes + assert {o.name for o in bpy.data.scenes[first_name].objects} == \ + first_objs diff --git a/tests/unit/test_importer_regressions.py b/tests/unit/test_importer_regressions.py new file mode 100644 index 00000000..a484ddac --- /dev/null +++ b/tests/unit/test_importer_regressions.py @@ -0,0 +1,246 @@ +"""Regression tests for importer crashes inherited from PR #137.""" + +import bpy +import numpy as np + + +def _write_png(path, size=4): + image = bpy.data.images.new('png-writer', size, size, alpha=True) + values = np.linspace(0.0, 1.0, size * size * 4, dtype=np.float32) + image.pixels.foreach_set(values) + image.filepath_raw = str(path) + image.file_format = 'PNG' + image.save() + bpy.data.images.remove(image) + + +def _write_ply(path): + path.write_text('''ply +format ascii 1.0 +element vertex 3 +property float x +property float y +property float z +element face 1 +property list uchar int vertex_indices +end_header +0 0 0 +1 0 0 +0 1 0 +3 0 1 2 +''') + + +def _import_xml(tmp_path, xml_body): + xml = f'\n{xml_body}\n' + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(xml) + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == {'FINISHED'} + + +def _mesh_materials(): + return [obj.active_material for obj in bpy.data.objects + if obj.type == 'MESH' and obj.active_material is not None] + + +def _find_nodes(bl_mat, bl_idname): + return [node for node in bl_mat.node_tree.nodes if node.bl_idname == bl_idname] + + +def test_area_emitter_float_radiance(mi_addon, fresh_scene, tmp_path): + # A non-Color radiance used to leave 'radiance'/'strength' unbound + _import_xml(tmp_path, ''' + + + + ''') + + mats = _mesh_materials() + assert len(mats) == 1 + emission_nodes = _find_nodes(mats[0], 'ShaderNodeEmission') + assert len(emission_nodes) == 1 + assert emission_nodes[0].inputs['Strength'].default_value == 5.0 + assert tuple(emission_nodes[0].inputs['Color'].default_value)[:3] == (1.0, 1.0, 1.0) + + +def test_mask_with_default_diffuse_child(mi_addon, fresh_scene, tmp_path): + # The mask fast path called get_texture('reflectance') on a diffuse + # child without an explicit reflectance, which crashes + _import_xml(tmp_path, ''' + + + + + + + ''') + + mats = _mesh_materials() + assert len(mats) == 1 + # The default 0.5 reflectance is not black, so the material is a mix + # of a transparent and a diffuse BSDF + assert len(_find_nodes(mats[0], 'ShaderNodeMixShader')) == 1 + assert len(_find_nodes(mats[0], 'ShaderNodeBsdfTransparent')) == 1 + + +def test_mask_opacity_not_mutated(mi_addon, fresh_scene, tmp_path): + # Inverting the opacity in place mutated the parser state, so a second + # conversion of the same material saw an already-inverted value. The + # emissive shape bypasses the material cache and triggers a reconversion. + _import_xml(tmp_path, ''' + + + + + + + + + + + ''') + + mats = _mesh_materials() + assert len(mats) == 2 + for mat in mats: + transparent_nodes = _find_nodes(mat, 'ShaderNodeBsdfTransparent') + assert len(transparent_nodes) == 1 + color = tuple(transparent_nodes[0].inputs['Color'].default_value)[:3] + assert all(abs(c - 0.7) < 1e-5 for c in color) + + +def test_emitter_shape_without_bsdf(mi_addon, fresh_scene, tmp_path): + # A shape with an emitter but no BSDF used to lose its emitter. The + # cube cannot become a Blender light, so it keeps an emissive material. + _import_xml(tmp_path, ''' + + + ''') + + mats = _mesh_materials() + assert len(mats) == 1 + emission_nodes = _find_nodes(mats[0], 'ShaderNodeEmission') + assert len(emission_nodes) == 1 + assert emission_nodes[0].inputs['Strength'].default_value == 2.0 + + +def test_conductor_with_textured_property(mi_addon, fresh_scene, tmp_path): + # Copying a ResolvedReference property into the reflectance-probing + # load_dict call followed a dangling parser-state index and killed + # the whole Blender session with a segfault + _write_png(tmp_path / 'tex.png') + _import_xml(tmp_path, ''' + + + + + + + ''') + + mats = _mesh_materials() + assert len(mats) == 1 + assert len(_find_nodes(mats[0], 'ShaderNodeBsdfAnisotropic')) == 1 + + +def test_area_emitter_rectangle_becomes_light(mi_addon, fresh_scene, + tmp_path): + import math + + _import_xml(tmp_path, ''' + + + ''') + + bl_lights = [obj for obj in bpy.data.objects if obj.type == 'LIGHT'] + assert len(bl_lights) == 1 + data = bl_lights[0].data + assert data.type == 'AREA' + assert data.shape == 'RECTANGLE' + # A Mitsuba rectangle spans [-1, 1]: power = radiance * pi * area + assert abs(data.energy - 2.0 * math.pi * 4.0) < 1e-3 + assert not _mesh_materials() + + +def _path_tag_scene(tmp_path): + '''A scene whose shape, texture and envmap files live in a directory + that only a tag makes visible.''' + assets = tmp_path / 'assets' + assets.mkdir() + _write_ply(assets / 'tri.ply') + _write_png(assets / 'tex.png') + return ''' + + + + + + + + + + + + ''' + + +def test_path_tag_does_not_leak_into_file_resolver(mi_addon, fresh_scene, + tmp_path): + # parse_file prepends directories to the session-global file + # resolver; without a save/restore they polluted later exports/renders + import mitsuba as mi + body = _path_tag_scene(tmp_path) + before = [str(p) for p in mi.file_resolver()] + _import_xml(tmp_path, body) + assert [str(p) for p in mi.file_resolver()] == before + + +def test_path_tag_directories_resolve_filenames(mi_addon, fresh_scene, + tmp_path): + # Filenames were joined against the XML directory only, so files that + # relied on a directory came back as empty placeholders, + # missing textures and an error-colored world + _import_xml(tmp_path, _path_tag_scene(tmp_path)) + + meshes = [obj for obj in bpy.data.objects if obj.type == 'MESH'] + assert len(meshes) == 1 + assert len(meshes[0].data.vertices) == 3 + + mats = _mesh_materials() + assert len(mats) == 1 + tex_nodes = _find_nodes(mats[0], 'ShaderNodeTexImage') + assert len(tex_nodes) == 1 + assert tex_nodes[0].image is not None + + world_nodes = [node for node in bpy.context.scene.world.node_tree.nodes + if node.bl_idname == 'ShaderNodeTexEnvironment'] + assert len(world_nodes) == 1 + assert world_nodes[0].image is not None + + +def test_import_emissive_under_spectral_variant(mi_addon, fresh_scene, + tmp_path): + # Splitting radiance into color/strength went through + # get_emissive_texture, which instantiates a plugin from the current + # variant; under a spectral variant that is not an + # SRGBReflectanceSpectrum and a bare AssertionError broke the import + import mitsuba as mi + import pytest + if 'scalar_spectral' not in mi.variants(): + pytest.skip('scalar_spectral variant not available') + variant_before = mi.variant() + mi.set_variant('scalar_spectral') + try: + _import_xml(tmp_path, ''' + + + + ''') + + mats = _mesh_materials() + assert len(mats) == 1 + emission_nodes = _find_nodes(mats[0], 'ShaderNodeEmission') + assert len(emission_nodes) == 1 + assert emission_nodes[0].inputs['Strength'].default_value == 2.0 + finally: + mi.set_variant(variant_before) diff --git a/tests/unit/test_light_export.py b/tests/unit/test_light_export.py new file mode 100644 index 00000000..5fb9f193 --- /dev/null +++ b/tests/unit/test_light_export.py @@ -0,0 +1,235 @@ +"""Unit tests for the Blender light to Mitsuba emitter converters.""" + +import importlib +import math +import types + +import bpy +import numpy as np +import pytest +from bpy_extras.io_utils import axis_conversion +from mathutils import Matrix, Vector + + +@pytest.fixture(scope='session') +def lights(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.export.lights') + + +@pytest.fixture +def export_ctx(mi_addon, tmp_path): + import mitsuba as mi + mi.set_variant('scalar_rgb') + module = importlib.import_module(f'{mi_addon}.io.exporter.export_context') + ctx = module.ExportContext() + ctx.directory = str(tmp_path) + ctx.axis_mat = axis_conversion(to_forward='-Z', to_up='Y').to_4x4() + return ctx + + +@pytest.fixture +def log_capture(export_ctx): + logs = [] + export_ctx.log = lambda msg, level='INFO': logs.append((level, msg)) + return logs + + +def make_light(light_type, name='TestLight', location=(0, 0, 0), + rotation=(0, 0, 0), scale=(1, 1, 1), **data_props): + data = bpy.data.lights.new(name, light_type) + for key, value in data_props.items(): + setattr(data, key, value) + obj = bpy.data.objects.new(name, data) + obj.location = location + obj.rotation_euler = rotation + obj.scale = scale + bpy.context.collection.objects.link(obj) + bpy.context.view_layer.update() + return obj + + +def emitted_direction(export_ctx, obj): + """The world-space emission direction of a Blender light, in Mitsuba + coordinates (lights emit along their local -Z axis).""" + direction = export_ctx.axis_mat.to_3x3() \ + @ obj.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0)) + return np.array(direction.normalized()) + + +def to_world_z_axis(params): + """The +Z axis of an exported to_world transform.""" + matrix = np.array(params['to_world'].matrix) + return matrix[:3, 2] / np.linalg.norm(matrix[:3, 2]) + + +def test_point_light(fresh_scene, export_ctx, lights): + obj = make_light('POINT', location=(1, 2, 3), energy=100.0, + color=(1.0, 0.5, 0.25), shadow_soft_size=0.0) + params = lights.convert_light(export_ctx, obj) + assert params['type'] == 'point' + intensity = 100.0 / (4.0 * math.pi) + assert params['intensity']['value'] == \ + pytest.approx([intensity, 0.5 * intensity, 0.25 * intensity]) + expected_pos = export_ctx.axis_mat @ Vector((1, 2, 3)) + assert params['position'] == pytest.approx(list(expected_pos)) + + import mitsuba as mi + assert mi.load_dict(params) is not None + + +def test_point_light_with_radius(fresh_scene, export_ctx, lights): + obj = make_light('POINT', location=(0, 0, 1), energy=100.0, + color=(1.0, 1.0, 1.0), shadow_soft_size=0.5) + params = lights.convert_light(export_ctx, obj) + assert params['type'] == 'sphere' + assert params['radius'] == pytest.approx(0.5) + assert params['bsdf'] == { + 'type': 'diffuse', + 'reflectance': {'type' : 'rgb', 'value' : 0.0} + } + # The sphere must emit the same total power: L = P / (4 pi^2 r^2) + radiance = 100.0 / (4.0 * math.pi ** 2 * 0.5 ** 2) + assert params['emitter']['type'] == 'area' + assert params['emitter']['radiance']['value'] == \ + pytest.approx([radiance] * 3) + + import mitsuba as mi + assert mi.load_dict(params) is not None + + +def test_spot_light(fresh_scene, export_ctx, lights): + obj = make_light('SPOT', location=(0, 0, 5), + rotation=(math.radians(45), 0, 0), energy=50.0, + spot_size=math.radians(60), spot_blend=0.5) + params = lights.convert_light(export_ctx, obj) + assert params['type'] == 'spot' + assert params['cutoff_angle'] == pytest.approx(30.0) + expected_beam = math.degrees( + math.acos(0.5 + 0.5 * math.cos(math.radians(30)))) + assert params['beam_width'] == pytest.approx(expected_beam, abs=1e-4) + intensity = 50.0 / (4.0 * math.pi) + assert params['intensity']['value'] == pytest.approx([intensity] * 3) + # Mitsuba spots emit along the +Z axis of their to_world transform + np.testing.assert_allclose(to_world_z_axis(params), + emitted_direction(export_ctx, obj), + atol=1e-6) + + import mitsuba as mi + assert mi.load_dict(params) is not None + + +def test_sun_light(fresh_scene, export_ctx, lights): + obj = make_light('SUN', rotation=(math.radians(30), math.radians(15), 0), + energy=2.5, color=(1.0, 0.9, 0.8)) + params = lights.convert_light(export_ctx, obj) + assert params['type'] == 'directional' + assert params['irradiance']['value'] == \ + pytest.approx([2.5, 2.5 * 0.9, 2.5 * 0.8]) + np.testing.assert_allclose(to_world_z_axis(params), + emitted_direction(export_ctx, obj), + atol=1e-6) + + import mitsuba as mi + assert mi.load_dict(params) is not None + + +def test_sun_light_ignores_object_scale(fresh_scene, export_ctx, lights): + # Mitsuba's directional emitter does not normalize the direction it + # reads from to_world, so a scaled to_world would scale the irradiance + obj = make_light('SUN', rotation=(math.radians(30), 0, 0), energy=2.5, + scale=(5.0, 2.0, 3.0)) + params = lights.convert_light(export_ctx, obj) + matrix = np.array(params['to_world'].matrix) + assert np.linalg.norm(matrix[:3, 2]) == pytest.approx(1.0) + np.testing.assert_allclose(matrix[:3, 2], + emitted_direction(export_ctx, obj), atol=1e-6) + + +@pytest.mark.parametrize('shape,size,size_y,scale,expected_type,expected_area', [ + ('SQUARE', 2.0, None, (1, 1, 1), 'rectangle', 4.0), + ('RECTANGLE', 2.0, 3.0, (2, 1, 1), 'rectangle', 12.0), + ('DISK', 3.0, None, (1, 1, 1), 'disk', math.pi / 4.0 * 9.0), + ('ELLIPSE', 2.0, 1.0, (1, 3, 1), 'disk', math.pi / 4.0 * 6.0), +]) +def test_area_light(fresh_scene, export_ctx, lights, shape, size, size_y, + scale, expected_type, expected_area): + data_props = {'shape': shape, 'size': size, 'energy': 90.0} + if size_y is not None: + data_props['size_y'] = size_y + obj = make_light('AREA', scale=scale, **data_props) + params = lights.convert_light(export_ctx, obj) + assert params['type'] == expected_type + assert params['flip_normals'] is True + assert params['bsdf'] == { + 'type' : 'diffuse', + 'reflectance' : { + 'type' : 'rgb', + 'value' : 0.0 + } + } + + + radiance = 90.0 / (math.pi * expected_area) + assert params['emitter']['radiance']['value'] == \ + pytest.approx([radiance] * 3, rel=1e-5) + # The to_world transform carries half the world-space dimensions + matrix = np.array(params['to_world'].matrix) + size_y = size if size_y is None else size_y + assert np.linalg.norm(matrix[:3, 0]) == \ + pytest.approx(size / 2.0 * scale[0], rel=1e-5) + assert np.linalg.norm(matrix[:3, 1]) == \ + pytest.approx(size_y / 2.0 * scale[1], rel=1e-5) + + import mitsuba as mi + assert mi.load_dict(params) is not None + + +def test_area_light_spread_warns(fresh_scene, export_ctx, lights, + log_capture): + obj = make_light('AREA', shape='SQUARE', size=1.0, energy=10.0, + spread=math.radians(90)) + params = lights.convert_light(export_ctx, obj) + assert params['type'] == 'rectangle' + assert any(level == 'WARN' and 'spread' in msg + for level, msg in log_capture) + + +def test_degenerate_area_light_raises(fresh_scene, export_ctx, lights): + obj = make_light('AREA', shape='SQUARE', size=0.0, energy=10.0) + with pytest.raises(lights.ConversionError): + lights.convert_light(export_ctx, obj) + + +def test_export_light_never_raises(export_ctx, lights, log_capture): + fake_data = types.SimpleNamespace(type='LASER') + fake_obj = types.SimpleNamespace(data=fake_data, name_full='Fake', + matrix_world=Matrix()) + fake_instance = types.SimpleNamespace(object=fake_obj, + matrix_world=Matrix()) + lights.export_light(export_ctx, fake_instance) + assert list(export_ctx.scene_data.keys()) == ['type'] + assert any(level == 'WARN' for level, _ in log_capture) + + +def test_export_light_with_ids(fresh_scene, export_ctx, lights): + obj = make_light('POINT', name='Key', energy=10.0) + export_ctx.export_ids = True + instance = types.SimpleNamespace(object=obj, + matrix_world=obj.matrix_world) + lights.export_light(export_ctx, instance) + assert export_ctx.data_get('emit-Key') is not None + + +def test_spot_blend_inverse(lights): + spot_size = math.radians(70) + for blend in (0.0, 0.25, 0.5, 0.75, 1.0): + beam = lights.spot_beam_width(spot_size, blend) + assert lights.spot_blend(spot_size, beam) == \ + pytest.approx(blend, abs=1e-6) + + +def test_power_conversions_inverse(lights): + assert lights.intensity_to_power(lights.power_to_intensity(42.0)) == \ + pytest.approx(42.0) + assert lights.radiance_to_power( + lights.power_to_radiance(42.0, 3.5), 3.5) == pytest.approx(42.0) diff --git a/tests/unit/test_mat_principled.py b/tests/unit/test_mat_principled.py new file mode 100644 index 00000000..408fa350 --- /dev/null +++ b/tests/unit/test_mat_principled.py @@ -0,0 +1,388 @@ +"""Unit tests for the Principled BSDF converters (export and import).""" + +import importlib +import sys + +import bpy +import pytest + + +@pytest.fixture(scope='session') +def registry(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.export.materials') + + +@pytest.fixture +def exporter(mi_addon): + """Exports the current scene and returns the SceneConverter.""" + import mitsuba as mi + + def _export(directory, render=False): + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + converter = sys.modules[mi_addon].io.exporter.SceneConverter(render=render) + converter.export_ctx.directory = str(directory) + converter.scene_to_dict(bpy.context.evaluated_depsgraph_get()) + return converter + + return _export + +@pytest.fixture +def fake_uniform_texture(registry): + """Stand-in TEX_IMAGE converter returning a constant texture, so a + texture's average is exactly known regardless of the sampling grid.""" + converters = registry._resolve._texture_converters + previous = converters.get('TEX_IMAGE') + + @registry.texture_converter('TEX_IMAGE') + def convert_fake_uniform(export_ctx, node, out_socket): + return {'type': 'uniform', 'value': 0.25} + + yield {'type': 'uniform', 'value': 0.25} + if previous is None: + del converters['TEX_IMAGE'] + else: + converters['TEX_IMAGE'] = previous + +@pytest.fixture +def fake_image_texture(registry): + """Registers a stand-in TEX_IMAGE texture converter, restoring any + previously registered one afterwards.""" + converters = registry._resolve._texture_converters + previous = converters.get('TEX_IMAGE') + + @registry.texture_converter('TEX_IMAGE') + def convert_fake_image(export_ctx, node, out_socket): + return {'type': 'checkerboard'} + + yield {'type': 'checkerboard'} + if previous is None: + del converters['TEX_IMAGE'] + else: + converters['TEX_IMAGE'] = previous + + +def principled_node(name='Material'): + return bpy.data.materials[name].node_tree.nodes['Principled BSDF'] + + +def exported_entry(exporter, tmp_path, mat_id='mat-Material'): + converter = exporter(tmp_path) + return converter, converter.export_ctx.data_get(mat_id) + + +###################### +## Export side ## +###################### + +def test_export_default_material(fresh_scene, exporter, tmp_path): + converter, entry = exported_entry(exporter, tmp_path) + + assert entry['type'] == 'twosided' + params = entry['bsdf'] + assert params['type'] == 'principled' + assert params['base_color']['value'] == pytest.approx([0.8, 0.8, 0.8]) + assert params['roughness'] == pytest.approx(0.5) + assert params['metallic'] == 0.0 + assert params['anisotropic'] == 0.0 + assert params['spec_trans'] == 0.0 + assert params['specular'] == pytest.approx(0.5) + assert params['spec_tint'] == pytest.approx(0.0) + assert params['sheen'] == 0.0 + assert params['clearcoat'] == 0.0 + assert params['clearcoat_gloss'] == pytest.approx(0.97) + assert 'eta' not in params + + # No emission by default: the material is not stored as a bsdf/emitter pair + assert 'mat-Material' not in converter.export_ctx.exported_mats + + import mitsuba as mi + assert mi.load_dict(entry) is not None + + +def test_export_reflective_values(fresh_scene, exporter, tmp_path): + node = principled_node() + node.inputs['Base Color'].default_value = (0.2, 0.4, 0.6, 1.0) + node.inputs['Roughness'].default_value = 0.3 + node.inputs['Metallic'].default_value = 0.7 + node.inputs['Anisotropic'].default_value = 0.2 + node.inputs['Specular IOR Level'].default_value = 0.4 + node.inputs['Specular Tint'].default_value = (0.25, 0.25, 0.25, 1.0) + node.inputs['Sheen Weight'].default_value = 0.6 + node.inputs['Sheen Tint'].default_value = (0.5, 0.5, 0.5, 1.0) + node.inputs['Coat Weight'].default_value = 0.8 + node.inputs['Coat Roughness'].default_value = 0.1 + + _, entry = exported_entry(exporter, tmp_path) + params = entry['bsdf'] + assert params['base_color']['value'] == pytest.approx([0.2, 0.4, 0.6]) + # Blender and Mitsuba share the perceptual roughness convention + assert params['roughness'] == pytest.approx(0.3) + assert params['metallic'] == pytest.approx(0.7) + assert params['anisotropic'] == pytest.approx(0.2) + assert params['specular'] == pytest.approx(0.4) + assert params['spec_tint'] == pytest.approx(0.75) + assert params['sheen'] == pytest.approx(0.6) + assert params['sheen_tint'] == pytest.approx(0.5) + assert params['clearcoat'] == pytest.approx(0.8) + assert params['clearcoat_gloss'] == pytest.approx(0.9) + + +def test_export_transmissive_uses_eta(fresh_scene, exporter, tmp_path): + node = principled_node() + node.inputs['Transmission Weight'].default_value = 1.0 + node.inputs['IOR'].default_value = 1.45 + + _, entry = exported_entry(exporter, tmp_path) + # Transmissive materials are one-sided + assert entry['type'] == 'principled' + assert entry['spec_trans'] == pytest.approx(1.0) + assert entry['eta'] == pytest.approx(1.45) + assert 'specular' not in entry + + import mitsuba as mi + assert mi.load_dict(entry) is not None + + +def test_export_emission(fresh_scene, exporter, tmp_path): + node = principled_node() + node.inputs['Emission Strength'].default_value = 2.0 + node.inputs['Emission Color'].default_value = (1.0, 0.5, 0.25, 1.0) + + converter, entry = exported_entry(exporter, tmp_path) + assert entry['type'] == 'twosided' + pair = converter.export_ctx.exported_mats['mat-Material'] + assert pair['bsdf'] == 'mat-Material' + assert pair['emitter']['type'] == 'area' + assert pair['emitter']['radiance']['value'] == \ + pytest.approx([2.0, 1.0, 0.5]) + + +def test_export_black_emission_ignored(fresh_scene, exporter, tmp_path): + node = principled_node() + node.inputs['Emission Strength'].default_value = 5.0 + node.inputs['Emission Color'].default_value = (0.0, 0.0, 0.0, 1.0) + + converter, _ = exported_entry(exporter, tmp_path) + assert 'mat-Material' not in converter.export_ctx.exported_mats + + +def test_export_alpha_wraps_mask(fresh_scene, exporter, tmp_path): + principled_node().inputs['Alpha'].default_value = 0.25 + + _, entry = exported_entry(exporter, tmp_path) + assert entry['type'] == 'mask' + assert entry['opacity'] == pytest.approx(0.25) + assert entry['bsdf']['type'] == 'twosided' + assert entry['bsdf']['bsdf']['type'] == 'principled' + + import mitsuba as mi + assert mi.load_dict(entry) is not None + + +def test_export_textured_inputs(fresh_scene, exporter, tmp_path, + fake_image_texture): + node = principled_node() + tree = node.id_data + tex = tree.nodes.new('ShaderNodeTexImage') + tree.links.new(tex.outputs['Color'], node.inputs['Base Color']) + tree.links.new(tex.outputs['Alpha'], node.inputs['Alpha']) + + _, entry = exported_entry(exporter, tmp_path) + assert entry['type'] == 'mask' + assert entry['opacity'] == fake_image_texture + assert entry['bsdf']['bsdf']['base_color'] == fake_image_texture + + +def test_export_normal_map(fresh_scene, exporter, tmp_path, + fake_image_texture): + node = principled_node() + tree = node.id_data + tex = tree.nodes.new('ShaderNodeTexImage') + normal_map = tree.nodes.new('ShaderNodeNormalMap') + tree.links.new(tex.outputs['Color'], normal_map.inputs['Color']) + tree.links.new(normal_map.outputs['Normal'], node.inputs['Normal']) + node.inputs['Alpha'].default_value = 0.5 + + _, entry = exported_entry(exporter, tmp_path) + # The mask wraps the normalmap, which wraps the two-sided BSDF + assert entry['type'] == 'mask' + assert entry['bsdf']['type'] == 'normalmap' + assert entry['bsdf']['normalmap'] == fake_image_texture + assert entry['bsdf']['bsdf']['type'] == 'twosided' + assert entry['bsdf']['bsdf']['bsdf']['type'] == 'principled' + + import mitsuba as mi + assert mi.load_dict(entry) is not None + +def test_export_normal_map_with_strength(fresh_scene, exporter, tmp_path, + fake_image_texture): + node = principled_node() + tree = node.id_data + tex = tree.nodes.new('ShaderNodeTexImage') + normal_map = tree.nodes.new('ShaderNodeNormalMap') + tree.links.new(tex.outputs['Color'], normal_map.inputs['Color']) + tree.links.new(normal_map.outputs['Normal'], node.inputs['Normal']) + node.inputs['Alpha'].default_value = 0.5 + normal_map.inputs['Strength'].default_value = 5.0 + + expected_texture = { + 'type' : 'normal_map', + 'texture' : fake_image_texture, + 'strength': 5.0 + } + + _, entry = exported_entry(exporter, tmp_path) + # The mask wraps the normalmap, which wraps the two-sided BSDF + assert entry['type'] == 'mask' + assert entry['bsdf']['type'] == 'normalmap' + assert entry['bsdf']['normalmap'] == expected_texture + assert entry['bsdf']['bsdf']['type'] == 'twosided' + assert entry['bsdf']['bsdf']['bsdf']['type'] == 'principled' + assert entry['bsdf']['normalmap']['strength'] == pytest.approx(5.0) + + + import mitsuba as mi + assert mi.load_dict(entry) is not None + +def test_export_constant_bump_ignored(fresh_scene, exporter, tmp_path): + # A Bump node with a constant height perturbs nothing + node = principled_node() + tree = node.id_data + bump = tree.nodes.new('ShaderNodeBump') + tree.links.new(bump.outputs['Normal'], node.inputs['Normal']) + + _, entry = exported_entry(exporter, tmp_path) + assert entry['type'] == 'twosided' + + +def test_textured_coat_roughness_is_averaged(fresh_scene, exporter, tmp_path, fake_uniform_texture): + node = principled_node() + tree = node.id_data + tex = tree.nodes.new('ShaderNodeTexImage') + tree.links.new(tex.outputs['Color'], node.inputs['Coat Roughness']) + + _, entry = exported_entry(exporter, tmp_path) + assert entry['bsdf']['clearcoat_gloss'] == pytest.approx(0.75) + +def test_export_input_graph(fresh_scene, exporter, tmp_path): + import mitsuba as mi, drjit as dr + node = principled_node() + tree = node.id_data + math_node = tree.nodes.new('ShaderNodeMath') + math_node.operation = 'MULTIPLY' + math_node.inputs[0].default_value = 0.25 + math_node.inputs[1].default_value = 2.0 + tree.links.new(math_node.outputs['Value'], node.inputs['Metallic']) + _, entry = exported_entry(exporter, tmp_path) + + tex = mi.load_dict(entry['bsdf']['metallic']) + assert tex.eval_1(dr.zeros(mi.SurfaceInteraction3f)) == pytest.approx(0.5) + + +###################### +## Import side ## +###################### + +def import_xml(tmp_path, xml_body): + xml = f'\n{xml_body}\n' + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(xml) + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + + +def imported_material(name): + b_mat = bpy.data.materials[name] + surface = b_mat.node_tree.get_output_node('CYCLES').inputs['Surface'] + assert surface.is_linked + return b_mat, surface.links[0].from_node + + +def test_import_principled(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + + + + + + + + + + + ''') + + _, node = imported_material('mat-p') + assert node.bl_idname == 'ShaderNodeBsdfPrincipled' + inputs = node.inputs + assert tuple(inputs['Base Color'].default_value) == \ + pytest.approx((0.2, 0.4, 0.6, 1.0)) + assert inputs['Roughness'].default_value == pytest.approx(0.3) + assert inputs['Metallic'].default_value == pytest.approx(0.7) + assert inputs['Anisotropic'].default_value == pytest.approx(0.2) + assert inputs['Transmission Weight'].default_value == pytest.approx(1.0) + assert inputs['IOR'].default_value == pytest.approx(1.45) + assert inputs['Specular IOR Level'].default_value == \ + pytest.approx(((1.45 - 1.0) / (1.45 + 1.0)) ** 2 / 0.08) + assert tuple(inputs['Specular Tint'].default_value) == \ + pytest.approx((0.75, 0.75, 0.75, 1.0)) + assert inputs['Sheen Weight'].default_value == pytest.approx(0.6) + assert tuple(inputs['Sheen Tint'].default_value) == \ + pytest.approx((0.4, 0.4, 0.4, 1.0)) + assert inputs['Coat Weight'].default_value == pytest.approx(0.8) + assert inputs['Coat Roughness'].default_value == pytest.approx(0.1) + + +def test_import_principled_specular(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + ''') + + _, node = imported_material('mat-s') + assert node.inputs['Specular IOR Level'].default_value == \ + pytest.approx(0.5) + # F0 = 0.08 * 0.5 = 0.04 corresponds to an IOR of 1.5 + assert node.inputs['IOR'].default_value == pytest.approx(1.5) + + +def test_import_principled_defaults(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + ''') + + _, node = imported_material('mat-d') + assert node.bl_idname == 'ShaderNodeBsdfPrincipled' + assert tuple(node.inputs['Base Color'].default_value) == \ + pytest.approx((0.5, 0.5, 0.5, 1.0)) + assert node.inputs['Roughness'].default_value == pytest.approx(0.5) + + +def test_import_principled_with_emitter(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + + ''') + + b_mat, node = imported_material('mat-glow') + assert node.bl_idname == 'ShaderNodeAddShader' + emission = [n for n in b_mat.node_tree.nodes + if n.bl_idname == 'ShaderNodeEmission'] + assert len(emission) == 1 + assert emission[0].inputs['Strength'].default_value == pytest.approx(2.0) + assert tuple(emission[0].inputs['Color'].default_value) == \ + pytest.approx((1.0, 0.5, 0.25, 1.0)) + principled = [n for n in b_mat.node_tree.nodes + if n.bl_idname == 'ShaderNodeBsdfPrincipled'] + assert len(principled) == 1 diff --git a/tests/unit/test_material_combinators.py b/tests/unit/test_material_combinators.py new file mode 100644 index 00000000..220765cf --- /dev/null +++ b/tests/unit/test_material_combinators.py @@ -0,0 +1,604 @@ +"""Tests for the shader combinator converters (Mix, Add, Emission, Holdout).""" + +import contextlib +import importlib +import sys + +import bpy +import pytest + + +@pytest.fixture(scope='session') +def registry(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.export.materials') + + +@pytest.fixture +def exporter(mi_addon): + """Exports the current scene and returns the SceneConverter.""" + import mitsuba as mi + + def _export(directory, render=False): + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + converter = sys.modules[mi_addon].io.exporter.SceneConverter(render=render) + converter.export_ctx.directory = str(directory) + converter.scene_to_dict(bpy.context.evaluated_depsgraph_get()) + return converter + + return _export + + +@contextlib.contextmanager +def fake_texture_converter(registry, node_type, params): + """Temporarily registers a texture converter, restoring any previous one.""" + converters = registry._resolve._texture_converters + previous = converters.get(node_type) + converters[node_type] = lambda export_ctx, node, out_socket: dict(params) + try: + yield + finally: + if previous is None: + del converters[node_type] + else: + converters[node_type] = previous + + +def make_material(name): + b_mat = bpy.data.materials.new(name) + b_mat.use_nodes = True + tree = b_mat.node_tree + tree.nodes.remove(tree.nodes['Principled BSDF']) + return b_mat + + +def link_surface(b_mat, out_socket): + tree = b_mat.node_tree + tree.links.new(out_socket, + tree.nodes['Material Output'].inputs['Surface']) + + +def add_diffuse(b_mat, color=(0.2, 0.4, 0.6, 1.0)): + node = b_mat.node_tree.nodes.new('ShaderNodeBsdfDiffuse') + node.inputs['Color'].default_value = color + return node + + +def add_emission(b_mat, color=(1.0, 0.5, 0.25, 1.0), strength=2.0): + node = b_mat.node_tree.nodes.new('ShaderNodeEmission') + node.inputs['Color'].default_value = color + node.inputs['Strength'].default_value = strength + return node + + +def assign_material(b_mat, object_name='Cube'): + b_obj = bpy.data.objects[object_name] + b_obj.data.materials.clear() + b_obj.data.materials.append(b_mat) + + +def diffuse_dict(color): + return { + 'type': 'twosided', + 'bsdf': { + 'type': 'diffuse', + 'reflectance': {'type': 'rgb', 'value': pytest.approx(color)}, + }, + } + + +################## +## Emission ## +################## + +def test_emission_export(fresh_scene, exporter, tmp_path): + b_mat = make_material('Glow') + link_surface(b_mat, add_emission(b_mat).outputs['Emission']) + assign_material(b_mat) + + converter = exporter(tmp_path) + ctx = converter.export_ctx + # Mitsuba defaults a shape without a BSDF to a mid-gray diffuse, so an + # emitter-only material carries its own explicit black diffuse + assert ctx.data_get('mat-Glow') == { + 'type': 'diffuse', + 'reflectance': {'type': 'rgb', 'value': 0.0}, + } + assert ctx.exported_mats['mat-Glow'] == { + 'bsdf': 'mat-Glow', + 'emitter': {'type': 'area', + 'radiance': {'type': 'rgb', + 'value': pytest.approx([2.0, 1.0, 0.5])}, + 'twosided': True}, + } + + # The render-mode scene dict must load, emitter and shape included + import mitsuba as mi + converter = exporter(tmp_path, render=True) + assert mi.load_dict(converter.export_ctx.scene_data) is not None + + +def test_emission_zero_strength_exports_black_diffuse(fresh_scene, exporter, + tmp_path): + b_mat = make_material('Dark') + link_surface(b_mat, add_emission(b_mat, strength=0.0).outputs['Emission']) + assign_material(b_mat) + + ctx = exporter(tmp_path).export_ctx + assert ctx.data_get('mat-Dark') == { + 'type': 'diffuse', + 'reflectance': {'type': 'rgb', 'value': 0.0}, + } + assert 'mat-Dark' not in ctx.exported_mats + + +def test_emission_strength_graph(fresh_scene, exporter, tmp_path): + b_mat = make_material('MathGlow') + emission = add_emission(b_mat, color=(1.0, 1.0, 1.0, 1.0)) + math_node = b_mat.node_tree.nodes.new('ShaderNodeMath') + math_node.operation = 'MULTIPLY' + math_node.inputs[0].default_value = 3.0 + math_node.inputs[1].default_value = 0.5 + b_mat.node_tree.links.new(math_node.outputs['Value'], + emission.inputs['Strength']) + link_surface(b_mat, emission.outputs['Emission']) + assign_material(b_mat) + + ctx = exporter(tmp_path).export_ctx + assert ctx.exported_mats['mat-MathGlow']['emitter']['radiance'] == \ + {'type': 'rgb', 'value': pytest.approx([1.5, 1.5, 1.5])} + + +def test_emission_textured_color(fresh_scene, exporter, tmp_path, registry): + b_mat = make_material('TexGlow') + emission = add_emission(b_mat, strength=1.0) + noise = b_mat.node_tree.nodes.new('ShaderNodeTexNoise') + b_mat.node_tree.links.new(noise.outputs['Color'], + emission.inputs['Color']) + link_surface(b_mat, emission.outputs['Emission']) + assign_material(b_mat) + + with fake_texture_converter(registry, 'TEX_NOISE', + {'type': 'checkerboard'}): + ctx = exporter(tmp_path).export_ctx + assert ctx.exported_mats['mat-TexGlow']['emitter'] == \ + {'type': 'area', 'radiance': {'type': 'checkerboard'}, 'twosided' : True} + + +################### +## Add Shader ## +################### + +def test_add_emission_and_bsdf(fresh_scene, exporter, tmp_path): + b_mat = make_material('GlowingDiffuse') + add = b_mat.node_tree.nodes.new('ShaderNodeAddShader') + b_mat.node_tree.links.new(add_emission(b_mat).outputs['Emission'], + add.inputs[0]) + b_mat.node_tree.links.new(add_diffuse(b_mat).outputs['BSDF'], + add.inputs[1]) + link_surface(b_mat, add.outputs['Shader']) + assign_material(b_mat) + + ctx = exporter(tmp_path).export_ctx + assert ctx.data_get('mat-GlowingDiffuse') == diffuse_dict([0.2, 0.4, 0.6]) + assert ctx.exported_mats['mat-GlowingDiffuse'] == { + 'bsdf': 'mat-GlowingDiffuse', + 'emitter': {'type': 'area', + 'radiance': {'type': 'rgb', + 'value': pytest.approx([2.0, 1.0, 0.5])}, + 'twosided': True}, + } + + +def test_add_two_emissions_sums_radiance(fresh_scene, exporter, tmp_path): + b_mat = make_material('DoubleGlow') + add = b_mat.node_tree.nodes.new('ShaderNodeAddShader') + first = add_emission(b_mat, color=(1.0, 0.0, 0.0, 1.0), strength=1.0) + second = add_emission(b_mat, color=(0.0, 1.0, 0.0, 1.0), strength=3.0) + b_mat.node_tree.links.new(first.outputs['Emission'], add.inputs[0]) + b_mat.node_tree.links.new(second.outputs['Emission'], add.inputs[1]) + link_surface(b_mat, add.outputs['Shader']) + assign_material(b_mat) + + ctx = exporter(tmp_path).export_ctx + assert ctx.exported_mats['mat-DoubleGlow']['emitter']['radiance'] == \ + {'type': 'rgb', 'value': pytest.approx([1.0, 3.0, 0.0])} + + +def test_add_two_bsdfs_falls_back(fresh_scene, exporter, tmp_path, registry): + b_mat = make_material('TwoBsdfs') + add = b_mat.node_tree.nodes.new('ShaderNodeAddShader') + b_mat.node_tree.links.new(add_diffuse(b_mat).outputs['BSDF'], + add.inputs[0]) + b_mat.node_tree.links.new(add_diffuse(b_mat).outputs['BSDF'], + add.inputs[1]) + link_surface(b_mat, add.outputs['Shader']) + assign_material(b_mat) + + ctx = exporter(tmp_path).export_ctx + assert ctx.data_get('mat-TwoBsdfs') == registry.ERROR_BSDF + + +################### +## Mix Shader ## +################### + +def test_mix_two_bsdfs_constant_factor(fresh_scene, exporter, tmp_path): + b_mat = make_material('Blend') + mix = b_mat.node_tree.nodes.new('ShaderNodeMixShader') + mix.inputs['Fac'].default_value = 0.3 + first = add_diffuse(b_mat, color=(1.0, 0.0, 0.0, 1.0)) + second = add_diffuse(b_mat, color=(0.0, 0.0, 1.0, 1.0)) + b_mat.node_tree.links.new(first.outputs['BSDF'], mix.inputs[1]) + b_mat.node_tree.links.new(second.outputs['BSDF'], mix.inputs[2]) + link_surface(b_mat, mix.outputs['Shader']) + assign_material(b_mat) + + ctx = exporter(tmp_path).export_ctx + entry = ctx.data_get('mat-Blend') + assert entry['type'] == 'blendbsdf' + assert entry['weight'] == pytest.approx(0.3) + assert entry['bsdf1'] == diffuse_dict([1.0, 0.0, 0.0]) + assert entry['bsdf2'] == diffuse_dict([0.0, 0.0, 1.0]) + + import mitsuba as mi + assert mi.load_dict(entry) is not None + + +def test_mix_two_bsdfs_textured_factor(fresh_scene, exporter, tmp_path, + registry): + b_mat = make_material('TexBlend') + mix = b_mat.node_tree.nodes.new('ShaderNodeMixShader') + noise = b_mat.node_tree.nodes.new('ShaderNodeTexNoise') + b_mat.node_tree.links.new(noise.outputs['Fac'], mix.inputs['Fac']) + b_mat.node_tree.links.new(add_diffuse(b_mat).outputs['BSDF'], + mix.inputs[1]) + b_mat.node_tree.links.new(add_diffuse(b_mat).outputs['BSDF'], + mix.inputs[2]) + link_surface(b_mat, mix.outputs['Shader']) + assign_material(b_mat) + + with fake_texture_converter(registry, 'TEX_NOISE', + {'type': 'checkerboard'}): + ctx = exporter(tmp_path).export_ctx + assert ctx.data_get('mat-TexBlend')['weight'] == {'type': 'checkerboard'} + + +def test_mix_two_emissions(fresh_scene, exporter, tmp_path): + b_mat = make_material('GlowMix') + mix = b_mat.node_tree.nodes.new('ShaderNodeMixShader') + mix.inputs['Fac'].default_value = 0.25 + first = add_emission(b_mat, color=(1.0, 0.0, 0.0, 1.0), strength=1.0) + second = add_emission(b_mat, color=(0.0, 1.0, 0.0, 1.0), strength=2.0) + b_mat.node_tree.links.new(first.outputs['Emission'], mix.inputs[1]) + b_mat.node_tree.links.new(second.outputs['Emission'], mix.inputs[2]) + link_surface(b_mat, mix.outputs['Shader']) + assign_material(b_mat) + + ctx = exporter(tmp_path).export_ctx + assert ctx.exported_mats['mat-GlowMix']['emitter']['radiance'] == \ + {'type': 'rgb', 'value': pytest.approx([0.75, 0.5, 0.0])} + + +def test_mix_bsdf_with_emission_falls_back(fresh_scene, exporter, tmp_path, + registry): + b_mat = make_material('BadMix') + mix = b_mat.node_tree.nodes.new('ShaderNodeMixShader') + b_mat.node_tree.links.new(add_emission(b_mat).outputs['Emission'], + mix.inputs[1]) + b_mat.node_tree.links.new(add_diffuse(b_mat).outputs['BSDF'], + mix.inputs[2]) + link_surface(b_mat, mix.outputs['Shader']) + assign_material(b_mat) + + ctx = exporter(tmp_path).export_ctx + assert ctx.data_get('mat-BadMix') == registry.ERROR_BSDF + + +def test_mix_transparent_first_exports_mask(fresh_scene, exporter, tmp_path): + b_mat = make_material('Masked') + mix = b_mat.node_tree.nodes.new('ShaderNodeMixShader') + mix.inputs['Fac'].default_value = 0.7 + transparent = b_mat.node_tree.nodes.new('ShaderNodeBsdfTransparent') + b_mat.node_tree.links.new(transparent.outputs['BSDF'], mix.inputs[1]) + b_mat.node_tree.links.new(add_diffuse(b_mat).outputs['BSDF'], + mix.inputs[2]) + link_surface(b_mat, mix.outputs['Shader']) + assign_material(b_mat) + + ctx = exporter(tmp_path).export_ctx + entry = ctx.data_get('mat-Masked') + assert entry['type'] == 'mask' + assert entry['opacity'] == pytest.approx(0.7) + assert entry['bsdf'] == diffuse_dict([0.2, 0.4, 0.6]) + + import mitsuba as mi + assert mi.load_dict(entry) is not None + + +def test_mix_transparent_second_inverts_opacity(fresh_scene, exporter, + tmp_path): + b_mat = make_material('MaskedInv') + mix = b_mat.node_tree.nodes.new('ShaderNodeMixShader') + mix.inputs['Fac'].default_value = 0.7 + transparent = b_mat.node_tree.nodes.new('ShaderNodeBsdfTransparent') + b_mat.node_tree.links.new(add_diffuse(b_mat).outputs['BSDF'], + mix.inputs[1]) + b_mat.node_tree.links.new(transparent.outputs['BSDF'], mix.inputs[2]) + link_surface(b_mat, mix.outputs['Shader']) + assign_material(b_mat) + + entry = exporter(tmp_path).export_ctx.data_get('mat-MaskedInv') + assert entry['type'] == 'mask' + assert entry['opacity'] == pytest.approx(0.3) + + +def test_mix_transparent_textured_factor_uses_blend(fresh_scene, exporter, + tmp_path, registry): + b_mat = make_material('MaskedTex') + mix = b_mat.node_tree.nodes.new('ShaderNodeMixShader') + noise = b_mat.node_tree.nodes.new('ShaderNodeTexNoise') + b_mat.node_tree.links.new(noise.outputs['Fac'], mix.inputs['Fac']) + transparent = b_mat.node_tree.nodes.new('ShaderNodeBsdfTransparent') + b_mat.node_tree.links.new(add_diffuse(b_mat).outputs['BSDF'], + mix.inputs[1]) + b_mat.node_tree.links.new(transparent.outputs['BSDF'], mix.inputs[2]) + link_surface(b_mat, mix.outputs['Shader']) + assign_material(b_mat) + + with fake_texture_converter(registry, 'TEX_NOISE', + {'type': 'checkerboard'}): + ctx = exporter(tmp_path).export_ctx + entry = ctx.data_get('mat-MaskedTex') + assert entry['type'] == 'blendbsdf' + assert entry['weight'] == {'type': 'checkerboard'} + assert entry['bsdf1'] == diffuse_dict([0.2, 0.4, 0.6]) + assert entry['bsdf2'] == {'type': 'null'} + + +################ +## Holdout ## +################ + +def test_holdout_exports_null(fresh_scene, exporter, tmp_path): + b_mat = make_material('Hold') + holdout = b_mat.node_tree.nodes.new('ShaderNodeHoldout') + link_surface(b_mat, holdout.outputs['Holdout']) + assign_material(b_mat) + + ctx = exporter(tmp_path).export_ctx + assert ctx.data_get('mat-Hold') == {'type': 'null'} + + +####################### +## Import side ## +####################### + +def import_xml(tmp_path, xml_body): + xml = f'\n{xml_body}\n' + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(xml) + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + + +def imported_material(name): + b_mat = bpy.data.materials[name] + surface = b_mat.node_tree.get_output_node('CYCLES').inputs['Surface'] + assert surface.is_linked + return b_mat, surface.links[0].from_node + + +def nodes_of_type(b_mat, bl_idname): + return [n for n in b_mat.node_tree.nodes if n.bl_idname == bl_idname] + + +def test_import_twosided_single_child(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + + + ''') + + b_mat, node = imported_material('mat-two') + assert node.bl_idname == 'ShaderNodeBsdfDiffuse' + assert tuple(node.inputs['Color'].default_value) == \ + pytest.approx((0.2, 0.4, 0.6, 1.0)) + assert len(b_mat.node_tree.nodes) == 2 + + +def test_import_twosided_two_children(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + + + + + + ''') + + b_mat, node = imported_material('mat-frontback') + assert node.bl_idname == 'ShaderNodeMixShader' + fac = node.inputs['Fac'].links[0].from_socket + assert fac.node.bl_idname == 'ShaderNodeNewGeometry' + assert fac.name == 'Backfacing' + front = node.inputs[1].links[0].from_node + back = node.inputs[2].links[0].from_node + assert tuple(front.inputs['Color'].default_value) == \ + pytest.approx((1.0, 0.0, 0.0, 1.0)) + assert tuple(back.inputs['Color'].default_value) == \ + pytest.approx((0.0, 0.0, 1.0, 1.0)) + + +def test_import_twosided_legacy_child(mi_addon, fresh_scene, tmp_path): + # The principled importer still lives in the legacy module; the child + # must be bridged through it without leaving scaffolding behind + import_xml(tmp_path, ''' + + + + + + + ''') + + b_mat, node = imported_material('mat-legacy') + assert node.bl_idname == 'ShaderNodeBsdfPrincipled' + assert tuple(node.inputs['Base Color'].default_value) == \ + pytest.approx((0.8, 0.4, 0.2, 1.0)) + assert nodes_of_type(b_mat, 'NodeReroute') == [] + + +def test_import_blendbsdf(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + + + + + + + ''') + + _, node = imported_material('mat-blend') + assert node.bl_idname == 'ShaderNodeMixShader' + assert node.inputs['Fac'].default_value == pytest.approx(0.3) + first = node.inputs[1].links[0].from_node + second = node.inputs[2].links[0].from_node + assert tuple(first.inputs['Color'].default_value) == \ + pytest.approx((1.0, 0.0, 0.0, 1.0)) + assert tuple(second.inputs['Color'].default_value) == \ + pytest.approx((0.0, 0.0, 1.0, 1.0)) + + +def test_import_mask_black_diffuse(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + + + + ''') + + b_mat, node = imported_material('mat-glass') + assert node.bl_idname == 'ShaderNodeBsdfTransparent' + assert tuple(node.inputs['Color'].default_value) == \ + pytest.approx((0.7, 0.7, 0.7, 1.0)) + assert len(b_mat.node_tree.nodes) == 2 + + +def test_import_mask_general(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + + + + ''') + + _, node = imported_material('mat-masked') + assert node.bl_idname == 'ShaderNodeMixShader' + assert node.inputs['Fac'].default_value == pytest.approx(0.4) + assert node.inputs[1].links[0].from_node.bl_idname == \ + 'ShaderNodeBsdfTransparent' + diffuse = node.inputs[2].links[0].from_node + assert tuple(diffuse.inputs['Color'].default_value) == \ + pytest.approx((0.2, 0.4, 0.6, 1.0)) + + +def test_import_null(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + ''') + + b_mat, node = imported_material('mat-invisible') + assert node.bl_idname == 'ShaderNodeBsdfTransparent' + assert len(b_mat.node_tree.nodes) == 2 + + +#################### +## Roundtrips ## +#################### + +def roundtrip(exporter, tmp_path): + converter = exporter(tmp_path) + converter.dict_to_xml(str(tmp_path / 'scene.xml')) + bpy.ops.wm.read_homefile() + assert bpy.ops.import_scene.mitsuba( + filepath=str(tmp_path / 'scene.xml')) == {'FINISHED'} + + +def test_roundtrip_mix(mi_addon, fresh_scene, exporter, tmp_path): + b_mat = make_material('Blend') + mix = b_mat.node_tree.nodes.new('ShaderNodeMixShader') + mix.inputs['Fac'].default_value = 0.3 + first = add_diffuse(b_mat, color=(1.0, 0.0, 0.0, 1.0)) + second = add_diffuse(b_mat, color=(0.0, 0.0, 1.0, 1.0)) + b_mat.node_tree.links.new(first.outputs['BSDF'], mix.inputs[1]) + b_mat.node_tree.links.new(second.outputs['BSDF'], mix.inputs[2]) + link_surface(b_mat, mix.outputs['Shader']) + assign_material(b_mat) + + roundtrip(exporter, tmp_path) + _, node = imported_material('mat-Blend') + assert node.bl_idname == 'ShaderNodeMixShader' + assert node.inputs['Fac'].default_value == pytest.approx(0.3) + first = node.inputs[1].links[0].from_node + second = node.inputs[2].links[0].from_node + assert tuple(first.inputs['Color'].default_value) == \ + pytest.approx((1.0, 0.0, 0.0, 1.0)) + assert tuple(second.inputs['Color'].default_value) == \ + pytest.approx((0.0, 0.0, 1.0, 1.0)) + + +def test_roundtrip_mask(mi_addon, fresh_scene, exporter, tmp_path): + b_mat = make_material('Masked') + mix = b_mat.node_tree.nodes.new('ShaderNodeMixShader') + mix.inputs['Fac'].default_value = 0.7 + transparent = b_mat.node_tree.nodes.new('ShaderNodeBsdfTransparent') + b_mat.node_tree.links.new(transparent.outputs['BSDF'], mix.inputs[1]) + diffuse = add_diffuse(b_mat) + b_mat.node_tree.links.new(diffuse.outputs['BSDF'], mix.inputs[2]) + link_surface(b_mat, mix.outputs['Shader']) + assign_material(b_mat) + + roundtrip(exporter, tmp_path) + _, node = imported_material('mat-Masked') + assert node.bl_idname == 'ShaderNodeMixShader' + assert node.inputs['Fac'].default_value == pytest.approx(0.7) + assert node.inputs[1].links[0].from_node.bl_idname == \ + 'ShaderNodeBsdfTransparent' + diffuse = node.inputs[2].links[0].from_node + assert tuple(diffuse.inputs['Color'].default_value) == \ + pytest.approx((0.2, 0.4, 0.6, 1.0)) + + +def test_roundtrip_emission(mi_addon, fresh_scene, exporter, tmp_path): + b_mat = make_material('Glow') + link_surface(b_mat, add_emission(b_mat).outputs['Emission']) + assign_material(b_mat) + + roundtrip(exporter, tmp_path) + # An emitter-only material is exported as a black BSDF under the material + # id plus an area emitter on the shape + b_mat, node = imported_material('mat-Glow') + assert node.bl_idname == 'ShaderNodeAddShader' + emission = nodes_of_type(b_mat, 'ShaderNodeEmission') + assert len(emission) == 1 + assert emission[0].inputs['Strength'].default_value == pytest.approx(2.0) + assert tuple(emission[0].inputs['Color'].default_value) == \ + pytest.approx((1.0, 0.5, 0.25, 1.0)) diff --git a/tests/unit/test_material_eval.py b/tests/unit/test_material_eval.py new file mode 100644 index 00000000..0c85fc53 --- /dev/null +++ b/tests/unit/test_material_eval.py @@ -0,0 +1,626 @@ +"""Unit tests for material socket resolution.""" + +import importlib +import math +import mitsuba as mi, drjit as dr + +import bpy +import pytest + +mi.set_variant('scalar_rgb') +SI = dr.zeros(mi.SurfaceInteraction3f) + +@pytest.fixture(scope='session') +def ev(mi_addon): + return importlib.import_module( + f'{mi_addon}.convert.export.materials._resolve') + +@pytest.fixture(scope='session') +def si(): + return dr.zeros(mi.SurfaceInteraction3f) + +@pytest.fixture +def tree(): + b_mat = bpy.data.materials.new('eval-test') + b_mat.use_nodes = True + yield b_mat.node_tree + bpy.data.materials.remove(b_mat) + + +@pytest.fixture +def probe(tree): + """An unconnected node providing a float and a color input socket.""" + return tree.nodes.new('ShaderNodeEmission') + + +def texture(result): + assert result.__class__.__name__ == 'Texture', repr(result) + return result.params + + +def by_id(sockets, identifier): + return next(s for s in sockets if s.identifier == identifier) + + +def eval_texture(export_ctx, result, ctx): + assert result.__class__.__name__ == 'Texture', repr(result) + tex = mi.load_dict(result.params) + return tex + +def eval_float_texture(result): + tex = mi.load_dict(texture(result)) + return tex.eval_1(dr.zeros(mi.SurfaceInteraction3f)) + +def fold_float(export_ctx, ev, tree, probe, out_socket): + tree.links.new(out_socket, probe.inputs['Strength']) + return ev.resolve(export_ctx, probe.inputs['Strength']) + + +def fold_color(export_ctx, ev, tree, probe, out_socket): + tree.links.new(out_socket, probe.inputs['Color']) + return ev.resolve(export_ctx, probe.inputs['Color']) + + +def constant(result): + assert result.__class__.__name__ == 'Constant', repr(result) + return result.value + + +def math_node(tree, operation, a, b=0.0, c=0.0): + node = tree.nodes.new('ShaderNodeMath') + node.operation = operation + node.inputs[0].default_value = a + node.inputs[1].default_value = b + node.inputs[2].default_value = c + return node + + +def test_unlinked_float_socket(export_ctx, ev, probe): + probe.inputs['Strength'].default_value = 0.25 + result = ev.resolve(export_ctx, probe.inputs['Strength']) + assert constant(result) == 0.25 + + +def test_unlinked_color_socket(export_ctx, ev, probe): + probe.inputs['Color'].default_value = (0.1, 0.2, 0.3, 1.0) + value = constant(ev.resolve(export_ctx, probe.inputs['Color'])) + assert value == pytest.approx((0.1, 0.2, 0.3, 1.0)) + + +def test_value_node(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeValue') + node.outputs['Value'].default_value = 2.5 + result = fold_float(export_ctx, ev, tree, probe, node.outputs['Value']) + assert constant(result) == 2.5 + + +def test_rgb_node_reads_output_socket(export_ctx, ev, tree, probe): + # The color of an RGB node lives on its output socket; node.color is + # the unrelated header color + node = tree.nodes.new('ShaderNodeRGB') + node.outputs['Color'].default_value = (0.8, 0.6, 0.4, 1.0) + node.color = (0.9, 0.9, 0.9) + value = constant(fold_color(export_ctx, ev, tree, probe, node.outputs['Color'])) + assert value == pytest.approx((0.8, 0.6, 0.4, 1.0)) + + +@pytest.mark.parametrize('operation,a,b,c,expected', [ + ('ADD', 2.0, 3.0, 0.0, 5.0), + ('SUBTRACT', 2.0, 3.0, 0.0, -1.0), + ('MULTIPLY', 2.0, 3.0, 0.0, 6.0), + ('DIVIDE', 3.0, 2.0, 0.0, 1.5), + ('DIVIDE', 1.0, 0.0, 0.0, 0.0), + ('MULTIPLY_ADD', 2.0, 3.0, 4.0, 10.0), + ('POWER', 2.0, 10.0, 0.0, 1024.0), + ('POWER', -2.0, 0.5, 0.0, 0.0), + ('LOGARITHM', 8.0, 2.0, 0.0, 3.0), + ('SQRT', 9.0, 0.0, 0.0, 3.0), + ('SQRT', -1.0, 0.0, 0.0, 0.0), + ('INVERSE_SQRT', 4.0, 0.0, 0.0, 0.5), + ('ABSOLUTE', -3.0, 0.0, 0.0, 3.0), + ('EXPONENT', 0.0, 0.0, 0.0, 1.0), + ('MINIMUM', 2.0, 3.0, 0.0, 2.0), + ('MAXIMUM', 2.0, 3.0, 0.0, 3.0), + ('LESS_THAN', 1.0, 2.0, 0.0, 1.0), + ('GREATER_THAN', 1.0, 2.0, 0.0, 0.0), + ('SIGN', -5.0, 0.0, 0.0, -1.0), + ('COMPARE', 1.0, 1.0001, 0.001, 1.0), + ('COMPARE', 1.0, 1.1, 0.001, 0.0), + ('ROUND', 2.5, 0.0, 0.0, 3.0), + ('FLOOR', 2.7, 0.0, 0.0, 2.0), + ('CEIL', 2.1, 0.0, 0.0, 3.0), + ('TRUNC', -2.7, 0.0, 0.0, -2.0), + ('FRACT', 2.75, 0.0, 0.0, 0.75), + ('MODULO', 7.0, 3.0, 0.0, 1.0), + ('FLOORED_MODULO', -7.0, 3.0, 0.0, 2.0), + ('WRAP', 5.0, 3.0, 0.0, 2.0), + ('SNAP', 7.3, 2.0, 0.0, 6.0), + ('PINGPONG', 1.5, 1.0, 0.0, 0.5), + ('SINE', math.pi / 2.0, 0.0, 0.0, 1.0), + ('ARCSINE', 1.0, 0.0, 0.0, math.pi / 2.0), + ('ARCSINE', 2.0, 0.0, 0.0, math.pi / 2.0), + ('ARCTAN2', 1.0, 1.0, 0.0, math.pi / 4.0), + ('RADIANS', 180.0, 0.0, 0.0, math.pi), + ('DEGREES', math.pi, 0.0, 0.0, 180.0), +]) + +def test_math_operations(export_ctx, ev, tree, probe, operation, a, b, c, expected): + node = math_node(tree, operation, a, b, c) + result = fold_float(export_ctx, ev, tree,probe, node.outputs['Value']) + assert eval_float_texture(result) == pytest.approx(expected) + + +def test_math_use_clamp(export_ctx, ev, tree, probe): + node = math_node(tree, 'ADD', 2.0, 3.0) + node.use_clamp = True + result = fold_float(export_ctx, ev, tree, probe, node.outputs['Value']) + assert eval_float_texture(result) == 1.0 + + +def test_math_chain(export_ctx, ev, tree, probe): + add = math_node(tree, 'ADD', 3.0, 4.0) + mul = math_node(tree, 'MULTIPLY', 0.0, 2.0) + tree.links.new(add.outputs['Value'], mul.inputs[0]) + result = fold_float(export_ctx, ev, tree, probe, mul.outputs['Value']) + assert eval_float_texture(result) == 14.0 + + +def vector_math_node(tree, operation, a, b=(0.0, 0.0, 0.0)): + node = tree.nodes.new('ShaderNodeVectorMath') + node.operation = operation + node.inputs[0].default_value = a + node.inputs[1].default_value = b + return node + + +def test_vector_math_dot_product(export_ctx, ev, tree, probe): + node = vector_math_node(tree, 'DOT_PRODUCT', (1, 2, 3), (4, 5, 6)) + result = fold_float(export_ctx, ev, tree, probe, node.outputs['Value']) + assert eval_float_texture(result) == pytest.approx(32.0) + + +def test_vector_math_scale(export_ctx, ev, tree, probe): + node = vector_math_node(tree, 'SCALE', (1, 2, 3)) + node.inputs['Scale'].default_value = 2.0 + result = fold_color(export_ctx, ev, tree, probe, node.outputs['Vector']) + tex = mi.load_dict(texture(result)) + assert list(tex.eval_3(SI)) == pytest.approx((2.0, 4.0, 6.0)) + + +def test_vector_math_cross_product(export_ctx, ev, tree, probe): + node = vector_math_node(tree, 'CROSS_PRODUCT', (1, 0, 0), (0, 1, 0)) + result = fold_color(export_ctx, ev, tree, probe, node.outputs['Vector']) + tex = mi.load_dict(texture(result)) + assert list(tex.eval_3(SI)) == pytest.approx((0.0, 0.0, 1.0)) + + +def test_vector_math_normalize(export_ctx, ev, tree, probe): + node = vector_math_node(tree, 'NORMALIZE', (3, 0, 4)) + result = fold_color(export_ctx, ev, tree, probe, node.outputs['Vector']) + tex = mi.load_dict(texture(result)) + assert list(tex.eval_3(SI)) == pytest.approx((0.6, 0.0, 0.8)) + + +def mix_node(tree, data_type, factor, a, b, blend_type='MIX'): + node = tree.nodes.new('ShaderNodeMix') + node.data_type = data_type + node.blend_type = blend_type + suffix = {'FLOAT': 'Float', 'RGBA': 'Color'}[data_type] + by_id(node.inputs, 'Factor_Float').default_value = factor + by_id(node.inputs, f'A_{suffix}').default_value = a + by_id(node.inputs, f'B_{suffix}').default_value = b + return node, by_id(node.outputs, f'Result_{suffix}') + + +def test_mix_float(export_ctx, ev, tree, probe): + node, out = mix_node(tree, 'FLOAT', 0.25, 0.0, 8.0) + result = fold_float(export_ctx, ev, tree, probe, out) + assert eval_float_texture(result) == pytest.approx(2.0) + + + +def test_mix_float_clamp_factor(export_ctx, ev, tree, probe): + node, out = mix_node(tree, 'FLOAT', 2.0, 0.0, 8.0) + node.clamp_factor = True + result = fold_float(export_ctx, ev, tree, probe, out) + assert eval_float_texture(result) == pytest.approx(8.0) + + +def test_mix_float_unclamped_factor(export_ctx, ev, tree, probe): + node, out = mix_node(tree, 'FLOAT', 2.0, 0.0, 8.0) + node.clamp_factor = False + result = fold_float(export_ctx, ev, tree, probe, out) + assert eval_float_texture(result) == pytest.approx(16.0) + + +@pytest.mark.parametrize('blend_type,factor,expected', [ + ('MIX', 1.0, (0.5, 0.5, 0.25)), + ('MIX', 0.5, (0.35, 0.45, 0.525)), + ('MULTIPLY', 1.0, (0.1, 0.2, 0.2)), + ('ADD', 0.5, (0.45, 0.65, 0.925)), + ('SUBTRACT', 1.0, (-0.3, -0.1, 0.55)), + ('DARKEN', 1.0, (0.2, 0.4, 0.25)), + ('LIGHTEN', 1.0, (0.5, 0.5, 0.8)), +]) +def test_mix_color_blend(export_ctx, ev, tree, probe, blend_type, factor, expected): + a = (0.2, 0.4, 0.8, 1.0) + b = (0.5, 0.5, 0.25, 1.0) + node, out = mix_node(tree, 'RGBA', factor, a, b, blend_type) + node.clamp_result = False + result = fold_color(export_ctx, ev, tree, probe, out) + tex = mi.load_dict(texture(result)) + assert list(tex.eval_3(SI)) == pytest.approx(expected) + +def test_mix_color_unsupported_blend(export_ctx, ev, tree, probe): + _, out = mix_node(tree, 'RGBA', 0.5, (1, 0, 0, 1), (0, 1, 0, 1), 'BURN') + result = fold_color(export_ctx, ev, tree, probe, out) + assert isinstance(result, ev.Unsupported) + assert 'BURN' in result.reason + + +def test_invert(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeInvert') + node.inputs['Fac'].default_value = 0.5 + node.inputs['Color'].default_value = (0.2, 0.4, 1.0, 1.0) + result =fold_color(export_ctx, ev, tree, probe, node.outputs['Color']) + tex = mi.load_dict(texture(result)) + assert list(tex.eval_3(SI)) == pytest.approx((0.5, 0.5, 0.5)) + + +def test_gamma(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeGamma') + node.inputs['Color'].default_value = (0.25, 0.0, 1.0, 1.0) + node.inputs['Gamma'].default_value = 0.5 + result = fold_color(export_ctx, ev, tree, probe, node.outputs['Color']) + tex = mi.load_dict(texture(result)) + assert list(tex.eval_3(SI)) == pytest.approx((0.5, 0.0, 1.0)) + + +def test_brightness_contrast(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeBrightContrast') + node.inputs['Color'].default_value = (0.5, 0.0, 1.0, 1.0) + node.inputs['Bright'].default_value = 0.1 + node.inputs['Contrast'].default_value = 0.2 + # gain = 1.2, offset = 0.1 - 0.1 = 0, clamped at zero from below + result = fold_color(export_ctx, ev, tree, probe, node.outputs['Color']) + tex = mi.load_dict(texture(result)) + assert list(tex.eval_3(SI)) == pytest.approx((0.6, 0.0, 1.2)) + + +def map_range_node(tree, value, interpolation='LINEAR', clamp=True, + from_range=(0.0, 10.0), to_range=(0.0, 1.0), steps=4.0): + node = tree.nodes.new('ShaderNodeMapRange') + node.interpolation_type = interpolation + node.clamp = clamp + by_id(node.inputs, 'Value').default_value = value + by_id(node.inputs, 'From Min').default_value = from_range[0] + by_id(node.inputs, 'From Max').default_value = from_range[1] + by_id(node.inputs, 'To Min').default_value = to_range[0] + by_id(node.inputs, 'To Max').default_value = to_range[1] + by_id(node.inputs, 'Steps').default_value = steps + return node + + +@pytest.mark.parametrize('value,interpolation,clamp,expected', [ + (5.0, 'LINEAR', True, 0.5), + (15.0, 'LINEAR', True, 1.0), + (15.0, 'LINEAR', False, 1.5), + (5.0, 'SMOOTHSTEP', False, 0.5), + (2.5, 'SMOOTHSTEP', False, 0.15625), + (15.0, 'SMOOTHERSTEP', False, 1.0), + (5.0, 'STEPPED', True, 0.5), +]) +def test_map_range(export_ctx, ev, tree, probe, value, interpolation, clamp, expected): + node = map_range_node(tree, value, interpolation, clamp) + result = fold_float(export_ctx, ev, tree, probe, node.outputs['Result']) + assert eval_float_texture(result) == pytest.approx(expected) + + +def test_clamp_minmax(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeClamp') + node.inputs['Value'].default_value = 2.0 + node.inputs['Min'].default_value = 0.5 + node.inputs['Max'].default_value = 1.5 + result = fold_float(export_ctx, ev, tree, probe, node.outputs['Result']) + assert eval_float_texture(result) == 1.5 + + +def test_clamp_range_swapped(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeClamp') + node.clamp_type = 'RANGE' + node.inputs['Value'].default_value = 0.4 + node.inputs['Min'].default_value = 1.0 + node.inputs['Max'].default_value = 0.0 + result = fold_float(export_ctx, ev, tree, probe, node.outputs['Result']) + assert eval_float_texture(result) == pytest.approx(0.4) + + +def test_separate_combine_xyz(export_ctx, ev, tree, probe): + comb = tree.nodes.new('ShaderNodeCombineXYZ') + comb.inputs['X'].default_value = 1.0 + comb.inputs['Y'].default_value = 2.0 + comb.inputs['Z'].default_value = 3.0 + sep = tree.nodes.new('ShaderNodeSeparateXYZ') + tree.links.new(comb.outputs['Vector'], sep.inputs['Vector']) + result = fold_float(export_ctx, ev, tree, probe, sep.outputs['Y']) + assert eval_float_texture(result) == 2.0 + + +def test_separate_color_hsv(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeSeparateColor') + node.mode = 'HSV' + node.inputs['Color'].default_value = (0.5, 0.0, 0.0, 1.0) + result = fold_float(export_ctx, ev, tree, probe, node.outputs['Green']) + assert eval_float_texture(result) == pytest.approx(1.0) # saturation + result = fold_float(export_ctx, ev, tree, probe, node.outputs['Blue']) + assert eval_float_texture(result) == pytest.approx(0.5) # value + + +def test_combine_color_hsv(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeCombineColor') + node.mode = 'HSV' + node.inputs['Red'].default_value = 0.0 # hue + node.inputs['Green'].default_value = 1.0 # saturation + node.inputs['Blue'].default_value = 1.0 # value + result = fold_color(export_ctx, ev, tree, probe, node.outputs['Color']) + tex = mi.load_dict(texture(result)) + assert list(tex.eval_3(SI)) == pytest.approx((1.0, 0.0, 0.0)) + + +def test_rgb_to_bw(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeRGBToBW') + node.inputs['Color'].default_value = (1.0, 0.0, 0.0, 1.0) + result = fold_float(export_ctx, ev, tree, probe, node.outputs['Val']) + assert eval_float_texture(result) == pytest.approx(0.2126, rel=1e-3) + + +def test_float_to_color_conversion(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeValue') + node.outputs['Value'].default_value = 0.5 + value = constant(fold_color(export_ctx, ev, tree, probe, node.outputs['Value'])) + assert value == pytest.approx((0.5, 0.5, 0.5, 1.0)) + + +def test_color_to_float_conversion(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeRGB') + node.outputs['Color'].default_value = (1.0, 0.0, 0.0, 1.0) + result = fold_float(export_ctx, ev, tree, probe, node.outputs['Color']) + assert constant(result) == pytest.approx(0.2126) + + +def test_reroute_chain(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeValue') + node.outputs['Value'].default_value = 4.0 + reroute_a = tree.nodes.new('NodeReroute') + reroute_b = tree.nodes.new('NodeReroute') + tree.links.new(node.outputs['Value'], reroute_a.inputs[0]) + tree.links.new(reroute_a.outputs[0], reroute_b.inputs[0]) + result = fold_float(export_ctx, ev, tree, probe, reroute_b.outputs[0]) + assert constant(result) == 4.0 + + +def test_muted_node_passthrough(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeValue') + node.outputs['Value'].default_value = 3.0 + add = math_node(tree, 'ADD', 0.0, 100.0) + tree.links.new(node.outputs['Value'], add.inputs[0]) + tree.links.new(add.outputs['Value'], probe.inputs['Strength']) + add.mute = True + # Blender computes the pass-through links of muted nodes on update + bpy.context.view_layer.update() + result = ev.resolve(export_ctx, probe.inputs['Strength']) + assert constant(result) == 3.0 + + +def test_muted_link_is_unlinked(export_ctx, ev, tree, probe): + probe.inputs['Strength'].default_value = 0.75 + node = tree.nodes.new('ShaderNodeValue') + node.outputs['Value'].default_value = 3.0 + link = tree.links.new(node.outputs['Value'], probe.inputs['Strength']) + link.is_muted = True + result = ev.resolve(export_ctx, probe.inputs['Strength']) + assert constant(result) == 0.75 + + +def test_unsupported_node(export_ctx, ev, tree, probe): + node = tree.nodes.new('ShaderNodeTexVoronoi') + result = fold_color(export_ctx, ev, tree, probe, node.outputs['Color']) + assert isinstance(result, ev.Unsupported) + assert node.name in result.reason + assert 'TEX_VORONOI' in result.reason + assert 'Color' in result.reason + + +def test_unsupported_node_inside_fold(export_ctx, ev, tree, probe): + noise = tree.nodes.new('ShaderNodeTexVoronoi') + add = math_node(tree, 'ADD', 0.0, 1.0) + tree.links.new(noise.outputs['Color'], add.inputs[0]) + result = fold_float(export_ctx, ev, tree, probe, add.outputs['Value']) + assert isinstance(result, ev.Unsupported) + assert noise.name in result.reason + + +@pytest.fixture +def override_checker(ev): + """Temporarily replaces the TEX_CHECKER converter, restoring the real + one afterwards.""" + previous = ev._texture_converters.get('TEX_CHECKER') + + def _override(func): + ev.texture_converter('TEX_CHECKER')(func) + + yield _override + if previous is None: + ev._texture_converters.pop('TEX_CHECKER', None) + else: + ev._texture_converters['TEX_CHECKER'] = previous + + +def test_texture_converter_registry(export_ctx, ev, tree, probe, override_checker): + override_checker(lambda export_ctx, node, out_socket: + {'type': 'checkerboard'}) + + node = tree.nodes.new('ShaderNodeTexChecker') + result = fold_color(export_ctx, ev, tree, probe, node.outputs['Color']) + assert isinstance(result, ev.Texture) + assert result.params == {'type': 'checkerboard'} + + +def test_texture_converter_error_is_unsupported(export_ctx, ev, tree, probe, + override_checker): + def convert_checker(export_ctx, node, out_socket): + raise ev.ConversionError('checker says no') + override_checker(convert_checker) + + node = tree.nodes.new('ShaderNodeTexChecker') + result = fold_color(export_ctx, ev, tree, probe, node.outputs['Color']) + assert isinstance(result, ev.Unsupported) + assert result.reason == 'checker says no' + + +def test_eval_float_returns_default_on_unsupported(export_ctx, ev, tree, probe, mi_addon): + ctx_module = importlib.import_module( + f'{mi_addon}.io.exporter.export_context') + export_ctx = ctx_module.ExportContext() + noise = tree.nodes.new('ShaderNodeTexVoronoi') + tree.links.new(noise.outputs['Color'], probe.inputs['Color']) + if export_ctx.strict: + with pytest.raises(ev.ConversionError): + ev.eval_color(export_ctx, probe.inputs['Color'], default=0.125) + else: + assert ev.eval_color(export_ctx, probe.inputs['Color'], default=0.125) == 0.125 + + +def test_eval_color_spectrum(export_ctx, ev, tree, probe, mi_addon): + ctx_module = importlib.import_module( + f'{mi_addon}.io.exporter.export_context') + export_ctx = ctx_module.ExportContext() + node = tree.nodes.new('ShaderNodeRGB') + node.outputs['Color'].default_value = (0.8, 0.6, 0.4, 1.0) + tree.links.new(node.outputs['Color'], probe.inputs['Color']) + spectrum = ev.eval_color(export_ctx, probe.inputs['Color']) + assert spectrum['type'] == 'rgb' + assert spectrum['value'] == pytest.approx((0.8, 0.6, 0.4)) + + +def test_reroute_link_cycle_terminates(export_ctx, ev, tree, probe): + # Blender permits link cycles made purely of reroutes; resolving one + # must terminate instead of spinning forever + r1 = tree.nodes.new('NodeReroute') + r2 = tree.nodes.new('NodeReroute') + tree.links.new(r1.outputs[0], r2.inputs[0]) + tree.links.new(r2.outputs[0], r1.inputs[0]) + result = fold_float(export_ctx, ev, tree, probe, r2.outputs[0]) + assert isinstance(result, (ev.Constant, ev.Unsupported)) + + +def test_muted_node_link_cycle_terminates(export_ctx, ev, tree, probe): + a = math_node(tree, 'ADD', 0.0, 0.0) + b = math_node(tree, 'ADD', 0.0, 0.0) + tree.links.new(a.outputs['Value'], b.inputs[0]) + tree.links.new(b.outputs['Value'], a.inputs[0]) + a.mute = True + b.mute = True + result = fold_float(export_ctx, ev, tree, probe, b.outputs['Value']) + assert isinstance(result, (ev.Constant, ev.Unsupported)) + + +def test_fold_link_cycle_terminates(export_ctx, ev, tree, probe): + # A cycle through foldable nodes must not recurse without bound + a = math_node(tree, 'ADD', 0.0, 0.0) + b = math_node(tree, 'ADD', 0.0, 0.0) + tree.links.new(a.outputs['Value'], b.inputs[0]) + tree.links.new(b.outputs['Value'], a.inputs[0]) + result = fold_float(export_ctx, ev, tree, probe, b.outputs['Value']) + assert isinstance(result, (ev.Texture, ev.Unsupported)) + + + + + +def _make_group(name='TestGroup'): + '''A shader node group with one Value input and one Value output, + wired straight through: Group Input -> Group Output.''' + group = bpy.data.node_groups.new(name, 'ShaderNodeTree') + group.interface.new_socket('Amount', in_out='INPUT', + socket_type='NodeSocketFloat') + group.interface.new_socket('Result', in_out='OUTPUT', + socket_type='NodeSocketFloat') + g_in = group.nodes.new('NodeGroupInput') + g_out = group.nodes.new('NodeGroupOutput') + group.links.new(g_in.outputs['Amount'], g_out.inputs['Result']) + return group, g_in, g_out + + +def test_group_passthrough(export_ctx, ev, tree, probe): + '''A value linked into a group comes back out of it.''' + group, _, _ = _make_group() + node = tree.nodes.new('ShaderNodeValue') + node.outputs['Value'].default_value = 7.0 + group_node = tree.nodes.new('ShaderNodeGroup') + group_node.node_tree = group + tree.links.new(node.outputs['Value'], group_node.inputs['Amount']) + result = fold_float(export_ctx, ev, tree, probe, group_node.outputs['Result']) + assert constant(result) == 7.0 + + + +def test_group_inner_math(export_ctx, ev, tree, probe): + '''The interior is converted, not just passed through.''' + group, g_in, g_out = _make_group('MathGroup') + mul = group.nodes.new('ShaderNodeMath') + mul.operation = 'MULTIPLY' + mul.inputs[1].default_value = 3.0 + group.links.new(g_in.outputs['Amount'], mul.inputs[0]) + group.links.new(mul.outputs['Value'], g_out.inputs['Result']) + + node = tree.nodes.new('ShaderNodeValue') + node.outputs['Value'].default_value = 2.0 + group_node = tree.nodes.new('ShaderNodeGroup') + group_node.node_tree = group + tree.links.new(node.outputs['Value'], group_node.inputs['Amount']) + result = fold_float(export_ctx, ev, tree, probe, group_node.outputs['Result']) + assert eval_float_texture(result) == 6.0 + + +def test_nested_groups(export_ctx, ev, tree, probe): + '''Group inside a group: the ascend step needs a stack, not a single + saved node.''' + inner, i_in, i_out = _make_group('Inner') + outer, o_in, o_out = _make_group('Outer') + inner_node = outer.nodes.new('ShaderNodeGroup') + inner_node.node_tree = inner + outer.links.new(o_in.outputs['Amount'], inner_node.inputs['Amount']) + outer.links.new(inner_node.outputs['Result'], o_out.inputs['Result']) + + node = tree.nodes.new('ShaderNodeValue') + node.outputs['Value'].default_value = 9.0 + group_node = tree.nodes.new('ShaderNodeGroup') + group_node.node_tree = outer + tree.links.new(node.outputs['Value'], group_node.inputs['Amount']) + result = fold_float(export_ctx, ev, tree, probe, group_node.outputs['Result']) + assert constant(result) == 9.0 + + +def test_group_unsupported_node_inside(export_ctx, ev, tree, probe): + '''An unsupported node inside a group is reported as unsupported -- + not as "GROUP is not supported".''' + group, g_in, g_out = _make_group('NoiseGroup') + noise = group.nodes.new('ShaderNodeTexVoronoi') + group.links.new(noise.outputs['Color'], g_out.inputs['Result']) + + group_node = tree.nodes.new('ShaderNodeGroup') + group_node.node_tree = group + result = fold_color(export_ctx, ev, tree, probe, group_node.outputs['Result']) + assert isinstance(result, ev.Unsupported) + assert 'TEX_VORONOI' in result.reason + + +def test_group_with_no_tree(export_ctx, ev, tree, probe): + '''A group node with no node_tree assigned must not crash.''' + group_node = tree.nodes.new('ShaderNodeGroup') # node_tree stays None + probe.inputs['Strength'].default_value = 0.25 + tree.links.new(group_node.outputs[0], probe.inputs['Strength']) \ + if group_node.outputs else None + result = ev.resolve(export_ctx, probe.inputs['Strength']) + assert constant(result) == 0.25 diff --git a/tests/unit/test_material_groups.py b/tests/unit/test_material_groups.py new file mode 100644 index 00000000..2facf420 --- /dev/null +++ b/tests/unit/test_material_groups.py @@ -0,0 +1,160 @@ +'''Group-boundary resolution: the cases that distinguish a correct +instance-path fix from one that only works at depth zero.''' + +import importlib +import sys + +import bpy +import pytest + + +@pytest.fixture(scope='session') +def eval_mod(mi_addon): + return importlib.import_module( + f'{mi_addon}.convert.export.materials._resolve') + + +@pytest.fixture(scope='session') +def registry(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.export.materials') + + +@pytest.fixture +def export_ctx(mi_addon, tmp_path): + import mitsuba as mi + mi.set_variant('scalar_rgb') + ctx = sys.modules[mi_addon].io.exporter.export_context.ExportContext() + ctx.directory = str(tmp_path) + return ctx + +def texture(result): + assert result.__class__.__name__ == 'Texture', repr(result) + return result.params + +def eval_float_texture(result): + import mitsuba as mi, drjit as dr + tex = mi.load_dict(texture(result)) + return tex.eval_1(dr.zeros(mi.SurfaceInteraction3f)) + + + +def _float_group(name, factor=3.0): + '''A node group computing Amount * factor, as a reusable tree.''' + tree = bpy.data.node_groups.new(name, 'ShaderNodeTree') + tree.interface.new_socket('Amount', in_out='INPUT', + socket_type='NodeSocketFloat') + tree.interface.new_socket('Result', in_out='OUTPUT', + socket_type='NodeSocketFloat') + g_in = tree.nodes.new('NodeGroupInput') + g_out = tree.nodes.new('NodeGroupOutput') + math = tree.nodes.new('ShaderNodeMath') + math.operation = 'MULTIPLY' + math.inputs[1].default_value = factor + tree.links.new(g_in.outputs['Amount'], math.inputs[0]) + tree.links.new(math.outputs['Value'], g_out.inputs['Result']) + return tree + + +def _instance(tree, node_tree, amount): + '''A group node in `node_tree` using `tree`, with Amount set.''' + node = node_tree.nodes.new('ShaderNodeGroup') + node.node_tree = tree + node.inputs['Amount'].default_value = amount + return node + + +def _material(): + b_mat = bpy.data.materials.new('GroupTest') + b_mat.use_nodes = True + return b_mat, b_mat.node_tree + + +def test_shared_tree_resolves_per_instance(fresh_scene, export_ctx, eval_mod): + '''Two group nodes sharing one tree with different arguments. The inner + GROUP_INPUT node is the same object for both, so nothing but the group + instance path can tell the two apart.''' + b_mat, tree = _material() + group = _float_group('Shared') + a = _instance(group, tree, 2.0) + b = _instance(group, tree, 5.0) + + add = tree.nodes.new('ShaderNodeMath') + add.operation = 'ADD' + tree.links.new(a.outputs['Result'], add.inputs[0]) + tree.links.new(b.outputs['Result'], add.inputs[1]) + + emission = tree.nodes.new('ShaderNodeEmission') + tree.links.new(add.outputs['Value'], emission.inputs['Strength']) + + result = eval_mod.resolve(export_ctx, emission.inputs['Strength']) + assert eval_float_texture(result) == pytest.approx(21.0) # 2*3 + 5*3 + + +def test_nested_groups_unwind_in_order(fresh_scene, export_ctx, eval_mod): + '''A group inside a group: two pushes, two pops, landing back in the + root tree at the right socket.''' + b_mat, tree = _material() + inner = _float_group('Inner', factor=3.0) + + outer = bpy.data.node_groups.new('Outer', 'ShaderNodeTree') + outer.interface.new_socket('Amount', in_out='INPUT', + socket_type='NodeSocketFloat') + outer.interface.new_socket('Result', in_out='OUTPUT', + socket_type='NodeSocketFloat') + o_in = outer.nodes.new('NodeGroupInput') + o_out = outer.nodes.new('NodeGroupOutput') + inner_node = outer.nodes.new('ShaderNodeGroup') + inner_node.node_tree = inner + outer.links.new(o_in.outputs['Amount'], inner_node.inputs['Amount']) + outer.links.new(inner_node.outputs['Result'], o_out.inputs['Result']) + + outer_node = _instance(outer, tree, 0.0) + value = tree.nodes.new('ShaderNodeValue') + value.outputs['Value'].default_value = 2.0 + tree.links.new(value.outputs['Value'], outer_node.inputs['Amount']) + + emission = tree.nodes.new('ShaderNodeEmission') + tree.links.new(outer_node.outputs['Result'], + emission.inputs['Strength']) + + result = eval_mod.resolve(export_ctx, emission.inputs['Strength']) + assert eval_float_texture(result) == pytest.approx(6.0) + + +def test_shader_socket_crosses_group_boundary(fresh_scene, export_ctx, + registry): + '''A group with a Shader-type interface: the Add Shader converter is + reached inside the group and must trace one of its inputs back out to + the Diffuse node in the root tree.''' + b_mat, tree = _material() + group = bpy.data.node_groups.new('ShaderGroup', 'ShaderNodeTree') + group.interface.new_socket('Shader', in_out='INPUT', + socket_type='NodeSocketShader') + group.interface.new_socket('Shader', in_out='OUTPUT', + socket_type='NodeSocketShader') + g_in = group.nodes.new('NodeGroupInput') + g_out = group.nodes.new('NodeGroupOutput') + add = group.nodes.new('ShaderNodeAddShader') + emission = group.nodes.new('ShaderNodeEmission') + emission.inputs['Color'].default_value = (1.0, 0.0, 0.0, 1.0) + emission.inputs['Strength'].default_value = 4.0 + group.links.new(g_in.outputs['Shader'], add.inputs[0]) + group.links.new(emission.outputs['Emission'], add.inputs[1]) + group.links.new(add.outputs['Shader'], g_out.inputs['Shader']) + + group_node = tree.nodes.new('ShaderNodeGroup') + group_node.node_tree = group + diffuse = tree.nodes.new('ShaderNodeBsdfDiffuse') + tree.links.new(diffuse.outputs['BSDF'], group_node.inputs['Shader']) + + output = tree.get_output_node('CYCLES') + for link in list(output.inputs['Surface'].links): + tree.links.remove(link) + tree.links.new(group_node.outputs['Shader'], output.inputs['Surface']) + + result = registry.convert_material(export_ctx, b_mat) + # The BSDF came from outside the group, the emitter from inside + assert result['bsdf'] is not None + assert result['bsdf'] != registry.ERROR_BSDF + assert result['emitter'] is not None + assert result['emitter']['type'] == 'area' diff --git a/tests/unit/test_material_registry.py b/tests/unit/test_material_registry.py new file mode 100644 index 00000000..2e702e99 --- /dev/null +++ b/tests/unit/test_material_registry.py @@ -0,0 +1,307 @@ +"""Tests for the material converter registries (export and import).""" + +import importlib +import sys + +import bpy +import pytest + + +@pytest.fixture(scope='session') +def registry(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.export.materials') + +def has_converter(b_mat, registry): + if not registry.uses_nodes(b_mat): + return False + ref = registry.surface_ref(b_mat) + return ref is None or ref.node.type in registry._node_converters + + +@pytest.fixture +def exporter(mi_addon): + """Exports the current scene and returns the SceneConverter.""" + import mitsuba as mi + + def _export(directory, render=False): + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + converter = sys.modules[mi_addon].io.exporter.SceneConverter(render=render) + converter.export_ctx.directory = str(directory) + converter.scene_to_dict(bpy.context.evaluated_depsgraph_get()) + return converter + + return _export + + +def make_diffuse_material(name='Diffuse', color=(0.2, 0.4, 0.6, 1.0)): + b_mat = bpy.data.materials.new(name) + b_mat.use_nodes = True + tree = b_mat.node_tree + tree.nodes.remove(tree.nodes['Principled BSDF']) + diffuse = tree.nodes.new('ShaderNodeBsdfDiffuse') + diffuse.inputs['Color'].default_value = color + tree.links.new(diffuse.outputs['BSDF'], + tree.nodes['Material Output'].inputs['Surface']) + return b_mat + + +def assign_material(b_mat, object_name='Cube'): + b_obj = bpy.data.objects[object_name] + b_obj.data.materials.clear() + b_obj.data.materials.append(b_mat) + + +def test_diffuse_export(fresh_scene, exporter, tmp_path): + b_mat = make_diffuse_material() + assign_material(b_mat) + + converter = exporter(tmp_path) + entry = converter.export_ctx.data_get('mat-Diffuse') + assert entry == { + 'type': 'twosided', + 'bsdf': { + 'type': 'diffuse', + 'reflectance': {'type': 'rgb', + 'value': pytest.approx([0.2, 0.4, 0.6])}, + }, + } + + import mitsuba as mi + assert mi.load_dict(entry) is not None + + +def test_diffuse_export_input_graph(fresh_scene, exporter, tmp_path): + import mitsuba as mi, drjit as dr + b_mat = make_diffuse_material() + tree = b_mat.node_tree + diffuse = tree.nodes['Diffuse BSDF'] + mix = tree.nodes.new('ShaderNodeMix') + mix.data_type = 'RGBA' + a_color = next(s for s in mix.inputs if s.identifier == 'A_Color') + b_color = next(s for s in mix.inputs if s.identifier == 'B_Color') + factor = next(s for s in mix.inputs if s.identifier == 'Factor_Float') + a_color.default_value = (1.0, 0.0, 0.0, 1.0) + b_color.default_value = (0.0, 0.0, 1.0, 1.0) + factor.default_value = 0.25 + result = next(s for s in mix.outputs if s.identifier == 'Result_Color') + tree.links.new(result, diffuse.inputs['Color']) + assign_material(b_mat) + + converter = exporter(tmp_path) + entry = converter.export_ctx.data_get('mat-Diffuse') + + tex = mi.load_dict(entry['bsdf']['reflectance']) + si = dr.zeros(mi.SurfaceInteraction3f) + assert list(tex.eval_3(si)) == pytest.approx([0.75, 0.0, 0.25]) + + + +def test_default_principled_material(fresh_scene, exporter, tmp_path): + # The default cube material is a Principled BSDF + converter = exporter(tmp_path) + entry = converter.export_ctx.data_get('mat-Material') + assert entry['type'] == 'twosided' + assert entry['bsdf']['type'] == 'principled' + + +def test_unlinked_surface_exports_fallback(fresh_scene, exporter, tmp_path, + registry): + b_mat = bpy.data.materials.new('Unlinked') + b_mat.use_nodes = True + tree = b_mat.node_tree + for link in list(tree.links): + tree.links.remove(link) + assign_material(b_mat) + + converter = exporter(tmp_path) + entry = converter.export_ctx.data_get('mat-Unlinked') + assert entry == registry.ERROR_BSDF + assert entry is not registry.ERROR_BSDF + + +def test_converter_error_boundary(fresh_scene, exporter, tmp_path, registry): + @registry.node_converter('BSDF_TOON') + def convert_toon(export_ctx, node): + raise registry.ConversionError('toon says no') + + try: + b_mat = make_diffuse_material('Toon') + tree = b_mat.node_tree + tree.nodes.remove(tree.nodes['Diffuse BSDF']) + toon = tree.nodes.new('ShaderNodeBsdfToon') + tree.links.new(toon.outputs['BSDF'], + tree.nodes['Material Output'].inputs['Surface']) + assign_material(b_mat) + + converter = exporter(tmp_path) + entry = converter.export_ctx.data_get('mat-Toon') + assert entry == registry.ERROR_BSDF + finally: + del registry._node_converters['BSDF_TOON'] + + + + +def test_has_converter(fresh_scene, registry): + b_mat = make_diffuse_material('Sheen') + tree = b_mat.node_tree + tree.nodes.remove(tree.nodes['Diffuse BSDF']) + sheen = tree.nodes.new('ShaderNodeBsdfSheen') + tree.links.new(sheen.outputs['BSDF'], + tree.nodes['Material Output'].inputs['Surface']) + assert not has_converter(b_mat, registry) + + assert has_converter(make_diffuse_material('ForRegistry'), registry) + + no_nodes = bpy.data.materials.new('NoNodes') + assert not has_converter(no_nodes, registry) + + +def test_add_material_to_dict_layouts(mi_addon, registry): + materials = registry + export_context = sys.modules[mi_addon].io.exporter.export_context + + bsdf = {'type': 'diffuse'} + emitter = {'type': 'area', 'radiance': {'type': 'rgb', 'value': [1, 1, 1]}} + + # BSDF only: stored under the material id + ctx = export_context.ExportContext() + materials.add_material_to_dict(ctx, 'mat-a', dict(bsdf), None) + assert ctx.data_get('mat-a') == bsdf + assert 'mat-a' not in ctx.exported_mats + + # BSDF and emitter: BSDF in the dict, pair in the cache + ctx = export_context.ExportContext() + materials.add_material_to_dict(ctx, 'mat-b', dict(bsdf), dict(emitter)) + assert ctx.data_get('mat-b') == bsdf + assert ctx.exported_mats['mat-b'] == {'bsdf': 'mat-b', + 'emitter': emitter} + + +def test_shared_material_exported_once(fresh_scene, exporter, tmp_path): + b_mat = make_diffuse_material('Shared') + assign_material(b_mat) + bpy.ops.mesh.primitive_cube_add(location=(4, 0, 0)) + bpy.context.active_object.data.materials.append(b_mat) + + converter = exporter(tmp_path) + entries = [key for key in converter.export_ctx.scene_data + if key.startswith('mat-Shared')] + assert entries == ['mat-Shared'] + + +####################### +## Import side ## +####################### + +@pytest.fixture(scope='session') +def import_registry(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.importer.materials') + + +def import_xml(tmp_path, xml_body): + xml = f'\n{xml_body}\n' + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(xml) + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + + +def imported_material(name): + b_mat = bpy.data.materials[name] + surface = b_mat.node_tree.get_output_node('CYCLES').inputs['Surface'] + assert surface.is_linked + return b_mat, surface.links[0].from_node + + +def test_import_diffuse(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + ''') + + b_mat, node = imported_material('mat-thing') + assert node.bl_idname == 'ShaderNodeBsdfDiffuse' + assert tuple(node.inputs['Color'].default_value) == \ + pytest.approx((0.2, 0.4, 0.6, 1.0)) + + +def test_import_diffuse_default_reflectance(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + ''') + + _, node = imported_material('mat-plain') + assert node.bl_idname == 'ShaderNodeBsdfDiffuse' + assert tuple(node.inputs['Color'].default_value) == \ + pytest.approx((0.8, 0.8, 0.8, 1.0)) + + +def test_import_diffuse_with_emitter(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + + ''') + + b_mat, node = imported_material('mat-glowing') + assert node.bl_idname == 'ShaderNodeAddShader' + tree = b_mat.node_tree + emission = [n for n in tree.nodes + if n.bl_idname == 'ShaderNodeEmission'] + assert len(emission) == 1 + assert emission[0].inputs['Strength'].default_value == \ + pytest.approx(3.0) + diffuse = [n for n in tree.nodes + if n.bl_idname == 'ShaderNodeBsdfDiffuse'] + assert len(diffuse) == 1 + + +def test_import_error_placeholder(mi_addon, fresh_scene, tmp_path, + import_registry): + previous = import_registry._material_converters.get('plastic') + + @import_registry.material_converter('plastic') + def convert_plastic(builder, mi_props): + raise import_registry.ConversionError('plastic says no') + + try: + import_xml(tmp_path, ''' + + + ''') + finally: + if previous is None: + del import_registry._material_converters['plastic'] + else: + import_registry._material_converters['plastic'] = previous + + _, node = imported_material('mat-broken') + assert node.bl_idname == 'ShaderNodeBsdfDiffuse' + assert tuple(node.inputs['Color'].default_value) == \ + pytest.approx(import_registry.ERROR_COLOR) + + +def test_roundtrip_diffuse(mi_addon, fresh_scene, exporter, tmp_path): + b_mat = make_diffuse_material('Roundtrip', color=(0.25, 0.5, 0.75, 1.0)) + assign_material(b_mat) + converter = exporter(tmp_path) + converter.dict_to_xml(str(tmp_path / 'scene.xml')) + + bpy.ops.wm.read_homefile() + assert bpy.ops.import_scene.mitsuba( + filepath=str(tmp_path / 'scene.xml')) == {'FINISHED'} + # The exporter wraps the diffuse BSDF in twosided, which the legacy + # importer unwraps before dispatching the inner diffuse + b_mat = bpy.data.materials['mat-Roundtrip'] + diffuse = [n for n in b_mat.node_tree.nodes + if n.bl_idname == 'ShaderNodeBsdfDiffuse'] + assert len(diffuse) == 1 + assert tuple(diffuse[0].inputs['Color'].default_value) == \ + pytest.approx((0.25, 0.5, 0.75, 1.0)) diff --git a/tests/unit/test_materials_basic.py b/tests/unit/test_materials_basic.py new file mode 100644 index 00000000..5aa0b68e --- /dev/null +++ b/tests/unit/test_materials_basic.py @@ -0,0 +1,499 @@ +"""Tests for the basic BSDF converters: Glossy, Glass, Refraction, +Transparent and Translucent on export; conductors, dielectrics and +plastics on import.""" + +import importlib +import math +import sys + +import bpy +import pytest + + +@pytest.fixture(scope='session') +def registry(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.export.materials') + + +@pytest.fixture +def exporter(mi_addon): + """Exports the current scene and returns the SceneConverter.""" + import mitsuba as mi + + def _export(directory, render=False): + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + converter = sys.modules[mi_addon].io.exporter.SceneConverter( + render=render) + converter.export_ctx.directory = str(directory) + converter.scene_to_dict(bpy.context.evaluated_depsgraph_get()) + return converter + + return _export + + +def make_material(node_idname, name='Basic'): + """Creates a material whose surface is a single BSDF node and assigns + it to the default cube.""" + b_mat = bpy.data.materials.new(name) + b_mat.use_nodes = True + tree = b_mat.node_tree + tree.nodes.remove(tree.nodes['Principled BSDF']) + node = tree.nodes.new(node_idname) + tree.links.new(node.outputs[0], + tree.nodes['Material Output'].inputs['Surface']) + b_obj = bpy.data.objects['Cube'] + b_obj.data.materials.clear() + b_obj.data.materials.append(b_mat) + return node + + +def export_entry(exporter, tmp_path, name='Basic'): + converter = exporter(tmp_path) + entry = converter.export_ctx.data_get(f'mat-{name}') + assert entry is not None + + import mitsuba as mi + assert mi.load_dict(entry) is not None + return entry + + +def rgb(value): + return {'type': 'rgb', 'value': pytest.approx(list(value))} + + +#################### +## Glossy BSDF ## +#################### + +def test_export_glossy_smooth(fresh_scene, exporter, tmp_path): + node = make_material('ShaderNodeBsdfAnisotropic') + node.inputs['Color'].default_value = (0.8, 0.1, 0.1, 1.0) + node.inputs['Roughness'].default_value = 0.0 + + entry = export_entry(exporter, tmp_path) + assert entry == { + 'type': 'twosided', + 'bsdf': { + 'type': 'conductor', + 'specular_reflectance': rgb([0.8, 0.1, 0.1]), + }, + } + + +@pytest.mark.parametrize('bl_dist,mi_dist', [ + ('GGX', 'ggx'), ('BECKMANN', 'beckmann'), + ('MULTI_GGX', 'ggx'), ('ASHIKHMIN_SHIRLEY', 'beckmann')]) +def test_export_glossy_rough(fresh_scene, exporter, tmp_path, bl_dist, + mi_dist): + node = make_material('ShaderNodeBsdfAnisotropic') + node.distribution = bl_dist + node.inputs['Roughness'].default_value = 0.5 + + entry = export_entry(exporter, tmp_path) + bsdf = entry['bsdf'] + assert bsdf['type'] == 'roughconductor' + assert bsdf['distribution'] == mi_dist + assert bsdf['alpha'] == pytest.approx(0.25) + + +@pytest.mark.parametrize('anisotropy', [0.5, -0.5]) +def test_export_glossy_anisotropic(fresh_scene, exporter, tmp_path, + anisotropy): + node = make_material('ShaderNodeBsdfAnisotropic') + node.distribution = 'GGX' + node.inputs['Roughness'].default_value = 0.5 + node.inputs['Anisotropy'].default_value = anisotropy + + entry = export_entry(exporter, tmp_path) + bsdf = entry['bsdf'] + aspect = math.sqrt(1.0 - 0.9 * abs(anisotropy)) + alpha_u, alpha_v = 0.25 / aspect, 0.25 * aspect + if anisotropy < 0.0: + alpha_u, alpha_v = alpha_v, alpha_u + assert bsdf['alpha_u'] == pytest.approx(alpha_u) + assert bsdf['alpha_v'] == pytest.approx(alpha_v) + + +def test_export_glossy_textured_roughness(fresh_scene, exporter, tmp_path, + registry): + @registry.texture_converter('TEX_BRICK') + def convert_brick(export_ctx, node, out_socket): + return {'type': 'checkerboard'} + + try: + node = make_material('ShaderNodeBsdfAnisotropic') + node.distribution = 'GGX' + tree = node.id_data + brick = tree.nodes.new('ShaderNodeTexBrick') + tree.links.new(brick.outputs['Fac'], node.inputs['Roughness']) + + entry = export_entry(exporter, tmp_path) + # Texture-driven roughness passes through without squaring + assert entry['bsdf']['alpha'] == {'type': 'math', + 'expr' : 'in[0] >= 0 ? pow(in[0], (2.0)) : 0.0', + 'input_0' : {'type' : 'checkerboard'}} + finally: + del registry._resolve._texture_converters['TEX_BRICK'] + + +########################## +## Glass & Refraction ## +########################## + +def test_export_glass_smooth(fresh_scene, exporter, tmp_path): + node = make_material('ShaderNodeBsdfGlass') + node.inputs['Color'].default_value = (0.7, 0.8, 0.9, 1.0) + node.inputs['Roughness'].default_value = 0.0 + node.inputs['IOR'].default_value = 1.45 + + entry = export_entry(exporter, tmp_path) + assert entry == { + 'type': 'dielectric', + 'int_ior': pytest.approx(1.45), + 'specular_transmittance': rgb([0.7, 0.8, 0.9]), + } + + +def test_export_glass_thin(fresh_scene, exporter, tmp_path): + node = make_material('ShaderNodeBsdfGlass') + node.inputs['Roughness'].default_value = 0.0 + node.inputs['IOR'].default_value = 1.0 + + entry = export_entry(exporter, tmp_path) + assert entry['type'] == 'thindielectric' + + +def test_export_glass_rough(fresh_scene, exporter, tmp_path): + node = make_material('ShaderNodeBsdfGlass') + node.distribution = 'GGX' + node.inputs['Roughness'].default_value = 0.4 + node.inputs['IOR'].default_value = 1.45 + + entry = export_entry(exporter, tmp_path) + assert entry['type'] == 'roughdielectric' + assert entry['distribution'] == 'ggx' + assert entry['alpha'] == pytest.approx(0.16) + assert entry['int_ior'] == pytest.approx(1.45) + + +def test_export_glass__textured_ior_is_averaged(fresh_scene, exporter, tmp_path): + node = make_material('ShaderNodeBsdfGlass') + node.inputs['Roughness'].default_value = 0.0 + node.inputs['IOR'].default_value = 1.6 + tree = node.id_data + noise = tree.nodes.new('ShaderNodeTexNoise') + tree.links.new(noise.outputs['Fac'], node.inputs['IOR']) + + # The unsupported IOR input falls back to the socket default + entry = export_entry(exporter, tmp_path) + assert entry['type'] == 'dielectric' + assert 0.0 <= entry['int_ior'] <= 1.0 + + +def test_export_refraction(fresh_scene, exporter, tmp_path): + node = make_material('ShaderNodeBsdfRefraction') + node.distribution = 'BECKMANN' + node.inputs['Color'].default_value = (0.9, 0.9, 0.9, 1.0) + node.inputs['Roughness'].default_value = 0.3 + node.inputs['IOR'].default_value = 1.33 + + entry = export_entry(exporter, tmp_path) + assert entry['type'] == 'roughdielectric' + assert entry['distribution'] == 'beckmann' + assert entry['alpha'] == pytest.approx(0.09) + assert entry['int_ior'] == pytest.approx(1.33) + + +def test_export_refraction_smooth(fresh_scene, exporter, tmp_path): + node = make_material('ShaderNodeBsdfRefraction') + node.inputs['Roughness'].default_value = 0.0 + node.inputs['IOR'].default_value = 1.33 + + entry = export_entry(exporter, tmp_path) + assert entry['type'] == 'dielectric' + + +################################## +## Transparent & Translucent ## +################################## + +def test_export_transparent_white(fresh_scene, exporter, tmp_path): + node = make_material('ShaderNodeBsdfTransparent') + node.inputs['Color'].default_value = (1.0, 1.0, 1.0, 1.0) + + assert export_entry(exporter, tmp_path) == {'type': 'null'} + + +def test_export_transparent_tinted(fresh_scene, exporter, tmp_path): + node = make_material('ShaderNodeBsdfTransparent') + node.inputs['Color'].default_value = (0.25, 0.5, 0.75, 1.0) + + entry = export_entry(exporter, tmp_path) + assert entry['type'] == 'mask' + assert entry['opacity'] == rgb([0.75, 0.5, 0.25]) + assert entry['bsdf']['type'] == 'diffuse' + assert entry['bsdf']['reflectance']['value'] == 0.0 + + +def test_export_translucent(fresh_scene, exporter, tmp_path): + node = make_material('ShaderNodeBsdfTranslucent') + node.inputs['Color'].default_value = (0.2, 0.6, 0.4, 1.0) + + entry = export_entry(exporter, tmp_path) + assert entry == { + 'type': 'principledthin', + 'base_color': rgb([0.2, 0.6, 0.4]), + 'diff_trans': 2.0, + } + + +#################### +## Normal input ## +#################### + +@pytest.fixture +def fake_image_texture(registry): + """Registers a stand-in TEX_IMAGE texture converter, restoring any + previously registered one afterwards.""" + converters = registry._resolve._texture_converters + previous = converters.get('TEX_IMAGE') + + @registry.texture_converter('TEX_IMAGE') + def convert_fake_image(export_ctx, node, out_socket): + return {'type': 'checkerboard'} + + yield {'type': 'checkerboard'} + if previous is None: + del converters['TEX_IMAGE'] + else: + converters['TEX_IMAGE'] = previous + + +def link_normal_map(node): + tree = node.id_data + tex = tree.nodes.new('ShaderNodeTexImage') + normal_map = tree.nodes.new('ShaderNodeNormalMap') + tree.links.new(tex.outputs['Color'], normal_map.inputs['Color']) + tree.links.new(normal_map.outputs['Normal'], node.inputs['Normal']) + + +def test_export_glossy_normal_map(fresh_scene, exporter, tmp_path, + fake_image_texture): + node = make_material('ShaderNodeBsdfAnisotropic') + link_normal_map(node) + + entry = export_entry(exporter, tmp_path) + assert entry['type'] == 'normalmap' + assert entry['normalmap'] == fake_image_texture + assert entry['bsdf']['type'] == 'twosided' + + +def test_export_glass_normal_map(fresh_scene, exporter, tmp_path, + fake_image_texture): + node = make_material('ShaderNodeBsdfGlass') + link_normal_map(node) + + entry = export_entry(exporter, tmp_path) + assert entry['type'] == 'normalmap' + assert entry['normalmap'] == fake_image_texture + assert entry['bsdf']['type'] == 'dielectric' + + +################### +## Import side ## +################### + +def import_xml(tmp_path, xml_body): + xml = f'\n{xml_body}\n' + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(xml) + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + + +def imported_material(name): + b_mat = bpy.data.materials[name] + surface = b_mat.node_tree.get_output_node('CYCLES').inputs['Surface'] + assert surface.is_linked + return b_mat, surface.links[0].from_node + + +def test_import_conductor(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + ''') + + _, node = imported_material('mat-gold') + assert node.bl_idname == 'ShaderNodeBsdfAnisotropic' + assert node.inputs['Roughness'].default_value == 0.0 + color = tuple(node.inputs['Color'].default_value) + # Gold: strong red, weak blue + assert color[0] > 0.8 and color[0] > color[1] > color[2] + + +def test_import_roughconductor(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + + ''') + + _, node = imported_material('mat-brushed') + assert node.bl_idname == 'ShaderNodeBsdfAnisotropic' + assert node.distribution == 'GGX' + assert node.inputs['Roughness'].default_value == pytest.approx(0.3) + + +def test_import_roughconductor_anisotropic(mi_addon, fresh_scene, tmp_path): + # alpha_u/alpha_v as produced by the exporter for roughness 0.5, + # anisotropy 0.5 + aspect = math.sqrt(1.0 - 0.9 * 0.5) + import_xml(tmp_path, f''' + + + + + + ''') + + _, node = imported_material('mat-aniso') + assert node.inputs['Roughness'].default_value == pytest.approx(0.5) + assert node.inputs['Anisotropy'].default_value == \ + pytest.approx(0.5, rel=1e-4) + + +def test_import_dielectric(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + + ''') + + _, node = imported_material('mat-glass') + assert node.bl_idname == 'ShaderNodeBsdfGlass' + assert node.inputs['Roughness'].default_value == 0.0 + assert node.inputs['IOR'].default_value == pytest.approx(1.45) + assert tuple(node.inputs['Color'].default_value) == \ + pytest.approx((0.7, 0.8, 0.9, 1.0)) + + +def test_import_dielectric_named_ior(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + ''') + + _, node = imported_material('mat-water') + assert node.inputs['IOR'].default_value == pytest.approx(1.333) + + +def test_import_thindielectric(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + ''') + + _, node = imported_material('mat-thin') + assert node.bl_idname == 'ShaderNodeBsdfGlass' + assert node.inputs['IOR'].default_value == pytest.approx(1.0) + + +def test_import_roughdielectric(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + + + ''') + + _, node = imported_material('mat-frosted') + assert node.bl_idname == 'ShaderNodeBsdfGlass' + assert node.distribution == 'GGX' + assert node.inputs['Roughness'].default_value == pytest.approx(0.2) + assert node.inputs['IOR'].default_value == pytest.approx(1.52) + + +def test_import_plastic(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + ''') + + _, node = imported_material('mat-shiny') + assert node.bl_idname == 'ShaderNodeBsdfPrincipled' + assert tuple(node.inputs['Base Color'].default_value) == \ + pytest.approx((0.1, 0.2, 0.3, 1.0)) + assert node.inputs['IOR'].default_value == pytest.approx(1.49) + assert node.inputs['Roughness'].default_value == 0.0 + + +def test_import_roughplastic(mi_addon, fresh_scene, tmp_path): + import_xml(tmp_path, ''' + + + + + ''') + + _, node = imported_material('mat-matte') + assert node.bl_idname == 'ShaderNodeBsdfPrincipled' + assert node.inputs['Roughness'].default_value == pytest.approx(0.5) + + +################### +## Round trips ## +################### + +def roundtrip(exporter, tmp_path): + converter = exporter(tmp_path) + converter.dict_to_xml(str(tmp_path / 'scene.xml')) + bpy.ops.wm.read_homefile() + assert bpy.ops.import_scene.mitsuba( + filepath=str(tmp_path / 'scene.xml')) == {'FINISHED'} + + +def test_roundtrip_glass(mi_addon, fresh_scene, exporter, tmp_path): + node = make_material('ShaderNodeBsdfGlass', name='Glass') + node.distribution = 'GGX' + node.inputs['Color'].default_value = (0.9, 0.8, 0.7, 1.0) + node.inputs['Roughness'].default_value = 0.4 + node.inputs['IOR'].default_value = 1.45 + + roundtrip(exporter, tmp_path) + _, node = imported_material('mat-Glass') + assert node.bl_idname == 'ShaderNodeBsdfGlass' + assert node.distribution == 'GGX' + assert node.inputs['Roughness'].default_value == pytest.approx(0.4) + assert node.inputs['IOR'].default_value == pytest.approx(1.45) + assert tuple(node.inputs['Color'].default_value) == \ + pytest.approx((0.9, 0.8, 0.7, 1.0)) + + +def test_roundtrip_glossy(mi_addon, fresh_scene, exporter, tmp_path): + node = make_material('ShaderNodeBsdfAnisotropic', name='Glossy') + node.distribution = 'GGX' + node.inputs['Color'].default_value = (0.9, 0.5, 0.1, 1.0) + node.inputs['Roughness'].default_value = 0.5 + + roundtrip(exporter, tmp_path) + # The exporter wraps the conductor in twosided; the importer unwraps it + b_mat = bpy.data.materials['mat-Glossy'] + glossy = [n for n in b_mat.node_tree.nodes + if n.bl_idname == 'ShaderNodeBsdfAnisotropic'] + assert len(glossy) == 1 + assert glossy[0].distribution == 'GGX' + assert glossy[0].inputs['Roughness'].default_value == pytest.approx(0.5) diff --git a/tests/unit/test_packaging.py b/tests/unit/test_packaging.py new file mode 100644 index 00000000..400be844 --- /dev/null +++ b/tests/unit/test_packaging.py @@ -0,0 +1,101 @@ +"""Builds the extension zip and installs it into a factory Blender.""" + +import os +import subprocess +import textwrap + +import bpy +import pytest + +REPO_ROOT = os.path.dirname(os.path.dirname( + os.path.dirname(os.path.realpath(__file__)))) + +INSTALL_CHECK_SCRIPT = textwrap.dedent(''' + import sys + import bpy + + zip_path = sys.argv[sys.argv.index('--') + 1] + result = bpy.ops.extensions.package_install_files( + filepath=zip_path, repo='user_default', enable_on_install=True) + assert result == {'FINISHED'}, result + assert 'bl_ext.user_default.mitsuba_blender' in bpy.context.preferences.addons + + engines = {getattr(cls, 'bl_idname', None) + for cls in bpy.types.RenderEngine.__subclasses__()} + assert 'MITSUBA' in engines, engines + print('MI_PACKAGING_OK') +''') + + +@pytest.mark.packaging +def test_extension_build_and_install(tmp_path): + blender = bpy.app.binary_path + zip_path = tmp_path / 'mitsuba_blender.zip' + + build = subprocess.run( + [blender, '--command', 'extension', 'build', + '--source-dir', os.path.join(REPO_ROOT, 'mitsuba_blender'), + '--output-filepath', str(zip_path)], + capture_output=True, text=True, timeout=300) + assert build.returncode == 0, build.stdout + build.stderr + assert zip_path.is_file() + + script = tmp_path / 'install_check.py' + script.write_text(INSTALL_CHECK_SCRIPT) + + # Isolated user resources keep the install out of the developer's real + # extension repository. + env = os.environ.copy() + env['BLENDER_USER_RESOURCES'] = str(tmp_path / 'user_resources') + env['DRJIT_NO_RTLD_DEEPBIND'] = '1' + + install = subprocess.run( + [blender, '-b', '--factory-startup', '--python-exit-code', '1', + '--python', str(script), '--', str(zip_path)], + capture_output=True, text=True, timeout=300, env=env) + assert install.returncode == 0, install.stdout + install.stderr + assert 'MI_PACKAGING_OK' in install.stdout + + +@pytest.mark.packaging +def test_release_build_bundles_and_installs(tmp_path): + '''Exercises release/build_extension.py end to end: wheels downloaded and + listed in the manifest, per-platform zips built, and the linux zip + installable into an isolated factory Blender.''' + import sys + import zipfile + + dist = tmp_path / 'dist' + build = subprocess.run( + [sys.executable, os.path.join(REPO_ROOT, 'release', 'build_extension.py'), + '--blender', bpy.app.binary_path, '--output-dir', str(dist)], + capture_output=True, text=True, timeout=900) + if build.returncode != 0: + output = build.stdout + build.stderr + network_errors = ('Connection', 'Temporary failure', 'ReadTimeout', + 'No matching distribution', 'proxy') + if any(marker in output for marker in network_errors): + pytest.skip('wheel download failed (offline?)') + assert build.returncode == 0, output + + zips = sorted(dist.glob('*linux_x64*.zip')) + assert zips, f'no linux zip in {list(dist.iterdir())}' + with zipfile.ZipFile(zips[0]) as zf: + names = zf.namelist() + wheels = [n for n in names if n.startswith('wheels/') and n.endswith('.whl')] + assert any('mitsuba' in w for w in wheels), wheels + assert any('drjit' in w for w in wheels), wheels + manifest = zf.read('blender_manifest.toml').decode() + assert 'wheels = [' in manifest and '.whl' in manifest + + script = tmp_path / 'install_check.py' + script.write_text(INSTALL_CHECK_SCRIPT) + env = os.environ.copy() + env['BLENDER_USER_RESOURCES'] = str(tmp_path / 'user_resources') + env['DRJIT_NO_RTLD_DEEPBIND'] = '1' + install = subprocess.run( + [bpy.app.binary_path, '-b', '--factory-startup', '--python-exit-code', '1', + '--python', str(script), '--', str(zips[0])], + capture_output=True, text=True, timeout=300, env=env) + assert install.returncode == 0, install.stdout + install.stderr + assert 'MI_PACKAGING_OK' in install.stdout diff --git a/tests/unit/test_properties.py b/tests/unit/test_properties.py new file mode 100644 index 00000000..96a88d7a --- /dev/null +++ b/tests/unit/test_properties.py @@ -0,0 +1,166 @@ +"""Round trips through the hand-written render/camera property groups.""" + +import importlib + +import bpy +import pytest + + +def export_scene_dict(addon_module, tmp_path): + exporter = importlib.import_module(f'{addon_module}.io.exporter') + converter = exporter.SceneConverter(render=True) + converter.export_ctx.directory = str(tmp_path) + converter.scene_to_dict(bpy.context.evaluated_depsgraph_get()) + return converter.export_ctx.scene_data + + +def find_by_type(scene_dict, plugin_type): + return [v for v in scene_dict.values() + if isinstance(v, dict) and v.get('type') == plugin_type] + + +def test_integrator_settings_round_trip(mi_addon, fresh_scene, tmp_path): + scene = fresh_scene + scene.render.engine = 'MITSUBA' + scene.mitsuba.active_integrator = 'volpath' + props = scene.mitsuba.available_integrators.volpath + props.max_depth = 12 + props.rr_depth = 3 + props.hide_emitters = True + + scene_dict = export_scene_dict(mi_addon, tmp_path) + integrators = find_by_type(scene_dict, 'volpath') + assert len(integrators) == 1 + assert integrators[0] == { + 'type': 'volpath', + 'max_depth': 12, + 'rr_depth': 3, + 'hide_emitters': True, + } + + +def test_sampler_and_rfilter_round_trip(mi_addon, fresh_scene, tmp_path): + scene = fresh_scene + scene.render.engine = 'MITSUBA' + camera_settings = scene.camera.data.mitsuba + camera_settings.active_sampler = 'stratified' + camera_settings.samplers.stratified.sample_count = 64 + camera_settings.samplers.stratified.seed = 7 + camera_settings.samplers.stratified.jitter = False + camera_settings.active_rfilter = 'gaussian' + camera_settings.rfilters.gaussian.stddev = 0.8 + # Property changes do not tag the datablock, so the cached evaluated + # copy in the depsgraph would still hold the old values. + scene.camera.data.update_tag() + + scene_dict = export_scene_dict(mi_addon, tmp_path) + sensors = find_by_type(scene_dict, 'perspective') + assert len(sensors) == 1 + sensor = sensors[0] + assert sensor['sampler'] == { + 'type': 'stratified', + 'sample_count': 64, + 'seed': 7, + 'jitter': False, + } + rfilter = sensor['film']['rfilter'] + assert rfilter['type'] == 'gaussian' + assert rfilter['stddev'] == pytest.approx(0.8) + + +def test_custom_integrator_used_verbatim(mi_addon, fresh_scene, tmp_path): + scene = fresh_scene + scene.render.engine = 'MITSUBA' + scene.mitsuba.active_integrator = 'path' + scene.mitsuba.custom_integrator = \ + "{'type': 'stokes', 'nested': {'type': 'path', 'max_depth': 6}}" + + scene_dict = export_scene_dict(mi_addon, tmp_path) + assert find_by_type(scene_dict, 'path') == [] + integrators = find_by_type(scene_dict, 'stokes') + assert len(integrators) == 1 + assert integrators[0] == { + 'type': 'stokes', + 'nested': {'type': 'path', 'max_depth': 6}, + } + + +def test_custom_integrator_rejects_non_dict(mi_addon, fresh_scene): + scene = fresh_scene + scene.mitsuba.custom_integrator = "['not', 'a', 'dict']" + with pytest.raises(ValueError): + scene.mitsuba.integrator_to_dict() + + +def test_aov_integrator_dict(mi_addon, fresh_scene): + scene = fresh_scene + scene.mitsuba.active_integrator = 'aov' + aov = scene.mitsuba.available_integrators.aov + aov.depth = True + aov.sh_normal = True + aov.nested_integrator = 'volpath' + aov.volpath.max_depth = 4 + + result = scene.mitsuba.integrator_to_dict() + assert result['type'] == 'aov' + assert result['aovs'] == 'depth:depth,sh_normal:sh_normal' + assert result['volpath']['type'] == 'volpath' + assert result['volpath']['max_depth'] == 4 + + import mitsuba as mi + mi.set_variant('scalar_rgb') + assert mi.load_dict(result) is not None + + +def test_import_preserves_unsupported_integrator(mi_addon, fresh_scene, tmp_path): + # Integrators without a UI representation (e.g. moment) are stored in + # the custom dict escape hatch so they survive a round trip. + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(''' + + + + + + + ''') + assert bpy.ops.import_scene.mitsuba( + filepath=str(scene_file), import_render_settings=True) == {'FINISHED'} + + result = bpy.context.scene.mitsuba.integrator_to_dict() + assert result['type'] == 'moment' + nested = [v for v in result.values() if isinstance(v, dict)] + assert nested == [{'type': 'path', 'max_depth': 7}] + + +ALL_INTEGRATORS = ['path', 'direct', 'volpath', 'volpathmis', 'ptracer'] +ALL_SAMPLERS = ['independent', 'stratified', 'multijitter', 'orthogonal', + 'ldsampler'] +ALL_RFILTERS = ['box', 'tent', 'gaussian', 'mitchell', 'catmullrom', 'lanczos'] + + +@pytest.mark.parametrize('name', ALL_INTEGRATORS) +def test_integrator_dicts_load(mi_addon, fresh_scene, name): + import mitsuba as mi + mi.set_variant('scalar_rgb') + scene = fresh_scene + scene.mitsuba.active_integrator = name + assert mi.load_dict(scene.mitsuba.integrator_to_dict()) is not None + + +@pytest.mark.parametrize('name', ALL_SAMPLERS) +def test_sampler_dicts_load(mi_addon, fresh_scene, name): + import mitsuba as mi + mi.set_variant('scalar_rgb') + camera_settings = fresh_scene.camera.data.mitsuba + camera_settings.active_sampler = name + assert mi.load_dict(camera_settings.sampler_to_dict()) is not None + + +@pytest.mark.parametrize('name', ALL_RFILTERS) +def test_rfilter_dicts_load(mi_addon, fresh_scene, name): + import mitsuba as mi + mi.set_variant('scalar_rgb') + camera_settings = fresh_scene.camera.data.mitsuba + camera_settings.active_rfilter = name + assert mi.load_dict(camera_settings.rfilter_to_dict()) is not None diff --git a/tests/unit/test_registration.py b/tests/unit/test_registration.py new file mode 100644 index 00000000..4bd95b04 --- /dev/null +++ b/tests/unit/test_registration.py @@ -0,0 +1,37 @@ +"""Enable/disable/enable cycles of the extension must not leak state.""" + +import bpy +import pytest + + +def _blender_render_panels_with_mitsuba(): + return [panel.__name__ for panel in bpy.types.Panel.__subclasses__() + if 'BLENDER_RENDER' in getattr(panel, 'COMPAT_ENGINES', ()) + and 'MITSUBA' in panel.COMPAT_ENGINES] + + +def test_enable_disable_enable(mi_addon, fresh_scene): + scene = fresh_scene + + assert bpy.ops.preferences.addon_disable(module=mi_addon) == {'FINISHED'} + + # Everything must be unregistered + assert 'mitsuba' not in bpy.types.Scene.bl_rna.properties + assert 'mitsuba' not in bpy.types.Camera.bl_rna.properties + for class_name in ('MitsubaRenderSettings', 'MitsubaCameraSettings', + 'MITSUBA_RENDER_PT_integrator', 'MITSUBA_CAMERA_PT_sampler', + 'MITSUBA_CAMERA_PT_rfilter'): + assert getattr(bpy.types, class_name, None) is None, \ + f'{class_name} leaked through addon_disable' + assert _blender_render_panels_with_mitsuba() == [] + with pytest.raises(TypeError): + scene.render.engine = 'MITSUBA' + + assert bpy.ops.preferences.addon_enable(module=mi_addon) == {'FINISHED'} + + # And everything must be back + scene.render.engine = 'MITSUBA' + assert scene.mitsuba.active_integrator == 'path' + assert scene.camera.data.mitsuba.active_sampler == 'independent' + assert len(_blender_render_panels_with_mitsuba()) > 0 + assert getattr(bpy.types, 'MITSUBA_RENDER_PT_integrator', None) is not None diff --git a/tests/unit/test_render_progress.py b/tests/unit/test_render_progress.py new file mode 100644 index 00000000..ea789b6a --- /dev/null +++ b/tests/unit/test_render_progress.py @@ -0,0 +1,113 @@ +"""Worker-thread rendering: progress reporting and cancellation.""" + +import sys +import time + +import bpy +import pytest + + +def _final_module(mi_addon): + return sys.modules[mi_addon].engine.final + + +def _slow_scene(spp): + """A scene whose render time scales with spp (about 5ms per spp unit + at spp=1024 on a workstation).""" + import mitsuba as mi + mi.set_variant('scalar_rgb') + return mi.load_dict({ + 'type': 'scene', + 'integrator': {'type': 'path', 'max_depth': 16}, + 'sensor': { + 'type': 'perspective', + 'film': {'type': 'hdrfilm', 'width': 512, 'height': 512}, + 'sampler': {'type': 'independent', 'sample_count': spp}, + }, + 'emitter': {'type': 'constant'}, + 'shape': {'type': 'sphere', 'bsdf': {'type': 'dielectric'}}, + }) + + +def test_progress_updates(mi_addon): + final = _final_module(mi_addon) + scene = _slow_scene(spp=1024) + + fractions = [] + canceled = final.run_render( + scene.integrator(), scene, scene.sensors()[0], + test_break=lambda: False, update_progress=fractions.append) + + assert not canceled + assert fractions, 'the polling loop never reported progress' + assert fractions == sorted(fractions), 'progress must be monotonic' + assert 0.0 <= fractions[-1] <= 1.0 + assert max(fractions) > 0.2, 'progress never advanced' + + +def test_cancel_stops_render(mi_addon): + final = _final_module(mi_addon) + # A full render at this sample count takes more than a minute + scene = _slow_scene(spp=16384) + + start = time.monotonic() + canceled = final.run_render( + scene.integrator(), scene, scene.sensors()[0], + test_break=lambda: True, update_progress=lambda fraction: None) + elapsed = time.monotonic() - start + + assert canceled + assert elapsed < 20.0, f'cancellation took {elapsed:.1f}s' + + +def test_worker_exception_propagates(mi_addon): + final = _final_module(mi_addon) + + class BrokenIntegrator: + def render(self, scene, sensor): + raise RuntimeError('worker failure') + + def cancel(self): + pass + + with pytest.raises(RuntimeError, match='worker failure'): + final.run_render(BrokenIntegrator(), None, None, + test_break=lambda: False, + update_progress=lambda fraction: None) + + +def test_f12_cancel_does_not_deadlock(mi_addon, fresh_scene): + # RNA functions like test_break resolve before Python class attributes, + # so the break signal is injected by wrapping run_render instead. + final = _final_module(mi_addon) + scene = fresh_scene + scene.render.engine = 'MITSUBA' + scene.mitsuba.variant = 'scalar_rgb' + scene.render.resolution_x = 512 + scene.render.resolution_y = 512 + scene.render.resolution_percentage = 100 + # Slow enough that a full render takes minutes + scene.camera.data.mitsuba.samplers.independent.sample_count = 16384 + + outcome = {} + orig_run_render = final.run_render + + def breaking_run_render(integrator, mi_scene, sensor, test_break, + update_progress, **kwargs): + assert callable(test_break) and callable(update_progress) + outcome['canceled'] = orig_run_render( + integrator, mi_scene, sensor, lambda: True, update_progress, + **kwargs) + return outcome['canceled'] + + final.run_render = breaking_run_render + try: + start = time.monotonic() + result = bpy.ops.render.render() + elapsed = time.monotonic() - start + finally: + final.run_render = orig_run_render + + assert result == {'FINISHED'} + assert outcome.get('canceled') is True + assert elapsed < 45.0, f'canceled F12 render took {elapsed:.1f}s' diff --git a/tests/unit/test_render_settings_import.py b/tests/unit/test_render_settings_import.py new file mode 100644 index 00000000..6f39293d --- /dev/null +++ b/tests/unit/test_render_settings_import.py @@ -0,0 +1,123 @@ +"""Render-settings import is opt-in and never touches the active render +engine or the Cycles settings.""" + +import os + +import bpy +import pytest + +SCENES = os.path.join(os.path.dirname(__file__), '..', 'res', 'scenes') + + +def _import(name, **kwargs): + assert bpy.ops.import_scene.mitsuba( + filepath=os.path.join(SCENES, name), **kwargs) == {'FINISHED'} + return bpy.context.scene + + +def test_settings_not_applied_by_default(mi_addon, fresh_scene): + engine_before = bpy.context.scene.render.engine + res_before = (bpy.context.scene.render.resolution_x, + bpy.context.scene.render.resolution_y) + + scene = _import('film_hdrfilm.xml') + + assert scene.render.engine == engine_before + assert (scene.render.resolution_x, scene.render.resolution_y) == \ + res_before + # The integrator properties keep their defaults + assert scene.mitsuba.available_integrators.path.max_depth == -1 + + +def test_import_never_touches_engine_or_cycles(mi_addon, fresh_scene): + engine_before = bpy.context.scene.render.engine + cycles_before = (bpy.context.scene.cycles.samples, + bpy.context.scene.cycles.max_bounces, + bpy.context.scene.cycles.pixel_filter_type) + + scene = _import('test1.xml', import_render_settings=True) + + assert scene.render.engine == engine_before + assert (scene.cycles.samples, scene.cycles.max_bounces, + scene.cycles.pixel_filter_type) == cycles_before + + +def test_integrator_settings_applied(mi_addon, fresh_scene): + scene = _import('integrator_path.xml', import_render_settings=True) + + assert scene.mitsuba.variant == 'scalar_rgb' + assert scene.mitsuba.active_integrator == 'path' + integrator = scene.mitsuba.available_integrators.path + assert integrator.max_depth == 2 + assert integrator.rr_depth == 4 + assert integrator.hide_emitters + + +def test_sampler_settings_applied(mi_addon, fresh_scene): + scene = _import('sampler_stratified.xml', import_render_settings=True) + + camera = scene.camera.data.mitsuba + assert camera.active_sampler == 'stratified' + assert camera.samplers.stratified.sample_count == 12 + assert camera.samplers.stratified.seed == 1 + assert not camera.samplers.stratified.jitter + + +def test_rfilter_settings_applied(mi_addon, fresh_scene): + scene = _import('rfilter_gaussian.xml', import_render_settings=True) + + camera = scene.camera.data.mitsuba + assert camera.active_rfilter == 'gaussian' + assert camera.rfilters.gaussian.stddev == pytest.approx(1.5) + + +def test_film_settings_applied(mi_addon, fresh_scene): + scene = _import('film_hdrfilm.xml', import_render_settings=True) + + assert scene.render.resolution_percentage == 100 + assert scene.render.resolution_x == 1280 + assert scene.render.resolution_y == 720 + assert scene.render.image_settings.file_format == 'OPEN_EXR' + assert scene.render.image_settings.color_mode == 'RGBA' + assert scene.render.image_settings.color_depth == '32' + assert not scene.render.use_border + + +def test_film_crop_applied(mi_addon, fresh_scene): + scene = _import('film_hdrfilm_crop.xml', import_render_settings=True) + + assert scene.render.use_border + assert scene.render.border_min_x == pytest.approx(0.0) + assert scene.render.border_min_y == pytest.approx(0.0) + assert scene.render.border_max_x == pytest.approx(0.5) + assert scene.render.border_max_y == pytest.approx(0.5) + + +def test_top_level_sampler_via_ref(mi_addon, fresh_scene, tmp_path): + # A sampler declared at the scene level used to be converted from the + # root pass before any camera exists, crashing on scene.camera.data + import sys + importer = sys.modules[mi_addon].io.importer + from bl_ext.user_default.mitsuba_blender.io import bl_utils + from bpy_extras.io_utils import axis_conversion + + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(''' + + + + + + + ''') + + axis_mat = axis_conversion(to_forward='-Z', to_up='Y').to_4x4() + scene = bl_utils.init_empty_scene(name='sampler-ref-test') + warnings = importer.load_mitsuba_scene( + scene, scene.collection, str(scene_file), axis_mat, False, True, + import_render_settings=True) + + assert warnings == [] + camera = scene.camera.data.mitsuba + assert camera.active_sampler == 'stratified' + assert camera.samplers.stratified.sample_count == 12 diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py new file mode 100644 index 00000000..1ff871f7 --- /dev/null +++ b/tests/unit/test_smoke.py @@ -0,0 +1,52 @@ +import os + +import bpy +import numpy as np + + +def test_mitsuba_import(): + import mitsuba as mi + mi.set_variant('scalar_rgb') + assert mi.variant() == 'scalar_rgb' + for name in ('parse_dict', 'parse_file', 'write_file', 'transform_all'): + assert hasattr(mi.parser, name) + + +def test_addon_registers(mi_addon): + assert mi_addon in bpy.context.preferences.addons + + +def test_engine_available(mi_addon, fresh_scene): + engine_ids = {getattr(cls, 'bl_idname', None) + for cls in bpy.types.RenderEngine.__subclasses__()} + assert 'MITSUBA' in engine_ids + fresh_scene.render.engine = 'MITSUBA' + assert fresh_scene.render.engine == 'MITSUBA' + + +def test_render_dict(render_dict, compare_images): + img = render_dict({ + 'type': 'scene', + 'integrator': {'type': 'path', 'max_depth': 2}, + 'sensor': { + 'type': 'perspective', + 'film': { + 'type': 'hdrfilm', + 'width': 16, + 'height': 16, + 'rfilter': {'type': 'box'}, + }, + 'sampler': {'type': 'independent', 'sample_count': 2}, + }, + 'emitter': {'type': 'constant', 'radiance': 0.5}, + }, spp=2) + assert img.shape == (16, 16, 3) + compare_images(img, np.full((16, 16, 3), 0.5), mean_tol=1e-4, rmse_tol=1e-4) + + +def test_harness_isolated_from_user_profile(): + '''tests/run.py points BLENDER_USER_RESOURCES at a throwaway directory so + the addon install in conftest cannot touch the developer's real profile.''' + user_resources = os.environ.get('BLENDER_USER_RESOURCES') + assert user_resources, 'harness did not isolate BLENDER_USER_RESOURCES' + assert bpy.utils.resource_path('USER').startswith(user_resources) diff --git a/tests/unit/test_texture_export.py b/tests/unit/test_texture_export.py new file mode 100644 index 00000000..fe478a6c --- /dev/null +++ b/tests/unit/test_texture_export.py @@ -0,0 +1,580 @@ +"""Tests for the texture and vector-input export converters.""" + +import filecmp +import importlib +import math +import os +import sys +from contextlib import contextmanager + +import bpy +import numpy as np +import pytest +import mitsuba as mi +from mathutils import Matrix + +# The v -> 1 - v flip the mesh exporter applies to UV coordinates +FLIP = Matrix.Translation((0.0, 1.0, 0.0)) \ + @ Matrix.Diagonal((1.0, -1.0, 1.0, 1.0)) + +@contextmanager +def saved_file_resolver(): + '''Restore the state of Mitsuba's session-global file resolver on exit.''' + fr = mi.file_resolver() + paths = list(fr) + try: + yield fr + finally: + fr.clear() + for path in paths: + fr.append(path) + +@contextmanager +def resolver_append(directory): + '''Temporarily add a directory to Mitsuba's file resolver.''' + with saved_file_resolver() as fr: + fr.prepend(directory) + yield + + + +@pytest.fixture(scope='session') +def eval_mod(mi_addon): + return importlib.import_module( + f'{mi_addon}.convert.export.materials._resolve') + + +@pytest.fixture(scope='session') +def ref(eval_mod): + return lambda node, stack=(): eval_mod.NodeRef(node, stack) + + +@pytest.fixture(scope='session') +def registry(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.export.materials') + + +@pytest.fixture(scope='session') +def textures(mi_addon): + return importlib.import_module( + f'{mi_addon}.convert.export.materials.textures') + + +@pytest.fixture +def exporter(mi_addon): + import mitsuba as mi + + def _export(directory, render=False): + mi.set_variant('scalar_rgb') + bpy.context.scene.render.engine = 'MITSUBA' + converter = sys.modules[mi_addon].io.exporter.SceneConverter( + render=render) + converter.export_ctx.directory = str(directory) + converter.scene_to_dict(bpy.context.evaluated_depsgraph_get()) + return converter + + return _export + + +@pytest.fixture +def export_ctx(mi_addon, tmp_path): + import mitsuba as mi + mi.set_variant('scalar_rgb') + ctx = sys.modules[mi_addon].io.exporter.export_context.ExportContext() + ctx.directory = str(tmp_path) + return ctx + + +def make_image(name='Tex', size=2, colorspace='sRGB'): + image = bpy.data.images.new(name, size, size, alpha=True) + # Changing the color space reloads generated images, so set it first + image.colorspace_settings.name = colorspace + values = np.linspace(0.0, 1.0, size * size * 4, dtype=np.float32) + image.pixels.foreach_set(values) + return image + + +def save_image(image, path): + image.filepath_raw = str(path) + image.file_format = 'PNG' + image.save() + return image + + +def make_diffuse_with_texture(node_type, name='Textured'): + """A diffuse material with a texture node linked to its Color input.""" + b_mat = bpy.data.materials.new(name) + b_mat.use_nodes = True + tree = b_mat.node_tree + tree.nodes.remove(tree.nodes['Principled BSDF']) + diffuse = tree.nodes.new('ShaderNodeBsdfDiffuse') + tree.links.new(diffuse.outputs['BSDF'], + tree.nodes['Material Output'].inputs['Surface']) + tex = tree.nodes.new(node_type) + tree.links.new(tex.outputs[0], diffuse.inputs['Color']) + return b_mat, tex + + +def assign_material(b_mat, object_name='Cube'): + b_obj = bpy.data.objects[object_name] + b_obj.data.materials.clear() + b_obj.data.materials.append(b_mat) + + +def reflectance_of(converter, mat_id): + entry = converter.export_ctx.data_get(mat_id) + return entry['bsdf']['reflectance'] + + +################### +## File export ## +################### + +def test_image_texture_file_export(fresh_scene, exporter, tmp_path): + source_dir = tmp_path / 'source' + source_dir.mkdir() + image = save_image(make_image(), source_dir / 'tex.png') + image_count = len(bpy.data.images) + mtime = os.path.getmtime(source_dir / 'tex.png') + + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = image + assign_material(b_mat) + + export_dir = tmp_path / 'scene' + export_dir.mkdir() + converter = exporter(export_dir) + params = reflectance_of(converter, 'mat-Textured') + assert set(params) == {'type', 'filename', 'to_uv'} + assert params['type'] == 'bitmap' + assert params['filename'] == 'textures/tex.png' + assert np.allclose(np.array(params['to_uv'].matrix), np.array(FLIP), + atol=1e-6) + + # The source file is copied verbatim and the datablock is untouched + assert filecmp.cmp(source_dir / 'tex.png', + export_dir / 'textures' / 'tex.png', shallow=False) + assert os.path.getmtime(source_dir / 'tex.png') == mtime + assert image.filepath_raw == str(source_dir / 'tex.png') + assert image.file_format == 'PNG' + assert len(bpy.data.images) == image_count + + +def test_image_texture_raw_colorspace(fresh_scene, exporter, tmp_path): + image = save_image(make_image(colorspace='Non-Color'), tmp_path / 'n.png') + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = image + assign_material(b_mat) + + converter = exporter(tmp_path) + params = reflectance_of(converter, 'mat-Textured') + assert params['raw'] is True + + +def test_image_texture_sampling_modes(fresh_scene, exporter, tmp_path): + image = save_image(make_image(), tmp_path / 'tex.png') + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = image + tex.interpolation = 'Closest' + tex.extension = 'MIRROR' + assign_material(b_mat) + + converter = exporter(tmp_path) + params = reflectance_of(converter, 'mat-Textured') + assert params['filter_type'] == 'nearest' + assert params['wrap_mode'] == 'mirror' + + tex.extension = 'EXTEND' + converter = exporter(tmp_path) + entries = [v for k, v in converter.export_ctx.scene_data.items() + if k == 'mat-Textured'] + assert entries[0]['bsdf']['reflectance']['wrap_mode'] == 'clamp' + + +def test_unsaved_image_written_without_side_effects(fresh_scene, exporter, + tmp_path): + image = make_image('Generated') + image_count = len(bpy.data.images) + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = image + assign_material(b_mat) + + converter = exporter(tmp_path) + params = reflectance_of(converter, 'mat-Textured') + assert params['filename'] == 'textures/Generated.png' + # The datablock was not redirected to the exported file + assert image.filepath_raw == '' + assert len(bpy.data.images) == image_count + + # The written file holds the pixel buffer (byte-quantized) + loaded = bpy.data.images.load(str(tmp_path / 'textures/Generated.png')) + pixels = np.zeros(2 * 2 * 4, dtype=np.float32) + loaded.pixels.foreach_get(pixels) + original = np.zeros(2 * 2 * 4, dtype=np.float32) + image.pixels.foreach_get(original) + assert np.allclose(pixels, original, atol=1.0 / 255.0) + + +def test_image_export_dedup_and_name_clash(fresh_scene, export_ctx, + textures, tmp_path): + image = make_image('Shared') + first , _ = textures.export_image(export_ctx, image) + second, _ = textures.export_image(export_ctx, image) + assert first == second + + assert os.listdir(tmp_path / 'textures') == ['Shared.png'] + + # A different image saved under the same basename gets a fresh name + other_dir = tmp_path / 'other' + other_dir.mkdir() + other = save_image(make_image('Other'), other_dir / 'Shared.png') + third, _ = textures.export_image(export_ctx, other) + assert third != first + assert sorted(os.listdir(tmp_path / 'textures')) == \ + ['Shared-1.png', 'Shared.png'] + + +##################### +## Render export ## +##################### + +def test_image_texture_render_mode(fresh_scene, export_ctx, registry, tmp_path): + import mitsuba as mi + + export_ctx.render = True + image = make_image('InMemory', size=2) + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = image + + entry = registry.convert_material(export_ctx, b_mat)['bsdf'] + params = entry['bsdf']['reflectance'] + assert params['filename'].endswith('.png') + assert (tmp_path / params['filename']).is_file() + assert params.get('raw', False) is False + + + +def test_render_mode_data_image_is_raw(fresh_scene, export_ctx, registry): + export_ctx.render = True + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = make_image('Data', size=2, colorspace='Non-Color') + + entry = registry.convert_material(export_ctx, b_mat)['bsdf'] + params = entry['bsdf']['reflectance'] + assert params['raw'] is True + assert params['filename'].endswith('.png') + + +def test_render_export_instantiates_textured_bsdf(fresh_scene, exporter, + tmp_path): + image = make_image('InMemory', size=2) + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = image + assign_material(b_mat) + + converter = exporter(tmp_path, render=True) + assert 'mat-Textured' in converter.export_ctx.scene_data + assert (tmp_path / 'textures').exists() + scene = converter.dict_to_scene() + assert len(scene.shapes()) > 0 + + + +###################### +## UV coordinates ## +###################### + +def test_mapping_chain_to_uv(fresh_scene, exporter, tmp_path): + image = save_image(make_image(), tmp_path / 'tex.png') + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = image + tree = b_mat.node_tree + mapping = tree.nodes.new('ShaderNodeMapping') + mapping.vector_type = 'POINT' + mapping.inputs['Location'].default_value = (0.1, 0.2, 0.0) + mapping.inputs['Rotation'].default_value = (0.0, 0.0, math.pi / 2) + mapping.inputs['Scale'].default_value = (2.0, 3.0, 1.0) + coords = tree.nodes.new('ShaderNodeTexCoord') + tree.links.new(coords.outputs['UV'], mapping.inputs['Vector']) + tree.links.new(mapping.outputs['Vector'], tex.inputs['Vector']) + assign_material(b_mat) + + converter = exporter(tmp_path) + params = reflectance_of(converter, 'mat-Textured') + + from mathutils import Euler + blender = Matrix.Translation((0.1, 0.2, 0.0)) \ + @ Euler((0.0, 0.0, math.pi / 2)).to_matrix().to_4x4() \ + @ Matrix.Diagonal((2.0, 3.0, 1.0, 1.0)) + assert np.allclose(np.array(params['to_uv'].matrix), + np.array(FLIP @ blender), atol=1e-6) + + +def test_mapping_texture_mode_is_inverse(fresh_scene, exporter, tmp_path): + image = save_image(make_image(), tmp_path / 'tex.png') + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = image + tree = b_mat.node_tree + mapping = tree.nodes.new('ShaderNodeMapping') + mapping.vector_type = 'TEXTURE' + mapping.inputs['Scale'].default_value = (2.0, 4.0, 1.0) + coords = tree.nodes.new('ShaderNodeTexCoord') + tree.links.new(coords.outputs['UV'], mapping.inputs['Vector']) + tree.links.new(mapping.outputs['Vector'], tex.inputs['Vector']) + assign_material(b_mat) + + converter = exporter(tmp_path) + params = reflectance_of(converter, 'mat-Textured') + blender = Matrix.Diagonal((0.5, 0.25, 1.0, 1.0)) + assert np.allclose(np.array(params['to_uv'].matrix), + np.array(FLIP @ blender), atol=1e-6) + + +def test_unsupported_mapping_chain_is_ignored(fresh_scene, exporter, + tmp_path): + image = save_image(make_image(), tmp_path / 'tex.png') + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = image + tree = b_mat.node_tree + coords = tree.nodes.new('ShaderNodeTexCoord') + tree.links.new(coords.outputs['Object'], tex.inputs['Vector']) + assign_material(b_mat) + + converter = exporter(tmp_path) + params = reflectance_of(converter, 'mat-Textured') + # The mapping is dropped, but the row convention still has to survive + assert np.allclose(np.array(params['to_uv'].matrix), np.array(FLIP), + atol=1e-6) + + +############### +## Checker ## +############### + +def test_checker_export(fresh_scene, exporter, tmp_path): + import mitsuba as mi + + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexChecker') + tex.inputs['Color1'].default_value = (1.0, 0.0, 0.0, 1.0) + tex.inputs['Color2'].default_value = (0.0, 0.0, 1.0, 1.0) + tex.inputs['Scale'].default_value = 4.0 + assign_material(b_mat) + + converter = exporter(tmp_path) + params = reflectance_of(converter, 'mat-Textured') + assert params['type'] == 'checkerboard' + assert params['color1'] == {'type': 'rgb', + 'value': pytest.approx([1.0, 0.0, 0.0])} + assert params['color0'] == {'type': 'rgb', + 'value': pytest.approx([0.0, 0.0, 1.0])} + expected = [[2, 0, 0, 0], [0, 2, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] + assert np.allclose(np.array(params['to_uv'].matrix), expected, + atol=1e-6) + assert mi.load_dict(params) is not None + + +def test_checker_matches_blender_pattern(fresh_scene, exporter, tmp_path): + import mitsuba as mi + + scale = 3.0 + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexChecker') + tex.inputs['Color2'].default_value = (1.0, 1.0, 1.0, 1.0) + tex.inputs['Color1'].default_value = (0.0, 0.0, 0.0, 1.0) + tex.inputs['Scale'].default_value = scale + assign_material(b_mat) + + converter = exporter(tmp_path) + texture = mi.load_dict(reflectance_of(converter, 'mat-Textured')) + + si = mi.SurfaceInteraction3f() + for u in np.linspace(0.05, 0.95, 7): + for v in np.linspace(0.05, 0.95, 7): + # Blender shows Color1 where the cell parity is even + expected = (math.floor(scale * u) + math.floor(scale * v)) \ + % 2 == 0 + si.uv = [u, v] # texture coordinates port over verbatim + value = texture.eval(si)[0] + assert (value > 0.5) == expected, (u, v) + + +#################### +## Vertex color ## +#################### + +def test_vertex_color_export(fresh_scene, exporter, tmp_path): + b_mat, tex = make_diffuse_with_texture('ShaderNodeVertexColor') + tex.layer_name = 'Col' + assign_material(b_mat) + + converter = exporter(tmp_path) + params = reflectance_of(converter, 'mat-Textured') + assert params == {'type': 'mesh_attribute', 'name': 'vertex_Col'} + +def test_alpha_output(fresh_scene, export_ctx, registry): + b_mat, tex = make_diffuse_with_texture('ShaderNodeTexImage') + tex.image = make_image() + diffuse = next(n for n in b_mat.node_tree.nodes + if n.type == 'BSDF_DIFFUSE') + b_mat.node_tree.links.new(tex.outputs['Alpha'], + diffuse.inputs['Roughness']) + + result = registry.resolve(export_ctx, diffuse.inputs['Roughness']) + assert isinstance(result, registry.Texture) + assert result.params['type'] == 'bitmap' + assert result.params.get('raw') is True + + +########################### +## Normal and bump map ## +########################### + +def make_normal_chain(node_type): + b_mat = bpy.data.materials.new('NormalChain') + b_mat.use_nodes = True + tree = b_mat.node_tree + diffuse = tree.nodes.new('ShaderNodeBsdfDiffuse') + wrapper = tree.nodes.new(node_type) + tree.links.new(wrapper.outputs[0], diffuse.inputs['Normal']) + tex = tree.nodes.new('ShaderNodeTexImage') + tex.image = make_image('NormalTex', colorspace='Non-Color') + return diffuse, wrapper, tex + + +def test_normalmap_wrap(fresh_scene, export_ctx, textures): + export_ctx.render = True + diffuse, normal_map, tex = make_normal_chain('ShaderNodeNormalMap') + tree = diffuse.id_data + tree.links.new(tex.outputs['Color'], normal_map.inputs['Color']) + + bsdf = {'type': 'diffuse'} + result = textures.convert_normal_input( + export_ctx, diffuse.inputs['Normal'], bsdf) + assert result['type'] == 'normalmap' + assert result['bsdf'] is bsdf + assert result['normalmap']['type'] == 'bitmap' + assert result['normalmap']['raw'] is True + + import mitsuba as mi + with saved_file_resolver() as fr: + fr.prepend(mi.filesystem.path(export_ctx.directory)) + assert mi.load_dict(result) is not None + + +def test_bumpmap_wrap(fresh_scene, export_ctx, textures): + export_ctx.render = True + diffuse, bump, tex = make_normal_chain('ShaderNodeBump') + tree = diffuse.id_data + tree.links.new(tex.outputs['Color'], bump.inputs['Height']) + bump.inputs['Strength'].default_value = 0.5 + bump.inputs['Distance'].default_value = 0.3 + + result = textures.convert_normal_input( + export_ctx, diffuse.inputs['Normal'], {'type': 'diffuse'}) + assert result['type'] == 'bumpmap' + assert result['scale'] == pytest.approx(0.15) + assert result['texture']['type'] == 'bitmap' + + import mitsuba as mi + with saved_file_resolver() as fr: + fr.prepend(mi.filesystem.path(export_ctx.directory)) + assert mi.load_dict(result) is not None + + +def test_bump_over_normalmap(fresh_scene, export_ctx, textures): + export_ctx.render = True + diffuse, bump, tex = make_normal_chain('ShaderNodeBump') + tree = diffuse.id_data + tree.links.new(tex.outputs['Color'], bump.inputs['Height']) + normal_map = tree.nodes.new('ShaderNodeNormalMap') + normal_tex = tree.nodes.new('ShaderNodeTexImage') + normal_tex.image = make_image('Inner', colorspace='Non-Color') + tree.links.new(normal_tex.outputs['Color'], normal_map.inputs['Color']) + tree.links.new(normal_map.outputs['Normal'], bump.inputs['Normal']) + + result = textures.convert_normal_input( + export_ctx, diffuse.inputs['Normal'], {'type': 'diffuse'}) + assert result['type'] == 'bumpmap' + assert result['bsdf']['type'] == 'normalmap' + assert result['bsdf']['bsdf'] == {'type': 'diffuse'} + + +def test_unlinked_and_constant_normal_input(fresh_scene, export_ctx, + textures): + b_mat = bpy.data.materials.new('Plain') + b_mat.use_nodes = True + diffuse = b_mat.node_tree.nodes.new('ShaderNodeBsdfDiffuse') + bsdf = {'type': 'diffuse'} + assert textures.convert_normal_input( + export_ctx, diffuse.inputs['Normal'], bsdf) is bsdf + + # A constant normal map color has no effect either + diffuse2, normal_map, _ = make_normal_chain('ShaderNodeNormalMap') + assert textures.convert_normal_input( + export_ctx, diffuse2.inputs['Normal'], bsdf) is bsdf + + +def test_unsupported_normal_input(fresh_scene, export_ctx, textures): + b_mat = bpy.data.materials.new('Weird') + b_mat.use_nodes = True + tree = b_mat.node_tree + diffuse = tree.nodes.new('ShaderNodeBsdfDiffuse') + geometry = tree.nodes.new('ShaderNodeNewGeometry') + tree.links.new(geometry.outputs['Normal'], diffuse.inputs['Normal']) + bsdf = {'type': 'diffuse'} + assert textures.convert_normal_input( + export_ctx, diffuse.inputs['Normal'], bsdf) is bsdf + + +########################### +## Environment texture ## +########################### + +def make_environment_node(tmp_path=None): + world = bpy.data.worlds.new('EnvWorld') + world.use_nodes = True + node = world.node_tree.nodes.new('ShaderNodeTexEnvironment') + node.image = make_image('Env', size=4) + if tmp_path is not None: + save_image(node.image, tmp_path / 'env.png') + return node + + +def test_environment_texture_file(fresh_scene, export_ctx, textures, + tmp_path, ref): + node = make_environment_node(tmp_path) + params = textures.convert_environment_texture(export_ctx, ref(node)) + assert params == {'type': 'envmap', 'filename': 'textures/env.png'} + assert (tmp_path / 'textures' / 'env.png').exists() + + +def test_environment_texture_render(fresh_scene, export_ctx, textures, ref): + import mitsuba as mi + export_ctx.render = True + node = make_environment_node() + params = textures.convert_environment_texture(export_ctx, ref(node)) + assert params['type'] == 'envmap' + assert 'bitmap' not in params + assert params['filename'].endswith(('.exr', '.hdr', '.png')) + + +def test_vertex_color_name_matches_mesh_attribute(fresh_scene, exporter, + tmp_path): + # Attribute names with non-identifier characters are sanitized by the + # mesh exporter; the mesh_attribute reference must match + b_mesh = bpy.data.objects['Cube'].data + attr = b_mesh.color_attributes.new('My Color', 'FLOAT_COLOR', 'POINT') + color = np.tile([0.25, 0.5, 0.75, 1.0], len(b_mesh.vertices)) + attr.data.foreach_set('color', color) + + b_mat, tex = make_diffuse_with_texture('ShaderNodeVertexColor') + tex.layer_name = 'My Color' + assign_material(b_mat) + + converter = exporter(tmp_path) + params = reflectance_of(converter, 'mat-Textured') + assert params['name'] == 'vertex_My_Color' + + import mitsuba as mi + scene = converter.dict_to_scene() + mesh = next(s for s in scene.shapes() if isinstance(s, mi.Mesh)) + assert params['name'] in mi.traverse(mesh).keys() diff --git a/tests/unit/test_texture_import.py b/tests/unit/test_texture_import.py new file mode 100644 index 00000000..776a9fe2 --- /dev/null +++ b/tests/unit/test_texture_import.py @@ -0,0 +1,305 @@ +"""Tests for the Mitsuba texture and normal/bump map importers.""" + +import bpy +import numpy as np +import pytest + + +def write_png(path, size=4): + image = bpy.data.images.new('png-writer', size, size, alpha=True) + values = np.linspace(0.0, 1.0, size * size * 4, dtype=np.float32) + image.pixels.foreach_set(values) + image.filepath_raw = str(path) + image.file_format = 'PNG' + image.save() + bpy.data.images.remove(image) + + +def import_xml(tmp_path, xml_body): + xml = f'\n{xml_body}\n' + scene_file = tmp_path / 'scene.xml' + scene_file.write_text(xml) + assert bpy.ops.import_scene.mitsuba(filepath=str(scene_file)) == \ + {'FINISHED'} + + +def imported_material(name): + b_mat = bpy.data.materials[name] + surface = b_mat.node_tree.get_output_node('CYCLES').inputs['Surface'] + assert surface.is_linked + return b_mat, surface.links[0].from_node + + +def source_node(socket): + assert socket.is_linked + return socket.links[0].from_node + + +def diffuse_with_reflectance(tmp_path, texture_xml, mat_id='mat-tex'): + import_xml(tmp_path, f''' + + + {texture_xml} + + ''') + _, node = imported_material(mat_id) + assert node.bl_idname == 'ShaderNodeBsdfDiffuse' + return node + + +############## +## Bitmap ## +############## + +def test_import_bitmap(mi_addon, fresh_scene, tmp_path): + write_png(tmp_path / 'tex.png') + diffuse = diffuse_with_reflectance(tmp_path, ''' + + + + + + ''') + + tex = source_node(diffuse.inputs['Color']) + assert tex.bl_idname == 'ShaderNodeTexImage' + assert tuple(tex.image.size) == (4, 4) + assert tex.image.colorspace_settings.name == 'Non-Color' + assert tex.extension == 'EXTEND' + assert tex.interpolation == 'Closest' + # The mapping node carries the differing image row convention + mapping = source_node(tex.inputs['Vector']) + assert tuple(mapping.inputs['Location'].default_value) == \ + pytest.approx((0.0, 1.0, 0.0)) + assert tuple(mapping.inputs['Scale'].default_value) == \ + pytest.approx((1.0, -1.0, 1.0)) + + +def test_import_bitmap_missing_file(mi_addon, fresh_scene, tmp_path): + diffuse = diffuse_with_reflectance(tmp_path, ''' + + + ''') + + # The missing texture falls back to the default reflectance + assert not diffuse.inputs['Color'].is_linked + assert tuple(diffuse.inputs['Color'].default_value) == \ + pytest.approx((0.8, 0.8, 0.8, 1.0)) + + +def test_import_bitmap_to_uv(mi_addon, fresh_scene, tmp_path): + # The Mapping node composes the to_uv transform with the mirrored v axis + write_png(tmp_path / 'tex.png') + diffuse = diffuse_with_reflectance(tmp_path, ''' + + + + ''') + + tex = source_node(diffuse.inputs['Color']) + mapping = source_node(tex.inputs['Vector']) + assert mapping.bl_idname == 'ShaderNodeMapping' + assert tuple(mapping.inputs['Location'].default_value) == \ + pytest.approx((0.0, 1.0, 0.0)) + assert tuple(mapping.inputs['Scale'].default_value) == \ + pytest.approx((2.0, -2.0, 1.0)) + coords = source_node(mapping.inputs['Vector']) + assert coords.bl_idname == 'ShaderNodeTexCoord' + + +def test_import_bitmap_mirroring_to_uv(mi_addon, fresh_scene, tmp_path): + # A to_uv that already mirrors v cancels against the row convention, so + # no Mapping node is needed at all + write_png(tmp_path / 'tex.png') + diffuse = diffuse_with_reflectance(tmp_path, ''' + + + + + + ''') + + tex = source_node(diffuse.inputs['Color']) + assert not tex.inputs['Vector'].is_linked + + +def test_import_bitmap_is_cached(mi_addon, fresh_scene, tmp_path): + write_png(tmp_path / 'tex.png') + import_xml(tmp_path, ''' + + + + + + + + + + + + ''') + + b_mat, _ = imported_material('mat-cache') + tex_nodes = [n for n in b_mat.node_tree.nodes + if n.bl_idname == 'ShaderNodeTexImage'] + assert len(tex_nodes) == 2 + assert tex_nodes[0].image is tex_nodes[1].image + assert len([i for i in bpy.data.images + if i.name.startswith('tex')]) == 1 + + +#################### +## Checkerboard ## +#################### + +def test_import_checkerboard(mi_addon, fresh_scene, tmp_path): + diffuse = diffuse_with_reflectance(tmp_path, ''' + + + + + ''') + + checker = source_node(diffuse.inputs['Color']) + assert checker.bl_idname == 'ShaderNodeTexChecker' + assert tuple(checker.inputs['Color1'].default_value) == \ + pytest.approx((0.0, 0.0, 1.0, 1.0)) + assert tuple(checker.inputs['Color2'].default_value) == \ + pytest.approx((1.0, 0.0, 0.0, 1.0)) + assert checker.inputs['Scale'].default_value == pytest.approx(4.0) + assert not checker.inputs['Vector'].is_linked + + +def test_import_checkerboard_general_to_uv(mi_addon, fresh_scene, tmp_path): + diffuse = diffuse_with_reflectance(tmp_path, ''' + + + ''') + + # An isotropic, unshifted pattern needs no Mapping node at all + checker = source_node(diffuse.inputs['Color']) + assert checker.inputs['Scale'].default_value == pytest.approx(3.0) + assert not checker.inputs['Vector'].is_linked + + +############# +## Scale ## +############# + +def test_import_scale_texture(mi_addon, fresh_scene, tmp_path): + diffuse = diffuse_with_reflectance(tmp_path, ''' + + + + ''') + + mix = source_node(diffuse.inputs['Color']) + assert mix.bl_idname == 'ShaderNodeMix' + assert mix.blend_type == 'MULTIPLY' + factor = next(s for s in mix.inputs if s.identifier == 'Factor_Float') + assert factor.default_value == pytest.approx(1.0) + b_color = next(s for s in mix.inputs if s.identifier == 'B_Color') + assert tuple(b_color.default_value) == \ + pytest.approx((0.5, 0.5, 0.5, 1.0)) + a_color = next(s for s in mix.inputs if s.identifier == 'A_Color') + assert source_node(a_color).bl_idname == 'ShaderNodeTexChecker' + + +###################### +## Mesh attribute ## +###################### + +def test_import_mesh_attribute(mi_addon, fresh_scene, tmp_path): + diffuse = diffuse_with_reflectance(tmp_path, ''' + + + ''') + + color = source_node(diffuse.inputs['Color']) + assert color.bl_idname == 'ShaderNodeVertexColor' + assert color.layer_name == 'Col' + + +def test_import_mesh_attribute_generic(mi_addon, fresh_scene, tmp_path): + diffuse = diffuse_with_reflectance(tmp_path, ''' + + + ''') + + attribute = source_node(diffuse.inputs['Color']) + assert attribute.bl_idname == 'ShaderNodeAttribute' + assert attribute.attribute_name == 'temperature' + + +############################ +## Normal and bump maps ## +############################ + +def test_import_normalmap(mi_addon, fresh_scene, tmp_path): + write_png(tmp_path / 'normal.png') + import_xml(tmp_path, ''' + + + + + + + + + ''') + + _, diffuse = imported_material('mat-nm') + assert diffuse.bl_idname == 'ShaderNodeBsdfDiffuse' + normal_map = source_node(diffuse.inputs['Normal']) + assert normal_map.bl_idname == 'ShaderNodeNormalMap' + tex = source_node(normal_map.inputs['Color']) + assert tex.bl_idname == 'ShaderNodeTexImage' + assert tex.image.colorspace_settings.name == 'Non-Color' + + +def test_import_bumpmap(mi_addon, fresh_scene, tmp_path): + write_png(tmp_path / 'height.png') + import_xml(tmp_path, ''' + + + + + + + + + + ''') + + _, diffuse = imported_material('mat-bump') + bump = source_node(diffuse.inputs['Normal']) + assert bump.bl_idname == 'ShaderNodeBump' + assert bump.inputs['Distance'].default_value == pytest.approx(0.6) + assert source_node(bump.inputs['Height']).bl_idname == \ + 'ShaderNodeTexImage' + + +def test_import_bumpmap_over_normalmap(mi_addon, fresh_scene, tmp_path): + write_png(tmp_path / 'normal.png') + write_png(tmp_path / 'height.png') + import_xml(tmp_path, ''' + + + + + + + + + + + + + + ''') + + _, diffuse = imported_material('mat-chain') + bump = source_node(diffuse.inputs['Normal']) + assert bump.bl_idname == 'ShaderNodeBump' + normal_map = source_node(bump.inputs['Normal']) + assert normal_map.bl_idname == 'ShaderNodeNormalMap' diff --git a/tests/unit/test_world_export.py b/tests/unit/test_world_export.py new file mode 100644 index 00000000..4421788b --- /dev/null +++ b/tests/unit/test_world_export.py @@ -0,0 +1,291 @@ +"""Unit tests for the Blender world to Mitsuba emitter converter.""" + +import importlib +import math +import os + +import bpy +import numpy as np +import pytest +from bpy_extras.io_utils import axis_conversion +from mathutils import Matrix + + +@pytest.fixture(scope='session') +def world(mi_addon): + return importlib.import_module(f'{mi_addon}.convert.export.world') + + +@pytest.fixture +def export_ctx(mi_addon, tmp_path): + import mitsuba as mi + mi.set_variant('scalar_rgb') + module = importlib.import_module(f'{mi_addon}.io.exporter.export_context') + ctx = module.ExportContext() + ctx.directory = str(tmp_path) + ctx.axis_mat = axis_conversion(to_forward='-Z', to_up='Y').to_4x4() + return ctx + + +@pytest.fixture +def log_capture(export_ctx): + logs = [] + export_ctx.log = lambda msg, level='INFO': logs.append((level, msg)) + return logs + + +def make_world(color=None, strength=None, name='TestWorld'): + b_world = bpy.data.worlds.new(name) + b_world.use_nodes = True + background = b_world.node_tree.nodes['Background'] + if color is not None: + background.inputs['Color'].default_value = color + if strength is not None: + background.inputs['Strength'].default_value = strength + return b_world + + +def make_env_image(tmp_path, name='env'): + image = bpy.data.images.new(name, 8, 4, float_buffer=True) + image.filepath_raw = str(tmp_path / f'{name}.exr') + image.file_format = 'OPEN_EXR' + image.save() + return image + + +def make_env_world(tmp_path, rotation=None, vector_type='POINT', + strength=1.0): + b_world = make_world(strength=strength, name='EnvWorld') + tree = b_world.node_tree + background = tree.nodes['Background'] + environment = tree.nodes.new('ShaderNodeTexEnvironment') + environment.image = make_env_image(tmp_path) + tree.links.new(environment.outputs['Color'], + background.inputs['Color']) + if rotation is not None: + mapping = tree.nodes.new('ShaderNodeMapping') + mapping.vector_type = vector_type + mapping.inputs['Rotation'].default_value = rotation + coordinates = tree.nodes.new('ShaderNodeTexCoord') + tree.links.new(coordinates.outputs['Generated'], + mapping.inputs['Vector']) + tree.links.new(mapping.outputs['Vector'], + environment.inputs['Vector']) + return b_world + + +def load_with_resolver(params, directory): + """Load a Mitsuba dict whose file references are relative to a + directory.""" + import mitsuba as mi + fr = mi.file_resolver() + paths = list(fr) + fr.prepend(str(directory)) + try: + return mi.load_dict(params) + finally: + fr.clear() + for path in paths: + fr.append(path) + + +def test_constant_background(fresh_scene, export_ctx, world): + b_world = make_world(color=(0.2, 0.4, 0.6, 1.0), strength=2.0) + params = world.convert_world(export_ctx, b_world) + assert params['type'] == 'constant' + assert params['radiance']['value'] == \ + pytest.approx([0.4, 0.8, 1.2], rel=1e-5) + + import mitsuba as mi + assert mi.load_dict(params) is not None + + +def test_default_background_ignored(fresh_scene, export_ctx, world): + b_world = bpy.context.scene.world + assert world.convert_world(export_ctx, b_world, + ignore_background=True) is None + params = world.convert_world(export_ctx, b_world, + ignore_background=False) + assert params['type'] == 'constant' + assert params['radiance']['value'] == \ + pytest.approx(world.DEFAULT_BACKGROUND) + + +def test_default_color_at_other_strength_is_kept(fresh_scene, export_ctx, + world): + # Only an untouched world is the default one; a color/strength pair that + # merely multiplies out to the default gray must still be exported + gray = world.DEFAULT_BACKGROUND[0] + for color, strength in ((2.0 * gray, 0.5), (gray / 4.0, 4.0), + (1.0, gray), (gray, 2.0)): + b_world = make_world(color=(color, color, color, 1.0), + strength=strength) + params = world.convert_world(export_ctx, b_world, + ignore_background=True) + assert params is not None, \ + f'color {color} at strength {strength} was dropped' + assert params['radiance']['value'] == \ + pytest.approx([color * strength] * 3, rel=1e-5) + + +def test_zero_strength_skipped(fresh_scene, export_ctx, world): + b_world = make_world(color=(1.0, 1.0, 1.0, 1.0), strength=0.0) + assert world.convert_world(export_ctx, b_world) is None + + +def test_zero_radiance_skipped(fresh_scene, export_ctx, world): + b_world = make_world(color=(0.0, 0.0, 0.0, 1.0), strength=1.0) + assert world.convert_world(export_ctx, b_world) is None + + +def test_rgb_node_color(fresh_scene, export_ctx, world): + # The RGB node value lives on its output socket, not node.color + # (PR #153) + b_world = make_world(strength=0.5) + tree = b_world.node_tree + rgb = tree.nodes.new('ShaderNodeRGB') + rgb.outputs['Color'].default_value = (1.0, 0.0, 0.2, 1.0) + tree.links.new(rgb.outputs['Color'], + tree.nodes['Background'].inputs['Color']) + params = world.convert_world(export_ctx, b_world) + assert params['radiance']['value'] == \ + pytest.approx([0.5, 0.0, 0.1], rel=1e-5) + + +def test_world_without_nodes(fresh_scene, export_ctx, world): + b_world = bpy.data.worlds.new('Plain') + b_world.use_nodes = False + b_world.color = (0.1, 0.2, 0.3) + params = world.convert_world(export_ctx, b_world) + assert params['type'] == 'constant' + assert params['radiance']['value'] == \ + pytest.approx([0.1, 0.2, 0.3], rel=1e-5) + + +def test_unlinked_surface(fresh_scene, export_ctx, world): + b_world = make_world() + tree = b_world.node_tree + for link in list(tree.links): + tree.links.remove(link) + assert world.convert_world(export_ctx, b_world) is None + + +def test_no_world(export_ctx, world, log_capture): + assert world.convert_world(export_ctx, None) is None + + +def test_unsupported_surface_node(fresh_scene, export_ctx, world, + log_capture): + b_world = make_world() + tree = b_world.node_tree + mix = tree.nodes.new('ShaderNodeMixShader') + output = tree.nodes['World Output'] + tree.links.new(mix.outputs['Shader'], output.inputs['Surface']) + with pytest.raises(world.ConversionError): + world.convert_world(export_ctx, b_world) + # The export entry point swallows the error with a warning + world.export_world(export_ctx, b_world) + assert list(export_ctx.scene_data.keys()) == ['type'] + assert any(level == 'WARN' for level, _ in log_capture) + + +def test_envmap(fresh_scene, export_ctx, world, tmp_path): + b_world = make_env_world(tmp_path, strength=1.5) + params = world.convert_world(export_ctx, b_world) + assert params['type'] == 'envmap' + assert params['scale'] == pytest.approx(1.5) + assert os.path.isfile(os.path.join(str(tmp_path), params['filename'])) + # Without a mapping node, only the equirectangular convention change + expected = np.array(export_ctx.axis_mat @ world.ENVMAP_COORDINATE_MAT) + np.testing.assert_allclose(np.array(params['to_world'].matrix), + expected, atol=1e-6) + assert load_with_resolver(params, tmp_path) is not None + + +@pytest.mark.parametrize('vector_type,sign', [('POINT', -1.0), + ('TEXTURE', 1.0)]) +def test_envmap_rotation(fresh_scene, export_ctx, world, tmp_path, + vector_type, sign): + theta = math.radians(75) + b_world = make_env_world(tmp_path, rotation=(0.0, 0.0, theta), + vector_type=vector_type) + params = world.convert_world(export_ctx, b_world) + # Point mappings rotate the lookup direction, texture mappings rotate + # the environment itself (the inverse) + expected = np.array(export_ctx.axis_mat + @ Matrix.Rotation(sign * theta, 4, 'Z') + @ world.ENVMAP_COORDINATE_MAT) + np.testing.assert_allclose(np.array(params['to_world'].matrix), + expected, atol=1e-6) + + +def test_envmap_bad_mapping_source(fresh_scene, export_ctx, world, + tmp_path): + b_world = make_env_world(tmp_path) + tree = b_world.node_tree + environment = tree.nodes['Environment Texture'] + mapping = tree.nodes.new('ShaderNodeMapping') + # A mapping node without generated texture coordinates is rejected + tree.links.new(mapping.outputs['Vector'], + environment.inputs['Vector']) + with pytest.raises(world.ConversionError): + world.convert_world(export_ctx, b_world) + + +def test_export_world_adds_entry(fresh_scene, export_ctx, world): + b_world = make_world(color=(0.3, 0.3, 0.3, 1.0), strength=1.0) + export_ctx.export_ids = True + world.export_world(export_ctx, b_world) + assert export_ctx.data_get('World')['type'] == 'constant' + + +def test_envmap_render_mode_keeps_hdr(fresh_scene, export_ctx, world, + tmp_path): + import mitsuba as mi + # Render mode hands the pixels to Mitsuba in memory; values above 1 TODO: check if this is still true + # must survive and no files may be written + b_world = make_env_world(tmp_path) + image = bpy.data.images['env'] + pixels = np.full(len(image.pixels), 5.0, dtype=np.float32) + image.pixels.foreach_set(pixels) + chk = np.empty(len(image.pixels), dtype=np.float32) + image.pixels.foreach_get(chk) + print('is_dirty:', image.is_dirty) + print('buffer:', chk.min(), chk.max(), 'is_float:', image.is_float, + 'source:', image.source, 'has_data:', image.has_data) + + export_ctx.render = True + params = world.convert_world(export_ctx, b_world) + assert params['type'] == 'envmap' + assert 'bitmap' not in params + path = tmp_path / params['filename'] + assert path.suffix == '.exr' + bmp = mi.Bitmap(str(path)) + assert np.max(np.array(bmp)) == pytest.approx(5.0) + + from test_texture_export import resolver_append + with resolver_append(export_ctx.directory): + assert mi.load_dict(params) is not None + + +def test_envmap_export_leaves_image_datablock_alone(fresh_scene, export_ctx, + world, tmp_path): + # A TIFF source is converted through a temporary copy; the user's + # Image datablock must keep its format + source = bpy.data.images.new('tif-src', 8, 4) + source.filepath_raw = str(tmp_path / 'source.tif') + source.file_format = 'TIFF' + source.save() + image = bpy.data.images.load(str(tmp_path / 'source.tif')) + assert image.file_format == 'TIFF' + + b_world = make_world(name='EnvWorld') + tree = b_world.node_tree + environment = tree.nodes.new('ShaderNodeTexEnvironment') + environment.image = image + tree.links.new(environment.outputs['Color'], + tree.nodes['Background'].inputs['Color']) + + params = world.convert_world(export_ctx, b_world) + assert image.file_format == 'TIFF' + assert os.path.isfile(os.path.join(str(tmp_path), params['filename'])) diff --git a/tests/unit/test_ztest_fixture.py b/tests/unit/test_ztest_fixture.py new file mode 100644 index 00000000..e0f9f5ed --- /dev/null +++ b/tests/unit/test_ztest_fixture.py @@ -0,0 +1,44 @@ +"""Regression test for the z-test helper in tests/ztest.py. + +The mean image and the second moment must come from their respective splits +of the moment integrator output. A constant emitter of radiance 0.5 has mean +0.5 and second moment 0.25 with zero variance, which tells the two apart. +""" + +import numpy as np + +from ztest import MitsubaSceneRenderer + + +def test_bitmap_extract_moment_splits(): + import mitsuba as mi + mi.set_variant('scalar_rgb') + + scene = mi.load_dict({ + 'type': 'scene', + 'integrator': { + 'type': 'moment', + 'nested': {'type': 'path', 'max_depth': 2}, + }, + 'sensor': { + 'type': 'perspective', + 'film': { + 'type': 'hdrfilm', + 'width': 8, + 'height': 8, + 'rfilter': {'type': 'box'}, + }, + 'sampler': {'type': 'independent', 'sample_count': 4}, + }, + 'emitter': {'type': 'constant', 'radiance': 0.5}, + }) + scene.integrator().render(scene, seed=0, develop=False) + bmp = scene.sensors()[0].film().bitmap(raw=False) + + img, var_img = MitsubaSceneRenderer()._bitmap_extract(bmp) + + # The mean image is in XYZ; luminance (Y) of linear rgb (0.5, 0.5, 0.5) + # is exactly 0.5. The buggy version returned the second moment (0.25). + assert np.allclose(img[:, :, 1], 0.5, atol=1e-3) + # A constant image has zero sample variance. + assert np.allclose(var_img, 0.0, atol=1e-3) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py deleted file mode 100644 index ca35b719..00000000 --- a/tests/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import mi_scene_utils \ No newline at end of file diff --git a/tests/utils/mi_scene_utils.py b/tests/utils/mi_scene_utils.py deleted file mode 100644 index b675985b..00000000 --- a/tests/utils/mi_scene_utils.py +++ /dev/null @@ -1,38 +0,0 @@ -import os -import numpy as np - -def _bitmap_extract(bmp, require_variance=True): - from mitsuba import Bitmap, Struct - """Extract different channels from moment integrator AOVs""" - # AVOs from the moment integrator are in XYZ (float32) - split = bmp.split() - if len(split) == 1: - if require_variance: - raise RuntimeError( - 'Could not extract variance image from bitmap. ' - 'Did you wrap the integrator into a `moment` integrator?\n{}'.format(bmp)) - b_root = split[0][1] - if b_root.channel_count() >= 3 and b_root.pixel_format() != Bitmap.PixelFormat.XYZ: - b_root = b_root.convert(Bitmap.PixelFormat.XYZ, Struct.Type.Float32, False) - return np.array(b_root, copy=True), None - else: - img = np.array(split[1][1], copy=False) - img_m2 = np.array(split[2][1], copy=False) - return img, img_m2 - img * img - -def xyz_to_rgb_bmp(arr): - """Convert an XYZ image to RGB""" - from mitsuba import Bitmap, Struct - xyz_bmp = Bitmap(arr, Bitmap.PixelFormat.XYZ) - return xyz_bmp.convert(Bitmap.PixelFormat.RGB, Struct.Type.Float32, False) - -def render_scene(scene_file, spp, res): - from mitsuba import load_file - - scene = load_file(scene_file, spp=spp, resx=res[0], resy=res[1]) - scene.integrator().render(scene, seed=0, develop=False) - - bmp = scene.sensors()[0].film().bitmap(raw=False) - img, var_img = _bitmap_extract(bmp) - - return img, var_img diff --git a/tests/fixtures/__init__.py b/tests/ztest.py similarity index 66% rename from tests/fixtures/__init__.py rename to tests/ztest.py index d2f08ba9..c27b0f7a 100644 --- a/tests/fixtures/__init__.py +++ b/tests/ztest.py @@ -1,64 +1,20 @@ -import pytest -import numpy as np - -import os - -################################ -## ResourceResolver fixture ## -################################ - -class ResourceResolver: - def __init__(self): - self.root = os.path.join(os.getcwd(), 'tests/res') - - def get_absolute_resource_path(self, relative_path): - return os.path.join(self.root, relative_path) - - def ensure_resource_dir(self, relative_dir): - absolute_dir = self.get_absolute_resource_path(relative_dir) - os.makedirs(absolute_dir, exist_ok=True) - return absolute_dir - -@pytest.fixture -def resource_resolver(): - return ResourceResolver() - -################################## -## MitsubaSceneParser fixture ## -################################## +"""Statistical z-test comparison of two rendered Mitsuba scenes. -class MitsubaSceneParser: - def __init__(self): - self.props = None +The scenes must wrap their integrator in a `moment` integrator so that a +variance estimate is available for the test statistic. +""" - def load_xml(self, scene_file): - import mitsuba as mi - - config = mi.parser.ParserConfig(mi.variant()) - mi_state = mi.parser.parse_file(config, scene_file) - mi.parser.transform_all(config, mi_state) - self.state = mi_state - - def get_props_by_name(self, plugin_name): - for node in self.state.nodes: - if node.props.plugin_name() == plugin_name: - return node.props - return None +import os -@pytest.fixture -def mitsuba_scene_parser(): - return MitsubaSceneParser() +import numpy as np -#################################### -## MitsubaSceneRenderer fixture ## -#################################### class MitsubaSceneRenderer: def _bitmap_extract(self, bmp, require_variance=True): + """Extract mean and variance images from moment integrator AOVs.""" from mitsuba import Bitmap, Struct - """Extract different channels from moment integrator AOVs""" - # AVOs from the moment integrator are in XYZ (float32) + # AOVs from the moment integrator are in XYZ (float32) split = bmp.split() if len(split) == 1: if require_variance: @@ -72,7 +28,8 @@ def _bitmap_extract(self, bmp, require_variance=True): else: # Check which split contains moments - it may not be the first one after root m2_index = 1 if split[1][0].startswith('m2_') else 2 - img = np.array(split[m2_index][1], copy=False) + mean_index = 2 if m2_index == 1 else 1 + img = np.array(split[mean_index][1], copy=False) img_m2 = np.array(split[m2_index][1], copy=False) return img, img_m2 - img * img @@ -87,22 +44,15 @@ def render_scene(self, scene_file, **kwargs): return img, var_img -@pytest.fixture -def mitsuba_scene_renderer(): - return MitsubaSceneRenderer() - -################################### -## MitsubaRenderTester fixture ## -################################### class MitsubaRenderTester: def __init__(self, mitsuba_scene_renderer): self.scene_renderer = mitsuba_scene_renderer def z_test(self, mean, sample_count, reference, reference_var): + """Implementation of the Z-test statistical test""" import drjit as dr from drjit.scalar import ArrayXf as Float - """Implementation of the Z-test statistical test""" # Sanitize the variance images reference_var = np.maximum(reference_var, 1e-4) @@ -147,13 +97,9 @@ def compare_scenes(self, xml_ref, xml_out, spp, resolution, output_dir, signific err_bmp = 0.02 * np.array(img_bmp) err_bmp[~success] = 1.0 err_bmp = Bitmap(err_bmp) - + ref_img_bmp.write(os.path.join(output_dir, 'ref.exr')) img_bmp.write(os.path.join(output_dir, 'out.exr')) err_bmp.write(os.path.join(output_dir, 'err.exr')) return np.count_nonzero(success) / 3 >= 0.9975 * pixel_count - -@pytest.fixture -def mitsuba_scene_ztest(mitsuba_scene_renderer): - return MitsubaRenderTester(mitsuba_scene_renderer)