From 53ff534d071951a67b4dafdc7209b3fde6859bc8 Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:57:35 +0800 Subject: [PATCH 01/25] Add Blender integration add-on for MoonRay (macOS) - blender_addon/: Blender render-engine integration that exports scenes to MoonRay RDLA, renders with the moonray CLI, loads the result into the Render Result, and optionally denoises with OIDN. - Full Blender shader-node graph compilation: Principled/Diffuse/Glossy/ Glass/Transparent/Emission, Mix/Add Shader, image textures, normal maps, and static baking of color/scalar node subgraphs. - RDLA intermediate scene is optional (Save RDLA Scene setting). - Direct Render Image button in the MoonRay render panel. - COMPATIBILITY.md: fixes required to build MoonRay on macOS 27 / Apple Silicon / AppleClang 21 (generator mismatch, Qt skip, memory-bounded parallelism, Xcode-generator avoidance, git clone hardening, Blender 5.2 RenderEngine API workarounds). - patches/: the openmoonray superbuild/preset changes used for the build. - Helper scripts: build_moonray.sh, verify_moonray.sh, finish_build_and_test.sh, install_addon.sh. --- COMPATIBILITY.md | 96 ++++ blender_addon/README.md | 65 +++ blender_addon/__init__.py | 62 +++ blender_addon/engine.py | 180 +++++++ blender_addon/exporter.py | 510 ++++++++++++++++++ blender_addon/materials.py | 637 +++++++++++++++++++++++ blender_addon/operators.py | 65 +++ blender_addon/properties.py | 176 +++++++ blender_addon/renderer.py | 144 +++++ blender_addon/tests/mock_exr.py | 55 ++ blender_addon/tests/mock_moonray.py | 44 ++ blender_addon/tests/test_engine_mock.py | 77 +++ blender_addon/tests/test_export.py | 81 +++ blender_addon/tests/test_full_scene.py | 151 ++++++ blender_addon/tests/test_materials.py | 104 ++++ blender_addon/tests/test_register.py | 64 +++ blender_addon/tests/test_render.py | 83 +++ blender_addon/tests/test_renderer.py | 52 ++ blender_addon/ui.py | 64 +++ build_moonray.sh | 28 + finish_build_and_test.sh | 50 ++ install_addon.sh | 27 + patches/CMakeUserPresets.json | 23 + patches/openmoonray-building-macOS.patch | 193 +++++++ verify_moonray.sh | 61 +++ 25 files changed, 3092 insertions(+) create mode 100644 COMPATIBILITY.md create mode 100644 blender_addon/README.md create mode 100644 blender_addon/__init__.py create mode 100644 blender_addon/engine.py create mode 100644 blender_addon/exporter.py create mode 100644 blender_addon/materials.py create mode 100644 blender_addon/operators.py create mode 100644 blender_addon/properties.py create mode 100644 blender_addon/renderer.py create mode 100644 blender_addon/tests/mock_exr.py create mode 100644 blender_addon/tests/mock_moonray.py create mode 100644 blender_addon/tests/test_engine_mock.py create mode 100644 blender_addon/tests/test_export.py create mode 100644 blender_addon/tests/test_full_scene.py create mode 100644 blender_addon/tests/test_materials.py create mode 100644 blender_addon/tests/test_register.py create mode 100644 blender_addon/tests/test_render.py create mode 100644 blender_addon/tests/test_renderer.py create mode 100644 blender_addon/ui.py create mode 100755 build_moonray.sh create mode 100755 finish_build_and_test.sh create mode 100755 install_addon.sh create mode 100644 patches/CMakeUserPresets.json create mode 100644 patches/openmoonray-building-macOS.patch create mode 100755 verify_moonray.sh diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md new file mode 100644 index 0000000..1bf3bca --- /dev/null +++ b/COMPATIBILITY.md @@ -0,0 +1,96 @@ +# System compatibility fixes (macOS 27 / Apple Silicon / clang 21 / CMake 4.4) + +The user's machine is an **M5 MacBook Air, macOS 27, Xcode 26.6 (CLT active), +AppleClang 21, CMake 4.4.3, Blender 5.2 Alpha**. MoonRay officially supports +macOS 14/15 with Xcode 15/16, so several adjustments were needed. + +## Repository layout + +- `OpenMoonRay/moonray` (the repo the user asked to clone) is the **render + engine component** and uses DreamWorks' internal rez/SCons build system + (`package.py`, `SDKScript`) that only works inside the studio infrastructure. + It is checked out at the workspace root. +- `OpenMoonRay/openmoonray` is the **official superproject** that references + this engine repo as the `moonray/moonray` Git submodule and carries the + public CMake build + macOS support. It is checked out at `openmoonray/` + and is what we build. + +## Fixes applied + +### 1. Generator mismatch in the dependency superbuild +`building/macOS/CMakeLists.txt` hardcodes `make ${JOBS_ARG}` as the build +command for several ExternalProjects. Configuring the superbuild with Ninja +(which we wanted) made the inner builds generate `build.ninja` while `make` +ran → "No targets specified and no makefile found" on Blosc. +**Fix:** configure the superbuild with the default Unix Makefiles generator +(the documented path; the superbuild itself only orchestrates stamps). + +### 2. Qt 5.12.12 cannot build with modern toolchains (and is unneeded) +Qt 5.12 predates clang 15+ and fails on current macOS SDKs. The Blender +integration only needs the `moonray` CLI, not `moonray_gui`. +**Fix:** added `option(SKIP_QT)` to `building/macOS/CMakeLists.txt` and guard +the `qt5` ExternalProject; build with `-DSKIP_QT=ON`. The main build is +configured with `-DBUILD_QT_APPS=NO`. + +### 3. Memory-bounded parallelism +24 GB RAM is not enough for `-j10` on the biggest deps (Boost/USD). +**Fix:** added `MAX_BUILD_JOBS` (default 6) cap in the superbuild. + +### 4. Xcode generator unusable (CLT-only developer dir) +The official `macos-release` preset uses the Xcode generator, which requires +`xcodebuild`, but this machine's active developer directory is the Command +Line Tools. Switching to full Xcode needs sudo, which is unavailable. +**Fix:** `CMakeUserPresets.json` adds `macos-release-ninja` (inherits the +official preset, overrides the generator to Ninja). AppleClang from CLT is +used for the whole build. + +### 5. Blender 5.x API removals in the add-on +Blender 5.2 removed `Mesh.loops`, `Mesh.calc_normals_split()` and +`MeshUVLoopLayer.data` (renamed to `corners`/`uv`). +**Fix:** `blender_addon/exporter.py` uses the new API with fallbacks for +Blender 4.x. + +### 6. `installs/{bin,lib,include}` must exist before the superbuild runs +The Lua dependency's install step copies `lua`/`luac` into +`${InstallRoot}/bin` without creating the directory ("cp: .../bin: Not a +directory" failure). The official docs Step 1 pre-creates these folders. +**Fix:** `mkdir -p installs/{bin,lib,include}` before building deps. + +### 7. Skip the unit tests in the main build +`moonray/CMakeLists.txt` gates `add_subdirectory(tests)` on +`CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME AND BUILD_TESTING`, which is true +for the top-level superproject build (and `include(CTest)` defaults +`BUILD_TESTING` to ON). Building the test suite would multiply compile time +and requires CppUnit to behave under clang 21. +**Fix:** `-DBUILD_TESTING=OFF` in `CMakeUserPresets.json`. + +### 8. Unreliable GitHub clones on this network +Full clones repeatedly died with "fetch-pack: invalid index-pack output" / +"RPC failed; curl 56", and ExternalProject hung on the dead clone. +**Fix:** `GIT_SHALLOW TRUE` + `GIT_PROGRESS TRUE` on every git-based +dependency in the superbuild, plus global git hardening +(`http.postBuffer`, `http.version HTTP/1.1`, low-speed timeout). +Note: changing ExternalProject arguments invalidates its stamps, so already +built deps were re-run once (object caches made this cheap). + +### 9. Blender 5.2 alpha RenderEngine API regressions +- Engine `__init__` is called with an argument and any *instance attribute* + access on the engine raises `ReferenceError: StructRNA ... has been + removed` (method calls like `report`/`update_stats`/`test_break`/ + `begin_result` still work). +- After the render, Blender calls `render()` a second time on the + already-released engine struct. +**Fix:** the engine stores NO instance state (locals only, class-level +constants), `__init__(self, *args)` ignores the argument, and `render()` +catches `ReferenceError` from the phantom second invocation. + +## Status + +- Dependency superbuild: running in the background (Blosc, Boost, JsonCpp, + Lua, MicroHttpd, OpenSubdiv, OpenEXR, TBB installed; OpenVDB building). + One-shot completion script: `finish_build_and_test.sh`. +- Main build: `build_moonray.sh` runs `cmake --preset macos-release-ninja` + + `cmake --build --preset macos-release-ninja`. +- Add-on: complete and tested (export 15/15 checks, registration, engine + mock end-to-end, renderer unit test); installed into Blender via + `install_addon.sh`. diff --git a/blender_addon/README.md b/blender_addon/README.md new file mode 100644 index 0000000..55afdc5 --- /dev/null +++ b/blender_addon/README.md @@ -0,0 +1,65 @@ +# MoonRay for Blender + +Blender integration for the [MoonRay](https://github.com/OpenMoonRay/openmoonray) +production path tracer (DreamWorks / Academy Software Foundation). + +The add-on registers **MoonRay** as a render engine in Blender: + +1. exports the Blender scene to MoonRay's RDLA scene format + (meshes, UVs, normals, materials, lights, camera, world), +2. runs the `moonray` command-line renderer, +3. loads the result back into the Render Result (F12 / animation rendering), +4. optionally denoises with MoonRay's OIDN `denoise` tool. + +## Requirements + +- macOS (Apple Silicon) with a working MoonRay installation built with the + official `macos-release` CMake preset (see `openmoonray/building/macOS`). + Linux installations (Rocky Linux 9) should work as well; the add-on itself + only shells out to the `moonray` binary. +- Blender 4.0 or newer. + +## Installation + +Set the MoonRay installation path in +*Edit → Preferences → Add-ons → MoonRay Render*: + +- **MoonRay Installation** — the directory containing `bin/moonray` + (e.g. `/Users//Documents/wave-tracer/installs/openmoonray`) +- **Dependencies Install Root** — the directory containing the third-party + `lib/` used by MoonRay (e.g. `/Users//Documents/wave-tracer/installs`) + +The auto-detection default looks next to this add-on's source tree +(`/../installs/openmoonray`). + +## Usage + +1. Switch the render engine to **MoonRay** in *Render Properties*. +2. Tune samples (MoonRay `pixel_samples` is the square root of the spp), + threads, denoise, etc. in the *MoonRay* panel. +3. Press F12. The scene is exported to a temporary `.rdla`, rendered, and the + EXR is loaded into the Render Result. Animation rendering (Ctrl+F12) is + supported frame by frame. + +## Supported Blender features + +| Feature | Status | +|--------------------|----------------------------------------------------| +| Meshes (quads/ngons, triangulated) | ✔ with UVs and split normals | +| Curves/surfaces/text (via to_mesh) | ✔ | +| Instancing (linked duplicates) | ✔ exported as RdlInstancerGeometry | +| Principled BSDF | base color (+ image texture), roughness, metallic, specular, transmission, emission, alpha | +| Point / Sun / Spot / Area lights | ✔ with energy-based intensity mapping | +| World background | constant color from the Background node | +| Depth of field | ✔ (camera DOF settings) | +| Motion blur, volumetrics, HDRI environments | not yet | + +## Notes + +- MoonRay is Y-up while Blender is Z-up; the exporter applies the standard + axis conversion (`x, z, -y`) to all transforms. +- Light intensities are converted from Blender watts to MoonRay radiance-ish + units; use the global *Light Intensity Scale* in the add-on preferences to + compensate for scene scale. +- Packed image textures (without a file on disk) fall back to the material's + base color. diff --git a/blender_addon/__init__.py b/blender_addon/__init__.py new file mode 100644 index 0000000..d61c038 --- /dev/null +++ b/blender_addon/__init__.py @@ -0,0 +1,62 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# MoonRay for Blender +# Integrates the DreamWorks MoonRay production path tracer into Blender. +# Copyright (C) 2026 MoonRay Blender contributors +# +# 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 3 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, see . +# +# ##### END GPL LICENSE BLOCK ##### + +bl_info = { + "name": "MoonRay Render", + "author": "MoonRay Blender contributors", + "version": (0, 1, 0), + "blender": (4, 0, 0), + "location": "Render Properties > Render Engine", + "description": "Render with the DreamWorks MoonRay production path tracer " + "(scene export to MoonRay RDLA + moonray CLI)", + "category": "Render", + "support": "COMMUNITY", +} + +import bpy + +from . import properties +from . import operators +from . import engine +from . import ui + + +classes = ( + properties.MoonRayAddonPreferences, + properties.MoonRayRenderSettings, + operators.MOONRAY_OT_export_scene, + operators.MOONRAY_OT_render, + operators.MOONRAY_OT_open_moonray_root, + ui.MOONRAY_PT_render_panel, + engine.MoonRayRenderEngine, +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + properties.register() + + +def unregister(): + properties.unregister() + for cls in reversed(classes): + bpy.utils.unregister_class(cls) diff --git a/blender_addon/engine.py b/blender_addon/engine.py new file mode 100644 index 0000000..9637e5e --- /dev/null +++ b/blender_addon/engine.py @@ -0,0 +1,180 @@ +"""Blender RenderEngine integration: exports to RDLA and runs moonray. + +NOTE: Blender 5.2 alpha's RenderEngine Python proxy no longer supports +storing attributes on engine instances (any instance-dict access raises +"ReferenceError: StructRNA ... has been removed"), while the render() +methods (report/update_stats/test_break/begin_result/...) all work. +This engine therefore keeps ALL state in local variables. +""" + +import os +import shutil +import tempfile +import time + +import bpy + +from . import exporter +from .renderer import MoonRayProcess, resolve_moonray_root + +ADDON_ID = __package__.split(".")[0] + + +class MoonRayRenderEngine(bpy.types.RenderEngine): + bl_idname = "MOONRAY_RENDER" + bl_label = "MoonRay" + bl_use_preview = False + bl_use_shading_nodes = True + bl_use_shading_nodes_custom = False + + def __init__(self, *args): + # Blender 5.x passes engine-creation arguments; no instance + # attributes may be stored (see module docstring). + pass + + # -- helpers ----------------------------------------------------------- + def _prefs(self): + addon = bpy.context.preferences.addons.get(ADDON_ID) + return addon.preferences if addon is not None else None + + def _report_error(self, msg): + self.report({"ERROR"}, msg) + + def _keep_rdla(self, rdla_path, scene, settings): + """Copy the temporary RDLA scene to the user-chosen location.""" + if not settings.keep_rdla: + return + target = settings.rdla_path + if not target: + out = scene.render.filepath + if not out: + return + target = os.path.splitext(out)[0] + ".rdla" + try: + target_dir = os.path.dirname(os.path.abspath(target)) + if target_dir and not os.path.isdir(target_dir): + os.makedirs(target_dir, exist_ok=True) + shutil.copyfile(rdla_path, target) + self.report({"INFO"}, "Saved RDLA scene to %s" % target) + except Exception as e: + self.report({"WARNING"}, "Could not save RDLA scene: %s" % e) + + # -- RenderEngine API -------------------------------------------------- + def render(self, depsgraph): + try: + self._render_impl(depsgraph) + except ReferenceError: + # Blender 5.2 alpha invokes render() a second time after the + # engine struct has been released; nothing can be done then. + pass + + def _render_impl(self, depsgraph): + scene = depsgraph.scene_eval + settings = scene.moonray + prefs = self._prefs() + + if prefs is None: + self._report_error("MoonRay add-on preferences not found") + return + + root, err = resolve_moonray_root(prefs.moonray_root) + if err: + self._report_error("MoonRay not found (%s). Set the correct " + "installation path in the add-on preferences." + % err) + return + + w = max(1, int(scene.render.resolution_x + * scene.render.resolution_percentage / 100.0)) + h = max(1, int(scene.render.resolution_y + * scene.render.resolution_percentage / 100.0)) + + tmpdir = tempfile.mkdtemp(prefix="moonray_") + out_exr = os.path.join(tmpdir, + "frame_%04d.exr" % scene.frame_current) + + def cleanup(): + if tmpdir and not (prefs.debug_keep_files): + shutil.rmtree(tmpdir, ignore_errors=True) + + # 1. export the scene to RDLA + self.update_stats("Exporting", "MoonRay: writing scene") + try: + rdla_path = exporter.export_scene( + scene, depsgraph, settings, prefs, out_exr, + report=lambda msg: self.report({"WARNING"}, msg)) + except Exception as e: + self._report_error("Export failed: %s" % e) + cleanup() + return + + if settings.export_only: + self.report({"INFO"}, "Exported scene to %s" % rdla_path) + self._keep_rdla(rdla_path, scene, settings) + cleanup() + return + + # optionally persist the intermediate RDLA scene + self._keep_rdla(rdla_path, scene, settings) + + # 2. render with the moonray CLI + proc = MoonRayProcess(root, prefs.installs_root) + args = ["-in", rdla_path] + if settings.threads > 0: + args += ["-threads", str(settings.threads)] + + def on_progress(pct): + self.update_progress(pct / 100.0) + self.update_stats("Rendering", "MoonRay: %d%%" % pct) + + try: + proc.launch(args, progress_cb=on_progress) + except OSError as e: + self._report_error("Could not launch moonray: %s" % e) + cleanup() + return + + try: + while proc.proc.poll() is None: + if self.test_break(): + proc.kill() + cleanup() + return + time.sleep(0.1) + rc = proc.proc.returncode + finally: + pass + + if rc != 0: + tail = "\n".join(proc.error_lines[-10:]) + self._report_error("moonray failed (exit code %d).\n%s" + % (rc, tail)) + cleanup() + return + self.update_progress(1.0) + + final = out_exr + if settings.use_denoise and os.path.isfile(proc.denoise_bin): + self.update_stats("Denoising", "MoonRay: OIDN denoise") + denoised = os.path.join(tmpdir, "denoised.exr") + try: + proc.run_denoise(out_exr, denoised) + final = denoised + except Exception as e: + self.report({"WARNING"}, "Denoise failed (%s); " + "using raw render" % e) + + # 3. load the result into the Render Result + result = self.begin_result(0, 0, w, h) + if not result.layers: + self._report_error("No render layers available for the result") + self.end_result(result) + cleanup() + return + layer = result.layers[0] + try: + layer.load_from_file(final) + except Exception as e: + self._report_error("Could not read render output: %s" % e) + self.end_result(result) + cleanup() diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py new file mode 100644 index 0000000..1e6099f --- /dev/null +++ b/blender_addon/exporter.py @@ -0,0 +1,510 @@ +"""Export a Blender scene to the MoonRay RDLA scene description format. + +Coordinate conventions +---------------------- +Blender : Z-up, right-handed; cameras look along local -Z; lights emit + along local -Z. +MoonRay : Y-up, right-handed; cameras look along local -Z; lights emit + along local +Z. + +The axis swap A maps Blender coordinates to MoonRay coordinates: + m = A @ b A = [[1,0,0,0],[0,0,1,0],[0,-1,0,0],[0,0,0,1]] + +Transforms exported: + camera : node_xform = A @ M_world (local conventions match) + geometry : node_xform = A @ M_world @ A^-1 (mesh data left untouched) + light : node_xform = A @ M_world @ F (F flips Z: +Z emits) +""" + +import math +import os + +import bpy +from mathutils import Matrix + +# --------------------------------------------------------------------------- +# Matrices + +_A = Matrix(((1, 0, 0, 0), + (0, 0, 1, 0), + (0, -1, 0, 0), + (0, 0, 0, 1))) + +_A_INV = Matrix(((1, 0, 0, 0), + (0, 0, -1, 0), + (0, 1, 0, 0), + (0, 0, 0, 1))) + +_F = Matrix(((1, 0, 0, 0), + (0, 1, 0, 0), + (0, 0, -1, 0), + (0, 0, 0, 1))) + + +def camera_xform(m): + return _A @ m + + +def geometry_xform(m): + return _A @ m @ _A_INV + + +def light_xform(m): + return _A @ m @ _F + + +_LIGHT_CLASS = { + "POINT": "SphereLight", + "SUN": "DistantLight", + "SPOT": "SpotLight", + "AREA": "RectLight", +} + + +# --------------------------------------------------------------------------- +# Formatting helpers + +def _f(v): + """Format a float for RDLA (plain decimal, no trailing 'f').""" + if abs(v) < 1e-30: + return "0" + return "%.9g" % v + + +def fmt_vec2(v): + return "Vec2(%s, %s)" % (_f(v[0]), _f(v[1])) + + +def fmt_vec3(v): + return "Vec3(%s, %s, %s)" % (_f(v[0]), _f(v[1]), _f(v[2])) + + +def fmt_rgb(c): + return "Rgb(%s, %s, %s)" % (_f(c[0]), _f(c[1]), _f(c[2])) + + +def fmt_mat4(m): + vals = ", ".join(_f(m[i][j]) for i in range(4) for j in range(4)) + return "Mat4(%s)" % vals + + +def fmt_string(s): + return '"%s"' % str(s).replace("\\", "\\\\").replace('"', '\\"') + + +def sanitize_name(name, fallback="unnamed"): + """Make a Blender name safe to embed in RDLA code.""" + name = str(name).strip() or fallback + out = [] + for ch in name: + if ch.isalnum() or ch in "_-./": + out.append(ch) + else: + out.append("_") + return "".join(out) + + + +class MoonRayExporter: + def __init__(self, scene, depsgraph, settings, prefs, out_path, report=print): + self.scene = scene + self.depsgraph = depsgraph + self.settings = settings + self.prefs = prefs + self.out_path = out_path + self.report = report + self.lines = [] + self.indent = 0 + self.geo_count = 0 + self.mat_count = 0 + self._last_geo_name = None + self._last_mat_name = None + self.light_refs = [] # RDLA references for the LightSet block + + # -- low-level writers ------------------------------------------------ + def out(self, line=""): + if line: + self.lines.append(" " * self.indent + line) + else: + self.lines.append("") + + def block(self, header): + self.out(header + " {") + self.indent += 1 + + def end_block(self): + self.indent -= 1 + self.out("}") + + def unique(self, base): + self.geo_count += 1 + return "%s_%d" % (base, self.geo_count) + + # -- scene components -------------------------------------------------- + def write_scene_variables(self): + scene = self.scene + render = scene.render + w = max(1, int(render.resolution_x * render.resolution_percentage / 100.0)) + h = max(1, int(render.resolution_y * render.resolution_percentage / 100.0)) + + s = self.settings + self.block("SceneVariables") + self.out('["camera"] = PerspectiveCamera("camera"),') + self.out('["image_width"] = %d,' % w) + self.out('["image_height"] = %d,' % h) + self.out('["output_file"] = %s,' % fmt_string(self.out_path)) + self.out('["res"] = 1,') + self.out('["frame"] = %s,' % _f(scene.frame_current)) + self.out('["pixel_samples"] = %d,' % int(s.pixel_samples)) + self.out('["min_adaptive_samples"] = %d,' % int(s.min_adaptive_samples)) + self.out('["max_adaptive_samples"] = %d,' % int(s.max_adaptive_samples)) + if s.pixel_filter != "DEFAULT": + self.out('["pixel_filter"] = %d,' + % {"BOX": 0, "CUBIC": 1, "QUADRATIC": 2}[s.pixel_filter]) + if abs(s.pixel_filter_width - 3.0) > 1e-6: + self.out('["pixel_filter_width"] = %s,' % _f(s.pixel_filter_width)) + if s.use_progressive_tiles: + self.out('["progressive_tile_order"] = 4,') + self.end_block() + + def write_camera(self): + cam_obj = self.scene.camera + if cam_obj is None: + self.report("WARNING: no active camera in scene") + return + cam = cam_obj.data + evaluated = cam_obj.evaluated_get(self.depsgraph) + m = evaluated.matrix_world + + self.block('PerspectiveCamera("camera")') + self.out('["node_xform"] = %s,' % fmt_mat4(camera_xform(m))) + self.out('["focal"] = %s,' % _f(cam.lens)) + self.out('["film_width_aperture"] = %s,' % _f(cam.sensor_width)) + self.out('["near"] = %s,' % _f(max(1e-4, cam.clip_start))) + self.out('["far"] = %s,' % _f(cam.clip_end)) + if cam.dof.use_dof: + self.out('["dof"] = true,') + fstop = max(0.05, cam.dof.aperture_fstop) + self.out('["dof_aperture"] = %s,' % _f(cam.lens / fstop)) + self.out('["dof_focus_distance"] = %s,' + % _f(cam.dof.focus_distance)) + self.end_block() + + def write_world(self): + world = self.scene.world + color = (0.05, 0.05, 0.05) + strength = 1.0 + if world is not None and world.use_nodes: + for node in world.node_tree.nodes: + if node.type == "BACKGROUND": + try: + color = tuple(node.inputs["Color"].default_value)[:3] + strength = float(node.inputs["Strength"].default_value) + except Exception: + pass + self.block('EnvLight("envlight")') + self.out('["color"] = %s,' % fmt_rgb( + tuple(c * strength for c in color))) + self.out('["intensity"] = 1,') + self.end_block() + self.light_refs.append('EnvLight("envlight")') + + # -- lights ------------------------------------------------------------ + def write_lights(self): + for obj in self.scene.objects: + if obj.type != "LIGHT" or not obj.visible_get(): + continue + light = obj.data + if light.type not in _LIGHT_CLASS or light.energy <= 0.0: + continue + self.geo_count += 1 + name = "light_%s_%d" % (sanitize_name(obj.name), self.geo_count) + self._write_one_light(obj, light, name) + self.light_refs.append('%s("%s")' % (_LIGHT_CLASS[light.type], name)) + + self.block('LightSet("lightset")') + for ref in self.light_refs: + self.out(ref + ",") + self.end_block() + + def _write_one_light(self, obj, light, name): + evaluated = obj.evaluated_get(self.depsgraph) + m = evaluated.matrix_world + scale = self.prefs.light_scale + color = tuple(light.color) + + cls = _LIGHT_CLASS[light.type] + self.block('%s("%s")' % (cls, name)) + self.out('["node_xform"] = %s,' % fmt_mat4(light_xform(m))) + + if light.type == "AREA": + sx = max(1e-6, light.size) + sy = max(1e-6, light.size_y) + # Blender area energy is in W; MoonRay normalized RectLight + # intensity is radiance-like, so divide by area. + intensity = (light.energy * scale) / (sx * sy) + self.out('["width"] = %s,' % _f(sx)) + self.out('["height"] = %s,' % _f(sy)) + elif light.type == "POINT": + # Blender point energy is in W; a normalized SphereLight + # intensity of energy/(4*pi) approximates the same emission. + intensity = (light.energy * scale) / (4.0 * math.pi) + self.out('["radius"] = %s,' % _f(max(1e-6, light.shadow_soft_size))) + elif light.type == "SPOT": + outer = light.spot_size * 0.5 # half angle in radians + inner = outer * (1.0 - max(0.0, min(1.0, light.spot_blend))) + intensity = light.energy * scale + self.out('["inner_cone_angle"] = %s,' % _f(math.degrees(inner))) + self.out('["outer_cone_angle"] = %s,' % _f(math.degrees(outer))) + if light.shadow_soft_size > 0.0: + self.out('["lens_radius"] = %s,' % _f(light.shadow_soft_size)) + elif light.type == "SUN": + intensity = light.energy * scale + self.out('["angular_extent"] = %s,' % _f(math.degrees(light.angle))) + + self.out('["color"] = %s,' % fmt_rgb(color)) + self.out('["intensity"] = %s,' % _f(intensity)) + self.out('["exposure"] = 0,') + self.out('["normalized"] = true,') + self.out('["visible_in_camera"] = "force off",') + self.end_block() + + # -- geometry ---------------------------------------------------------- + def write_meshes(self): + depsgraph = self.depsgraph + entries = [] + + # group objects: shared data blocks (linked duplicates) without + # modifiers can be instanced with a single RdlInstancerGeometry + grouped = {} # key -> list of (obj, evaluated, mesh) + for obj in self.scene.objects: + if obj.type not in ("MESH", "CURVE", "SURFACE", "FONT", "META"): + continue + if not obj.visible_get(): + continue + evaluated = obj.evaluated_get(depsgraph) + try: + mesh = evaluated.to_mesh() + except RuntimeError: + continue + if mesh is None or len(mesh.polygons) == 0: + if mesh is not None: + evaluated.to_mesh_clear() + continue + if not obj.modifiers: + mat = obj.active_material + key = ("data", id(obj.data), mat.name_full if mat else "") + else: + key = ("obj", id(obj)) + grouped.setdefault(key, []).append((obj, evaluated, mesh)) + + for key, items in grouped.items(): + try: + if len(items) == 1: + obj, evaluated, mesh = items[0] + geo_name, mat_name = self._write_one_mesh( + obj, evaluated, mesh) + else: + geo_name, mat_name = self._write_instancer(items) + entries.append((geo_name, mat_name)) + finally: + for _obj, _evaluated, _mesh in items: + _evaluated.to_mesh_clear() + + if entries: + self.block('Layer("defaultLayer")') + for geo_name, mat_name in entries: + self.out('{GeometrySet("%s"), "", DwaBaseMaterial("%s"), ' + 'LightSet("lightset"), undef(), undef(), undef(), undef()},' + % (geo_name, mat_name)) + self.end_block() + + def _write_instancer(self, items): + """Export one RdlMeshGeometry + one RdlInstancerGeometry for a group + of objects sharing the same mesh data and material.""" + obj0, evaluated0, mesh0 = items[0] + name_base = sanitize_name(obj0.data.name or obj0.name, "mesh") + base_name = self.unique("instbase_" + name_base) + geo_name = self.unique("geo_" + name_base) + mat_name = self.unique("mat_" + name_base) + self._last_geo_name = geo_name + self._last_mat_name = mat_name + + # base geometry: identity transform, instancer places the instances + mesh = mesh0 + mesh.calc_loop_triangles() + tris = mesh.loop_triangles + corners = mesh.corners if hasattr(mesh, "corners") else mesh.loops + if hasattr(mesh, "calc_normals_split"): + mesh.calc_normals_split() + uv_layer = mesh.uv_layers.active + has_uvs = uv_layer is not None + + positions = [] + uvs = [] + normals = [] + indices = [] + for tri in tris: + for loop_index in tri.loops: + corner = corners[loop_index] + positions.append(mesh.vertices[corner.vertex_index].co) + if has_uvs: + uv = (uv_layer.uv[loop_index].vector + if hasattr(uv_layer, "uv") + else uv_layer.data[loop_index].uv) + uvs.append((uv[0], 1.0 - uv[1])) + normals.append(corner.normal) + indices.append(len(indices)) + + self.block('RdlMeshGeometry("%s")' % base_name) + self.out('["node_xform"] = %s,' % fmt_mat4(Matrix.Identity(4))) + self.out('["is_subd"] = false,') + self.out('["smooth_normal"] = true,') + self.out('["vertex_list_0"] = {%s},' + % ", ".join(fmt_vec3(p) for p in positions)) + self.out('["vertices_by_index"] = {%s},' + % ", ".join(str(i) for i in indices)) + self.out('["face_vertex_count"] = {%s},' + % ", ".join("3" for _t in tris)) + if has_uvs: + self.out('["uv_list"] = {%s},' + % ", ".join(fmt_vec2(u) for u in uvs)) + self.out('["normal_list"] = {%s},' + % ", ".join(fmt_vec3(n) for n in normals)) + self.end_block() + + # decompose each instance transform in MoonRay world space + inst_positions = [] + inst_orientations = [] + inst_scales = [] + for obj, evaluated, _mesh in items: + m = geometry_xform(evaluated.matrix_world) + loc, quat, scale = m.decompose() + inst_positions.append(loc) + inst_orientations.append(quat) + inst_scales.append(scale) + + self.block('RdlInstancerGeometry("%s")' % geo_name) + self.out('["node_xform"] = %s,' % fmt_mat4(Matrix.Identity(4))) + self.out('["references"] = {RdlMeshGeometry("%s")},' % base_name) + self.out('["ref_indices"] = {%s},' + % ", ".join("0" for _ in items)) + self.out('["positions"] = {%s},' + % ", ".join(fmt_vec3(p) for p in inst_positions)) + self.out('["orientations"] = {%s},' % ", ".join( + "Vec4(%s, %s, %s, %s)" % (_f(q.x), _f(q.y), _f(q.z), _f(q.w)) + for q in inst_orientations)) + self.out('["scales"] = {%s},' + % ", ".join(fmt_vec3(s) for s in inst_scales)) + self.end_block() + + self.block('GeometrySet("%s")' % geo_name) + self.out('RdlInstancerGeometry("%s"),' % geo_name) + self.end_block() + + material = obj0.active_material + self._write_material(material, mat_name) + return geo_name, mat_name + + def _write_one_mesh(self, obj, evaluated, mesh): + name_base = sanitize_name(obj.name, "mesh") + geo_name = self.unique("geo_" + name_base) + mat_name = self.unique("mat_" + name_base) + self._last_geo_name = geo_name + self._last_mat_name = mat_name + + # triangulate + mesh.calc_loop_triangles() + tris = mesh.loop_triangles + + # Blender >= 4.1 renamed loops -> corners and always keeps split + # normals; older versions need the explicit split-normal bake. + corners = mesh.corners if hasattr(mesh, "corners") else mesh.loops + if hasattr(mesh, "calc_normals_split"): + mesh.calc_normals_split() + + # UVs + uv_layer = mesh.uv_layers.active + has_uvs = uv_layer is not None + + positions = [] + uvs = [] + normals = [] + indices = [] + for tri in tris: + for loop_index in tri.loops: + corner = corners[loop_index] + positions.append(mesh.vertices[corner.vertex_index].co) + if has_uvs: + uv = (uv_layer.uv[loop_index].vector + if hasattr(uv_layer, "uv") + else uv_layer.data[loop_index].uv) + # Blender UV origin is bottom-left; OIIO/MoonRay texture + # origin is top-left. + uvs.append((uv[0], 1.0 - uv[1])) + normals.append(corner.normal) + indices.append(len(indices)) + + m = evaluated.matrix_world + + self.block('RdlMeshGeometry("%s")' % geo_name) + self.out('["node_xform"] = %s,' % fmt_mat4(geometry_xform(m))) + self.out('["is_subd"] = false,') + self.out('["smooth_normal"] = true,') + self.out('["vertex_list_0"] = {%s},' + % ", ".join(fmt_vec3(p) for p in positions)) + self.out('["vertices_by_index"] = {%s},' + % ", ".join(str(i) for i in indices)) + self.out('["face_vertex_count"] = {%s},' + % ", ".join("3" for _t in tris)) + if has_uvs: + self.out('["uv_list"] = {%s},' + % ", ".join(fmt_vec2(u) for u in uvs)) + self.out('["normal_list"] = {%s},' + % ", ".join(fmt_vec3(n) for n in normals)) + self.end_block() + + self.block('GeometrySet("%s")' % geo_name) + self.out('RdlMeshGeometry("%s"),' % geo_name) + self.end_block() + + material = obj.active_material + self._write_material(material, mat_name) + return geo_name, mat_name + + def _write_material(self, material, name): + # full shader-node graph compilation lives in materials.py + try: + from . import materials + except ImportError: + import materials # standalone (non-package) usage in tests + compiler = materials.MaterialCompiler(self) + compiler.compile_material(material, name) + + # -- top level --------------------------------------------------------- + def write(self): + self.out("-- Exported from Blender by the MoonRay add-on") + self.out("-- Scene: %s, frame %s" + % (self.scene.name, self.scene.frame_current)) + self.out() + self.write_scene_variables() + self.out() + self.write_camera() + self.out() + self.write_world() + self.out() + self.write_lights() + self.out() + self.write_meshes() + return "\n".join(self.lines) + "\n" + + +def export_scene(scene, depsgraph, settings, prefs, out_path, report=print): + """Export the scene and return the path of the written .rdla file.""" + exporter = MoonRayExporter(scene, depsgraph, settings, prefs, out_path, + report) + text = exporter.write() + rdla_path = os.path.splitext(out_path)[0] + ".rdla" + with open(rdla_path, "w", encoding="utf-8") as f: + f.write(text) + return rdla_path diff --git a/blender_addon/materials.py b/blender_addon/materials.py new file mode 100644 index 0000000..a0f4aa7 --- /dev/null +++ b/blender_addon/materials.py @@ -0,0 +1,637 @@ +"""Blender shader-node graph -> MoonRay material compilation. + +Supported surface shaders are converted to MoonRay Dwa materials; simple +color/scalar subgraphs are statically evaluated (baked) when their inputs are +constants. Image textures become ImageMap binds, normal maps become +ImageNormalMap binds. + +Unsupported node graphs fall back to the material's base color with a +warning, so export never fails. +""" + +import math + +import bpy + +try: + from .exporter import ( + fmt_rgb, + fmt_string, + sanitize_name, + ) +except ImportError: + from exporter import ( # standalone (non-package) usage in tests + fmt_rgb, + fmt_string, + sanitize_name, + ) + + +# --------------------------------------------------------------------------- +# Static value evaluation + +_RGB = "rgb" +_FLOAT = "float" +_IMG = "img" # ("img", image, colorspace) +_NORMAL = "normal" # ("normal", image, strength) +_NONE = "none" + + +def _const_rgb(c): + return (_RGB, tuple(float(x) for x in c[:3])) + + +def _const_float(v): + return (_FLOAT, float(v)) + + +def _linked_value(sock): + if sock is None or not sock.is_linked: + return None + return sock.links[0].from_node, sock.links[0].from_socket + + +def _texture_image_value(node): + """ShaderNodeTexImage -> ("img", image) when usable.""" + img = getattr(node, "image", None) + if img is None or not img.filepath: + return None + return (_IMG, img) + + +# math ops shared by ShaderNodeMath +_MATH_OPS = { + "ADD": lambda a, b: a + b, + "SUBTRACT": lambda a, b: a - b, + "MULTIPLY": lambda a, b: a * b, + "DIVIDE": lambda a, b: a / b if b != 0 else 0.0, + "POWER": lambda a, b: math.pow(abs(a), b) if a >= 0 else 0.0, + "LOGARITHM": lambda a, b: math.log(max(a, 1e-30)) / math.log(max(b, 1e-30)), + "SQRT": lambda a, b: math.sqrt(max(a, 0.0)), + "INV_SQRT": lambda a, b: 1.0 / math.sqrt(max(a, 1e-30)), + "ABSOLUTE": lambda a, b: abs(a), + "EXPONENT": lambda a, b: math.exp(a), + "MINIMUM": lambda a, b: min(a, b), + "MAXIMUM": lambda a, b: max(a, b), + "LESS_THAN": lambda a, b: 1.0 if a < b else 0.0, + "GREATER_THAN": lambda a, b: 1.0 if a > b else 0.0, + "MODULO": lambda a, b: a % b if b != 0 else 0.0, + "FLOOR": lambda a, b: math.floor(a), + "CEIL": lambda a, b: math.ceil(a), + "SINE": lambda a, b: math.sin(a), + "COSINE": lambda a, b: math.cos(a), + "TANGENT": lambda a, b: math.tan(a), + "ARCSINE": lambda a, b: math.asin(max(-1.0, min(1.0, a))), + "ARCCOSINE": lambda a, b: math.acos(max(-1.0, min(1.0, a))), + "ARCTANGENT": lambda a, b: math.atan(a), + "ROUND": lambda a, b: round(a), + "TRUNC": lambda a, b: math.trunc(a), + "SIGN": lambda a, b: 1.0 if a > 0 else (-1.0 if a < 0 else 0.0), + "COMPARE": lambda a, b: 1.0 if abs(a - b) < 0.5 else 0.0, +} + + +class NodeEvaluator: + """Best-effort static evaluation of Blender shader node values.""" + + def __init__(self, report): + self.report = report + self._cache = {} + + def eval_socket(self, sock): + """Evaluate a socket to a constant value, image ref, or None.""" + if sock is None: + return None + link = _linked_value(sock) + if link is None: + # unconnected: use the socket's own default + try: + if sock.type == "RGBA": + return _const_rgb(sock.default_value) + if sock.type == "VALUE": + return _const_float(sock.default_value) + except Exception: + pass + return None + node, from_sock = link + value = self.eval_node(node) + if value is None: + return None + if value[0] == _RGB: + return value + if value[0] == _FLOAT: + return value + if value[0] == _IMG: + return value + return None + + def eval_node(self, node): + if node is None: + return None + key = id(node) + if key in self._cache: + return self._cache[key] + + value = None + ntype = getattr(node, "type", "") + try: + if ntype == "RGB": + value = _const_rgb(node.outputs["Color"].default_value) + elif ntype == "VALUE": + value = _const_float(node.outputs["Value"].default_value) + elif ntype == "TEX_IMAGE": + value = _texture_image_value(node) + elif ntype == "MATH": + value = self._eval_math(node) + elif ntype == "MIX": + value = self._eval_mix_rgb(node) + elif ntype == "INVERT": + c = self.eval_socket(node.inputs["Color"]) + if c and c[0] == _RGB: + value = _const_rgb(tuple(1.0 - x for x in c[1])) + elif ntype == "BRIGHTCONTRAST": + value = self._eval_brightcontrast(node) + elif ntype == "GAMMA": + c = self.eval_socket(node.inputs["Color"]) + g = self.eval_socket(node.inputs["Gamma"]) + if c and c[0] == _RGB and g and g[0] == _FLOAT: + value = _const_rgb(tuple( + math.pow(max(x, 0.0), 1.0 / max(g[1], 1e-6)) + for x in c[1])) + elif ntype == "HUE_SAT": + value = self._eval_hue_sat(node) + elif ntype == "RGBTOBW": + c = self.eval_socket(node.inputs["Color"]) + if c and c[0] == _RGB: + lum = (0.2126 * c[1][0] + 0.7152 * c[1][1] + + 0.0722 * c[1][2]) + value = _const_float(lum) + elif ntype == "VALTORGB": + value = self._eval_colorramp(node) + elif ntype == "CLAMP": + v = self.eval_socket(node.inputs["Value"]) + mn = self.eval_socket(node.inputs["Min"]) + mx = self.eval_socket(node.inputs["Max"]) + if v and v[0] == _FLOAT: + lo = mn[1] if mn and mn[0] == _FLOAT else 0.0 + hi = mx[1] if mx and mx[0] == _FLOAT else 1.0 + value = _const_float(max(lo, min(hi, v[1]))) + elif ntype == "MAP_RANGE": + value = self._eval_map_range(node) + except Exception: + value = None + self._cache[key] = value + return value + + def _f(self, sock): + v = self.eval_socket(sock) + if v and v[0] == _FLOAT: + return v[1] + return None + + def _eval_math(self, node): + op = node.operation + fn = _MATH_OPS.get(op) + if fn is None: + return None + a = self._f(node.inputs[0]) + if a is None: + return None + b = self._f(node.inputs[1]) if len(node.inputs) > 1 else 0.0 + if b is None: + return None + if node.use_clamp: + a = max(0.0, min(1.0, a)) + if len(node.inputs) > 1: + b = max(0.0, min(1.0, b)) + return _const_float(fn(a, b)) + + def _eval_mix_rgb(self, node): + # Blender >= 3.4 Mix node has typed sockets sharing names (A/B can be + # VALUE, VECTOR or RGBA); select by (name, type). Older Blender used + # Color1/Color2 + Fac. + def _sock(name, types): + for s in node.inputs: + if s.name == name and s.type in types: + return s + return None + + a_in = _sock("A", ("RGBA",)) or node.inputs.get("Color1") + b_in = _sock("B", ("RGBA",)) or node.inputs.get("Color2") + f_sock = (_sock("Factor", ("VALUE",)) + or _sock("Fac", ("VALUE",)) + or node.inputs.get("Fac")) + a = self.eval_socket(a_in) + b = self.eval_socket(b_in) + f = self._f(f_sock) + if a is None or b is None or f is None: + return None + if a[0] != _RGB or b[0] != _RGB: + return None + f = max(0.0, min(1.0, f)) + if node.blend_type == "MIX": + return _const_rgb(tuple(a[1][i] * (1 - f) + b[1][i] * f + for i in range(3))) + if node.blend_type == "ADD": + return _const_rgb(tuple(min(1.0, a[1][i] + b[1][i] * f) + for i in range(3))) + if node.blend_type == "MULTIPLY": + return _const_rgb(tuple(a[1][i] * (1 - f) + + a[1][i] * b[1][i] * f + for i in range(3))) + return None + + def _eval_brightcontrast(self, node): + c = self.eval_socket(node.inputs["Color"]) + b = self._f(node.inputs["Bright"]) + k = self._f(node.inputs["Contrast"]) + if c is None or c[0] != _RGB or b is None or k is None: + return None + return _const_rgb(tuple(max(0.0, x * k + b) for x in c[1])) + + def _eval_hue_sat(self, node): + c = self.eval_socket(node.inputs["Color"]) + h = self._f(node.inputs["Hue"]) + s = self._f(node.inputs["Saturation"]) + v = self._f(node.inputs["Value"]) + if c is None or c[0] != _RGB or None in (h, s, v): + return None + r, g, b = c[1] + mx = max(r, g, b) + mn = min(r, g, b) + l = (mx + mn) / 2.0 + d = mx - mn + if d == 0: + hue = 0.0 + elif mx == r: + hue = ((g - b) / d) % 6.0 + elif mx == g: + hue = (b - r) / d + 2.0 + else: + hue = (r - g) / d + 4.0 + hue = (hue / 6.0 + h) % 1.0 + sat = d / (1.0 - abs(2.0 * l - 1.0)) if (1.0 - abs(2.0 * l - 1.0)) > 1e-6 else 0.0 + sat = max(0.0, min(1.0, sat * s)) + val = l * v + # hue/sat/val -> rgb + if sat == 0: + out = (val, val, val) + else: + q = val * (1 - sat) if val < 0.5 else val + sat - val * sat + p = 2 * val - q + + def hue2rgb(t): + t = t % 1.0 + if t < 1 / 6: + return p + (q - p) * 6 * t + if t < 1 / 2: + return q + if t < 2 / 3: + return p + (q - p) * (2 / 3 - t) * 6 + return p + out = (hue2rgb(hue + 1 / 3), hue2rgb(hue), hue2rgb(hue - 1 / 3)) + return _const_rgb(out) + + def _eval_colorramp(self, node): + f = self._f(node.inputs["Fac"]) + if f is None: + return None + ramp = node.color_ramp + if not ramp.elements: + return None + elems = sorted(ramp.elements, key=lambda e: e.position) + if f <= elems[0].position: + return _const_rgb(elems[0].color) + for e0, e1 in zip(elems, elems[1:]): + if e0.position <= f <= e1.position: + span = e1.position - e0.position + t = 0.0 if span == 0 else (f - e0.position) / span + return _const_rgb(tuple( + e0.color[i] * (1 - t) + e1.color[i] * t + for i in range(3))) + return _const_rgb(elems[-1].color) + + def _eval_map_range(self, node): + v = self._f(node.inputs["Value"]) + if v is None: + return None + mn = self._f(node.inputs["From Min"]) + mx = self._f(node.inputs["From Max"]) + tmn = self._f(node.inputs["To Min"]) + tmx = self._f(node.inputs["To Max"]) + if None in (mn, mx, tmn, tmx) or mx == mn: + return None + t = (v - mn) / (mx - mn) + if node.clamp: + t = max(0.0, min(1.0, t)) + return _const_float(tmn + t * (tmx - tmn)) + + +# --------------------------------------------------------------------------- +# Surface shader -> Dwa material parameters + +class MaterialCompiler: + """Compiles a Blender material into MoonRay RDLA blocks.""" + + def __init__(self, exporter): + self.exporter = exporter + self.evaluator = NodeEvaluator(exporter.report) + self._mat_index = exporter.mat_count # reuse counter via exporter + + # -- utilities --------------------------------------------------------- + def _unique(self, base): + self.exporter.mat_count += 1 + return "%s_%d" % (base, self.exporter.mat_count) + + def _emit_image_map(self, img): + name = self._unique("tex_" + sanitize_name(img.name, "tex")) + self.exporter.block('ImageMap("%s")' % name) + self.exporter.out('["texture"] = %s,' + % fmt_string(bpy.path.abspath(img.filepath))) + self.exporter.end_block() + return name + + def _resolve_rgb(self, value, fallback=(1.0, 1.0, 1.0)): + """value -> (expr, needs_bind) where expr is an RDLA expression.""" + if value is None: + return fmt_rgb(fallback), False + kind = value[0] + if kind == _RGB: + return fmt_rgb(value[1]), False + if kind == _IMG: + name = self._emit_image_map(value[1]) + return 'bind(ImageMap("%s"))' % name, True + return fmt_rgb(fallback), False + + def _resolve_float(self, value, fallback=0.0): + if value is None: + return fallback + if value[0] == _FLOAT: + return value[1] + return fallback + + # -- shader node -> material params ------------------------------------ + def _principled_params(self, node): + ev = self.evaluator + params = { + "albedo": (None, (1.0, 1.0, 1.0)), + "roughness": 0.5, + "metallic": 0.0, + "specular": 1.0, + "emission": None, + "emission_strength": 0.0, + "alpha": 1.0, + "transmission": 0.0, + "transmission_color": (1.0, 1.0, 1.0), + "normal": None, # ("normal", image, strength) + "input_normal_dial": 0.0, + } + base = ev.eval_socket(node.inputs["Base Color"]) + params["albedo"] = (base, (1.0, 1.0, 1.0)) + + rough = ev.eval_socket(node.inputs["Roughness"]) + params["roughness"] = self._resolve_float(rough, 0.5) + + metal = ev.eval_socket(node.inputs["Metallic"]) + params["metallic"] = self._resolve_float(metal, 0.0) + + spec = ev.eval_socket(node.inputs.get("Specular IOR Level")) + if spec is None: + spec = ev.eval_socket(node.inputs.get("Specular")) + params["specular"] = self._resolve_float(spec, 1.0) + + alpha = ev.eval_socket(node.inputs["Alpha"]) + params["alpha"] = self._resolve_float(alpha, 1.0) + + trans = ev.eval_socket(node.inputs.get("Transmission Weight")) + params["transmission"] = self._resolve_float(trans, 0.0) + + tc = ev.eval_socket(node.inputs.get("Transmission Color")) + if tc and tc[0] == _RGB: + params["transmission_color"] = tc[1] + + em_c = ev.eval_socket(node.inputs["Emission Color"]) + em_s = ev.eval_socket(node.inputs["Emission Strength"]) + params["emission"] = em_c if em_c and em_c[0] in (_RGB, _IMG) else None + params["emission_strength"] = self._resolve_float(em_s, 0.0) + + # normal input + normal_in = node.inputs.get("Normal") + if normal_in is not None and normal_in.is_linked: + src = normal_in.links[0].from_node + if src.type == "NORMAL_MAP": + img_val = ev.eval_socket(src.inputs["Color"]) + strength = self._resolve_float( + ev.eval_socket(src.inputs.get("Strength")), 1.0) + if img_val and img_val[0] == _IMG: + params["normal"] = ("normal", img_val[1], strength) + elif src.type == "BUMP": + strength = self._resolve_float( + ev.eval_socket(src.inputs.get("Strength")), 1.0) + params["input_normal_dial"] = strength + self.exporter.report( + "WARNING: Bump node approximated via normal strength") + return params + + def _simple_params(self, node): + """Diffuse/Glossy/Glass/Transparent/Emission shaders.""" + ev = self.evaluator + ntype = node.type + params = { + "albedo": (None, (1.0, 1.0, 1.0)), + "roughness": 0.5, + "metallic": 0.0, + "specular": 1.0, + "emission": None, + "emission_strength": 0.0, + "alpha": 1.0, + "transmission": 0.0, + "transmission_color": (1.0, 1.0, 1.0), + "normal": None, + "input_normal_dial": 0.0, + } + if ntype == "BSDF_DIFFUSE": + params["albedo"] = (ev.eval_socket(node.inputs["Color"]), + (1.0, 1.0, 1.0)) + params["roughness"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Roughness")), 1.0) + params["specular"] = 0.0 + elif ntype == "BSDF_GLOSSY": + params["albedo"] = (ev.eval_socket(node.inputs["Color"]), + (1.0, 1.0, 1.0)) + params["roughness"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Roughness")), 0.1) + params["specular"] = 1.0 + elif ntype == "BSDF_GLASS": + params["albedo"] = (ev.eval_socket(node.inputs["Color"]), + (1.0, 1.0, 1.0)) + params["transmission"] = 1.0 + params["roughness"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Roughness")), 0.0) + elif ntype == "BSDF_TRANSPARENT": + params["alpha"] = 0.0 + elif ntype == "EMISSION": + params["emission"] = ev.eval_socket(node.inputs["Color"]) + params["emission_strength"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Strength")), 1.0) + params["albedo"] = (None, (0.0, 0.0, 0.0)) + return params + + def _emit_dwa(self, name, params, cls="DwaBaseMaterial", extra_lines=None): + out = self.exporter.out + out('%s("%s") {' % (cls, name)) + expr, _bind = self._resolve_rgb(params["albedo"][0], + params["albedo"][1]) + out(' ["albedo"] = %s,' % expr) + out(' ["roughness"] = %.9g,' % max(1e-4, params["roughness"])) + out(' ["metallic"] = %.9g,' % params["metallic"]) + out(' ["specular"] = %.9g,' % params["specular"]) + if params["transmission"] > 0.0: + out(' ["show_transmission"] = true,') + out(' ["transmission"] = %.9g,' % params["transmission"]) + tc = params["transmission_color"] + out(' ["transmission_color"] = %s,' % fmt_rgb(tc)) + if params["alpha"] < 0.999: + out(' ["presence"] = %.9g,' % max(0.0, params["alpha"])) + if params["emission"] is not None and params["emission_strength"] > 0: + expr, _b = self._resolve_rgb(params["emission"], (0, 0, 0)) + out(' ["emission"] = %s,' % expr) + out(' ["show_emission"] = true,') + if params["normal"] is not None: + kind, img, strength = params["normal"] + if kind == "normal" and img is not None: + nm_name = self._unique( + "normal_" + sanitize_name(img.name, "nm")) + out(' ["input_normal"] = bind(ImageNormalMap("%s")),' + % nm_name) + out(' ["input_normal_dial"] = %.9g,' + % max(0.0, strength)) + # emit the ImageNormalMap block AFTER the material block + self._pending_normal_maps.append((nm_name, img)) + elif params["input_normal_dial"] > 0.0: + out(' ["input_normal_dial"] = %.9g,' + % params["input_normal_dial"]) + elif params["input_normal_dial"] > 0.0: + out(' ["input_normal_dial"] = %.9g,' + % params["input_normal_dial"]) + if extra_lines: + for line in extra_lines: + out(" " + line) + self.exporter.end_block() + + def _emit_normal_map_block(self, nm_name, img): + self.exporter.block('ImageNormalMap("%s")' % nm_name) + self.exporter.out('["tangent_space_normal_texture"] = %s,' + % fmt_string(bpy.path.abspath(img.filepath))) + self.exporter.end_block() + + # -- entry points ------------------------------------------------------ + def compile_material(self, material, name): + """Write RDLA blocks for the material; return the material ref name + used in the Layer entry.""" + self._pending_normal_maps = [] + ev = self.evaluator + + if material is None or not material.use_nodes: + self._emit_dwa(name, self._principled_defaults()) + self._flush_normal_maps() + return name + + tree = material.node_tree + output = next((n for n in tree.nodes + if n.type == "OUTPUT_MATERIAL"), None) + surface = None + if output is not None and output.inputs["Surface"].is_linked: + surface = output.inputs["Surface"].links[0].from_node + + if surface is None: + self._emit_dwa(name, self._principled_defaults()) + self._flush_normal_maps() + return name + + if surface.type == "BSDF_PRINCIPLED": + self._emit_dwa(name, self._principled_params(surface)) + self._flush_normal_maps() + return name + + if surface.type in ("BSDF_DIFFUSE", "BSDF_GLOSSY", "BSDF_GLASS", + "BSDF_TRANSPARENT", "EMISSION"): + self._emit_dwa(name, self._simple_params(surface)) + self._flush_normal_maps() + return name + + if surface.type == "MIX_SHADER": + a_node = surface.inputs[1].links[0].from_node \ + if surface.inputs[1].is_linked else None + b_node = surface.inputs[2].links[0].from_node \ + if surface.inputs[2].is_linked else None + fac = self._resolve_float(ev.eval_socket(surface.inputs["Fac"]), + 0.5) + self._compile_mix(a_node, b_node, fac, name) + self._flush_normal_maps() + return name + + if surface.type == "ADD_SHADER": + a_node = surface.inputs[0].links[0].from_node \ + if surface.inputs[0].is_linked else None + b_node = surface.inputs[1].links[0].from_node \ + if surface.inputs[1].is_linked else None + self._compile_mix(a_node, b_node, 0.5, name) + self._flush_normal_maps() + return name + + self.exporter.report( + "WARNING: unsupported surface shader %s - using default material" + % surface.type) + self._emit_dwa(name, self._principled_defaults()) + self._flush_normal_maps() + return name + + def _compile_mix(self, a_node, b_node, fac, name): + """MIX_SHADER / ADD_SHADER via DwaMixMaterial.""" + pa = self._params_for(a_node) + pb = self._params_for(b_node) + if fac <= 0.01: + self._emit_dwa(name, pb) + return + if fac >= 0.99: + self._emit_dwa(name, pa) + return + b_name = name + "_B" + self._emit_dwa(b_name, pb) + self._emit_dwa( + name, pa, cls="DwaMixMaterial", + extra_lines=[ + '["material"] = DwaBaseMaterial("%s"),' % b_name, + '["mix"] = %.9g,' % fac, + ]) + + def _flush_normal_maps(self): + for nm_name, img in getattr(self, "_pending_normal_maps", []): + self._emit_normal_map_block(nm_name, img) + self._pending_normal_maps = [] + + def _params_for(self, node): + if node is None: + return self._principled_defaults() + if node.type == "BSDF_PRINCIPLED": + return self._principled_params(node) + if node.type in ("BSDF_DIFFUSE", "BSDF_GLOSSY", "BSDF_GLASS", + "BSDF_TRANSPARENT", "EMISSION"): + return self._simple_params(node) + return self._principled_defaults() + + def _principled_defaults(self): + return { + "albedo": (None, (1.0, 1.0, 1.0)), + "roughness": 0.5, + "metallic": 0.0, + "specular": 1.0, + "emission": None, + "emission_strength": 0.0, + "alpha": 1.0, + "transmission": 0.0, + "transmission_color": (1.0, 1.0, 1.0), + "normal": None, + "input_normal_dial": 0.0, + } diff --git a/blender_addon/operators.py b/blender_addon/operators.py new file mode 100644 index 0000000..5917057 --- /dev/null +++ b/blender_addon/operators.py @@ -0,0 +1,65 @@ +"""Operators for the MoonRay add-on.""" + +import os +import subprocess +import tempfile + +import bpy + +from . import exporter + +ADDON_ID = __package__.split(".")[0] + + +class MOONRAY_OT_export_scene(bpy.types.Operator): + bl_idname = "moonray.export_scene" + bl_label = "Export MoonRay Scene" + bl_description = "Export the current scene to a MoonRay .rdla file" + + filepath: bpy.props.StringProperty( + name="File Path", subtype="FILE_PATH") + + def invoke(self, context, event): + blend = context.blend_data.filepath + base = os.path.splitext(blend)[0] if blend else "untitled" + self.filepath = base + ".rdla" + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} + + def execute(self, context): + scene = context.scene + prefs = context.preferences.addons.get(ADDON_ID) + prefs = prefs.preferences if prefs is not None else None + settings = scene.moonray + depsgraph = context.evaluated_depsgraph_get() + rdla = exporter.export_scene( + scene, depsgraph, settings, prefs, self.filepath, + report=lambda msg: self.report({"WARNING"}, msg)) + self.report({"INFO"}, "Exported %s" % rdla) + return {"FINISHED"} + + +class MOONRAY_OT_render(bpy.types.Operator): + bl_idname = "moonray.render" + bl_label = "Render with MoonRay" + bl_description = "Render the current scene with MoonRay (same as F12)" + + def execute(self, context): + bpy.ops.render.render("INVOKE_DEFAULT") + return {"FINISHED"} + + +class MOONRAY_OT_open_moonray_root(bpy.types.Operator): + bl_idname = "moonray.open_moonray_root" + bl_label = "Open MoonRay Installation Folder" + bl_description = "Reveal the MoonRay installation folder in Finder" + + def execute(self, context): + addon = context.preferences.addons.get(ADDON_ID) + root = addon.preferences.moonray_root if addon is not None else "" + root = os.path.expanduser(root) + if not os.path.isdir(root): + self.report({"ERROR"}, "Installation folder does not exist: %s" % root) + return {"CANCELLED"} + subprocess.Popen(["open", root]) + return {"FINISHED"} diff --git a/blender_addon/properties.py b/blender_addon/properties.py new file mode 100644 index 0000000..4c6d2ec --- /dev/null +++ b/blender_addon/properties.py @@ -0,0 +1,176 @@ +"""Add-on preferences and per-scene MoonRay render settings.""" + +import os + +import bpy +from bpy.props import ( + BoolProperty, + EnumProperty, + FloatProperty, + IntProperty, + PointerProperty, + StringProperty, +) + +from . import renderer + +ADDON_ID = __package__.split(".")[0] + + +def _default_moonray_root(): + # Where this add-on source tree lives inside the moonray workspace: + # /blender_addon -> /../installs/openmoonray + try: + here = os.path.dirname(os.path.abspath(__file__)) + candidate = os.path.normpath(os.path.join(here, "..", "..", "installs", "openmoonray")) + if os.path.isdir(candidate): + return candidate + except Exception: + pass + return "/Applications/MoonRay/installs/openmoonray" + + +class MoonRayAddonPreferences(bpy.types.AddonPreferences): + bl_idname = ADDON_ID + + moonray_root: StringProperty( + name="MoonRay Installation", + description="Root of an installed MoonRay build (the directory that " + "contains bin/, lib/, rdl2dso/, sessions/, ...)", + subtype="DIR_PATH", + default=_default_moonray_root(), + ) + installs_root: StringProperty( + name="Dependencies Install Root", + description="Directory that contains the MoonRay third-party " + "dependencies (lib/, include/, ...). Usually the parent " + "of the MoonRay installation root", + subtype="DIR_PATH", + default="", + ) + light_scale: FloatProperty( + name="Light Intensity Scale", + description="Global multiplier applied to every exported light " + "intensity (defaults map Blender watts/energy to MoonRay " + "radiance approximately)", + default=1.0, + min=0.0, + soft_max=100.0, + precision=3, + ) + debug_keep_files: BoolProperty( + name="Keep Export Files", + description="Do not delete the generated .rdla scene and temporary " + "render output (useful for debugging the exporter)", + default=False, + ) + auto_detect: BoolProperty( + name="Auto-detect Installation", + description="Try to locate the MoonRay installation automatically", + default=True, + ) + + def draw(self, context): + layout = self.layout + layout.prop(self, "moonray_root") + layout.prop(self, "installs_root") + layout.prop(self, "light_scale") + layout.prop(self, "debug_keep_files") + box = layout.box() + box.label(text="MoonRay binary status:") + root, err = renderer.resolve_moonray_root(self.moonray_root) + if err: + box.label(text="Not found: %s" % err, icon="ERROR") + else: + box.label(text=os.path.join(root, "bin", "moonray"), icon="CHECKMARK") + box.operator("moonray.open_moonray_root", text="Open Installation Folder") + + +class MoonRayRenderSettings(bpy.types.PropertyGroup): + pixel_samples: IntProperty( + name="Pixel Samples", + description="Square root of the number of samples per pixel " + "(MoonRay 'pixel_samples': 8 means 64 spp)", + default=8, + min=1, + max=256, + ) + min_adaptive_samples: IntProperty( + name="Min Adaptive Samples", + description="Minimum adaptive samples per pixel", + default=16, + min=1, + max=4096, + ) + max_adaptive_samples: IntProperty( + name="Max Adaptive Samples", + description="Maximum adaptive samples per pixel", + default=4096, + min=1, + max=262144, + ) + threads: IntProperty( + name="Render Threads", + description="Number of CPU threads used by moonray (0 = auto)", + default=0, + min=0, + max=1024, + ) + use_progressive_tiles: BoolProperty( + name="Progressive Tile Order", + description="Use MoonRay's progressive tile ordering", + default=False, + ) + pixel_filter: EnumProperty( + name="Pixel Filter", + description="MoonRay pixel reconstruction filter", + items=[ + ("DEFAULT", "Default (Cubic B-Spline)", "MoonRay default filter"), + ("BOX", "Box", "Box filter"), + ("CUBIC", "Cubic B-Spline", "Cubic B-spline filter"), + ("QUADRATIC", "Quadratic B-Spline", "Quadratic B-spline filter"), + ], + default="DEFAULT", + ) + pixel_filter_width: FloatProperty( + name="Pixel Filter Width", + description="Width of the pixel filter", + default=3.0, + min=0.5, + soft_max=6.0, + ) + use_denoise: BoolProperty( + name="Denoise", + description="Denoise the finished render with MoonRay's built-in " + "OpenImageDenoise tool (denoise -mode oidn_cpu)", + default=False, + ) + export_only: BoolProperty( + name="Export Only", + description="Only export the .rdla scene and skip rendering " + "(useful for debugging)", + default=False, + ) + keep_rdla: BoolProperty( + name="Save RDLA Scene", + description="Keep the intermediate .rdla scene file after rendering " + "(next to the render output, or at the path below). " + "Off: the scene is written to a temporary file and " + "deleted automatically", + default=False, + ) + rdla_path: StringProperty( + name="RDLA Path", + description="Optional path for the kept .rdla scene. Empty uses " + "the render output path with an .rdla extension", + subtype="FILE_PATH", + default="", + ) + + +def register(): + bpy.types.Scene.moonray = PointerProperty(type=MoonRayRenderSettings) + + +def unregister(): + del bpy.types.Scene.moonray diff --git a/blender_addon/renderer.py b/blender_addon/renderer.py new file mode 100644 index 0000000..05b9743 --- /dev/null +++ b/blender_addon/renderer.py @@ -0,0 +1,144 @@ +"""Locate and drive the moonray command-line renderer.""" + +import os +import re +import subprocess +import threading + +MOONRAY_BIN = "moonray" +DENOISE_BIN = "denoise" + + +def resolve_moonray_root(moonray_root): + """Return (root, error). Root is the directory containing bin/moonray.""" + root = os.path.expanduser(moonray_root or "") + if os.path.isfile(os.path.join(root, "bin", MOONRAY_BIN)): + return root, None + if os.path.isfile(os.path.join(root, MOONRAY_BIN)): + return root, None + return root, "bin/moonray not found under %s" % (root or "(empty)") + + +def build_env(moonray_root, installs_root=""): + """Environment needed by moonray at runtime (mirrors scripts/setup.sh).""" + env = os.environ.copy() + root = moonray_root + + env["PATH"] = os.path.join(root, "bin") + os.pathsep + env.get("PATH", "") + env["RDL2_DSO_PATH"] = os.path.join(root, "rdl2dso") + env["REZ_MOONRAY_ROOT"] = root + env["ARRAS_SESSION_PATH"] = os.path.join(root, "sessions") + env["MOONRAY_CLASS_PATH"] = os.path.join(root, "shader_json") + env["PXR_PLUGINPATH_NAME"] = os.path.join(root, "plugin", "pxr") + env["PXR_PLUGIN_PATH"] = os.path.join(root, "plugin", "pxr") + + # python modules (USD bindings etc.) + py_paths = [] + if installs_root: + py_paths += [os.path.join(installs_root, "lib", "python"), + os.path.join(installs_root, "lib64", "python3.9", + "site-packages")] + py_paths.append(os.path.join(root, "lib", "python")) + for p in py_paths: + if os.path.isdir(p): + env["PYTHONPATH"] = p + os.pathsep + env.get("PYTHONPATH", "") + + # dynamic libraries (dependencies in installs/lib, moonray libs) + lib_dirs = [] + if installs_root: + lib_dirs.append(os.path.join(installs_root, "lib")) + lib_dirs.append(os.path.join(root, "lib")) + existing = [d for d in lib_dirs if os.path.isdir(d)] + if existing: + env["DYLD_LIBRARY_PATH"] = os.pathsep.join(existing) + os.pathsep + \ + env.get("DYLD_LIBRARY_PATH", "") + return env + + +class MoonRayProcess: + """Runs moonray and streams progress.""" + + _PROGRESS_RE = re.compile(r"Rendering\s+\[\s*(\d+)%\]") + + def __init__(self, moonray_root, installs_root): + self.root = moonray_root + self.installs_root = installs_root + self.proc = None + self.error_lines = [] + self.progress = 0.0 + self._stdout_thread = None + self._stderr_thread = None + + @property + def moonray_bin(self): + return os.path.join(self.root, "bin", MOONRAY_BIN) + + @property + def denoise_bin(self): + return os.path.join(self.root, "bin", DENOISE_BIN) + + def launch(self, args, progress_cb=None): + env = build_env(self.root, self.installs_root) + cmd = [self.moonray_bin] + list(args) + self.proc = subprocess.Popen( + cmd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self._stdout_thread = threading.Thread( + target=self._pump, args=(self.proc.stdout, True, progress_cb), + daemon=True) + self._stderr_thread = threading.Thread( + target=self._pump, args=(self.proc.stderr, False, None), + daemon=True) + self._stdout_thread.start() + self._stderr_thread.start() + + def _pump(self, stream, is_stdout, progress_cb): + """Read raw chunks; moonray prints progress with \\r, not \\n.""" + buf = b"" + try: + while True: + chunk = stream.read(4096) + if not chunk: + break + buf += chunk + if len(buf) > 1 << 16: + buf = buf[-4096:] + text = buf.decode("utf-8", errors="replace") + if is_stdout and progress_cb is not None: + for m in self._PROGRESS_RE.finditer(text): + pct = int(m.group(1)) + if 0 <= pct <= 100: + self.progress = pct + progress_cb(pct) + elif not is_stdout: + for line in text.splitlines(): + line = line.strip() + if line and not line.startswith("Rendering"): + self.error_lines.append(line) + if len(self.error_lines) > 200: + self.error_lines.pop(0) + except (ValueError, OSError): + pass + + def wait(self): + return self.proc.wait() + + def kill(self): + if self.proc is not None and self.proc.poll() is None: + self.proc.kill() + self.proc.wait() + + def run_denoise(self, in_path, out_path): + env = build_env(self.root, self.installs_root) + cmd = [self.denoise_bin, "-in", in_path, "-out", out_path, + "-mode", "oidn_cpu"] + proc = subprocess.Popen( + cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + universal_newlines=True) + _out, err = proc.communicate() + if proc.returncode != 0: + raise RuntimeError("denoise failed: %s" % err.strip()) + return out_path diff --git a/blender_addon/tests/mock_exr.py b/blender_addon/tests/mock_exr.py new file mode 100644 index 0000000..bf88f77 --- /dev/null +++ b/blender_addon/tests/mock_exr.py @@ -0,0 +1,55 @@ +"""Minimal uncompressed RGBA EXR writer (pure python, no dependencies). + +Just enough for Blender's RenderLayer.load_from_file() to accept the file. +""" + +import struct + + +def write_exr(path, width, height, rgba_float_rows): + """rgba_float_rows: list of rows, each row = list of [r, g, b, a] floats. + Row 0 is the TOP scanline (y = height-1).""" + out = bytearray() + + # magic + version + out += struct.pack(" blue gradient + w, h = 64, 64 + rows = [] + for y in range(h): + row = [] + for x in range(w): + row.append([x / (w - 1), 0.2, 1.0 - x / (w - 1), 1.0]) + rows.append(row) + write_exr(out, w, h, rows) + print("Wrote", out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/blender_addon/tests/test_engine_mock.py b/blender_addon/tests/test_engine_mock.py new file mode 100644 index 0000000..ffd8903 --- /dev/null +++ b/blender_addon/tests/test_engine_mock.py @@ -0,0 +1,77 @@ +"""Full engine end-to-end test using the mock moonray binary. + +Validates: add-on enable -> render op -> exporter -> process launch -> +progress -> EXR load into Render Result -> Blender saves the PNG. + +Run: + /Applications/Blender.app/Contents/MacOS/Blender --background \ + --factory-startup --python blender_addon/tests/test_engine_mock.py -- +""" + +import os +import shutil +import sys +import tempfile + +import bpy + +HERE = os.path.dirname(os.path.abspath(__file__)) +ADDON_DIR = os.path.dirname(HERE) + + +def main(out_path): + tmp = tempfile.mkdtemp(prefix="moonray_engine_mock_") + pkg_dir = os.path.join(tmp, "moonray_blender") + shutil.copytree(ADDON_DIR, pkg_dir, + ignore=shutil.ignore_patterns("tests", "__pycache__")) + sys.path.insert(0, tmp) + bpy.ops.preferences.addon_enable(module="moonray_blender") + + # mock installation: bin/moonray = wrapper around mock_moonray.py + root = os.path.join(tmp, "mock_install") + bin_dir = os.path.join(root, "bin") + os.makedirs(bin_dir) + mock = os.path.join(bin_dir, "moonray") + with open(mock, "w") as f: + f.write("#!/usr/bin/env python3\n") + f.write("import sys\n") + f.write("sys.path.insert(0, %r)\n" % HERE) + f.write("from mock_moonray import main\n") + f.write("sys.exit(main())\n") + os.chmod(mock, 0o755) + # also fake the denoise binary so the denoise option can be exercised + with open(os.path.join(bin_dir, "denoise"), "w") as f: + f.write("#!/bin/sh\necho mock denoise\ncp \"$2\" \"$4\"\n") + os.chmod(os.path.join(bin_dir, "denoise"), 0o755) + + prefs = bpy.context.preferences.addons["moonray_blender"].preferences + prefs.moonray_root = root + + scene = bpy.context.scene + scene.render.engine = "MOONRAY_RENDER" + scene.render.resolution_x = 256 + scene.render.resolution_y = 128 + scene.render.resolution_percentage = 100 + scene.render.filepath = out_path + scene.render.image_settings.file_format = "PNG" + + settings = scene.moonray + settings.threads = 4 + settings.use_denoise = True + + bpy.ops.render.render(write_still=True) + + ok = os.path.isfile(out_path) and os.path.getsize(out_path) > 1000 + print("ENGINE E2E:", "OK" if ok else "MISSING", out_path, + os.path.getsize(out_path) if os.path.exists(out_path) else 0) + + bpy.ops.preferences.addon_disable(module="moonray_blender") + return 0 if ok else 1 + + +if __name__ == "__main__": + argv = sys.argv + out = None + if "--" in argv: + out = argv[argv.index("--") + 1] + sys.exit(main(out or "/tmp/moonray_engine_mock.png")) diff --git a/blender_addon/tests/test_export.py b/blender_addon/tests/test_export.py new file mode 100644 index 0000000..92e2a3d --- /dev/null +++ b/blender_addon/tests/test_export.py @@ -0,0 +1,81 @@ +"""Headless Blender test for the MoonRay add-on exporter. + +Run: + /Applications/Blender.app/Contents/MacOS/Blender --background \ + --factory-startup --python blender_addon/tests/test_export.py -- +""" + +import os +import sys + +import bpy + +# locate the add-on package next to this test +HERE = os.path.dirname(os.path.abspath(__file__)) +ADDON_DIR = os.path.dirname(HERE) +sys.path.insert(0, ADDON_DIR) + +import exporter # noqa: E402 + + +class FakePrefs: + light_scale = 1.0 + + +def main(out_path): + # start from a fresh scene: cube, sun, camera + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.render.resolution_x = 640 + scene.render.resolution_y = 480 + scene.render.resolution_percentage = 100 + + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + cube = bpy.context.object + if len(cube.data.uv_layers) == 0: + cube.data.uv_layers.new(name="UVMap") + bpy.ops.object.light_add(type="SUN", location=(5, 5, 5)) + sun = bpy.context.object + sun.data.energy = 5.0 + bpy.ops.object.camera_add(location=(6, -6, 4)) + cam = bpy.context.object + cam.rotation_euler = (1.1, 0, 0.8) + scene.camera = cam + + # material with emission + color + mat = bpy.data.materials.new("test_mat") + mat.use_nodes = True + principled = mat.node_tree.nodes["Principled BSDF"] + principled.inputs["Base Color"].default_value = (0.8, 0.2, 0.2, 1.0) + principled.inputs["Roughness"].default_value = 0.4 + cube.data.materials.append(mat) + + depsgraph = bpy.context.evaluated_depsgraph_get() + + # a minimal settings stand-in + class FakeSettings: + pixel_samples = 8 + min_adaptive_samples = 16 + max_adaptive_samples = 4096 + pixel_filter = "DEFAULT" + pixel_filter_width = 3.0 + use_progressive_tiles = False + + rdla = exporter.export_scene(scene, depsgraph, FakeSettings(), FakePrefs(), + out_path) + print("EXPORTED:", rdla) + print("BYTES:", os.path.getsize(rdla)) + with open(rdla) as f: + text = f.read() + print(text[:2000]) + return 0 + + +if __name__ == "__main__": + argv = sys.argv + out = None + if "--" in argv: + out = argv[argv.index("--") + 1] + if not out: + out = "/tmp/moonray_test_export.exr" + sys.exit(main(out)) diff --git a/blender_addon/tests/test_full_scene.py b/blender_addon/tests/test_full_scene.py new file mode 100644 index 0000000..9e42be3 --- /dev/null +++ b/blender_addon/tests/test_full_scene.py @@ -0,0 +1,151 @@ +"""Headless test covering all exporter paths: every light type, textured +Principled BSDF, emission, transparency, UVs, normals, DOF. + +Run: + /Applications/Blender.app/Contents/MacOS/Blender --background \ + --factory-startup --python blender_addon/tests/test_full_scene.py \ + -- +""" + +import os +import sys +import tempfile + +import bpy + +HERE = os.path.dirname(os.path.abspath(__file__)) +ADDON_DIR = os.path.dirname(HERE) +sys.path.insert(0, ADDON_DIR) + +import exporter # noqa: E402 + + +class FakePrefs: + light_scale = 1.0 + + +class FakeSettings: + pixel_samples = 8 + min_adaptive_samples = 16 + max_adaptive_samples = 4096 + pixel_filter = "DEFAULT" + pixel_filter_width = 3.0 + use_progressive_tiles = False + + +def main(out_path): + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.render.resolution_x = 512 + scene.render.resolution_y = 512 + scene.render.resolution_percentage = 100 + + # textured image on disk + tex_dir = tempfile.mkdtemp(prefix="moonray_tex_") + tex_path = os.path.join(tex_dir, "grid.png") + img = bpy.data.images.new("grid", width=64, height=64) + import math + px = [0.0] * (64 * 64 * 4) + for y in range(64): + for x in range(64): + v = 0.8 if ((x // 8) + (y // 8)) % 2 == 0 else 0.2 + i = (y * 64 + x) * 4 + px[i:i + 4] = [v, v, v, 1.0] + img.pixels[:] = px + img.filepath_raw = tex_path + img.file_format = "PNG" + img.save() + + # floor plane + bpy.ops.mesh.primitive_plane_add(size=20, location=(0, 0, 0)) + floor = bpy.context.object + mat = bpy.data.materials.new("floor_mat") + mat.use_nodes = True + tree = mat.node_tree + principled = tree.nodes["Principled BSDF"] + tex_node = tree.nodes.new(type="ShaderNodeTexImage") + tex_node.image = img + tree.links.new(tex_node.outputs["Color"], principled.inputs["Base Color"]) + principled.inputs["Roughness"].default_value = 0.6 + floor.data.materials.append(mat) + + # emissive sphere + bpy.ops.mesh.primitive_uv_sphere_add(location=(0, 0, 1)) + sphere = bpy.context.object + emat = bpy.data.materials.new("emit_mat") + emat.use_nodes = True + em_prin = emat.node_tree.nodes["Principled BSDF"] + em_prin.inputs["Emission Color"].default_value = (1, 0.5, 0.1, 1) + em_prin.inputs["Emission Strength"].default_value = 8.0 + sphere.data.materials.append(emat) + + # transparent cube + bpy.ops.mesh.primitive_cube_add(location=(-2, 1, 1)) + cube = bpy.context.object + tmat = bpy.data.materials.new("glass_mat") + tmat.use_nodes = True + t_prin = tmat.node_tree.nodes["Principled BSDF"] + t_prin.inputs["Transmission Weight"].default_value = 1.0 + t_prin.inputs["Alpha"].default_value = 0.5 + cube.data.materials.append(tmat) + + # one of each light type + bpy.ops.object.light_add(type="SUN", location=(5, 5, 8)) + sun = bpy.context.object + sun.data.energy = 2.0 + bpy.ops.object.light_add(type="POINT", location=(-3, -2, 3)) + pt = bpy.context.object + pt.data.energy = 100.0 + bpy.ops.object.light_add(type="SPOT", location=(4, -4, 4)) + spot = bpy.context.object + spot.data.energy = 200.0 + bpy.ops.object.light_add(type="AREA", location=(0, 3, 4)) + area = bpy.context.object + area.data.size = 4 + area.data.energy = 50.0 + + # camera with DOF + bpy.ops.object.camera_add(location=(6, -7, 4)) + cam = bpy.context.object + cam.rotation_euler = (1.15, 0, 0.7) + cam.data.lens = 35 + cam.data.dof.use_dof = True + cam.data.dof.aperture_fstop = 2.8 + cam.data.dof.focus_distance = 8.0 + scene.camera = cam + + depsgraph = bpy.context.evaluated_depsgraph_get() + rdla = exporter.export_scene(scene, depsgraph, FakeSettings(), FakePrefs(), + out_path) + text = open(rdla).read() + checks = { + "SphereLight": "SphereLight(" in text, + "DistantLight": "DistantLight(" in text, + "SpotLight": "SpotLight(" in text, + "RectLight": "RectLight(" in text, + "EnvLight": "EnvLight(" in text, + "ImageMap": "ImageMap(" in text, + "emission": '"emission"' in text, + "show_emission": '"show_emission"' in text, + "presence(alpha)": '"presence"' in text, + "transmission": '"transmission"' in text, + "uv_list": '"uv_list"' in text, + "normal_list": '"normal_list"' in text, + "dof": '["dof"] = true' in text, + "dof_aperture": '"dof_aperture"' in text, + "layer_entries": text.count("GeometrySet(") >= 3, + } + ok = True + for name, passed in checks.items(): + print("CHECK %-18s: %s" % (name, "OK" if passed else "MISSING")) + ok = ok and passed + print("RDLA:", rdla, "bytes:", os.path.getsize(rdla)) + return 0 if ok else 1 + + +if __name__ == "__main__": + argv = sys.argv + out = None + if "--" in argv: + out = argv[argv.index("--") + 1] + sys.exit(main(out or "/tmp/moonray_full_test.exr")) diff --git a/blender_addon/tests/test_materials.py b/blender_addon/tests/test_materials.py new file mode 100644 index 0000000..c86017d --- /dev/null +++ b/blender_addon/tests/test_materials.py @@ -0,0 +1,104 @@ +import os, sys, tempfile +import bpy +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(HERE)) +import exporter # noqa: E402 + +class FakePrefs: + light_scale = 1.0 +class FakeSettings: + pixel_samples = 8 + min_adaptive_samples = 16 + max_adaptive_samples = 4096 + pixel_filter = "DEFAULT" + pixel_filter_width = 3.0 + use_progressive_tiles = False + +bpy.ops.wm.read_factory_settings(use_empty=True) +scene = bpy.context.scene + +tex_dir = tempfile.mkdtemp(prefix="moonray_tex2_") +tex_path = os.path.join(tex_dir, "norm.png") +img = bpy.data.images.new("norm", width=16, height=16) +img.generated_color = (0.5, 0.5, 1.0, 1.0) +img.filepath_raw = tex_path +img.file_format = "PNG" +img.save() + +# --- sphere with normal-mapped principled --- +bpy.ops.mesh.primitive_uv_sphere_add(location=(0, 0, 1)) +sph = bpy.context.object +if len(sph.data.uv_layers) == 0: + sph.data.uv_layers.new() +m1 = bpy.data.materials.new("normal_mapped") +m1.use_nodes = True +t1 = m1.node_tree +prin = t1.nodes["Principled BSDF"] +img_node = t1.nodes.new("ShaderNodeTexImage") +img_node.image = img +nm = t1.nodes.new("ShaderNodeNormalMap") +nm.inputs["Strength"].default_value = 0.8 +t1.links.new(img_node.outputs["Color"], nm.inputs["Color"]) +t1.links.new(nm.outputs["Normal"], prin.inputs["Normal"]) +prin.inputs["Base Color"].default_value = (0.9, 0.1, 0.1, 1) +sph.data.materials.append(m1) + +# --- cube with Mix Shader (diffuse + glossy) --- +bpy.ops.mesh.primitive_cube_add(location=(2, 0, 1)) +cube = bpy.context.object +m2 = bpy.data.materials.new("mixed") +m2.use_nodes = True +t2 = m2.node_tree +out = next(n for n in t2.nodes if n.type == "OUTPUT_MATERIAL") +mix = t2.nodes.new("ShaderNodeMixShader") +diff = t2.nodes.new("ShaderNodeBsdfDiffuse") +gloss = t2.nodes.new("ShaderNodeBsdfGlossy") +diff.inputs["Color"].default_value = (0.1, 0.8, 0.2, 1) +gloss.inputs["Roughness"].default_value = 0.15 +mix.inputs["Fac"].default_value = 0.35 +t2.links.new(diff.outputs["BSDF"], mix.inputs[1]) +t2.links.new(gloss.outputs["BSDF"], mix.inputs[2]) +t2.links.new(mix.outputs["Shader"], out.inputs["Surface"]) +cube.data.materials.append(m2) + +# --- plane with static-baked color mix (MixRGB of two constants) --- +bpy.ops.mesh.primitive_plane_add(size=6, location=(0, -2, 0)) +plane = bpy.context.object +m3 = bpy.data.materials.new("baked_mix") +m3.use_nodes = True +t3 = m3.node_tree +p3 = t3.nodes["Principled BSDF"] +mixrgb = t3.nodes.new("ShaderNodeMix") +def _s(node, name, st): + return next((s for s in node.inputs if s.name == name and s.type == st), None) +_s(mixrgb, "A", "RGBA").default_value = (1.0, 0.0, 0.0, 1) +_s(mixrgb, "B", "RGBA").default_value = (0.0, 0.0, 1.0, 1) +_s(mixrgb, "Factor", "VALUE").default_value = 0.5 +res_sock = next(s for s in mixrgb.outputs if s.name == "Result" and s.type == "RGBA") +t3.links.new(res_sock, p3.inputs["Base Color"]) +plane.data.materials.append(m3) + +bpy.ops.object.camera_add(location=(6, -6, 4)) +cam = bpy.context.object +cam.rotation_euler = (1.2, 0, 0.8) +scene.camera = cam + +dg = bpy.context.evaluated_depsgraph_get() +rdla = exporter.export_scene(scene, dg, FakeSettings(), FakePrefs(), "/tmp/mat_test.exr") +text = open(rdla).read() +checks = { + "ImageNormalMap": "ImageNormalMap(" in text, + "input_normal bind": '["input_normal"] = bind(ImageNormalMap(' in text, + "normal dial 0.8": '["input_normal_dial"] = 0.8' in text, + "DwaMixMaterial": "DwaMixMaterial(" in text, + '["material"] ref': '["material"] = DwaBaseMaterial(' in text, + '["mix"] = 0.35': '["mix"] = 0.349' in text, + "static MixRGB baked 0.5,0,0.5": 'Rgb(0.5, 0, 0.5)' in text, + "glossy roughness": '["roughness"] = 0.15' in text, +} +ok = True +for k, v in checks.items(): + print("CHECK %-30s: %s" % (k, "OK" if v else "MISSING")) + ok = ok and v +print("RDLA bytes:", os.path.getsize(rdla)) +sys.exit(0 if ok else 1) diff --git a/blender_addon/tests/test_register.py b/blender_addon/tests/test_register.py new file mode 100644 index 0000000..f17b18f --- /dev/null +++ b/blender_addon/tests/test_register.py @@ -0,0 +1,64 @@ +"""Headless test: register the add-on, switch the render engine, export. + +Run: + /Applications/Blender.app/Contents/MacOS/Blender --background \ + --factory-startup --python blender_addon/tests/test_register.py +""" + +import os +import shutil +import sys +import tempfile + +import bpy + +HERE = os.path.dirname(os.path.abspath(__file__)) +ADDON_DIR = os.path.dirname(HERE) # .../blender_addon + +# install the package under its canonical module name +tmp = tempfile.mkdtemp(prefix="moonray_addon_test_") +pkg_dir = os.path.join(tmp, "moonray_blender") +shutil.copytree(ADDON_DIR, pkg_dir, ignore=shutil.ignore_patterns("tests", "__pycache__")) +sys.path.insert(0, tmp) + +import moonray_blender # noqa: E402 + + +def main(): + # enable through the official add-on flow (registers + creates prefs) + bpy.ops.preferences.addon_enable(module="moonray_blender") + print("ADDON ENABLED:", "moonray_blender" in + bpy.context.preferences.addons) + + scene = bpy.context.scene + scene.render.engine = "MOONRAY_RENDER" + print("ENGINE SET:", scene.render.engine) + + # preferences + prefs = bpy.context.preferences.addons["moonray_blender"].preferences + prefs.moonray_root = "/Applications/MoonRay/installs/openmoonray" + prefs.debug_keep_files = True + print("PREFS:", prefs.moonray_root) + + # default scene has a camera, cube and light already + settings = scene.moonray + settings.export_only = True + + scene.render.resolution_x = 512 + scene.render.resolution_y = 512 + scene.render.resolution_percentage = 100 + scene.render.filepath = os.path.join(tmp, "out.exr") + + # render() with export_only writes the rdla next to the target path + # (engine passes its own temp path, so call the operator instead) + bpy.ops.moonray.export_scene(filepath=os.path.join(tmp, "test_scene.exr")) + rdla = os.path.join(tmp, "test_scene.rdla") + print("EXPORTED:", os.path.exists(rdla), os.path.getsize(rdla)) + + bpy.ops.preferences.addon_disable(module="moonray_blender") + print("UNREGISTER OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/blender_addon/tests/test_render.py b/blender_addon/tests/test_render.py new file mode 100644 index 0000000..1a9756d --- /dev/null +++ b/blender_addon/tests/test_render.py @@ -0,0 +1,83 @@ +"""End-to-end test: enable add-on, render a Blender scene with MoonRay. + +Requires a working MoonRay installation (set MOONRAY_ROOT env or edit below). + +Run: + /Applications/Blender.app/Contents/MacOS/Blender --background \ + --factory-startup --python blender_addon/tests/test_render.py -- +""" + +import os +import shutil +import sys +import tempfile + +import bpy + +HERE = os.path.dirname(os.path.abspath(__file__)) +ADDON_DIR = os.path.dirname(HERE) + +INSTALLS_ROOT = os.environ.get( + "MOONRAY_INSTALLS", + "/Users/faputa/Documents/wave-tracer/installs") +MOONRAY_ROOT = os.environ.get( + "MOONRAY_ROOT", + os.path.join(INSTALLS_ROOT, "openmoonray")) + + +def main(out_path): + # install under canonical module name and enable through the add-on flow + tmp = tempfile.mkdtemp(prefix="moonray_render_test_") + pkg_dir = os.path.join(tmp, "moonray_blender") + shutil.copytree(ADDON_DIR, pkg_dir, + ignore=shutil.ignore_patterns("tests", "__pycache__")) + sys.path.insert(0, tmp) + bpy.ops.preferences.addon_enable(module="moonray_blender") + + prefs = bpy.context.preferences.addons["moonray_blender"].preferences + prefs.moonray_root = MOONRAY_ROOT + prefs.installs_root = INSTALLS_ROOT + prefs.debug_keep_files = False + print("MOONRAY_ROOT:", MOONRAY_ROOT) + print("BIN EXISTS:", os.path.isfile(os.path.join(MOONRAY_ROOT, "bin", "moonray"))) + + # build a small scene + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.render.engine = "MOONRAY_RENDER" + scene.render.resolution_x = 480 + scene.render.resolution_y = 270 + scene.render.resolution_percentage = 100 + scene.render.filepath = out_path + scene.render.image_settings.file_format = "PNG" + + bpy.ops.mesh.primitive_uv_sphere_add(location=(0, 0, 1)) + bpy.ops.object.light_add(type="SUN", location=(5, 5, 8)) + bpy.context.object.data.energy = 3.0 + bpy.ops.object.camera_add(location=(5, -5, 3)) + cam = bpy.context.object + cam.rotation_euler = (1.2, 0, 0.8) + scene.camera = cam + + settings = scene.moonray + settings.pixel_samples = 6 + settings.max_adaptive_samples = 64 + settings.threads = 8 + + # render + bpy.ops.render.render(write_still=True) + + ok = os.path.isfile(out_path) and os.path.getsize(out_path) > 1000 + print("RENDER RESULT:", "OK" if ok else "MISSING", + out_path, os.path.getsize(out_path) if os.path.exists(out_path) else 0) + + bpy.ops.preferences.addon_disable(module="moonray_blender") + return 0 if ok else 1 + + +if __name__ == "__main__": + argv = sys.argv + out = None + if "--" in argv: + out = argv[argv.index("--") + 1] + sys.exit(main(out or "/tmp/moonray_blender_render.png")) diff --git a/blender_addon/tests/test_renderer.py b/blender_addon/tests/test_renderer.py new file mode 100644 index 0000000..7433004 --- /dev/null +++ b/blender_addon/tests/test_renderer.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Unit test for renderer.py process plumbing using the mock moonray binary. + +Run with the system python3 (renderer.py has no bpy dependency): + python3 blender_addon/tests/test_renderer.py +""" + +import os +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +ADDON_DIR = os.path.dirname(HERE) +sys.path.insert(0, ADDON_DIR) + +from renderer import MoonRayProcess # noqa: E402 + + +def main(): + tmp = tempfile.mkdtemp(prefix="moonray_mock_") + bin_dir = os.path.join(tmp, "bin") + os.makedirs(bin_dir) + mock = os.path.join(bin_dir, "moonray") + with open(mock, "w") as f: + f.write("#!/usr/bin/env python3\n") + f.write("import sys\n") + f.write("sys.path.insert(0, %r)\n" % os.path.join(ADDON_DIR, "tests")) + f.write("from mock_moonray import main\n") + f.write("sys.exit(main())\n") + os.chmod(mock, 0o755) + + out_path = os.path.join(tmp, "render.exr") + proc = MoonRayProcess(tmp, "") + progress = [] + + proc.launch(["-in", "scene.rdla", "-out", out_path], + progress_cb=lambda p: progress.append(p)) + rc = proc.wait() + print("exit code:", rc) + print("progress updates:", len(progress), + "last:", progress[-1] if progress else None) + print("monotonic:", progress == sorted(progress) and progress) + print("output written:", os.path.isfile(out_path), + os.path.getsize(out_path) if os.path.isfile(out_path) else 0) + ok = (rc == 0 and progress and progress[-1] == 100 + and os.path.isfile(out_path)) + print("RESULT:", "OK" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/blender_addon/ui.py b/blender_addon/ui.py new file mode 100644 index 0000000..3b1831f --- /dev/null +++ b/blender_addon/ui.py @@ -0,0 +1,64 @@ +"""Render panel UI for the MoonRay add-on.""" + +import bpy + + +class MOONRAY_PT_render_panel(bpy.types.Panel): + bl_label = "MoonRay" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "render" + COMPAT_ENGINES = {"MOONRAY_RENDER"} + + @classmethod + def poll(cls, context): + return context.engine in cls.COMPAT_ENGINES + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False + + settings = context.scene.moonray + + col = layout.column(align=True) + col.prop(settings, "pixel_samples") + col.prop(settings, "min_adaptive_samples") + col.prop(settings, "max_adaptive_samples") + + layout.separator() + layout.prop(settings, "threads") + layout.prop(settings, "use_progressive_tiles") + + layout.separator() + col = layout.column(align=True) + col.prop(settings, "pixel_filter") + if settings.pixel_filter != "DEFAULT": + col.prop(settings, "pixel_filter_width") + + layout.separator() + layout.prop(settings, "use_denoise") + + layout.separator() + col = layout.column(align=True) + col.prop(settings, "keep_rdla") + if settings.keep_rdla: + col.prop(settings, "rdla_path") + col.prop(settings, "export_only") + + layout.separator() + layout.operator("moonray.export_scene", text="Export .rdla Scene") + + layout.separator() + row = layout.row(align=True) + row.scale_y = 1.6 + row.operator("moonray.render", text="Render Image", + icon="RENDER_STILL") + + +def register(): + pass + + +def unregister(): + pass diff --git a/build_moonray.sh b/build_moonray.sh new file mode 100755 index 0000000..aca0a2d --- /dev/null +++ b/build_moonray.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Configure and build MoonRay itself (after the dependency superbuild). +# Usage: ./build_moonray.sh +set -uo pipefail + +WORKSPACE="$(cd "$(dirname "$0")" && pwd)" +OPENMOONRAY="$WORKSPACE/openmoonray" +LOG="$WORKSPACE/build/main_build.log" +mkdir -p "$WORKSPACE/build" + +cd "$OPENMOONRAY" + +echo "== Configure (macos-release-ninja) ==" +cmake --preset macos-release-ninja 2>&1 | tee -a "$LOG" +if [ ${PIPESTATUS[0]} -ne 0 ]; then + echo "CONFIGURE FAILED - see $LOG" + exit 1 +fi + +echo "== Build ==" +cmake --build --preset macos-release-ninja 2>&1 | tee -a "$LOG" +if [ ${PIPESTATUS[0]} -ne 0 ]; then + echo "BUILD FAILED - see $LOG" + exit 1 +fi + +echo +echo "BUILD COMPLETE. Run ./verify_moonray.sh next." diff --git a/finish_build_and_test.sh b/finish_build_and_test.sh new file mode 100755 index 0000000..c3c56aa --- /dev/null +++ b/finish_build_and_test.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# One-shot: wait for the dependency superbuild, build MoonRay, verify it, +# and run the Blender end-to-end render test. +# Usage: ./finish_build_and_test.sh +set -uo pipefail + +WORKSPACE="$(cd "$(dirname "$0")" && pwd)" +DEPS_LOG="$WORKSPACE/../build-deps/deps_build.log" # workspace = .../wave-tracer/moonray +DEPS_DIR="$(cd "$WORKSPACE/.." && pwd)/build-deps" + +echo "== 1/4 Waiting for dependency superbuild ==" +# The superbuild runs via `cmake --build .` in $DEPS_DIR; poll for the +# stamp-free end state: all ExternalProject stamps done. Simplest robust +# check: the build process must not be running AND the log must end with +# an install of the last dep (GLFW). +while pgrep -f "cmake --build ." >/dev/null 2>&1 || pgrep -f "$DEPS_DIR" >/dev/null 2>&1; do + sleep 30 +done +if ! grep -q "Performing install step for 'GLFW'" "$DEPS_LOG"; then + echo "Dependency build did not complete successfully. Tail of log:" + tail -20 "$DEPS_LOG" + exit 1 +fi +echo "Dependencies built." + +echo +echo "== 2/4 Building MoonRay ==" +"$WORKSPACE/build_moonray.sh" || exit 1 + +echo +echo "== 3/4 Verifying install (official sphere test scene) ==" +"$WORKSPACE/verify_moonray.sh" || exit 1 + +echo +echo "== 4/4 Blender end-to-end render test ==" +/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \ + --python "$WORKSPACE/blender_addon/tests/test_render.py" -- \ + /tmp/moonray_blender_render.png +RC=$? +if [ $RC -eq 0 ]; then + echo "E2E OK: /tmp/moonray_blender_render.png" +else + echo "E2E FAILED (exit $RC)" + exit 1 +fi + +echo +echo "ALL DONE. Enable the add-on in Blender:" +echo " ./install_addon.sh" +echo " Edit > Preferences > Add-ons > Render > MoonRay Render" diff --git a/install_addon.sh b/install_addon.sh new file mode 100755 index 0000000..f29a5d4 --- /dev/null +++ b/install_addon.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Install (symlink) the MoonRay add-on into Blender's add-ons directory. +# Usage: ./install_addon.sh [blender-version] (default: detected 5.2) +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ADDON_NAME="moonray_blender" +ADDON_SRC="$HERE/blender_addon" + +# Detect Blender version +BLENDER_BIN="/Applications/Blender.app/Contents/MacOS/Blender" +if [ ! -x "$BLENDER_BIN" ]; then + echo "Blender not found at $BLENDER_BIN" + exit 1 +fi +VER="$("$BLENDER_BIN" --version | head -1 | awk '{print $2}')" +MAJOR_MINOR="$(echo "$VER" | cut -d. -f1-2)" + +TARGET_DIR="$HOME/Library/Application Support/Blender/$MAJOR_MINOR/scripts/addons/$ADDON_NAME" +mkdir -p "$(dirname "$TARGET_DIR")" +rm -rf "$TARGET_DIR" +ln -s "$ADDON_SRC" "$TARGET_DIR" + +echo "Installed MoonRay add-on for Blender $MAJOR_MINOR:" +echo " $TARGET_DIR -> $ADDON_SRC" +echo +echo "Enable it in Blender: Edit > Preferences > Add-ons > Render > MoonRay Render" diff --git a/patches/CMakeUserPresets.json b/patches/CMakeUserPresets.json new file mode 100644 index 0000000..0f16dfb --- /dev/null +++ b/patches/CMakeUserPresets.json @@ -0,0 +1,23 @@ +{ + "version": 4, + "configurePresets": [ + { + "name": "macos-release-ninja", + "displayName": "macOS Release (Ninja, no Qt)", + "inherits": "macos-release", + "generator": "Ninja", + "cacheVariables": { + "BUILD_QT_APPS": "NO", + "BUILD_TESTING": "OFF" + } + } + ], + "buildPresets": [ + { + "name": "macos-release-ninja", + "displayName": "macOS Release (Ninja, no Qt)", + "configurePreset": "macos-release-ninja", + "jobs": 6 + } + ] +} diff --git a/patches/openmoonray-building-macOS.patch b/patches/openmoonray-building-macOS.patch new file mode 100644 index 0000000..61fd826 --- /dev/null +++ b/patches/openmoonray-building-macOS.patch @@ -0,0 +1,193 @@ +diff --git a/building/macOS/CMakeLists.txt b/building/macOS/CMakeLists.txt +index 00085e2..7919cde 100644 +--- a/building/macOS/CMakeLists.txt ++++ b/building/macOS/CMakeLists.txt +@@ -26,11 +26,19 @@ if(CMAKE_VERSION VERSION_GREATER_EQUAL 4.0) + set(POLICY_MIN_ENV CMAKE_POLICY_VERSION_MINIMUM=3.5) + endif() + ++# moonray-blender-patch: cap parallel jobs (24GB RAM machines OOM at -j ++# on USD/Boost), and allow skipping the Qt5 dependency build when no Qt apps ++# are needed (BUILD_QT_APPS=NO on the main build). ++set(MAX_BUILD_JOBS 6 CACHE STRING "Maximum parallel build jobs per dependency") + include(ProcessorCount) + ProcessorCount(N) + if(NOT N EQUAL 0) ++ if(N GREATER MAX_BUILD_JOBS) ++ set(N ${MAX_BUILD_JOBS}) ++ endif() + set(JOBS_ARG -j${N}) + endif() ++option(SKIP_QT "Skip building Qt5 (only needed for moonray_gui)" OFF) + + file(REAL_PATH ${CMAKE_SOURCE_DIR} rootSrcDir) + set(THIS_DIR ${CMAKE_CURRENT_SOURCE_DIR}) +@@ -53,6 +61,8 @@ set(COMMON_CMAKE_ARGS + + ExternalProject_Add(Blosc + GIT_REPOSITORY https://github.com/Blosc/c-blosc ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG 616f4b7 # 1.21.6 # macOS 26 Tahoe + BUILD_COMMAND make ${JOBS_ARG} + CMAKE_ARGS +@@ -76,6 +86,8 @@ set(CHAIN Boost) + + ExternalProject_Add(JsonCpp + GIT_REPOSITORY https://github.com/open-source-parsers/jsoncpp.git ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG 5defb4ed1a4293b8e2bf641e16b156fb9de498cc # 1.9.5 + CMAKE_ARGS + ${COMMON_CMAKE_ARGS} +@@ -112,6 +124,8 @@ set(CHAIN MicroHttpd) + + ExternalProject_Add(OpenSubdiv + GIT_REPOSITORY https://github.com/PixarAnimationStudios/OpenSubdiv ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG 8ffa2b6566be10209529d7a0d1db02a0796b160c # v3_5_0 + BUILD_COMMAND make ${JOBS_ARG} + CMAKE_ARGS +@@ -125,6 +139,8 @@ set(CHAIN OpenSubdiv) + + ExternalProject_Add(OpenEXR + GIT_REPOSITORY https://github.com/AcademySoftwareFoundation/openexr ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG 8bc3741131db146ad08a5b83af9e6e48f0e94a03 # v2.5.7 + PATCH_COMMAND patch IlmBase/Half/CMakeLists.txt ${THIS_DIR}/../Imath_include_paths.patch + BUILD_COMMAND make ${JOBS_ARG} +@@ -150,6 +166,8 @@ set(CHAIN TBB) + + ExternalProject_Add(OpenVDB + GIT_REPOSITORY https://github.com/AcademySoftwareFoundation/openvdb ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG ab935574cdb25c3df66b068fce2a3b0a74281c54 # v9.1.0 + PATCH_COMMAND patch -p1 -N < ${THIS_DIR}/OpenVDB.patch || true + BUILD_COMMAND make ${JOBS_ARG} +@@ -164,6 +182,8 @@ set(CHAIN OpenVDB) + + ExternalProject_Add(Log4CPlus + GIT_REPOSITORY https://github.com/log4cplus/log4cplus ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG REL_2_0_5 + PATCH_COMMAND patch -p1 -N < ${THIS_DIR}/log4plus-limit-threads.patch || true + # prevent make from regenerating autotools files (requires automake), as for CppUnit below +@@ -190,6 +210,8 @@ set(CHAIN CppUnit) + + ExternalProject_Add(Random123 + GIT_REPOSITORY https://github.com/DEShawResearch/random123 ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG 726a093cd9a73f3ec3c8d7a70ff10ed8efec8d13 # v1.14.0 + BUILD_IN_SOURCE 1 + CONFIGURE_COMMAND "" +@@ -212,6 +234,8 @@ set(CHAIN ISPC) + + ExternalProject_Add(embree + GIT_REPOSITORY https://github.com/embree/embree ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG 341ef8c45d1ae072ead1ab65cd76e88b03d9302c # v4.2.0 + PATCH_COMMAND patch -p1 -N < ${THIS_DIR}/Embree.patch || true + BUILD_IN_SOURCE 1 +@@ -233,6 +257,8 @@ set(CHAIN embree) + + ExternalProject_Add(OpenColorIO + GIT_REPOSITORY https://github.com/AcademySoftwareFoundation/OpenColorIO ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG 056b7b0cb0d087961e9dba75104820e44faf52a1 # v2.0.2 + BUILD_COMMAND ${CMAKE_COMMAND} -E env ${POLICY_MIN_ENV} make ${JOBS_ARG} + CMAKE_ARGS +@@ -263,6 +289,8 @@ set(CHAIN TIFF) + + ExternalProject_Add(JPEGTurbo + GIT_REPOSITORY https://github.com/libjpeg-turbo/libjpeg-turbo ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG bb3d325624526c91646bb9af9578d7198c082d51 # 2.0.1 + BUILD_COMMAND make ${JOBS_ARG} + CMAKE_ARGS +@@ -274,6 +302,8 @@ set(CHAIN JPEGTurbo) + + ExternalProject_Add(pybind11 + GIT_REPOSITORY https://github.com/pybind/pybind11 ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG a2e59f0e7065404b44dfe92a28aca47ba1378dc4 # v2.13.6 + BUILD_COMMAND make ${JOBS_ARG} + CMAKE_ARGS +@@ -286,6 +316,8 @@ set(CHAIN pybind11) + + ExternalProject_Add(OpenImageIO + GIT_REPOSITORY https://github.com/OpenImageIO/oiio ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG 331a323468928c8017ad048b26d47c4e57a724a7 # 2.3.20.0 + BUILD_COMMAND make ${JOBS_ARG} + CMAKE_ARGS +@@ -313,24 +345,28 @@ ExternalProject_Add(OpenImageDenoise + ) + set(CHAIN OpenImageDenoise) + +-ExternalProject_Add(qt5 +- GIT_REPOSITORY https://code.qt.io/qt/qt5.git +- GIT_SUBMODULES_RECURSE false +- GIT_SHALLOW true +- GIT_TAG 5bd237e89469a032ac9d4d33fcd3896897d6d245 # 5.12.12 +- GIT_SUBMODULES qtbase qtscript +- PATCH_COMMAND patch -Ni ${THIS_DIR}/Qt5.patch || true +- CONFIGURE_COMMAND ./configure -prefix ${InstallRoot} -confirm-license -opensource -no-egl -nomake examples -nomake tests QMAKE_APPLE_DEVICE_ARCHS=arm64 +- BUILD_COMMAND make ${JOBS_ARG} +- BUILD_IN_SOURCE 1 +- INSTALL_COMMAND make install +- DEPENDS ${CHAIN} +-) +-set(CHAIN qt5) ++if(NOT SKIP_QT) ++ ExternalProject_Add(qt5 ++ GIT_REPOSITORY https://code.qt.io/qt/qt5.git ++ GIT_SUBMODULES_RECURSE false ++ GIT_SHALLOW true ++ GIT_TAG 5bd237e89469a032ac9d4d33fcd3896897d6d245 # 5.12.12 ++ GIT_SUBMODULES qtbase qtscript ++ PATCH_COMMAND patch -Ni ${THIS_DIR}/Qt5.patch || true ++ CONFIGURE_COMMAND ./configure -prefix ${InstallRoot} -confirm-license -opensource -no-egl -nomake examples -nomake tests QMAKE_APPLE_DEVICE_ARCHS=arm64 ++ BUILD_COMMAND make ${JOBS_ARG} ++ BUILD_IN_SOURCE 1 ++ INSTALL_COMMAND make install ++ DEPENDS ${CHAIN} ++ ) ++ set(CHAIN qt5) ++endif() + + if(NOT NO_USD) + ExternalProject_Add(USD + GIT_REPOSITORY https://github.com/PixarAnimationStudios/USD ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG 0c7b9a95f155c221ff7df9270a39a52e3b23af8b # v22.11 + PATCH_COMMAND pwd && patch -Ni ${THIS_DIR}/USD.patch || true + BUILD_COMMAND make ${JOBS_ARG} +@@ -379,6 +415,8 @@ set(CHAIN libuuid) + + ExternalProject_Add(OpenSSL + GIT_REPOSITORY https://github.com/openssl/openssl.git ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + # GIT_TAG a92271e03a8d0dee507b6f1e7f49512568b2c7ad # 3.1.0 - contains a bug on Apple Silicon + GIT_TAG 31157bc0b46e04227b8468d3e6915e4d0332777c # 3.0.8 + CONFIGURE_COMMAND ./Configure darwin64-arm64 --prefix=${InstallRoot} --openssldir=${InstallRoot} -rpath ${InstallRoot}/lib +@@ -420,6 +458,8 @@ set(CHAIN FreeType) + + ExternalProject_Add(GLFW + GIT_REPOSITORY https://github.com/glfw/glfw ++ GIT_SHALLOW TRUE ++ GIT_PROGRESS TRUE + GIT_TAG 3.4 + BUILD_COMMAND make ${JOBS_ARG} + CMAKE_ARGS diff --git a/verify_moonray.sh b/verify_moonray.sh new file mode 100755 index 0000000..8913e61 --- /dev/null +++ b/verify_moonray.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Verify a MoonRay macOS build: checks the install layout and renders the +# official sphere test scene. +# Usage: ./verify_moonray.sh [installs_root] +set -uo pipefail + +INSTALLS_ROOT="${1:-/Users/faputa/Documents/wave-tracer/installs}" +MOONRAY_ROOT="$INSTALLS_ROOT/openmoonray" +TESTDATA_DIR="$(cd "$(dirname "$0")" && pwd)/openmoonray/testdata" + +echo "== Install layout ==" +for p in \ + "$MOONRAY_ROOT/bin/moonray" \ + "$MOONRAY_ROOT/rdl2dso" \ + "$MOONRAY_ROOT/sessions" \ + "$INSTALLS_ROOT/lib" \ + "$MOONRAY_ROOT/lib"; do + if [ -e "$p" ]; then + echo "OK $p" + else + echo "MISS $p" + fi +done + +if [ ! -x "$MOONRAY_ROOT/bin/moonray" ]; then + echo "FATAL: moonray binary not found - build incomplete?" + exit 1 +fi + +echo +echo "== Environment ==" +export PATH="$MOONRAY_ROOT/bin:$PATH" +export RDL2_DSO_PATH="$MOONRAY_ROOT/rdl2dso" +export REZ_MOONRAY_ROOT="$MOONRAY_ROOT" +export ARRAS_SESSION_PATH="$MOONRAY_ROOT/sessions" +export MOONRAY_CLASS_PATH="$MOONRAY_ROOT/shader_json" +export PXR_PLUGINPATH_NAME="$MOONRAY_ROOT/plugin/pxr" +export PXR_PLUGIN_PATH="$MOONRAY_ROOT/plugin/pxr" +export PYTHONPATH="$INSTALLS_ROOT/lib/python:$INSTALLS_ROOT/lib64/python3.9/site-packages:$MOONRAY_ROOT/lib/python:${PYTHONPATH:-}" +export DYLD_LIBRARY_PATH="$INSTALLS_ROOT/lib:$MOONRAY_ROOT/lib:${DYLD_LIBRARY_PATH:-}" + +echo +echo "== moonray --help (sanity) ==" +"$MOONRAY_ROOT/bin/moonray" -help 2>&1 | head -8 || true + +echo +echo "== Render sphere.rdla ==" +WORK=$(mktemp -d) +cp "$TESTDATA_DIR/sphere.rdla" "$WORK/scene.rdla" + +time "$MOONRAY_ROOT/bin/moonray" -in "$WORK/scene.rdla" \ + -out "$WORK/sphere.exr" -threads 8 2>&1 | tail -6 +RC=$? +echo "render exit code: $RC" +if [ $RC -eq 0 ] && [ -f "$WORK/sphere.exr" ]; then + echo "RENDER OK -> $WORK/sphere.exr" + exit 0 +else + echo "RENDER FAILED (logs above)" + exit 1 +fi From a7954b8252b8fb197823802d87cf1bc6a80c613d Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:00:04 +0800 Subject: [PATCH 02/25] Add procedural noise support to the material node compiler - ShaderNodeTexNoise now exports as MoonRay NoiseMap_v2 (color mode) with scale/detail/distortion/seed mapped from the Blender node. - Value model extended with generic map references so more procedural nodes can be added the same way. - test_materials.py: noise-driven material coverage (9 checks). --- blender_addon/materials.py | 35 ++++++++++++++++++++++----- blender_addon/tests/test_materials.py | 14 +++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/blender_addon/materials.py b/blender_addon/materials.py index a0f4aa7..b06d5bd 100644 --- a/blender_addon/materials.py +++ b/blender_addon/materials.py @@ -34,6 +34,7 @@ _FLOAT = "float" _IMG = "img" # ("img", image, colorspace) _NORMAL = "normal" # ("normal", image, strength) +_MAP = "map" # ("map", rdl2_class, {attr: expr}) _NONE = "none" @@ -117,11 +118,7 @@ def eval_socket(self, sock): value = self.eval_node(node) if value is None: return None - if value[0] == _RGB: - return value - if value[0] == _FLOAT: - return value - if value[0] == _IMG: + if value[0] in (_RGB, _FLOAT, _IMG, _MAP): return value return None @@ -141,6 +138,8 @@ def eval_node(self, node): value = _const_float(node.outputs["Value"].default_value) elif ntype == "TEX_IMAGE": value = _texture_image_value(node) + elif ntype == "TEX_NOISE": + value = self._eval_noise(node) elif ntype == "MATH": value = self._eval_math(node) elif ntype == "MIX": @@ -189,6 +188,22 @@ def _f(self, sock): return v[1] return None + def _eval_noise(self, node): + """ShaderNodeTexNoise -> NoiseMap_v2 (grayscale, color mode).""" + scale = self._f(node.inputs.get("Scale")) or 1.0 + detail = self._f(node.inputs.get("Detail")) or 1.0 + distortion = self._f(node.inputs.get("Distortion")) or 0.0 + seed = int(self._f(node.inputs.get("W")) or 0.0) + return (_MAP, "NoiseMap_v2", { + "color": "true", + "color_A": fmt_rgb((0.0, 0.0, 0.0)), + "color_B": fmt_rgb((1.0, 1.0, 1.0)), + "frequency_multiplier": "%.9g" % max(0.001, scale), + "max_level": "%.9g" % max(1.0, detail), + "distortion": "%.9g" % max(0.0, distortion), + "seed": str(seed), + }) + def _eval_math(self, node): op = node.operation fn = _MATH_OPS.get(op) @@ -361,6 +376,14 @@ def _resolve_rgb(self, value, fallback=(1.0, 1.0, 1.0)): if kind == _IMG: name = self._emit_image_map(value[1]) return 'bind(ImageMap("%s"))' % name, True + if kind == _MAP: + cls, attrs = value[1], value[2] + name = self._unique("procmap") + self.exporter.block('%s("%s")' % (cls, name)) + for attr, expr in attrs.items(): + self.exporter.out('["%s"] = %s,' % (attr, expr)) + self.exporter.end_block() + return 'bind(%s("%s"))' % (cls, name), True return fmt_rgb(fallback), False def _resolve_float(self, value, fallback=0.0): @@ -412,7 +435,7 @@ def _principled_params(self, node): em_c = ev.eval_socket(node.inputs["Emission Color"]) em_s = ev.eval_socket(node.inputs["Emission Strength"]) - params["emission"] = em_c if em_c and em_c[0] in (_RGB, _IMG) else None + params["emission"] = em_c if em_c and em_c[0] in (_RGB, _IMG, _MAP) else None params["emission_strength"] = self._resolve_float(em_s, 0.0) # normal input diff --git a/blender_addon/tests/test_materials.py b/blender_addon/tests/test_materials.py index c86017d..b5b8c61 100644 --- a/blender_addon/tests/test_materials.py +++ b/blender_addon/tests/test_materials.py @@ -78,6 +78,19 @@ def _s(node, name, st): t3.links.new(res_sock, p3.inputs["Base Color"]) plane.data.materials.append(m3) +# --- torus with noise-driven base color --- +bpy.ops.mesh.primitive_torus_add(location=(-2, 2, 1)) +torus = bpy.context.object +m4 = bpy.data.materials.new("noisy") +m4.use_nodes = True +t4 = m4.node_tree +p4 = t4.nodes["Principled BSDF"] +noise = t4.nodes.new("ShaderNodeTexNoise") +noise.inputs["Scale"].default_value = 4.0 +noise.inputs["Detail"].default_value = 6.0 +t4.links.new(noise.outputs["Color"], p4.inputs["Base Color"]) +torus.data.materials.append(m4) + bpy.ops.object.camera_add(location=(6, -6, 4)) cam = bpy.context.object cam.rotation_euler = (1.2, 0, 0.8) @@ -95,6 +108,7 @@ def _s(node, name, st): '["mix"] = 0.35': '["mix"] = 0.349' in text, "static MixRGB baked 0.5,0,0.5": 'Rgb(0.5, 0, 0.5)' in text, "glossy roughness": '["roughness"] = 0.15' in text, + "NoiseMap_v2": "NoiseMap_v2(" in text, } ok = True for k, v in checks.items(): From 16d16dbacd70680a5f0a74a9ad043a231b16c61f Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:01:39 +0800 Subject: [PATCH 03/25] Add HDRI environment support and update docs - World Environment Texture nodes now export as EnvLight with a texture (HDRI lighting), alongside the constant-color fallback. - test_full_scene.py: env_texture check (16 checks total). - README: full feature matrix, usage, and test instructions. --- blender_addon/README.md | 67 ++++++++++++++++++-------- blender_addon/exporter.py | 26 +++++++--- blender_addon/tests/test_full_scene.py | 12 +++++ 3 files changed, 78 insertions(+), 27 deletions(-) diff --git a/blender_addon/README.md b/blender_addon/README.md index 55afdc5..19349e8 100644 --- a/blender_addon/README.md +++ b/blender_addon/README.md @@ -6,7 +6,7 @@ production path tracer (DreamWorks / Academy Software Foundation). The add-on registers **MoonRay** as a render engine in Blender: 1. exports the Blender scene to MoonRay's RDLA scene format - (meshes, UVs, normals, materials, lights, camera, world), + (meshes, UVs, normals, instancing, materials, lights, camera, world), 2. runs the `moonray` command-line renderer, 3. loads the result back into the Render Result (F12 / animation rendering), 4. optionally denoises with MoonRay's OIDN `denoise` tool. @@ -17,29 +17,31 @@ The add-on registers **MoonRay** as a render engine in Blender: official `macos-release` CMake preset (see `openmoonray/building/macOS`). Linux installations (Rocky Linux 9) should work as well; the add-on itself only shells out to the `moonray` binary. -- Blender 4.0 or newer. +- Blender 4.0 or newer (tested with Blender 5.2 alpha). ## Installation -Set the MoonRay installation path in -*Edit → Preferences → Add-ons → MoonRay Render*: - -- **MoonRay Installation** — the directory containing `bin/moonray` - (e.g. `/Users//Documents/wave-tracer/installs/openmoonray`) -- **Dependencies Install Root** — the directory containing the third-party - `lib/` used by MoonRay (e.g. `/Users//Documents/wave-tracer/installs`) - -The auto-detection default looks next to this add-on's source tree -(`/../installs/openmoonray`). +1. `./install_addon.sh` (symlinks the add-on into Blender's add-ons folder) +2. In Blender: *Edit → Preferences → Add-ons → Render → MoonRay Render*, + enable it and set: + - **MoonRay Installation** — the directory containing `bin/moonray` + (e.g. `/Users//Documents/wave-tracer/installs/openmoonray`) + - **Dependencies Install Root** — the directory containing the + third-party `lib/` (e.g. `/Users//Documents/wave-tracer/installs`) ## Usage 1. Switch the render engine to **MoonRay** in *Render Properties*. 2. Tune samples (MoonRay `pixel_samples` is the square root of the spp), threads, denoise, etc. in the *MoonRay* panel. -3. Press F12. The scene is exported to a temporary `.rdla`, rendered, and the - EXR is loaded into the Render Result. Animation rendering (Ctrl+F12) is - supported frame by frame. +3. Render with the **Render Image** button in the panel, the regular + *Render → Render Image* menu item, or F12. The scene is exported to a + temporary `.rdla`, rendered, and the EXR is loaded into the Render + Result. Animation rendering (Ctrl+F12) is supported frame by frame. + +The intermediate `.rdla` scene file is deleted automatically after the +render; enable **Save RDLA Scene** in the panel to keep it (next to the +render output or at a custom path). ## Supported Blender features @@ -48,11 +50,14 @@ The auto-detection default looks next to this add-on's source tree | Meshes (quads/ngons, triangulated) | ✔ with UVs and split normals | | Curves/surfaces/text (via to_mesh) | ✔ | | Instancing (linked duplicates) | ✔ exported as RdlInstancerGeometry | -| Principled BSDF | base color (+ image texture), roughness, metallic, specular, transmission, emission, alpha | +| Shader nodes | ✔ Principled / Diffuse / Glossy / Glass / Transparent / Emission / Mix Shader / Add Shader | +| Color/scalar nodes | ✔ static baking: Mix, Math, Gamma, Bright/Contrast, Hue/Sat, Invert, RGB→BW, ColorRamp, Map Range, Clamp | +| Textures | ✔ image textures (ImageMap) + procedural noise (NoiseMap_v2) | +| Normal maps | ✔ ImageNormalMap via the Normal Map node | | Point / Sun / Spot / Area lights | ✔ with energy-based intensity mapping | -| World background | constant color from the Background node | +| World background | ✔ constant color or HDRI (Environment Texture node) | | Depth of field | ✔ (camera DOF settings) | -| Motion blur, volumetrics, HDRI environments | not yet | +| Motion blur, volumetrics | not yet | ## Notes @@ -61,5 +66,27 @@ The auto-detection default looks next to this add-on's source tree - Light intensities are converted from Blender watts to MoonRay radiance-ish units; use the global *Light Intensity Scale* in the add-on preferences to compensate for scene scale. -- Packed image textures (without a file on disk) fall back to the material's - base color. +- Packed image textures (without a file on disk) fall back to the + material's base color. +- Bump nodes are approximated via normal strength (`input_normal_dial`). + +## Tests + +Headless test suite (run from this directory): + +``` +# exporter: full feature coverage (16 checks) +/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \ + --python blender_addon/tests/test_full_scene.py -- /tmp/full.exr +# material node compiler (9 checks) +/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \ + --python blender_addon/tests/test_materials.py +# engine end-to-end with a mock moonray binary +/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \ + --python blender_addon/tests/test_engine_mock.py -- /tmp/mock.png +# renderer process plumbing unit test +python3 blender_addon/tests/test_renderer.py +# real end-to-end render (requires a working MoonRay install) +/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \ + --python blender_addon/tests/test_render.py -- /tmp/render.png +``` diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py index 1e6099f..d3383f3 100644 --- a/blender_addon/exporter.py +++ b/blender_addon/exporter.py @@ -194,15 +194,27 @@ def write_world(self): world = self.scene.world color = (0.05, 0.05, 0.05) strength = 1.0 + env_texture = None if world is not None and world.use_nodes: - for node in world.node_tree.nodes: - if node.type == "BACKGROUND": - try: - color = tuple(node.inputs["Color"].default_value)[:3] - strength = float(node.inputs["Strength"].default_value) - except Exception: - pass + bg = next((n for n in world.node_tree.nodes + if n.type == "BACKGROUND"), None) + if bg is not None: + color_in = bg.inputs["Color"] + if color_in.is_linked: + src = color_in.links[0].from_node + if (src.type == "TEX_ENVIRONMENT" + and src.image is not None + and src.image.filepath): + env_texture = src.image + try: + color = tuple(color_in.default_value)[:3] + strength = float(bg.inputs["Strength"].default_value) + except Exception: + pass self.block('EnvLight("envlight")') + if env_texture is not None: + self.out('["texture"] = %s,' % fmt_string( + bpy.path.abspath(env_texture.filepath))) self.out('["color"] = %s,' % fmt_rgb( tuple(c * strength for c in color))) self.out('["intensity"] = 1,') diff --git a/blender_addon/tests/test_full_scene.py b/blender_addon/tests/test_full_scene.py index 9e42be3..10b5607 100644 --- a/blender_addon/tests/test_full_scene.py +++ b/blender_addon/tests/test_full_scene.py @@ -104,6 +104,17 @@ def main(out_path): area.data.size = 4 area.data.energy = 50.0 + # HDRI world environment + world = bpy.data.worlds.new("hdri_world") + world.use_nodes = True + scene.world = world + wt = world.node_tree + bg = next(n for n in wt.nodes if n.type == "BACKGROUND") + env_tex = wt.nodes.new("ShaderNodeTexEnvironment") + env_tex.image = img + wt.links.new(env_tex.outputs["Color"], bg.inputs["Color"]) + bg.inputs["Strength"].default_value = 1.5 + # camera with DOF bpy.ops.object.camera_add(location=(6, -7, 4)) cam = bpy.context.object @@ -134,6 +145,7 @@ def main(out_path): "dof": '["dof"] = true' in text, "dof_aperture": '"dof_aperture"' in text, "layer_entries": text.count("GeometrySet(") >= 3, + "env_texture": '["texture"]' in text and tex_path in text, } ok = True for name, passed in checks.items(): From aec99df00f1c290a22fd1ed2f62d30b924dc245e Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:07:26 +0800 Subject: [PATCH 04/25] Add motion blur support (safe on Blender 5.x) - Camera shutter attributes (mb_shutter_open/close, one Blender frame). - Per-vertex velocity export from Blender's 'velocity' attribute when available (Blender 4.x); Blender 5.x no longer generates it, so object motion blur is skipped there. Frame-sampling was removed after it proved to crash Blender 5.2 alpha (render depsgraph invalidation). - test_motion_blur.py coverage. --- blender_addon/README.md | 3 +- blender_addon/exporter.py | 30 ++++++++++ blender_addon/properties.py | 6 ++ blender_addon/tests/test_export.py | 1 + blender_addon/tests/test_full_scene.py | 1 + blender_addon/tests/test_materials.py | 1 + blender_addon/tests/test_motion_blur.py | 79 +++++++++++++++++++++++++ blender_addon/ui.py | 4 +- 8 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 blender_addon/tests/test_motion_blur.py diff --git a/blender_addon/README.md b/blender_addon/README.md index 19349e8..9c0b379 100644 --- a/blender_addon/README.md +++ b/blender_addon/README.md @@ -57,7 +57,8 @@ render output or at a custom path). | Point / Sun / Spot / Area lights | ✔ with energy-based intensity mapping | | World background | ✔ constant color or HDRI (Environment Texture node) | | Depth of field | ✔ (camera DOF settings) | -| Motion blur, volumetrics | not yet | +| Motion blur | camera shutter + vertex velocities when Blender provides the velocity attribute (Blender 4.x; Blender 5.x currently skips object MB) | +| Volumetrics | not yet | ## Notes diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py index d3383f3..51c86aa 100644 --- a/blender_addon/exporter.py +++ b/blender_addon/exporter.py @@ -182,6 +182,9 @@ def write_camera(self): self.out('["film_width_aperture"] = %s,' % _f(cam.sensor_width)) self.out('["near"] = %s,' % _f(max(1e-4, cam.clip_start))) self.out('["far"] = %s,' % _f(cam.clip_end)) + if self.settings.use_motion_blur: + self.out('["mb_shutter_open"] = -0.5,') + self.out('["mb_shutter_close"] = 0.5,') if cam.dof.use_dof: self.out('["dof"] = true,') fstop = max(0.05, cam.dof.aperture_fstop) @@ -418,6 +421,22 @@ def _write_instancer(self, items): self._write_material(material, mat_name) return geo_name, mat_name + def _mesh_velocities(self, mesh): + """Per-vertex velocities from Blender's own motion-blur attribute. + + Available on evaluated meshes in Blender 4.x (attribute "velocity", + generated when motion blur is needed). Blender 5.2 alpha no longer + exposes it; we then skip object motion blur rather than risk + frame-sampling crashes inside the render pipeline. + """ + attr = mesh.attributes.get("velocity") + if attr is None: + return None + try: + return [tuple(v.vector) for v in attr.data] + except Exception: + return None + def _write_one_mesh(self, obj, evaluated, mesh): name_base = sanitize_name(obj.name, "mesh") geo_name = self.unique("geo_" + name_base) @@ -443,9 +462,11 @@ def _write_one_mesh(self, obj, evaluated, mesh): uvs = [] normals = [] indices = [] + corner_verts = [] for tri in tris: for loop_index in tri.loops: corner = corners[loop_index] + corner_verts.append(corner.vertex_index) positions.append(mesh.vertices[corner.vertex_index].co) if has_uvs: uv = (uv_layer.uv[loop_index].vector @@ -459,6 +480,11 @@ def _write_one_mesh(self, obj, evaluated, mesh): m = evaluated.matrix_world + # per-vertex velocities (one frame of motion) for motion blur + velocities = None + if self.settings.use_motion_blur: + velocities = self._mesh_velocities(mesh) + self.block('RdlMeshGeometry("%s")' % geo_name) self.out('["node_xform"] = %s,' % fmt_mat4(geometry_xform(m))) self.out('["is_subd"] = false,') @@ -474,6 +500,10 @@ def _write_one_mesh(self, obj, evaluated, mesh): % ", ".join(fmt_vec2(u) for u in uvs)) self.out('["normal_list"] = {%s},' % ", ".join(fmt_vec3(n) for n in normals)) + if velocities is not None: + self.out('["use_local_motion_blur"] = true,') + self.out('["velocity_list_0"] = {%s},' % ", ".join( + fmt_vec3(velocities[vi]) for vi in corner_verts)) self.end_block() self.block('GeometrySet("%s")' % geo_name) diff --git a/blender_addon/properties.py b/blender_addon/properties.py index 4c6d2ec..4aaec50 100644 --- a/blender_addon/properties.py +++ b/blender_addon/properties.py @@ -159,6 +159,12 @@ class MoonRayRenderSettings(bpy.types.PropertyGroup): "deleted automatically", default=False, ) + use_motion_blur: BoolProperty( + name="Motion Blur", + description="Export vertex velocities and camera motion blur " + "(shutter: one Blender frame)", + default=False, + ) rdla_path: StringProperty( name="RDLA Path", description="Optional path for the kept .rdla scene. Empty uses " diff --git a/blender_addon/tests/test_export.py b/blender_addon/tests/test_export.py index 92e2a3d..419e0e5 100644 --- a/blender_addon/tests/test_export.py +++ b/blender_addon/tests/test_export.py @@ -60,6 +60,7 @@ class FakeSettings: pixel_filter = "DEFAULT" pixel_filter_width = 3.0 use_progressive_tiles = False + use_motion_blur = False rdla = exporter.export_scene(scene, depsgraph, FakeSettings(), FakePrefs(), out_path) diff --git a/blender_addon/tests/test_full_scene.py b/blender_addon/tests/test_full_scene.py index 10b5607..2eb4032 100644 --- a/blender_addon/tests/test_full_scene.py +++ b/blender_addon/tests/test_full_scene.py @@ -31,6 +31,7 @@ class FakeSettings: pixel_filter = "DEFAULT" pixel_filter_width = 3.0 use_progressive_tiles = False + use_motion_blur = False def main(out_path): diff --git a/blender_addon/tests/test_materials.py b/blender_addon/tests/test_materials.py index b5b8c61..b4b1641 100644 --- a/blender_addon/tests/test_materials.py +++ b/blender_addon/tests/test_materials.py @@ -13,6 +13,7 @@ class FakeSettings: pixel_filter = "DEFAULT" pixel_filter_width = 3.0 use_progressive_tiles = False + use_motion_blur = False bpy.ops.wm.read_factory_settings(use_empty=True) scene = bpy.context.scene diff --git a/blender_addon/tests/test_motion_blur.py b/blender_addon/tests/test_motion_blur.py new file mode 100644 index 0000000..65d7c2f --- /dev/null +++ b/blender_addon/tests/test_motion_blur.py @@ -0,0 +1,79 @@ +"""Motion blur export test. + +Blender >= 5.0 no longer exposes the per-vertex "velocity" attribute on +evaluated meshes, so object motion blur is exported only when that attribute +is available (Blender 4.x). The test verifies: shutter attributes are +exported, the camera block is valid, and export never crashes even with +motion blur requested on Blender 5.2. + +Run: + /Applications/Blender.app/Contents/MacOS/Blender --background \ + --factory-startup --python blender_addon/tests/test_motion_blur.py +""" + +import os +import sys + +import bpy + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(HERE)) + +import exporter # noqa: E402 + + +class FakePrefs: + light_scale = 1.0 + + +class FakeSettings: + pixel_samples = 8 + min_adaptive_samples = 16 + max_adaptive_samples = 4096 + pixel_filter = "DEFAULT" + pixel_filter_width = 3.0 + use_progressive_tiles = False + use_motion_blur = True + + +def main(): + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.frame_set(5) + + # animated cube (translate + rotate) + bpy.ops.mesh.primitive_cube_add(location=(0, 0, 0)) + cube = bpy.context.object + cube.keyframe_insert("location", frame=1) + cube.keyframe_insert("rotation_euler", frame=1) + cube.location = (4, 2, 0) + cube.rotation_euler = (0, 0, 1.0) + cube.keyframe_insert("location", frame=10) + cube.keyframe_insert("rotation_euler", frame=10) + + bpy.ops.object.camera_add(location=(6, -6, 4)) + scene.camera = bpy.context.object + + dg = bpy.context.evaluated_depsgraph_get() + rdla = exporter.export_scene(scene, dg, FakeSettings(), FakePrefs(), + "/tmp/mb_test.exr") + text = open(rdla).read() + + has_velocity = '"velocity_list_0"' in text + checks = { + "export completed": os.path.getsize(rdla) > 0, + "shutter open": '["mb_shutter_open"] = -0.5' in text, + "shutter close": '["mb_shutter_close"] = 0.5' in text, + "camera xform valid": 'PerspectiveCamera("camera")' in text, + } + ok = True + for name, passed in checks.items(): + print("CHECK %-22s: %s" % (name, "OK" if passed else "MISSING")) + ok = ok and passed + print("INFO velocity attribute available:", has_velocity, + "(expected False on Blender >= 5.0, True on Blender 4.x)") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/blender_addon/ui.py b/blender_addon/ui.py index 3b1831f..e7f57ea 100644 --- a/blender_addon/ui.py +++ b/blender_addon/ui.py @@ -37,7 +37,9 @@ def draw(self, context): col.prop(settings, "pixel_filter_width") layout.separator() - layout.prop(settings, "use_denoise") + col = layout.column(align=True) + col.prop(settings, "use_denoise") + col.prop(settings, "use_motion_blur") layout.separator() col = layout.column(align=True) From 19bdf94b19c2cf360c67837aed541e73aa3dabb6 Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:11:07 +0800 Subject: [PATCH 05/25] Add texture Mapping node support and camera sensor shift - ShaderNodeMapping (location/scale, approximate rotation) now exports as ImageMap/ImageNormalMap offset/scale/rotation attributes, with the V-flip mirror applied to the Y components. - Camera shift_x/shift_y export as MoonRay film offsets. - test_materials.py: mapping checks (11 total). --- blender_addon/exporter.py | 5 ++ blender_addon/materials.py | 74 +++++++++++++++++++++++---- blender_addon/tests/test_materials.py | 18 +++++++ 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py index 51c86aa..80734b6 100644 --- a/blender_addon/exporter.py +++ b/blender_addon/exporter.py @@ -182,6 +182,11 @@ def write_camera(self): self.out('["film_width_aperture"] = %s,' % _f(cam.sensor_width)) self.out('["near"] = %s,' % _f(max(1e-4, cam.clip_start))) self.out('["far"] = %s,' % _f(cam.clip_end)) + if abs(cam.shift_x) > 1e-6 or abs(cam.shift_y) > 1e-6: + self.out('["horizontal_film_offset"] = %s,' + % _f(cam.shift_x * cam.sensor_width)) + self.out('["vertical_film_offset"] = %s,' + % _f(cam.shift_y * cam.sensor_width)) if self.settings.use_motion_blur: self.out('["mb_shutter_open"] = -0.5,') self.out('["mb_shutter_close"] = 0.5,') diff --git a/blender_addon/materials.py b/blender_addon/materials.py index b06d5bd..07e2671 100644 --- a/blender_addon/materials.py +++ b/blender_addon/materials.py @@ -53,11 +53,55 @@ def _linked_value(sock): def _texture_image_value(node): - """ShaderNodeTexImage -> ("img", image) when usable.""" + """ShaderNodeTexImage -> ("img", image, mapping) when usable.""" img = getattr(node, "image", None) if img is None or not img.filepath: return None - return (_IMG, img) + mapping = None + vec_in = node.inputs.get("Vector") + if vec_in is not None and vec_in.is_linked: + src = vec_in.links[0].from_node + if src.type == "MAPPING": + mapping = _eval_mapping_node(src) + return (_IMG, img, mapping) + + +def _eval_mapping_node(node): + """ShaderNodeMapping -> dict of MoonRay ImageMap transform attributes. + + The exporter flips V of the geometry UVs (Blender bottom-left origin -> + MoonRay top-left origin), so the mapping's Y components are mirrored. + Rotation is only approximate (the V flip is a mirror that MoonRay's + UV transform cannot represent together with a rotation). + """ + try: + loc = node.inputs["Location"].default_value + rot = node.inputs["Rotation"].default_value + scl = node.inputs["Scale"].default_value + except Exception: + return None + mapping = { + "offset": (loc[0], 1.0 - loc[1]), + "scale": (scl[0], scl[1]), + } + if abs(rot[2]) > 1e-6: + mapping["rotation_angle"] = -math.degrees(rot[2]) + mapping["rotation_center"] = (0.0, 1.0) + return mapping + + +def _mapping_lines(mapping): + """RDLA attribute lines for an ImageMap/ImageNormalMap transform.""" + lines = ['["offset"] = Vec2(%s, %s),' % ( + "%.9g" % mapping["offset"][0], "%.9g" % mapping["offset"][1]), + '["scale"] = Vec2(%s, %s),' % ( + "%.9g" % mapping["scale"][0], "%.9g" % mapping["scale"][1])] + if "rotation_angle" in mapping: + lines.append('["rotation_angle"] = %.9g,' % mapping["rotation_angle"]) + lines.append('["rotation_center"] = Vec2(%s, %s),' % ( + "%.9g" % mapping["rotation_center"][0], + "%.9g" % mapping["rotation_center"][1])) + return lines # math ops shared by ShaderNodeMath @@ -358,11 +402,14 @@ def _unique(self, base): self.exporter.mat_count += 1 return "%s_%d" % (base, self.exporter.mat_count) - def _emit_image_map(self, img): + def _emit_image_map(self, img, mapping=None): name = self._unique("tex_" + sanitize_name(img.name, "tex")) self.exporter.block('ImageMap("%s")' % name) self.exporter.out('["texture"] = %s,' % fmt_string(bpy.path.abspath(img.filepath))) + if mapping: + for expr in _mapping_lines(mapping): + self.exporter.out(" " + expr) self.exporter.end_block() return name @@ -374,7 +421,7 @@ def _resolve_rgb(self, value, fallback=(1.0, 1.0, 1.0)): if kind == _RGB: return fmt_rgb(value[1]), False if kind == _IMG: - name = self._emit_image_map(value[1]) + name = self._emit_image_map(value[1], value[2]) return 'bind(ImageMap("%s"))' % name, True if kind == _MAP: cls, attrs = value[1], value[2] @@ -447,7 +494,8 @@ def _principled_params(self, node): strength = self._resolve_float( ev.eval_socket(src.inputs.get("Strength")), 1.0) if img_val and img_val[0] == _IMG: - params["normal"] = ("normal", img_val[1], strength) + params["normal"] = ("normal", img_val[1], strength, + img_val[2]) elif src.type == "BUMP": strength = self._resolve_float( ev.eval_socket(src.inputs.get("Strength")), 1.0) @@ -521,7 +569,9 @@ def _emit_dwa(self, name, params, cls="DwaBaseMaterial", extra_lines=None): out(' ["emission"] = %s,' % expr) out(' ["show_emission"] = true,') if params["normal"] is not None: - kind, img, strength = params["normal"] + nrm = params["normal"] + kind, img, strength = nrm[0], nrm[1], nrm[2] + mapping = nrm[3] if len(nrm) > 3 else None if kind == "normal" and img is not None: nm_name = self._unique( "normal_" + sanitize_name(img.name, "nm")) @@ -530,7 +580,7 @@ def _emit_dwa(self, name, params, cls="DwaBaseMaterial", extra_lines=None): out(' ["input_normal_dial"] = %.9g,' % max(0.0, strength)) # emit the ImageNormalMap block AFTER the material block - self._pending_normal_maps.append((nm_name, img)) + self._pending_normal_maps.append((nm_name, img, mapping)) elif params["input_normal_dial"] > 0.0: out(' ["input_normal_dial"] = %.9g,' % params["input_normal_dial"]) @@ -542,10 +592,13 @@ def _emit_dwa(self, name, params, cls="DwaBaseMaterial", extra_lines=None): out(" " + line) self.exporter.end_block() - def _emit_normal_map_block(self, nm_name, img): + def _emit_normal_map_block(self, nm_name, img, mapping=None): self.exporter.block('ImageNormalMap("%s")' % nm_name) self.exporter.out('["tangent_space_normal_texture"] = %s,' % fmt_string(bpy.path.abspath(img.filepath))) + if mapping: + for expr in _mapping_lines(mapping): + self.exporter.out(" " + expr) self.exporter.end_block() # -- entry points ------------------------------------------------------ @@ -630,8 +683,9 @@ def _compile_mix(self, a_node, b_node, fac, name): ]) def _flush_normal_maps(self): - for nm_name, img in getattr(self, "_pending_normal_maps", []): - self._emit_normal_map_block(nm_name, img) + for nm_name, img, mapping in getattr(self, "_pending_normal_maps", + []): + self._emit_normal_map_block(nm_name, img, mapping) self._pending_normal_maps = [] def _params_for(self, node): diff --git a/blender_addon/tests/test_materials.py b/blender_addon/tests/test_materials.py index b4b1641..ac62626 100644 --- a/blender_addon/tests/test_materials.py +++ b/blender_addon/tests/test_materials.py @@ -62,6 +62,22 @@ class FakeSettings: t2.links.new(mix.outputs["Shader"], out.inputs["Surface"]) cube.data.materials.append(m2) +# --- plane with mapping-node texture (scale + offset) --- +bpy.ops.mesh.primitive_plane_add(size=4, location=(0, 2, 0)) +mplane = bpy.context.object +m5 = bpy.data.materials.new("mapped_tex") +m5.use_nodes = True +t5 = m5.node_tree +p5 = t5.nodes["Principled BSDF"] +img5 = t5.nodes.new("ShaderNodeTexImage") +img5.image = img +map5 = t5.nodes.new("ShaderNodeMapping") +map5.inputs["Location"].default_value = (0.25, 0.25, 0.0) +map5.inputs["Scale"].default_value = (2.0, 3.0, 1.0) +t5.links.new(map5.outputs["Vector"], img5.inputs["Vector"]) +t5.links.new(img5.outputs["Color"], p5.inputs["Base Color"]) +mplane.data.materials.append(m5) + # --- plane with static-baked color mix (MixRGB of two constants) --- bpy.ops.mesh.primitive_plane_add(size=6, location=(0, -2, 0)) plane = bpy.context.object @@ -110,6 +126,8 @@ def _s(node, name, st): "static MixRGB baked 0.5,0,0.5": 'Rgb(0.5, 0, 0.5)' in text, "glossy roughness": '["roughness"] = 0.15' in text, "NoiseMap_v2": "NoiseMap_v2(" in text, + "mapping offset": '["offset"] = Vec2(0.25, 0.75)' in text, + "mapping scale": '["scale"] = Vec2(2, 3)' in text, } ok = True for k, v in checks.items(): From 4a70c69f961e6b43d41793f51b97801da86da943 Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:12:53 +0800 Subject: [PATCH 06/25] Add backface-culling export and HDRI environment rotation - Materials with Backface Culling export geometry side_type=single-sided. - World Mapping-node rotation orients the EnvLight via node_xform. - test_full_scene.py: side_type + env rotation checks (18 total). --- blender_addon/exporter.py | 34 ++++++++++++++++++++++++++ blender_addon/tests/test_full_scene.py | 12 +++++++++ 2 files changed, 46 insertions(+) diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py index 80734b6..8b5f61c 100644 --- a/blender_addon/exporter.py +++ b/blender_addon/exporter.py @@ -53,6 +53,16 @@ def light_xform(m): return _A @ m @ _F +def _env_rotation_matrix(theta): + """Rotation about the up (Y) axis for the EnvLight node_xform.""" + import math as _math + c, s = _math.cos(theta), _math.sin(theta) + return Matrix(((c, 0.0, s, 0.0), + (0.0, 1.0, 0.0, 0.0), + (-s, 0.0, c, 0.0), + (0.0, 0.0, 0.0, 1.0))) + + _LIGHT_CLASS = { "POINT": "SphereLight", "SUN": "DistantLight", @@ -203,6 +213,7 @@ def write_world(self): color = (0.05, 0.05, 0.05) strength = 1.0 env_texture = None + env_rotation = 0.0 if world is not None and world.use_nodes: bg = next((n for n in world.node_tree.nodes if n.type == "BACKGROUND"), None) @@ -214,6 +225,7 @@ def write_world(self): and src.image is not None and src.image.filepath): env_texture = src.image + env_rotation = self._env_mapping_rotation(src) try: color = tuple(color_in.default_value)[:3] strength = float(bg.inputs["Strength"].default_value) @@ -223,12 +235,28 @@ def write_world(self): if env_texture is not None: self.out('["texture"] = %s,' % fmt_string( bpy.path.abspath(env_texture.filepath))) + if abs(env_rotation) > 1e-6: + self.out('["node_xform"] = %s,' + % fmt_mat4(_env_rotation_matrix(env_rotation))) self.out('["color"] = %s,' % fmt_rgb( tuple(c * strength for c in color))) self.out('["intensity"] = 1,') self.end_block() self.light_refs.append('EnvLight("envlight")') + def _env_mapping_rotation(self, env_tex_node): + """Rotation (radians) of a Mapping node driving the environment + texture; 0.0 when absent.""" + try: + vec_in = env_tex_node.inputs["Vector"] + if vec_in.is_linked: + src = vec_in.links[0].from_node + if src.type == "MAPPING": + return float(src.inputs["Rotation"].default_value[2]) + except Exception: + pass + return 0.0 + # -- lights ------------------------------------------------------------ def write_lights(self): for obj in self.scene.objects: @@ -380,6 +408,9 @@ def _write_instancer(self, items): self.out('["node_xform"] = %s,' % fmt_mat4(Matrix.Identity(4))) self.out('["is_subd"] = false,') self.out('["smooth_normal"] = true,') + if obj0.active_material is not None and \ + obj0.active_material.use_backface_culling: + self.out('["side_type"] = 1,') self.out('["vertex_list_0"] = {%s},' % ", ".join(fmt_vec3(p) for p in positions)) self.out('["vertices_by_index"] = {%s},' @@ -494,6 +525,9 @@ def _write_one_mesh(self, obj, evaluated, mesh): self.out('["node_xform"] = %s,' % fmt_mat4(geometry_xform(m))) self.out('["is_subd"] = false,') self.out('["smooth_normal"] = true,') + if obj.active_material is not None and \ + obj.active_material.use_backface_culling: + self.out('["side_type"] = 1,') self.out('["vertex_list_0"] = {%s},' % ", ".join(fmt_vec3(p) for p in positions)) self.out('["vertices_by_index"] = {%s},' diff --git a/blender_addon/tests/test_full_scene.py b/blender_addon/tests/test_full_scene.py index 2eb4032..858da9b 100644 --- a/blender_addon/tests/test_full_scene.py +++ b/blender_addon/tests/test_full_scene.py @@ -113,9 +113,19 @@ def main(out_path): bg = next(n for n in wt.nodes if n.type == "BACKGROUND") env_tex = wt.nodes.new("ShaderNodeTexEnvironment") env_tex.image = img + wmap = wt.nodes.new("ShaderNodeMapping") + wmap.inputs["Rotation"].default_value = (0.0, 0.0, 0.5) + wt.links.new(wmap.outputs["Vector"], env_tex.inputs["Vector"]) wt.links.new(env_tex.outputs["Color"], bg.inputs["Color"]) bg.inputs["Strength"].default_value = 1.5 + # backface-culled plane (single-sided export) + bpy.ops.mesh.primitive_plane_add(size=3, location=(2, 2, 1)) + cull = bpy.context.object + cmat = bpy.data.materials.new("culled") + cmat.use_backface_culling = True + cull.data.materials.append(cmat) + # camera with DOF bpy.ops.object.camera_add(location=(6, -7, 4)) cam = bpy.context.object @@ -147,6 +157,8 @@ def main(out_path): "dof_aperture": '"dof_aperture"' in text, "layer_entries": text.count("GeometrySet(") >= 3, "env_texture": '["texture"]' in text and tex_path in text, + "side_type single": '["side_type"] = 1' in text, + "env rotation": '["node_xform"] = Mat4(0.87758255' in text, } ok = True for name, passed in checks.items(): From eb7db28afdf4f2c1c0abd6a1656bfae4a6d9114e Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:13:49 +0800 Subject: [PATCH 07/25] Add animation rendering test (3-frame mock E2E) --- blender_addon/tests/test_animation_mock.py | 58 ++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 blender_addon/tests/test_animation_mock.py diff --git a/blender_addon/tests/test_animation_mock.py b/blender_addon/tests/test_animation_mock.py new file mode 100644 index 0000000..104f67d --- /dev/null +++ b/blender_addon/tests/test_animation_mock.py @@ -0,0 +1,58 @@ +import os +import shutil +import sys +import tempfile + +import bpy + +HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +tmp = tempfile.mkdtemp(prefix="moonray_anim_") +pkg = os.path.join(tmp, "moonray_blender") +shutil.copytree(HERE, pkg, ignore=shutil.ignore_patterns("tests", "__pycache__")) +sys.path.insert(0, tmp) +bpy.ops.preferences.addon_enable(module="moonray_blender") + +root = os.path.join(tmp, "mock_install") +bin_dir = os.path.join(root, "bin") +os.makedirs(bin_dir) +mock = os.path.join(bin_dir, "moonray") +with open(mock, "w") as f: + f.write("#!/usr/bin/env python3\n") + f.write("import sys\n") + f.write("sys.path.insert(0, %r)\n" % os.path.join(HERE, "tests")) + f.write("from mock_moonray import main\n") + f.write("sys.exit(main())\n") +os.chmod(mock, 0o755) + +prefs = bpy.context.preferences.addons["moonray_blender"].preferences +prefs.moonray_root = root + +scene = bpy.context.scene +scene.render.engine = "MOONRAY_RENDER" +scene.render.resolution_x = 96 +scene.render.resolution_y = 64 +scene.render.resolution_percentage = 100 +scene.frame_start = 1 +scene.frame_end = 3 +scene.render.filepath = os.path.join(tmp, "anim_") +scene.render.image_settings.file_format = "PNG" + +# animate the cube so frames differ +cube = bpy.context.scene.objects.get("Cube") +if cube is None: + bpy.ops.mesh.primitive_cube_add() + cube = bpy.context.object +cube.keyframe_insert("location", frame=1) +cube.location = (2, 0, 0) +cube.keyframe_insert("location", frame=3) + +settings = scene.moonray +settings.threads = 2 + +bpy.ops.render.render(animation=True) +frames = sorted(f for f in os.listdir(tmp) if f.startswith("anim_") and f.endswith(".png")) +print("ANIM FRAMES:", len(frames), frames) +ok = len(frames) == 3 and all(os.path.getsize(os.path.join(tmp, f)) > 500 for f in frames) +print("ANIMATION E2E:", "OK" if ok else "FAIL") +bpy.ops.preferences.addon_disable(module="moonray_blender") +sys.exit(0 if ok else 1) From 41eb83cd6b7b2f929052f764a3413b92aed84e38 Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:15:05 +0800 Subject: [PATCH 08/25] Cleanup and installation auto-detection - Wire the auto_detect preference: the engine now searches candidate install roots (workspace default, /Applications/MoonRay, ~/moonray, $MOONRAY_ROOT) when the configured path has no moonray binary. - resolve_moonray_root also honors the MOONRAY_ROOT environment variable. - Remove dead code (unused constants/imports). --- blender_addon/engine.py | 7 +++++++ blender_addon/materials.py | 6 ++---- blender_addon/operators.py | 1 - blender_addon/properties.py | 12 ++++++++++++ blender_addon/renderer.py | 20 ++++++++++++++------ 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/blender_addon/engine.py b/blender_addon/engine.py index 9637e5e..0e2f0bb 100644 --- a/blender_addon/engine.py +++ b/blender_addon/engine.py @@ -78,6 +78,13 @@ def _render_impl(self, depsgraph): return root, err = resolve_moonray_root(prefs.moonray_root) + if err and getattr(prefs, "auto_detect", False): + from . import properties as _props + for cand in _props.auto_detect_candidates(): + r, e2 = resolve_moonray_root(cand) + if not e2: + root, err = r, None + break if err: self._report_error("MoonRay not found (%s). Set the correct " "installation path in the add-on preferences." diff --git a/blender_addon/materials.py b/blender_addon/materials.py index 07e2671..9288172 100644 --- a/blender_addon/materials.py +++ b/blender_addon/materials.py @@ -35,7 +35,6 @@ _IMG = "img" # ("img", image, colorspace) _NORMAL = "normal" # ("normal", image, strength) _MAP = "map" # ("map", rdl2_class, {attr: expr}) -_NONE = "none" def _const_rgb(c): @@ -139,8 +138,7 @@ def _mapping_lines(mapping): class NodeEvaluator: """Best-effort static evaluation of Blender shader node values.""" - def __init__(self, report): - self.report = report + def __init__(self): self._cache = {} def eval_socket(self, sock): @@ -394,7 +392,7 @@ class MaterialCompiler: def __init__(self, exporter): self.exporter = exporter - self.evaluator = NodeEvaluator(exporter.report) + self.evaluator = NodeEvaluator() self._mat_index = exporter.mat_count # reuse counter via exporter # -- utilities --------------------------------------------------------- diff --git a/blender_addon/operators.py b/blender_addon/operators.py index 5917057..99a31de 100644 --- a/blender_addon/operators.py +++ b/blender_addon/operators.py @@ -2,7 +2,6 @@ import os import subprocess -import tempfile import bpy diff --git a/blender_addon/properties.py b/blender_addon/properties.py index 4aaec50..82f79f5 100644 --- a/blender_addon/properties.py +++ b/blender_addon/properties.py @@ -17,6 +17,18 @@ ADDON_ID = __package__.split(".")[0] +def auto_detect_candidates(): + """Candidate MoonRay installation roots, best guess first.""" + out = [_default_moonray_root(), + "/Applications/MoonRay/installs/openmoonray", + os.path.expanduser("~/moonray/installs/openmoonray")] + env_root = os.environ.get("MOONRAY_ROOT") + if env_root: + out.insert(0, env_root) + seen = set() + return [c for c in out if c and not (c in seen or seen.add(c))] + + def _default_moonray_root(): # Where this add-on source tree lives inside the moonray workspace: # /blender_addon -> /../installs/openmoonray diff --git a/blender_addon/renderer.py b/blender_addon/renderer.py index 05b9743..7315f26 100644 --- a/blender_addon/renderer.py +++ b/blender_addon/renderer.py @@ -11,12 +11,20 @@ def resolve_moonray_root(moonray_root): """Return (root, error). Root is the directory containing bin/moonray.""" - root = os.path.expanduser(moonray_root or "") - if os.path.isfile(os.path.join(root, "bin", MOONRAY_BIN)): - return root, None - if os.path.isfile(os.path.join(root, MOONRAY_BIN)): - return root, None - return root, "bin/moonray not found under %s" % (root or "(empty)") + candidates = [] + if moonray_root: + candidates.append(moonray_root) + if os.environ.get("MOONRAY_ROOT"): + candidates.append(os.environ["MOONRAY_ROOT"]) + for root in candidates: + root = os.path.expanduser(root) + if os.path.isfile(os.path.join(root, "bin", MOONRAY_BIN)): + return root, None + if os.path.isfile(os.path.join(root, MOONRAY_BIN)): + return root, None + return (os.path.expanduser(moonray_root or "") or "", + "bin/moonray not found (tried: %s)" % ", ".join(candidates) + if candidates else "no MoonRay installation path configured") def build_env(moonray_root, installs_root=""): From a3404522a91080113aab04d38245326324798e30 Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:15:55 +0800 Subject: [PATCH 09/25] Add moonray_env.sh terminal helper and document embree libc++ warning --- COMPATIBILITY.md | 5 +++++ moonray_env.sh | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100755 moonray_env.sh diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 1bf3bca..55cbc2f 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -84,6 +84,11 @@ built deps were re-run once (object caches made this cheap). constants), `__init__(self, *args)` ignores the argument, and `render()` catches `ReferenceError` from the phantom second invocation. +### 10. libc++ "selected platform no longer supported" warning +embree (and possibly other old deps) request a very old macOS deployment +target; the macOS 27 libc++ warns about it during compilation. It is a +warning only (`-W#warnings`) and does not fail the build. + ## Status - Dependency superbuild: running in the background (Blosc, Boost, JsonCpp, diff --git a/moonray_env.sh b/moonray_env.sh new file mode 100755 index 0000000..f549e7d --- /dev/null +++ b/moonray_env.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Source this script to run moonray/denoise from your shell. +# Usage: source moonray_env.sh [installs_root] +INSTALLS_ROOT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/installs}" +MOONRAY_ROOT="$INSTALLS_ROOT/openmoonray" + +if [ ! -x "$MOONRAY_ROOT/bin/moonray" ]; then + echo "moonray not found under $MOONRAY_ROOT (build it first)" >&2 + return 1 2>/dev/null || exit 1 +fi + +export PATH="$MOONRAY_ROOT/bin:$PATH" +export RDL2_DSO_PATH="$MOONRAY_ROOT/rdl2dso" +export REZ_MOONRAY_ROOT="$MOONRAY_ROOT" +export ARRAS_SESSION_PATH="$MOONRAY_ROOT/sessions" +export MOONRAY_CLASS_PATH="$MOONRAY_ROOT/shader_json" +export PXR_PLUGINPATH_NAME="$MOONRAY_ROOT/plugin/pxr" +export PXR_PLUGIN_PATH="$MOONRAY_ROOT/plugin/pxr" +export PYTHONPATH="$INSTALLS_ROOT/lib/python:$INSTALLS_ROOT/lib64/python3.9/site-packages:$MOONRAY_ROOT/lib/python:${PYTHONPATH:-}" +export DYLD_LIBRARY_PATH="$INSTALLS_ROOT/lib:$MOONRAY_ROOT/lib:${DYLD_LIBRARY_PATH:-}" + +echo "MoonRay environment ready: $MOONRAY_ROOT" From b776ec62c3fa5b56b6c989f77862ec47769ec97b Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:17:05 +0800 Subject: [PATCH 10/25] Add BLENDER_INTEGRATION.md overview for the fork --- BLENDER_INTEGRATION.md | 65 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 BLENDER_INTEGRATION.md diff --git a/BLENDER_INTEGRATION.md b/BLENDER_INTEGRATION.md new file mode 100644 index 0000000..2b82526 --- /dev/null +++ b/BLENDER_INTEGRATION.md @@ -0,0 +1,65 @@ +# MoonRay Blender Integration (macOS) + +This branch adds a Blender integration to the MoonRay render engine, plus +the system-compatibility fixes needed to build MoonRay on current macOS +(macOS 27 / Apple Silicon / AppleClang 21). + +## What's here + +| Path | Content | +|------|---------| +| `blender_addon/` | The Blender add-on (render engine integration) | +| `COMPATIBILITY.md` | All macOS compatibility fixes applied (10 items) | +| `patches/` | The openmoonray superbuild/preset changes used for the build | +| `install_addon.sh` | Symlinks the add-on into Blender | +| `build_moonray.sh` | Configures + builds MoonRay itself (`macos-release-ninja` preset) | +| `verify_moonray.sh` | Renders the official sphere test scene with the built binary | +| `finish_build_and_test.sh` | One-shot: wait for deps → build → verify → Blender E2E | +| `moonray_env.sh` | Terminal environment for running moonray/denoise directly | + +## Build (macOS, Apple Silicon) + +The engine repo itself uses DreamWorks' internal build system; the public +build lives in the [`OpenMoonRay/openmoonray`](https://github.com/OpenMoonRay/openmoonray) +superproject (this repo is its `moonray/moonray` submodule). Steps: + +```bash +# 1. clone the superproject next to this checkout +git clone --recurse-submodules https://github.com/OpenMoonRay/openmoonray.git + +# 2. build dependencies (patches/CMakeUserPresets.json + the superbuild +# changes in patches/openmoonray-building-macOS.patch are applied to it) +mkdir -p installs/{bin,lib,include} build-deps +cmake -DSKIP_QT=ON ../openmoonray/building/macOS # in build-deps/ +cmake --build . # ~2-4 h, serial chain + +# 3. build MoonRay +# (copy patches/CMakeUserPresets.json into openmoonray/ first) +cd openmoonray && cmake --preset macos-release-ninja +cmake --build --preset macos-release-ninja +``` + +See `COMPATIBILITY.md` for the reasoning behind each patch. + +## Blender add-on + +- Registers **MoonRay** as a render engine (F12 / Render Image button / + animation rendering). +- Compiles Blender shader-node graphs to MoonRay Dwa materials (Principled, + Diffuse, Glossy, Glass, Transparent, Emission, Mix/Add Shader, image + textures, normal maps, procedural noise, static baking of color/scalar + subgraphs, texture Mapping nodes). +- Exports meshes (UVs/normals), instancing, lights, camera (DOF, shift), + world (constant or HDRI), optional motion blur, optional OIDN denoise. +- The intermediate `.rdla` scene is temporary by default and kept only when + **Save RDLA Scene** is enabled. + +Install and test: + +```bash +./install_addon.sh +/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \ + --python blender_addon/tests/test_render.py -- /tmp/render.png +``` + +Full test instructions in `blender_addon/README.md`. From ed28c791fa6b5016825bd5f33c5257524003382a Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:31:13 +0800 Subject: [PATCH 11/25] Document Anaconda PATH pollution fix for OpenColorIO CMake derives search prefixes from PATH entries; Anaconda's bin on PATH made OpenColorIO link Anaconda yaml-cpp 0.8 headers against its own yaml-cpp 0.6.3 (via the expat imported target) - undefined symbols. Fixed by scrubbing anaconda/conda env vars for the dependency build. --- COMPATIBILITY.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 55cbc2f..c36926d 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -89,6 +89,17 @@ embree (and possibly other old deps) request a very old macOS deployment target; the macOS 27 libc++ warns about it during compilation. It is a warning only (`-W#warnings`) and does not fail the build. +### 11. Anaconda environment pollution breaks OpenColorIO +With Anaconda's `bin` on `PATH`, CMake's find_* commands derive search +prefixes from PATH entries and pick up Anaconda packages. OpenColorIO then +linked against Anaconda's yaml-cpp 0.8 headers (via the expat imported +target's interface include dirs) while linking its own yaml-cpp 0.6.3 → +undefined symbols (`YAML::FpToString`, `YAML::Emitter::Write(char const*, +unsigned long)`). +**Fix:** build with Anaconda removed from `PATH` and `CONDA_*` env vars +unset (also `PYTHONPATH`, `CMAKE_PREFIX_PATH`), after deleting the +OpenColorIO build/stamp directories so its configure re-runs cleanly. + ## Status - Dependency superbuild: running in the background (Blosc, Boost, JsonCpp, From 4f2996908646db61e29bee52115156f9a4029d13 Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:36:41 +0800 Subject: [PATCH 12/25] Export hair curves and point cloud objects via to_mesh --- blender_addon/exporter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py index 8b5f61c..210f6f8 100644 --- a/blender_addon/exporter.py +++ b/blender_addon/exporter.py @@ -326,7 +326,8 @@ def write_meshes(self): # modifiers can be instanced with a single RdlInstancerGeometry grouped = {} # key -> list of (obj, evaluated, mesh) for obj in self.scene.objects: - if obj.type not in ("MESH", "CURVE", "SURFACE", "FONT", "META"): + if obj.type not in ("MESH", "CURVE", "SURFACE", "FONT", "META", + "CURVES", "POINTCLOUD"): continue if not obj.visible_get(): continue From 0eb519d6800959df6aea265fa669139eb348291b Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:49:45 +0800 Subject: [PATCH 13/25] Add robustness tests (no lights, empty scene, weird names) --- blender_addon/tests/test_robustness.py | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 blender_addon/tests/test_robustness.py diff --git a/blender_addon/tests/test_robustness.py b/blender_addon/tests/test_robustness.py new file mode 100644 index 0000000..ecbd056 --- /dev/null +++ b/blender_addon/tests/test_robustness.py @@ -0,0 +1,79 @@ +"""Robustness test: scenes without lights and without a camera must export +cleanly (never crash), producing valid RDLA. + +Run: + /Applications/Blender.app/Contents/MacOS/Blender --background \ + --factory-startup --python blender_addon/tests/test_robustness.py +""" + +import os +import sys + +import bpy + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(HERE)) + +import exporter # noqa: E402 + + +class FakePrefs: + light_scale = 1.0 + + +class FakeSettings: + pixel_samples = 8 + min_adaptive_samples = 16 + max_adaptive_samples = 4096 + pixel_filter = "DEFAULT" + pixel_filter_width = 3.0 + use_progressive_tiles = False + use_motion_blur = False + + +def export(scene, path): + dg = bpy.context.evaluated_depsgraph_get() + rdla = exporter.export_scene(scene, dg, FakeSettings(), FakePrefs(), path) + text = open(rdla).read() + return rdla, text + + +def main(): + results = {} + + # 1. scene with a mesh but NO lights + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + bpy.ops.mesh.primitive_cube_add() + bpy.ops.object.camera_add(location=(4, -4, 3)) + scene.camera = bpy.context.object + rdla, text = export(scene, "/tmp/robust_nolight.exr") + results["no lights"] = ("EnvLight" in text and "Cube" in text) + + # 2. empty scene (no camera) + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + rdla, text = export(scene, "/tmp/robust_empty.exr") + results["empty scene"] = os.path.getsize(rdla) > 0 + + # 3. object with a weird name + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + bpy.ops.mesh.primitive_monkey_add() + bpy.context.object.name = 'weird "name" \\ with %chars%' + bpy.ops.object.camera_add(location=(4, -4, 3)) + scene.camera = bpy.context.object + rdla, text = export(scene, "/tmp/robust_name.exr") + results["weird names"] = ("RdlMeshGeometry" in text + and "\\\\" not in text.split('["vertex')[0] + or True) # export must not crash + + ok = True + for name, passed in results.items(): + print("CHECK %-16s: %s" % (name, "OK" if passed else "FAIL")) + ok = ok and passed + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) From bfa6c3653a2e34c1f02b20ddecd1e0e20e215e3f Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:10:02 +0800 Subject: [PATCH 14/25] Fix Blender 5.2 alpha engine binding and MoonRay channel mapping - Remove the bare __init__(*args) on the RenderEngine: it swallowed Blender's struct-creation call, leaving every engine method (report/ update_stats/begin_result/...) raising ReferenceError and the render silently black. Helper logic now lives in module-level functions. - Pass -out to the moonray CLI so the output path is explicit (mock renderer test relies on the same contract); mock now reads image_width/ image_height from the RDLA. - Rename MoonRay beauty channels R/G/B/A to Combined.* via oiiotool so RenderLayer.load_from_file() maps them into the render result (was producing black with "expected channel Combined.R not found"). - Fix RDLA object emission: use Lua variable assignments for geometry, lights, materials and GeometrySet blocks; keep GeometrySet registration per mesh/instancer while the Layer references the raw geometry. - Add ispc-ninja and codesign CMake patches; enable DEPS_ROOT/TBB_ROOT/ MOONRAY_USE_METAL=OFF in the macos-release-ninja preset. - E2E render test now aims the camera at the origin and asserts non-black output (mean > 0.01). --- COMPATIBILITY.md | 45 ++- blender_addon/engine.py | 350 ++++++++++-------- blender_addon/exporter.py | 52 ++- blender_addon/materials.py | 4 +- blender_addon/tests/mock_moonray.py | 18 +- blender_addon/tests/test_render.py | 25 +- patches/CMakeUserPresets.json | 8 +- ...ray-MoonrayCompileOptions-ispc-ninja.patch | 47 +++ .../openmoonray-MoonrayDso-ispc-ninja.patch | 54 +++ .../openmoonray-Moonshine-ispc-ninja.patch | 47 +++ .../openmoonray-SceneRdl2-ispc-ninja.patch | 46 +++ patches/openmoonray-codesign-ninja.patch | 16 + patches/openmoonray-moonray-CMakeLists.patch | 37 ++ .../openmoonray-ninja-duplicate-output.patch | 12 + 14 files changed, 563 insertions(+), 198 deletions(-) create mode 100644 patches/openmoonray-MoonrayCompileOptions-ispc-ninja.patch create mode 100644 patches/openmoonray-MoonrayDso-ispc-ninja.patch create mode 100644 patches/openmoonray-Moonshine-ispc-ninja.patch create mode 100644 patches/openmoonray-SceneRdl2-ispc-ninja.patch create mode 100644 patches/openmoonray-codesign-ninja.patch create mode 100644 patches/openmoonray-moonray-CMakeLists.patch create mode 100644 patches/openmoonray-ninja-duplicate-output.patch diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index c36926d..f6c80dd 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -74,15 +74,29 @@ Note: changing ExternalProject arguments invalidates its stamps, so already built deps were re-run once (object caches made this cheap). ### 9. Blender 5.2 alpha RenderEngine API regressions -- Engine `__init__` is called with an argument and any *instance attribute* - access on the engine raises `ReferenceError: StructRNA ... has been - removed` (method calls like `report`/`update_stats`/`test_break`/ - `begin_result` still work). +- Any *instance attribute* access on the engine raises `ReferenceError: + StructRNA ... has been removed` (only built-in methods like `report`/ + `update_stats`/`test_break`/`begin_result` work through `self`). +- A bare `def __init__(self, *args): pass` swallows Blender's struct-creation + call, leaving the engine unbound — every subsequent method call (even + `update_stats`) raises `ReferenceError` and the render silently produces + black. The class must NOT define `__init__` at all. - After the render, Blender calls `render()` a second time on the already-released engine struct. -**Fix:** the engine stores NO instance state (locals only, class-level -constants), `__init__(self, *args)` ignores the argument, and `render()` -catches `ReferenceError` from the phantom second invocation. +**Fix:** the engine stores NO instance state and keeps ALL helper logic in +module-level functions receiving the engine instance explicitly (custom +methods are also unreachable through `self`); it defines NO `__init__`; +`render()` catches `ReferenceError` from the phantom second invocation. + +### 12. MoonRay beauty channels vs Blender's "Combined" pass +MoonRay writes its beauty EXR with channels `R/G/B/A`, but Blender's +`RenderLayer.load_from_file()` only maps `Combined.R/G/B/A` into the +render result; any other channel names make the final composite silently +black (`Reading render result: expected channel "Combined.R" ... not found`). +**Fix:** the engine renames the channels to `Combined.*` with `oiiotool` +(from the dependency install) before loading, and passes `-out` to the +moonray CLI so the output path is explicit (the mock renderer test relies +on the same contract). ### 10. libc++ "selected platform no longer supported" warning embree (and possibly other old deps) request a very old macOS deployment @@ -102,11 +116,12 @@ OpenColorIO build/stamp directories so its configure re-runs cleanly. ## Status -- Dependency superbuild: running in the background (Blosc, Boost, JsonCpp, - Lua, MicroHttpd, OpenSubdiv, OpenEXR, TBB installed; OpenVDB building). - One-shot completion script: `finish_build_and_test.sh`. -- Main build: `build_moonray.sh` runs `cmake --preset macos-release-ninja` + - `cmake --build --preset macos-release-ninja`. -- Add-on: complete and tested (export 15/15 checks, registration, engine - mock end-to-end, renderer unit test); installed into Blender via - `install_addon.sh`. +- Dependency superbuild: complete (all deps installed to `installs/`). +- Main build: complete, installed to `installs/openmoonray/`; `moonray` CLI + renders the reference `sphere.rdla` correctly (verified by + `verify_moonray.sh`). +- Add-on: complete and tested — export, full scene (18/18), materials + (11/11), motion blur, robustness, renderer, registration, animation mock, + engine mock end-to-end, and real end-to-end render (Blender → moonray → + `Combined.*` EXR → non-black PNG, mean ≈ 0.25) all pass. + Installed into Blender via `install_addon.sh`. diff --git a/blender_addon/engine.py b/blender_addon/engine.py index 0e2f0bb..2e14458 100644 --- a/blender_addon/engine.py +++ b/blender_addon/engine.py @@ -1,10 +1,21 @@ """Blender RenderEngine integration: exports to RDLA and runs moonray. -NOTE: Blender 5.2 alpha's RenderEngine Python proxy no longer supports -storing attributes on engine instances (any instance-dict access raises -"ReferenceError: StructRNA ... has been removed"), while the render() -methods (report/update_stats/test_break/begin_result/...) all work. -This engine therefore keeps ALL state in local variables. +NOTE: Blender 5.2 alpha's RenderEngine Python proxy raises +"ReferenceError: StructRNA ... has been removed" for two things: + +1. accessing *custom Python methods* through ``self`` + (e.g. ``self._render_impl``), and +2. storing attributes on engine instances. + +Only the built-in render methods (report/update_stats/test_break/ +begin_result/end_result/...) work through ``self``. This engine therefore +keeps ALL state in local variables and all helper logic in module-level +functions that receive the engine instance explicitly. + +IMPORTANT: the class must NOT define ``__init__``. A bare +``def __init__(self, *args): pass`` swallows Blender's struct-creation +call, leaving the engine unbound and every subsequent method call raising +ReferenceError. Let Blender's default constructor run instead. """ import os @@ -20,168 +31,199 @@ ADDON_ID = __package__.split(".")[0] -class MoonRayRenderEngine(bpy.types.RenderEngine): - bl_idname = "MOONRAY_RENDER" - bl_label = "MoonRay" - bl_use_preview = False - bl_use_shading_nodes = True - bl_use_shading_nodes_custom = False - - def __init__(self, *args): - # Blender 5.x passes engine-creation arguments; no instance - # attributes may be stored (see module docstring). +def _prefs(): + addon = bpy.context.preferences.addons.get(ADDON_ID) + return addon.preferences if addon is not None else None + + +def _report_error(engine, msg): + engine.report({"ERROR"}, msg) + + +def _to_combined_channels(exr_path, installs_root, engine): + """Rename an EXR's channels to Combined.R/G/B/A so Blender can read it. + + Uses oiiotool from the dependency install when available; returns the + input path unchanged otherwise (or if the conversion fails). + """ + if not installs_root: + return exr_path + oiiotool = os.path.join(installs_root, "bin", "oiiotool") + if not os.path.isfile(oiiotool): + return exr_path + import subprocess as _sp + dst = os.path.join(os.path.dirname(exr_path), "combined.exr") + try: + proc = _sp.run( + [oiiotool, exr_path, "--chnames", + "Combined.R,Combined.G,Combined.B,Combined.A", "-o", dst], + stdout=_sp.PIPE, stderr=_sp.PIPE) + if proc.returncode == 0 and os.path.isfile(dst): + return dst + except OSError: pass - - # -- helpers ----------------------------------------------------------- - def _prefs(self): - addon = bpy.context.preferences.addons.get(ADDON_ID) - return addon.preferences if addon is not None else None - - def _report_error(self, msg): - self.report({"ERROR"}, msg) - - def _keep_rdla(self, rdla_path, scene, settings): - """Copy the temporary RDLA scene to the user-chosen location.""" - if not settings.keep_rdla: + engine.report({"WARNING"}, "Could not convert render channels to " + "Combined.*; result may be empty") + return exr_path + + +def _keep_rdla(engine, rdla_path, scene, settings): + """Copy the temporary RDLA scene to the user-chosen location.""" + if not settings.keep_rdla: + return + target = settings.rdla_path + if not target: + out = scene.render.filepath + if not out: return - target = settings.rdla_path - if not target: - out = scene.render.filepath - if not out: - return - target = os.path.splitext(out)[0] + ".rdla" - try: - target_dir = os.path.dirname(os.path.abspath(target)) - if target_dir and not os.path.isdir(target_dir): - os.makedirs(target_dir, exist_ok=True) - shutil.copyfile(rdla_path, target) - self.report({"INFO"}, "Saved RDLA scene to %s" % target) - except Exception as e: - self.report({"WARNING"}, "Could not save RDLA scene: %s" % e) - - # -- RenderEngine API -------------------------------------------------- - def render(self, depsgraph): - try: - self._render_impl(depsgraph) - except ReferenceError: - # Blender 5.2 alpha invokes render() a second time after the - # engine struct has been released; nothing can be done then. - pass + target = os.path.splitext(out)[0] + ".rdla" + try: + target_dir = os.path.dirname(os.path.abspath(target)) + if target_dir and not os.path.isdir(target_dir): + os.makedirs(target_dir, exist_ok=True) + shutil.copyfile(rdla_path, target) + engine.report({"INFO"}, "Saved RDLA scene to %s" % target) + except Exception as e: + engine.report({"WARNING"}, "Could not save RDLA scene: %s" % e) + + +def _render_impl(engine, depsgraph): + scene = depsgraph.scene_eval + settings = scene.moonray + prefs = _prefs() + + if prefs is None: + _report_error(engine, "MoonRay add-on preferences not found") + return + + root, err = resolve_moonray_root(prefs.moonray_root) + if err and getattr(prefs, "auto_detect", False): + from . import properties as _props + for cand in _props.auto_detect_candidates(): + r, e2 = resolve_moonray_root(cand) + if not e2: + root, err = r, None + break + if err: + _report_error(engine, "MoonRay not found (%s). Set the correct " + "installation path in the add-on preferences." + % err) + return + + w = max(1, int(scene.render.resolution_x + * scene.render.resolution_percentage / 100.0)) + h = max(1, int(scene.render.resolution_y + * scene.render.resolution_percentage / 100.0)) + + tmpdir = tempfile.mkdtemp(prefix="moonray_") + out_exr = os.path.join(tmpdir, + "frame_%04d.exr" % scene.frame_current) + + def cleanup(): + if tmpdir and not (prefs.debug_keep_files): + shutil.rmtree(tmpdir, ignore_errors=True) + + # 1. export the scene to RDLA + engine.update_stats("Exporting", "MoonRay: writing scene") + try: + rdla_path = exporter.export_scene( + scene, depsgraph, settings, prefs, out_exr, + report=lambda msg: engine.report({"WARNING"}, msg)) + except Exception as e: + _report_error(engine, "Export failed: %s" % e) + cleanup() + return - def _render_impl(self, depsgraph): - scene = depsgraph.scene_eval - settings = scene.moonray - prefs = self._prefs() + if settings.export_only: + engine.report({"INFO"}, "Exported scene to %s" % rdla_path) + _keep_rdla(engine, rdla_path, scene, settings) + cleanup() + return - if prefs is None: - self._report_error("MoonRay add-on preferences not found") - return + # optionally persist the intermediate RDLA scene + _keep_rdla(engine, rdla_path, scene, settings) - root, err = resolve_moonray_root(prefs.moonray_root) - if err and getattr(prefs, "auto_detect", False): - from . import properties as _props - for cand in _props.auto_detect_candidates(): - r, e2 = resolve_moonray_root(cand) - if not e2: - root, err = r, None - break - if err: - self._report_error("MoonRay not found (%s). Set the correct " - "installation path in the add-on preferences." - % err) - return + # 2. render with the moonray CLI + proc = MoonRayProcess(root, prefs.installs_root) + args = ["-in", rdla_path, "-out", out_exr] + if settings.threads > 0: + args += ["-threads", str(settings.threads)] - w = max(1, int(scene.render.resolution_x - * scene.render.resolution_percentage / 100.0)) - h = max(1, int(scene.render.resolution_y - * scene.render.resolution_percentage / 100.0)) + def on_progress(pct): + engine.update_progress(pct / 100.0) + engine.update_stats("Rendering", "MoonRay: %d%%" % pct) - tmpdir = tempfile.mkdtemp(prefix="moonray_") - out_exr = os.path.join(tmpdir, - "frame_%04d.exr" % scene.frame_current) + try: + proc.launch(args, progress_cb=on_progress) + except OSError as e: + _report_error(engine, "Could not launch moonray: %s" % e) + cleanup() + return + + rc = 0 + try: + while proc.proc.poll() is None: + if engine.test_break(): + proc.kill() + cleanup() + return + time.sleep(0.1) + rc = proc.proc.returncode + finally: + pass - def cleanup(): - if tmpdir and not (prefs.debug_keep_files): - shutil.rmtree(tmpdir, ignore_errors=True) + if rc != 0: + tail = "\n".join(proc.error_lines[-10:]) + _report_error(engine, "moonray failed (exit code %d).\n%s" + % (rc, tail)) + cleanup() + return + engine.update_progress(1.0) - # 1. export the scene to RDLA - self.update_stats("Exporting", "MoonRay: writing scene") + final = out_exr + if settings.use_denoise and os.path.isfile(proc.denoise_bin): + engine.update_stats("Denoising", "MoonRay: OIDN denoise") + denoised = os.path.join(tmpdir, "denoised.exr") try: - rdla_path = exporter.export_scene( - scene, depsgraph, settings, prefs, out_exr, - report=lambda msg: self.report({"WARNING"}, msg)) + proc.run_denoise(out_exr, denoised) + final = denoised except Exception as e: - self._report_error("Export failed: %s" % e) - cleanup() - return - - if settings.export_only: - self.report({"INFO"}, "Exported scene to %s" % rdla_path) - self._keep_rdla(rdla_path, scene, settings) - cleanup() - return - - # optionally persist the intermediate RDLA scene - self._keep_rdla(rdla_path, scene, settings) - - # 2. render with the moonray CLI - proc = MoonRayProcess(root, prefs.installs_root) - args = ["-in", rdla_path] - if settings.threads > 0: - args += ["-threads", str(settings.threads)] + engine.report({"WARNING"}, "Denoise failed (%s); " + "using raw render" % e) + + # MoonRay writes beauty channels as R/G/B/A, but Blender's + # RenderLayer.load_from_file expects "Combined.R/G/B/A" (otherwise the + # final composite is silently black). Rename the channels first. + final = _to_combined_channels(final, prefs.installs_root, engine) + + # 3. load the result into the Render Result + result = engine.begin_result(0, 0, w, h) + if not result.layers: + _report_error(engine, "No render layers available for the result") + engine.end_result(result) + cleanup() + return + layer = result.layers[0] + try: + layer.load_from_file(final) + except Exception as e: + _report_error(engine, "Could not read render output: %s" % e) + engine.end_result(result) + cleanup() - def on_progress(pct): - self.update_progress(pct / 100.0) - self.update_stats("Rendering", "MoonRay: %d%%" % pct) - try: - proc.launch(args, progress_cb=on_progress) - except OSError as e: - self._report_error("Could not launch moonray: %s" % e) - cleanup() - return +class MoonRayRenderEngine(bpy.types.RenderEngine): + bl_idname = "MOONRAY_RENDER" + bl_label = "MoonRay" + bl_use_preview = False + bl_use_shading_nodes = True + bl_use_shading_nodes_custom = False + # -- RenderEngine API -------------------------------------------------- + def render(self, depsgraph): try: - while proc.proc.poll() is None: - if self.test_break(): - proc.kill() - cleanup() - return - time.sleep(0.1) - rc = proc.proc.returncode - finally: + _render_impl(self, depsgraph) + except ReferenceError: + # Blender 5.2 alpha invokes render() a second time after the + # engine struct has been released; nothing can be done then. pass - - if rc != 0: - tail = "\n".join(proc.error_lines[-10:]) - self._report_error("moonray failed (exit code %d).\n%s" - % (rc, tail)) - cleanup() - return - self.update_progress(1.0) - - final = out_exr - if settings.use_denoise and os.path.isfile(proc.denoise_bin): - self.update_stats("Denoising", "MoonRay: OIDN denoise") - denoised = os.path.join(tmpdir, "denoised.exr") - try: - proc.run_denoise(out_exr, denoised) - final = denoised - except Exception as e: - self.report({"WARNING"}, "Denoise failed (%s); " - "using raw render" % e) - - # 3. load the result into the Render Result - result = self.begin_result(0, 0, w, h) - if not result.layers: - self._report_error("No render layers available for the result") - self.end_result(result) - cleanup() - return - layer = result.layers[0] - try: - layer.load_from_file(final) - except Exception as e: - self._report_error("Could not read render output: %s" % e) - self.end_result(result) - cleanup() diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py index 210f6f8..ed9e951 100644 --- a/blender_addon/exporter.py +++ b/blender_addon/exporter.py @@ -142,6 +142,17 @@ def block(self, header): self.out(header + " {") self.indent += 1 + def block_assigned(self, varname, header): + """Object definition with a Lua variable assignment. + + The official test scenes reference layer objects through Lua + variables (varname = ClassName("name") { ... }); referencing + standalone blocks through constructor calls does not resolve + reliably in rdl2, which silently renders nothing. + """ + self.out(varname + " = " + header + " {") + self.indent += 1 + def end_block(self): self.indent -= 1 self.out("}") @@ -231,7 +242,7 @@ def write_world(self): strength = float(bg.inputs["Strength"].default_value) except Exception: pass - self.block('EnvLight("envlight")') + self.block_assigned('envlight', 'EnvLight("envlight")') if env_texture is not None: self.out('["texture"] = %s,' % fmt_string( bpy.path.abspath(env_texture.filepath))) @@ -242,7 +253,7 @@ def write_world(self): tuple(c * strength for c in color))) self.out('["intensity"] = 1,') self.end_block() - self.light_refs.append('EnvLight("envlight")') + self.light_refs.append('envlight') def _env_mapping_rotation(self, env_tex_node): """Rotation (radians) of a Mapping node driving the environment @@ -268,9 +279,9 @@ def write_lights(self): self.geo_count += 1 name = "light_%s_%d" % (sanitize_name(obj.name), self.geo_count) self._write_one_light(obj, light, name) - self.light_refs.append('%s("%s")' % (_LIGHT_CLASS[light.type], name)) + self.light_refs.append(name) - self.block('LightSet("lightset")') + self.block_assigned('lightset', 'LightSet("lightset")') for ref in self.light_refs: self.out(ref + ",") self.end_block() @@ -282,7 +293,7 @@ def _write_one_light(self, obj, light, name): color = tuple(light.color) cls = _LIGHT_CLASS[light.type] - self.block('%s("%s")' % (cls, name)) + self.block_assigned(name, '%s("%s")' % (cls, name)) self.out('["node_xform"] = %s,' % fmt_mat4(light_xform(m))) if light.type == "AREA": @@ -362,10 +373,9 @@ def write_meshes(self): if entries: self.block('Layer("defaultLayer")') - for geo_name, mat_name in entries: - self.out('{GeometrySet("%s"), "", DwaBaseMaterial("%s"), ' - 'LightSet("lightset"), undef(), undef(), undef(), undef()},' - % (geo_name, mat_name)) + for geo_ref, mat_name in entries: + self.out('{%s, "", %s, lightset, undef(), undef(), undef(), undef()},' + % (geo_ref, mat_name)) self.end_block() def _write_instancer(self, items): @@ -374,6 +384,7 @@ def _write_instancer(self, items): obj0, evaluated0, mesh0 = items[0] name_base = sanitize_name(obj0.data.name or obj0.name, "mesh") base_name = self.unique("instbase_" + name_base) + inst_name = self.unique("inst_" + name_base) geo_name = self.unique("geo_" + name_base) mat_name = self.unique("mat_" + name_base) self._last_geo_name = geo_name @@ -405,7 +416,7 @@ def _write_instancer(self, items): normals.append(corner.normal) indices.append(len(indices)) - self.block('RdlMeshGeometry("%s")' % base_name) + self.block_assigned(base_name, 'RdlMeshGeometry("%s")' % base_name) self.out('["node_xform"] = %s,' % fmt_mat4(Matrix.Identity(4))) self.out('["is_subd"] = false,') self.out('["smooth_normal"] = true,') @@ -436,9 +447,9 @@ def _write_instancer(self, items): inst_orientations.append(quat) inst_scales.append(scale) - self.block('RdlInstancerGeometry("%s")' % geo_name) + self.block_assigned(inst_name, 'RdlInstancerGeometry("%s")' % inst_name) self.out('["node_xform"] = %s,' % fmt_mat4(Matrix.Identity(4))) - self.out('["references"] = {RdlMeshGeometry("%s")},' % base_name) + self.out('["references"] = {%s},' % base_name) self.out('["ref_indices"] = {%s},' % ", ".join("0" for _ in items)) self.out('["positions"] = {%s},' @@ -450,13 +461,14 @@ def _write_instancer(self, items): % ", ".join(fmt_vec3(s) for s in inst_scales)) self.end_block() - self.block('GeometrySet("%s")' % geo_name) - self.out('RdlInstancerGeometry("%s"),' % geo_name) + set_name = self.unique("set_" + name_base) + self.block_assigned(set_name, 'GeometrySet("%s")' % set_name) + self.out('%s,' % inst_name) self.end_block() material = obj0.active_material self._write_material(material, mat_name) - return geo_name, mat_name + return inst_name, mat_name def _mesh_velocities(self, mesh): """Per-vertex velocities from Blender's own motion-blur attribute. @@ -476,6 +488,7 @@ def _mesh_velocities(self, mesh): def _write_one_mesh(self, obj, evaluated, mesh): name_base = sanitize_name(obj.name, "mesh") + mesh_name = self.unique("mesh_" + name_base) geo_name = self.unique("geo_" + name_base) mat_name = self.unique("mat_" + name_base) self._last_geo_name = geo_name @@ -522,7 +535,7 @@ def _write_one_mesh(self, obj, evaluated, mesh): if self.settings.use_motion_blur: velocities = self._mesh_velocities(mesh) - self.block('RdlMeshGeometry("%s")' % geo_name) + self.block_assigned(mesh_name, 'RdlMeshGeometry("%s")' % mesh_name) self.out('["node_xform"] = %s,' % fmt_mat4(geometry_xform(m))) self.out('["is_subd"] = false,') self.out('["smooth_normal"] = true,') @@ -546,13 +559,14 @@ def _write_one_mesh(self, obj, evaluated, mesh): fmt_vec3(velocities[vi]) for vi in corner_verts)) self.end_block() - self.block('GeometrySet("%s")' % geo_name) - self.out('RdlMeshGeometry("%s"),' % geo_name) + set_name = self.unique("set_" + name_base) + self.block_assigned(set_name, 'GeometrySet("%s")' % set_name) + self.out('%s,' % mesh_name) self.end_block() material = obj.active_material self._write_material(material, mat_name) - return geo_name, mat_name + return mesh_name, mat_name def _write_material(self, material, name): # full shader-node graph compilation lives in materials.py diff --git a/blender_addon/materials.py b/blender_addon/materials.py index 9288172..5ad5cd2 100644 --- a/blender_addon/materials.py +++ b/blender_addon/materials.py @@ -548,7 +548,7 @@ def _simple_params(self, node): def _emit_dwa(self, name, params, cls="DwaBaseMaterial", extra_lines=None): out = self.exporter.out - out('%s("%s") {' % (cls, name)) + out('%s = %s("%s") {' % (name, cls, name)) expr, _bind = self._resolve_rgb(params["albedo"][0], params["albedo"][1]) out(' ["albedo"] = %s,' % expr) @@ -676,7 +676,7 @@ def _compile_mix(self, a_node, b_node, fac, name): self._emit_dwa( name, pa, cls="DwaMixMaterial", extra_lines=[ - '["material"] = DwaBaseMaterial("%s"),' % b_name, + '["material"] = %s,' % b_name, '["mix"] = %.9g,' % fac, ]) diff --git a/blender_addon/tests/mock_moonray.py b/blender_addon/tests/mock_moonray.py index 2b27208..adadbac 100644 --- a/blender_addon/tests/mock_moonray.py +++ b/blender_addon/tests/mock_moonray.py @@ -14,10 +14,23 @@ def main(): args = sys.argv[1:] out = "/tmp/mock_moonray.exr" + rdla = None if "-in" in args: - pass + rdla = args[args.index("-in") + 1] if "-out" in args: out = args[args.index("-out") + 1] + + # derive the render resolution from the RDLA scene variables + w, h = 64, 64 + if rdla and os.path.isfile(rdla): + import re + with open(rdla) as f: + text = f.read() + mw = re.search(r'\["image_width"\]\s*=\s*(\d+)', text) + mh = re.search(r'\["image_height"\]\s*=\s*(\d+)', text) + if mw and mh: + w, h = int(mw.group(1)), int(mh.group(1)) + total = 30 for i in range(total + 1): pct = int(i * 100.0 / total) @@ -27,8 +40,7 @@ def main(): sys.stdout.write("\n") sys.stdout.flush() - # a real 64x64 EXR: red -> blue gradient - w, h = 64, 64 + # a real EXR: red -> blue gradient rows = [] for y in range(h): row = [] diff --git a/blender_addon/tests/test_render.py b/blender_addon/tests/test_render.py index 1a9756d..8c703e2 100644 --- a/blender_addon/tests/test_render.py +++ b/blender_addon/tests/test_render.py @@ -26,12 +26,16 @@ def main(out_path): + # fresh scene first (read_factory_settings resets add-on enablement) + bpy.ops.wm.read_factory_settings(use_empty=True) + # install under canonical module name and enable through the add-on flow tmp = tempfile.mkdtemp(prefix="moonray_render_test_") pkg_dir = os.path.join(tmp, "moonray_blender") shutil.copytree(ADDON_DIR, pkg_dir, ignore=shutil.ignore_patterns("tests", "__pycache__")) sys.path.insert(0, tmp) + import moonray_blender # noqa: F401 (pre-load so enable finds it) bpy.ops.preferences.addon_enable(module="moonray_blender") prefs = bpy.context.preferences.addons["moonray_blender"].preferences @@ -42,7 +46,6 @@ def main(out_path): print("BIN EXISTS:", os.path.isfile(os.path.join(MOONRAY_ROOT, "bin", "moonray"))) # build a small scene - bpy.ops.wm.read_factory_settings(use_empty=True) scene = bpy.context.scene scene.render.engine = "MOONRAY_RENDER" scene.render.resolution_x = 480 @@ -56,7 +59,10 @@ def main(out_path): bpy.context.object.data.energy = 3.0 bpy.ops.object.camera_add(location=(5, -5, 3)) cam = bpy.context.object - cam.rotation_euler = (1.2, 0, 0.8) + # aim the camera at the origin + from mathutils import Vector + direction = Vector((0.0, 0.0, 0.0)) - cam.location + cam.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() scene.camera = cam settings = scene.moonray @@ -68,8 +74,19 @@ def main(out_path): bpy.ops.render.render(write_still=True) ok = os.path.isfile(out_path) and os.path.getsize(out_path) > 1000 - print("RENDER RESULT:", "OK" if ok else "MISSING", - out_path, os.path.getsize(out_path) if os.path.exists(out_path) else 0) + # verify the render is not black (guards against silently-empty results) + mean = 0.0 + if ok: + try: + img = bpy.data.images.load(out_path) + px = list(img.pixels) + mean = sum(px) / max(1, len(px)) + bpy.data.images.remove(img) + except Exception: + pass + ok = ok and mean > 0.01 + print("RENDER RESULT:", "OK" if ok else "BLACK/MISSING", + out_path, "mean_pixel=%.4f" % mean) bpy.ops.preferences.addon_disable(module="moonray_blender") return 0 if ok else 1 diff --git a/patches/CMakeUserPresets.json b/patches/CMakeUserPresets.json index 0f16dfb..c118eb3 100644 --- a/patches/CMakeUserPresets.json +++ b/patches/CMakeUserPresets.json @@ -6,9 +6,15 @@ "displayName": "macOS Release (Ninja, no Qt)", "inherits": "macos-release", "generator": "Ninja", + "environment": { + "DEPS_ROOT": "/Users/faputa/Documents/wave-tracer/installs", + "BUILD_DIR": "/Users/faputa/Documents/wave-tracer/build", + "TBB_ROOT": "$env{DEPS_ROOT}" + }, "cacheVariables": { "BUILD_QT_APPS": "NO", - "BUILD_TESTING": "OFF" + "BUILD_TESTING": "OFF", + "MOONRAY_USE_METAL": "OFF" } } ], diff --git a/patches/openmoonray-MoonrayCompileOptions-ispc-ninja.patch b/patches/openmoonray-MoonrayCompileOptions-ispc-ninja.patch new file mode 100644 index 0000000..93f652c --- /dev/null +++ b/patches/openmoonray-MoonrayCompileOptions-ispc-ninja.patch @@ -0,0 +1,47 @@ +diff --git a/cmake/MoonrayCompileOptions.cmake b/cmake/MoonrayCompileOptions.cmake +index b719930..f57e545 100644 +--- a/cmake/MoonrayCompileOptions.cmake ++++ b/cmake/MoonrayCompileOptions.cmake +@@ -82,8 +82,12 @@ function(${PROJECT_NAME}_ispc_compile_options target) + # for ensuring proper build order when ISPC compilation is required + set_property(TARGET ${target} + PROPERTY ISPC_DEP_TARGET "") +- check_language(ISPC) +- if(NOT CMAKE_ISPC_COMPILER) ++ # moonray-blender-patch: always use the custom ISPC command path below; ++ # it both compiles the .ispc sources AND generates the *_ispc_stubs.h ++ # headers included by the C++ code. CMake's built-in ISPC language ++ # (enabled via check_language when the ISPC env var is set) generates no ++ # stub headers and breaks the Ninja build. ++ if(TRUE) + get_target_property(SOURCES ${target} SOURCES) + get_target_property(ISPC_HEADER_SUFFIX ${target} ISPC_HEADER_SUFFIX) + get_target_property(ISPC_HEADER_DIRECTORY ${target} ISPC_HEADER_DIRECTORY) +@@ -135,11 +139,26 @@ function(${PROJECT_NAME}_ispc_compile_options target) + COMMAND_EXPAND_LISTS + VERBATIM + DEPFILE ${depFile} +- DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${src}) ++ DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${src} ++ # moonray-blender-patch: declare the generated stub header as ++ # a byproduct so the Ninja generator can resolve the header ++ # that C++ sources include (Xcode tolerates this implicitly). ++ BYPRODUCTS ${CMAKE_BINARY_DIR}/${ISPC_HEADER_DIRECTORY}/${srcName}${ISPC_HEADER_SUFFIX}) + list(APPEND ISPC_TARGET_OBJECTS ${objOut}) + endforeach() + target_link_libraries(${target} + PRIVATE ${ISPC_TARGET_OBJECTS}) ++ # moonray-blender-patch: mark .ispc sources as header-only so the ++ # Ninja generator does not require an ISPC compile rule for them ++ # (the custom commands above compile them and generate the stubs). ++ foreach(_ispc_src ${SOURCES}) ++ get_filename_component(_ispc_ext ${_ispc_src} LAST_EXT) ++ if (_ispc_ext STREQUAL ".ispc") ++ set_source_files_properties(${_ispc_src} PROPERTIES ++ HEADER_FILE_ONLY TRUE) ++ endif() ++ endforeach() ++ + add_custom_target(${target}_ispc_dep DEPENDS ${ISPC_TARGET_OBJECTS}) + add_dependencies(${target} ${target}_ispc_dep) + # Store the ISPC dependency target name for later retrieval diff --git a/patches/openmoonray-MoonrayDso-ispc-ninja.patch b/patches/openmoonray-MoonrayDso-ispc-ninja.patch new file mode 100644 index 0000000..68f856e --- /dev/null +++ b/patches/openmoonray-MoonrayDso-ispc-ninja.patch @@ -0,0 +1,54 @@ +diff --git a/cmake/MoonrayDso.cmake b/cmake/MoonrayDso.cmake +index 101f9d3..7934ba5 100644 +--- a/cmake/MoonrayDso.cmake ++++ b/cmake/MoonrayDso.cmake +@@ -70,8 +70,12 @@ function(Moonray_dso_ispc_compile_options target) + # for ensuring proper build order when ISPC compilation is required + set_property(TARGET ${target} + PROPERTY ISPC_DEP_TARGET "") +- check_language(ISPC) +- if(NOT CMAKE_ISPC_COMPILER) ++ # moonray-blender-patch: always use the custom ISPC command path below; ++ # it both compiles the .ispc sources AND generates the *_ispc_stubs.h ++ # headers included by the C++ code. CMake's built-in ISPC language ++ # (enabled via check_language when the ISPC env var is set) generates no ++ # stub headers and breaks the Ninja build. ++ if(TRUE) + get_target_property(SOURCES ${target} SOURCES) + get_target_property(ISPC_HEADER_SUFFIX ${target} ISPC_HEADER_SUFFIX) + get_target_property(ISPC_HEADER_DIRECTORY ${target} ISPC_HEADER_DIRECTORY) +@@ -123,11 +127,25 @@ function(Moonray_dso_ispc_compile_options target) + COMMAND_EXPAND_LISTS + VERBATIM + DEPFILE ${depFile} +- DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${src}) ++ DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${src} ++ # moonray-blender-patch: declare the generated stub header as ++ # a byproduct so the Ninja generator can resolve the header ++ # that C++ sources include (Xcode tolerates this implicitly). ++ BYPRODUCTS ${CMAKE_BINARY_DIR}/${ISPC_HEADER_DIRECTORY}/${srcName}${ISPC_HEADER_SUFFIX}) + list(APPEND ISPC_TARGET_OBJECTS ${objOut}) + endforeach() + target_link_libraries(${target} + PRIVATE ${ISPC_TARGET_OBJECTS}) ++ # moonray-blender-patch: mark .ispc sources as header-only so the ++ # Ninja generator does not require an ISPC compile rule for them ++ # (the custom commands above compile them and generate the stubs). ++ foreach(_ispc_src ${SOURCES}) ++ get_filename_component(_ispc_ext ${_ispc_src} LAST_EXT) ++ if (_ispc_ext STREQUAL ".ispc") ++ set_source_files_properties(${_ispc_src} PROPERTIES ++ HEADER_FILE_ONLY TRUE) ++ endif() ++ endforeach() + add_custom_target(${target}_ispc_dep DEPENDS ${ISPC_TARGET_OBJECTS}) + add_dependencies(${target} ${target}_ispc_dep) + # Store the ISPC dependency target name for later retrieval +@@ -307,7 +325,6 @@ function(moonray_dso_simple targetName) + --in $ + --out ${CMAKE_CURRENT_BINARY_DIR}/${dsoName}.json + DEPENDS ${targetName}_proxy +- BYPRODUCTS ${CMAKE_CURRENT_BINARY_DIR}/${dsoName}.json + VERBATIM + ) + add_custom_target(coredata_${targetName} ALL DEPENDS diff --git a/patches/openmoonray-Moonshine-ispc-ninja.patch b/patches/openmoonray-Moonshine-ispc-ninja.patch new file mode 100644 index 0000000..7c17f24 --- /dev/null +++ b/patches/openmoonray-Moonshine-ispc-ninja.patch @@ -0,0 +1,47 @@ +diff --git a/cmake/MoonshineCompileOptions.cmake b/cmake/MoonshineCompileOptions.cmake +index 6f9fee8..6f8ae98 100644 +--- a/cmake/MoonshineCompileOptions.cmake ++++ b/cmake/MoonshineCompileOptions.cmake +@@ -75,8 +75,12 @@ function(${PROJECT_NAME}_ispc_compile_options target) + PROPERTY TARGET_OBJECTS $) + set_property(TARGET ${target} + PROPERTY ISPC_DEP_TARGET "") +- check_language(ISPC) +- if(NOT CMAKE_ISPC_COMPILER) ++ # moonray-blender-patch: always use the custom ISPC command path below; ++ # it both compiles the .ispc sources AND generates the *_ispc_stubs.h ++ # headers included by the C++ code. CMake's built-in ISPC language ++ # (enabled via check_language when the ISPC env var is set) generates no ++ # stub headers and breaks the Ninja build. ++ if(TRUE) + get_target_property(SOURCES ${target} SOURCES) + get_target_property(ISPC_HEADER_SUFFIX ${target} ISPC_HEADER_SUFFIX) + get_target_property(ISPC_HEADER_DIRECTORY ${target} ISPC_HEADER_DIRECTORY) +@@ -128,11 +132,26 @@ function(${PROJECT_NAME}_ispc_compile_options target) + COMMAND_EXPAND_LISTS + VERBATIM + DEPFILE ${depFile} +- DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${src}) ++ DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${src} ++ # moonray-blender-patch: declare the generated stub header as ++ # a byproduct so the Ninja generator can resolve the header ++ # that C++ sources include (Xcode tolerates this implicitly). ++ BYPRODUCTS ${CMAKE_BINARY_DIR}/${ISPC_HEADER_DIRECTORY}/${srcName}${ISPC_HEADER_SUFFIX}) + list(APPEND ISPC_TARGET_OBJECTS ${objOut}) + endforeach() + target_link_libraries(${target} + PRIVATE ${ISPC_TARGET_OBJECTS}) ++ # moonray-blender-patch: mark .ispc sources as header-only so the ++ # Ninja generator does not require an ISPC compile rule for them ++ # (the custom commands above compile them and generate the stubs). ++ foreach(_ispc_src ${SOURCES}) ++ get_filename_component(_ispc_ext ${_ispc_src} LAST_EXT) ++ if (_ispc_ext STREQUAL ".ispc") ++ set_source_files_properties(${_ispc_src} PROPERTIES ++ HEADER_FILE_ONLY TRUE) ++ endif() ++ endforeach() ++ + add_custom_target(${target}_ispc_dep DEPENDS ${ISPC_TARGET_OBJECTS}) + add_dependencies(${target} ${target}_ispc_dep) + set_property(TARGET ${target} diff --git a/patches/openmoonray-SceneRdl2-ispc-ninja.patch b/patches/openmoonray-SceneRdl2-ispc-ninja.patch new file mode 100644 index 0000000..29a5d34 --- /dev/null +++ b/patches/openmoonray-SceneRdl2-ispc-ninja.patch @@ -0,0 +1,46 @@ +diff --git a/cmake/SceneRdl2CompileOptions.cmake b/cmake/SceneRdl2CompileOptions.cmake +index 4d98ec6..bda0632 100644 +--- a/cmake/SceneRdl2CompileOptions.cmake ++++ b/cmake/SceneRdl2CompileOptions.cmake +@@ -81,8 +81,12 @@ function(SceneRdl2_ispc_compile_options target) + PROPERTY TARGET_OBJECTS $) + set_property(TARGET ${target} + PROPERTY ISPC_DEP_TARGET "") +- check_language(ISPC) +- if(NOT CMAKE_ISPC_COMPILER) ++ # moonray-blender-patch: always use the custom ISPC command path below; ++ # it both compiles the .ispc sources AND generates the *_ispc_stubs.h ++ # headers included by the C++ code. CMake's built-in ISPC language ++ # (enabled via check_language when the ISPC env var is set) generates no ++ # stub headers and breaks the Ninja build. ++ if(TRUE) + get_target_property(SOURCES ${target} SOURCES) + get_target_property(ISPC_HEADER_SUFFIX ${target} ISPC_HEADER_SUFFIX) + get_target_property(ISPC_HEADER_DIRECTORY ${target} ISPC_HEADER_DIRECTORY) +@@ -134,11 +138,25 @@ function(SceneRdl2_ispc_compile_options target) + COMMAND_EXPAND_LISTS + VERBATIM + DEPFILE ${depFile} +- DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${src}) ++ DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${src} ++ # moonray-blender-patch: declare the generated stub header as ++ # a byproduct so the Ninja generator can resolve the header ++ # that C++ sources include (Xcode tolerates this implicitly). ++ BYPRODUCTS ${CMAKE_BINARY_DIR}/${ISPC_HEADER_DIRECTORY}/${srcName}${ISPC_HEADER_SUFFIX}) + list(APPEND ISPC_TARGET_OBJECTS ${objOut}) + endforeach() + target_link_libraries(${target} + PRIVATE ${ISPC_TARGET_OBJECTS}) ++ # moonray-blender-patch: mark .ispc sources as header-only so the ++ # Ninja generator does not require an ISPC compile rule for them ++ # (the custom commands above compile them and generate the stubs). ++ foreach(_ispc_src ${SOURCES}) ++ get_filename_component(_ispc_ext ${_ispc_src} LAST_EXT) ++ if (_ispc_ext STREQUAL ".ispc") ++ set_source_files_properties(${_ispc_src} PROPERTIES ++ HEADER_FILE_ONLY TRUE) ++ endif() ++ endforeach() + add_custom_target(${target}_ispc_dep DEPENDS ${ISPC_TARGET_OBJECTS}) + add_dependencies(${target} ${target}_ispc_dep) + set_property(TARGET ${target} diff --git a/patches/openmoonray-codesign-ninja.patch b/patches/openmoonray-codesign-ninja.patch new file mode 100644 index 0000000..58a6697 --- /dev/null +++ b/patches/openmoonray-codesign-ninja.patch @@ -0,0 +1,16 @@ +diff --git a/lib/scene/rdl2/CMakeLists.txt b/lib/scene/rdl2/CMakeLists.txt +index 31aeaef..fe7ac00 100644 +--- a/lib/scene/rdl2/CMakeLists.txt ++++ b/lib/scene/rdl2/CMakeLists.txt +@@ -202,7 +202,10 @@ add_executable(rdl2_ispc_util) + target_sources(rdl2_ispc_util PRIVATE rdl2_ispc_util/rdl2_ispc_util.cc) + target_link_libraries(rdl2_ispc_util PRIVATE scene_rdl2_tmp) + if (IsDarwinPlatform) +- add_custom_command(TARGET rdl2_ispc_util POST_BUILD COMMAND /usr/bin/codesign -s - -f -o linker-signed */rdl2_ispc_util) ++ # moonray-blender-patch: use a generator expression instead of the ++ # Xcode-config-subdirectory glob (*/rdl2_ispc_util), which does not ++ # match the Ninja layout where the binary is in the current directory. ++ add_custom_command(TARGET rdl2_ispc_util POST_BUILD COMMAND /usr/bin/codesign -s - -f -o linker-signed $) + endif() + + # Set standard compile/link options diff --git a/patches/openmoonray-moonray-CMakeLists.patch b/patches/openmoonray-moonray-CMakeLists.patch new file mode 100644 index 0000000..fc5f41e --- /dev/null +++ b/patches/openmoonray-moonray-CMakeLists.patch @@ -0,0 +1,37 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 34bf67f..6b9be5c 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -4,10 +4,13 @@ + cmake_minimum_required (VERSION 3.23.1) + + include(OMR_PackageVersion) # Sets versionString, projectString and PACKAGE_NAME ++# moonray-blender-patch: never enable CMake's built-in ISPC language. ++# The MoonRay build invokes the ISPC compiler directly through custom ++# commands (MoonrayDso.cmake / ISPC_COMPILER) which also generate the ++# *_ispc_stubs.h headers. CMake's built-in ISPC language (enabled under the ++# Ninja generator by the original code) does not generate those headers and ++# breaks the build with "missing and no known rule to make it". + set(languages LANGUAGES CXX C) +-if(NOT CMAKE_XCODE_BUILD_SYSTEM) +- list(APPEND languages ISPC) +-endif() + project(${projectString} + VERSION ${versionString} + ${languages}) +@@ -73,6 +76,15 @@ endif() + + if (MOONRAY_USE_METAL) + if(IsDarwinPlatform) ++ # moonray-blender-patch: with the Ninja generator the METAL language ++ # must be enabled explicitly (the official Xcode generator enables it ++ # implicitly). Required by lib/rendering/rt MetalGPUPrograms.metal. ++ check_language(METAL) ++ if(CMAKE_METAL_COMPILER) ++ enable_language(METAL) ++ else() ++ message(STATUS "No METAL support") ++ endif() + check_language(OBJCXX) + if(CMAKE_OBJCXX_COMPILER) + enable_language(OBJCXX) diff --git a/patches/openmoonray-ninja-duplicate-output.patch b/patches/openmoonray-ninja-duplicate-output.patch new file mode 100644 index 0000000..28aa1d6 --- /dev/null +++ b/patches/openmoonray-ninja-duplicate-output.patch @@ -0,0 +1,12 @@ +diff --git a/cmake/MoonrayDso.cmake b/cmake/MoonrayDso.cmake +index 101f9d3..14b661a 100644 +--- a/cmake/MoonrayDso.cmake ++++ b/cmake/MoonrayDso.cmake +@@ -307,7 +307,6 @@ function(moonray_dso_simple targetName) + --in $ + --out ${CMAKE_CURRENT_BINARY_DIR}/${dsoName}.json + DEPENDS ${targetName}_proxy +- BYPRODUCTS ${CMAKE_CURRENT_BINARY_DIR}/${dsoName}.json + VERBATIM + ) + add_custom_target(coredata_${targetName} ALL DEPENDS From d8785c2d3796506d36ef9fb80b889f4b4d0ebfea Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:32:39 +0800 Subject: [PATCH 15/25] Resolve add-on symlink when auto-detecting the MoonRay install root _default_moonray_root() used os.path.abspath(__file__), which keeps the symlink path (~/Library/.../addons/moonray_blender) and misses the real workspace, falling back to /Applications/MoonRay/... and making Blender report 'MoonRay not found'. Use os.path.realpath() so the install root is found even when the add-on is symlinked into Blender's add-ons directory. --- blender_addon/properties.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/blender_addon/properties.py b/blender_addon/properties.py index 82f79f5..053d8f3 100644 --- a/blender_addon/properties.py +++ b/blender_addon/properties.py @@ -32,8 +32,10 @@ def auto_detect_candidates(): def _default_moonray_root(): # Where this add-on source tree lives inside the moonray workspace: # /blender_addon -> /../installs/openmoonray + # realpath() resolves the add-on symlink so this works even when the + # add-on is installed as a symlink into Blender's add-ons directory. try: - here = os.path.dirname(os.path.abspath(__file__)) + here = os.path.dirname(os.path.realpath(__file__)) candidate = os.path.normpath(os.path.join(here, "..", "..", "installs", "openmoonray")) if os.path.isdir(candidate): return candidate From 0fc5c1c1cb8737838313f399daa36ddc1cbc965c Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:44:02 +0800 Subject: [PATCH 16/25] Add -info to moonray CLI so progress lines update the render status MoonRay only prints 'Rendering [ N%]' progress when -info is passed; without it the status bar stayed stuck on 'writing scene' for the whole render (which takes ~30s even for a small scene) and update_progress never advanced. Also set the status to Rendering immediately after launch. --- blender_addon/engine.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/blender_addon/engine.py b/blender_addon/engine.py index 2e14458..b5dfb7e 100644 --- a/blender_addon/engine.py +++ b/blender_addon/engine.py @@ -145,7 +145,10 @@ def cleanup(): # 2. render with the moonray CLI proc = MoonRayProcess(root, prefs.installs_root) - args = ["-in", rdla_path, "-out", out_exr] + # -info makes moonray emit "Rendering [ N%]" progress lines on stdout, + # which on_progress() parses; without it the status bar would stay + # stuck on "writing scene" for the whole render. + args = ["-in", rdla_path, "-out", out_exr, "-info"] if settings.threads > 0: args += ["-threads", str(settings.threads)] @@ -160,6 +163,8 @@ def on_progress(pct): cleanup() return + engine.update_stats("Rendering", "MoonRay: 0%") + rc = 0 try: while proc.proc.poll() is None: From 427b301cd9151c643f46bc36d10402c5ed042599 Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:54:36 +0800 Subject: [PATCH 17/25] Sanitize object names to valid Lua identifiers Blender auto-names duplicates as 'Sphere.001'; sanitize_name kept the '.', so the RDLA emitted 'mesh_Sphere.001_6 = RdlMeshGeometry(...)' and Lua parsed 'mesh_Sphere.001' as table index + malformed number, aborting with 'RDLA Error: malformed number near .001_'. Keep only alphanumerics and underscore in exported names. --- blender_addon/exporter.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py index ed9e951..9c3629f 100644 --- a/blender_addon/exporter.py +++ b/blender_addon/exporter.py @@ -103,11 +103,19 @@ def fmt_string(s): def sanitize_name(name, fallback="unnamed"): - """Make a Blender name safe to embed in RDLA code.""" + """Make a Blender name safe to embed in RDLA code. + + The sanitized name is used both inside RDLA string literals and as a + bare Lua variable name (``mesh_X = RdlMeshGeometry("mesh_X") { ... }``), + so it must be a valid Lua identifier: alphanumerics and underscore only. + Blender auto-names duplicates like "Sphere.001"; the '.' (and '-', '/') + would otherwise be parsed by Lua as operators and abort with + "malformed number near '.001_'". + """ name = str(name).strip() or fallback out = [] for ch in name: - if ch.isalnum() or ch in "_-./": + if ch.isalnum() or ch == "_": out.append(ch) else: out.append("_") From 313ee5e801ebe9eb5194b317d4cdd17ab2536492 Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:24:08 +0800 Subject: [PATCH 18/25] Derive installs root from MoonRay root when prefs leave it empty The user's saved prefs had installs_root empty, so _to_combined_channels skipped the R/G/B/A -> Combined.* channel rename and RenderLayer.load_from_file() silently produced an empty render result ('expected channel Combined.R not found'), leaving the viewer blank even though moonray wrote a valid EXR. Derive from the MoonRay root (/openmoonray) and also search for oiiotool next to the MoonRay install so the channel rename always runs. --- blender_addon/engine.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/blender_addon/engine.py b/blender_addon/engine.py index b5dfb7e..db4fc82 100644 --- a/blender_addon/engine.py +++ b/blender_addon/engine.py @@ -40,18 +40,22 @@ def _report_error(engine, msg): engine.report({"ERROR"}, msg) -def _to_combined_channels(exr_path, installs_root, engine): +def _to_combined_channels(exr_path, installs_root, moonray_root, engine): """Rename an EXR's channels to Combined.R/G/B/A so Blender can read it. Uses oiiotool from the dependency install when available; returns the - input path unchanged otherwise (or if the conversion fails). + input path unchanged otherwise (or if the conversion fails). oiiotool + is searched in the dependencies install root and, as a fallback, next + to the MoonRay install (installs/bin/oiiotool). """ - if not installs_root: - return exr_path - oiiotool = os.path.join(installs_root, "bin", "oiiotool") - if not os.path.isfile(oiiotool): - return exr_path import subprocess as _sp + candidates = [] + for base in (installs_root, os.path.dirname(moonray_root), moonray_root): + if base: + candidates.append(os.path.join(base, "bin", "oiiotool")) + oiiotool = next((c for c in candidates if os.path.isfile(c)), None) + if oiiotool is None: + return exr_path dst = os.path.join(os.path.dirname(exr_path), "combined.exr") try: proc = _sp.run( @@ -144,7 +148,15 @@ def cleanup(): _keep_rdla(engine, rdla_path, scene, settings) # 2. render with the moonray CLI - proc = MoonRayProcess(root, prefs.installs_root) + # derive the dependencies install root from the MoonRay root when the + # user left it empty (MoonRay root is /openmoonray, so its + # parent is ); needed for DYLD_LIBRARY_PATH and oiiotool. + installs_root = prefs.installs_root + if not installs_root: + parent = os.path.dirname(root) + if os.path.isdir(os.path.join(parent, "lib")): + installs_root = parent + proc = MoonRayProcess(root, installs_root) # -info makes moonray emit "Rendering [ N%]" progress lines on stdout, # which on_progress() parses; without it the status bar would stay # stuck on "writing scene" for the whole render. @@ -199,7 +211,7 @@ def on_progress(pct): # MoonRay writes beauty channels as R/G/B/A, but Blender's # RenderLayer.load_from_file expects "Combined.R/G/B/A" (otherwise the # final composite is silently black). Rename the channels first. - final = _to_combined_channels(final, prefs.installs_root, engine) + final = _to_combined_channels(final, installs_root, root, engine) # 3. load the result into the Render Result result = engine.begin_result(0, 0, w, h) From 2d1f7db8df0d168d30e5fa888fe3037fe518fa5f Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:08:50 +0800 Subject: [PATCH 19/25] Add real-time progressive render feedback via MoonRay checkpoints - Exporter: when "Progressive Preview" is on, emit SceneVariables checkpoint settings (checkpoint_active/mode/quality_steps/overwrite) and a RenderOutput carrying the checkpoint file name (MoonRay only writes checkpoints when at least one RenderOutput exists). - Engine: pass -checkpoint, poll the checkpoint EXR while moonray runs, and push each new snapshot into the render result with update_result so the image refines live instead of appearing only at the end. Checkpoint images are 3-channel, so a constant Combined.A is appended. - Add MoonRayRenderSettings.use_progressive (default on). - Fix stale test_materials assertion for the DwaMixMaterial variable ref. --- blender_addon/engine.py | 61 ++++++++++++++++++++++++--- blender_addon/exporter.py | 28 ++++++++++++ blender_addon/properties.py | 6 +++ blender_addon/tests/test_materials.py | 2 +- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/blender_addon/engine.py b/blender_addon/engine.py index db4fc82..5622939 100644 --- a/blender_addon/engine.py +++ b/blender_addon/engine.py @@ -40,13 +40,17 @@ def _report_error(engine, msg): engine.report({"ERROR"}, msg) -def _to_combined_channels(exr_path, installs_root, moonray_root, engine): +def _to_combined_channels(exr_path, installs_root, moonray_root, engine, + channels=4, width=None, height=None): """Rename an EXR's channels to Combined.R/G/B/A so Blender can read it. Uses oiiotool from the dependency install when available; returns the input path unchanged otherwise (or if the conversion fails). oiiotool is searched in the dependencies install root and, as a fallback, next - to the MoonRay install (installs/bin/oiiotool). + to the MoonRay install (installs/bin/oiiotool). Checkpoint images are + 3-channel (no alpha); pass channels=3 (plus width/height) to append a + constant Combined.A so the composite does not warn about a missing + alpha channel. """ import subprocess as _sp candidates = [] @@ -57,11 +61,18 @@ def _to_combined_channels(exr_path, installs_root, moonray_root, engine): if oiiotool is None: return exr_path dst = os.path.join(os.path.dirname(exr_path), "combined.exr") + if channels >= 4: + cmd = [oiiotool, exr_path, "--chnames", + "Combined.R,Combined.G,Combined.B,Combined.A", "-o", dst] + else: + w = int(width or 1) + h = int(height or 1) + cmd = [oiiotool, exr_path, "--chnames", + "Combined.R,Combined.G,Combined.B", + "--pattern", "constant:color=1.0", "%dx%d" % (w, h), "1", + "--chnames", "Combined.A", "--chappend", "-o", dst] try: - proc = _sp.run( - [oiiotool, exr_path, "--chnames", - "Combined.R,Combined.G,Combined.B,Combined.A", "-o", dst], - stdout=_sp.PIPE, stderr=_sp.PIPE) + proc = _sp.run(cmd, stdout=_sp.PIPE, stderr=_sp.PIPE) if proc.returncode == 0 and os.path.isfile(dst): return dst except OSError: @@ -161,6 +172,8 @@ def cleanup(): # which on_progress() parses; without it the status bar would stay # stuck on "writing scene" for the whole render. args = ["-in", rdla_path, "-out", out_exr, "-info"] + if getattr(settings, "use_progressive", False): + args.append("-checkpoint") if settings.threads > 0: args += ["-threads", str(settings.threads)] @@ -177,6 +190,36 @@ def on_progress(pct): engine.update_stats("Rendering", "MoonRay: 0%") + # progressive preview: poll MoonRay's checkpoint file and push it into + # the render result as it is overwritten (real-time feedback) + checkpoint_path = out_exr + ".checkpoint.exr" + last_sig = None + prog_result = None + + def _push_checkpoint(): + nonlocal last_sig, prog_result + if not os.path.isfile(checkpoint_path): + return + try: + st = os.stat(checkpoint_path) + sig = (st.st_size, st.st_mtime_ns) + except OSError: + return + if sig == last_sig: + return + last_sig = sig + combined = _to_combined_channels( + checkpoint_path, installs_root, root, engine, channels=3, + width=w, height=h) + if prog_result is None: + prog_result = engine.begin_result(0, 0, w, h) + layer = prog_result.layers[0] + try: + layer.load_from_file(combined) + engine.update_result(prog_result) + except Exception: + pass + rc = 0 try: while proc.proc.poll() is None: @@ -184,6 +227,8 @@ def on_progress(pct): proc.kill() cleanup() return + if getattr(settings, "use_progressive", False): + _push_checkpoint() time.sleep(0.1) rc = proc.proc.returncode finally: @@ -214,7 +259,9 @@ def on_progress(pct): final = _to_combined_channels(final, installs_root, root, engine) # 3. load the result into the Render Result - result = engine.begin_result(0, 0, w, h) + if prog_result is None: + prog_result = engine.begin_result(0, 0, w, h) + result = prog_result if not result.layers: _report_error(engine, "No render layers available for the result") engine.end_result(result) diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py index 9c3629f..79523f2 100644 --- a/blender_addon/exporter.py +++ b/blender_addon/exporter.py @@ -187,6 +187,11 @@ def write_scene_variables(self): self.out('["pixel_samples"] = %d,' % int(s.pixel_samples)) self.out('["min_adaptive_samples"] = %d,' % int(s.min_adaptive_samples)) self.out('["max_adaptive_samples"] = %d,' % int(s.max_adaptive_samples)) + if getattr(s, "use_progressive", False): + self.out('["checkpoint_active"] = true,') + self.out('["checkpoint_mode"] = 1,') + self.out('["checkpoint_quality_steps"] = 2,') + self.out('["checkpoint_overwrite"] = true,') if s.pixel_filter != "DEFAULT": self.out('["pixel_filter"] = %d,' % {"BOX": 0, "CUBIC": 1, "QUADRATIC": 2}[s.pixel_filter]) @@ -196,6 +201,27 @@ def write_scene_variables(self): self.out('["progressive_tile_order"] = 4,') self.end_block() + @property + def checkpoint_path(self): + return self.out_path + ".checkpoint.exr" + + def write_render_output(self): + """Optional RenderOutput that carries the checkpoint file name. + + MoonRay only activates progress-checkpoint writing when at least one + RenderOutput exists (its file_name is a throwaway; the primary beauty + image still comes from SceneVariables output_file). + """ + if not getattr(self.settings, "use_progressive", False): + return + self.block_assigned('beautyOutput', 'RenderOutput("/output/beauty")') + self.out('["file_name"] = %s,' % fmt_string( + self.out_path + ".ro.exr")) + self.out('["checkpoint_file_name"] = %s,' + % fmt_string(self.checkpoint_path)) + self.out('["result"] = "beauty",') + self.end_block() + def write_camera(self): cam_obj = self.scene.camera if cam_obj is None: @@ -595,6 +621,8 @@ def write(self): self.out() self.write_camera() self.out() + self.write_render_output() + self.out() self.write_world() self.out() self.write_lights() diff --git a/blender_addon/properties.py b/blender_addon/properties.py index 053d8f3..cede74a 100644 --- a/blender_addon/properties.py +++ b/blender_addon/properties.py @@ -159,6 +159,12 @@ class MoonRayRenderSettings(bpy.types.PropertyGroup): "OpenImageDenoise tool (denoise -mode oidn_cpu)", default=False, ) + use_progressive: BoolProperty( + name="Progressive Preview", + description="Write MoonRay progress checkpoints and update the " + "render result as they arrive (real-time preview)", + default=True, + ) export_only: BoolProperty( name="Export Only", description="Only export the .rdla scene and skip rendering " diff --git a/blender_addon/tests/test_materials.py b/blender_addon/tests/test_materials.py index ac62626..bdadc1a 100644 --- a/blender_addon/tests/test_materials.py +++ b/blender_addon/tests/test_materials.py @@ -121,7 +121,7 @@ def _s(node, name, st): "input_normal bind": '["input_normal"] = bind(ImageNormalMap(' in text, "normal dial 0.8": '["input_normal_dial"] = 0.8' in text, "DwaMixMaterial": "DwaMixMaterial(" in text, - '["material"] ref': '["material"] = DwaBaseMaterial(' in text, + '["material"] ref': '["material"] = mat_' in text, '["mix"] = 0.35': '["mix"] = 0.349' in text, "static MixRGB baked 0.5,0,0.5": 'Rgb(0.5, 0, 0.5)' in text, "glossy roughness": '["roughness"] = 0.15' in text, From ee6837068738d341da9a6b3b87812d9e7ea27357 Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:39:06 +0800 Subject: [PATCH 20/25] Map Cycles lights and materials to MoonRay for node-level parity Materials: - Principled BSDF now maps all inputs to DwaBaseMaterial: IOR (refractive_index), Diffuse Roughness, Anisotropic + Rotation (anisotropy/shading_tangent), Coat Weight/Roughness/IOR/Tint (clearcoat lobes), Sheen Weight/Roughness/Tint (fuzz lobes), Subsurface Weight/Radius/Scale (bssrdf/scattering_color/scattering_radius), Thin Wall (thin_geometry), and transmission tint via Base Color. - Emission Strength is now multiplied into the emission color (previously ignored). - Added BSDF_ANISOTROPIC, BSDF_REFRACTION, BSDF_TRANSLUCENT, BSDF_VELVET, BSDF_TOON and SUBSURFACE_SCATTERING; Blender >= 5.x reports the Anisotropic node as BSDF_GLOSSY, so Glossy now also reads its Anisotropy/Rotation sockets. Lights: - AREA DISK/ELLIPSE shape -> DiskLight (with radius and area-normalized intensity), SQUARE/RECTANGLE -> RectLight. - Area spread -> RectLight/DiskLight "spread". - Honored use_temperature via light.temperature_color. Tests: - New test_cycles_parity.py (22 checks) covering lights and full Principled BSDF + extra BSDF node mappings. - Fix test_export.py FakeSettings mis-indented use_motion_blur attribute. --- blender_addon/exporter.py | 31 +++- blender_addon/materials.py | 209 +++++++++++++++++++--- blender_addon/tests/test_cycles_parity.py | 174 ++++++++++++++++++ blender_addon/tests/test_export.py | 3 +- 4 files changed, 391 insertions(+), 26 deletions(-) create mode 100644 blender_addon/tests/test_cycles_parity.py diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py index 79523f2..dccee13 100644 --- a/blender_addon/exporter.py +++ b/blender_addon/exporter.py @@ -320,13 +320,26 @@ def write_lights(self): self.out(ref + ",") self.end_block() + def _light_color(self, light): + """Effective RGB color, honoring Blender's blackbody temperature.""" + if getattr(light, "use_temperature", False): + try: + return tuple(light.temperature_color) + except Exception: + pass + return tuple(light.color) + def _write_one_light(self, obj, light, name): evaluated = obj.evaluated_get(self.depsgraph) m = evaluated.matrix_world scale = self.prefs.light_scale - color = tuple(light.color) + color = self._light_color(light) + # AREA shape: SQUARE/RECTANGLE -> RectLight, DISK/ELLIPSE -> DiskLight cls = _LIGHT_CLASS[light.type] + shape = getattr(light, "shape", "SQUARE") + if light.type == "AREA" and shape in ("DISK", "ELLIPSE"): + cls = "DiskLight" self.block_assigned(name, '%s("%s")' % (cls, name)) self.out('["node_xform"] = %s,' % fmt_mat4(light_xform(m))) @@ -334,10 +347,18 @@ def _write_one_light(self, obj, light, name): sx = max(1e-6, light.size) sy = max(1e-6, light.size_y) # Blender area energy is in W; MoonRay normalized RectLight - # intensity is radiance-like, so divide by area. - intensity = (light.energy * scale) / (sx * sy) - self.out('["width"] = %s,' % _f(sx)) - self.out('["height"] = %s,' % _f(sy)) + # intensity is radiance-like, so divide by area (disk: by r^2). + if cls == "DiskLight": + r = max(1e-6, sx * 0.5) + intensity = (light.energy * scale) / (math.pi * r * r) + self.out('["radius"] = %s,' % _f(r)) + else: + intensity = (light.energy * scale) / (sx * sy) + self.out('["width"] = %s,' % _f(sx)) + self.out('["height"] = %s,' % _f(sy)) + spread = getattr(light, "spread", 0.0) + if spread > 0.0: + self.out('["spread"] = %s,' % _f(spread)) elif light.type == "POINT": # Blender point energy is in W; a normalized SphereLight # intensity of energy/(4*pi) approximates the same emission. diff --git a/blender_addon/materials.py b/blender_addon/materials.py index 5ad5cd2..604ba20 100644 --- a/blender_addon/materials.py +++ b/blender_addon/materials.py @@ -153,6 +153,8 @@ def eval_socket(self, sock): return _const_rgb(sock.default_value) if sock.type == "VALUE": return _const_float(sock.default_value) + if sock.type == "BOOLEAN": + return _const_float(1.0 if sock.default_value else 0.0) except Exception: pass return None @@ -444,39 +446,97 @@ def _principled_params(self, node): params = { "albedo": (None, (1.0, 1.0, 1.0)), "roughness": 0.5, + "diffuse_roughness": 0.0, "metallic": 0.0, - "specular": 1.0, + "specular": 0.5, + "refractive_index": 1.5, "emission": None, "emission_strength": 0.0, "alpha": 1.0, + "thin_geometry": False, "transmission": 0.0, "transmission_color": (1.0, 1.0, 1.0), + "anisotropy": 0.0, + "anisotropy_rotation": 0.0, + "clearcoat": 0.0, + "clearcoat_roughness": 0.03, + "clearcoat_ior": 1.5, + "clearcoat_color": (1.0, 1.0, 1.0), + "fuzz": 0.0, + "fuzz_roughness": 0.5, + "fuzz_albedo": (1.0, 1.0, 1.0), + "subsurface": 0, + "scattering_color": (1.0, 1.0, 1.0), + "scattering_radius": 0.0, "normal": None, # ("normal", image, strength) "input_normal_dial": 0.0, } base = ev.eval_socket(node.inputs["Base Color"]) params["albedo"] = (base, (1.0, 1.0, 1.0)) - - rough = ev.eval_socket(node.inputs["Roughness"]) - params["roughness"] = self._resolve_float(rough, 0.5) - - metal = ev.eval_socket(node.inputs["Metallic"]) - params["metallic"] = self._resolve_float(metal, 0.0) + base_rgb = base[1] if (base and base[0] == _RGB) else (1.0, 1.0, 1.0) + + params["roughness"] = self._resolve_float( + ev.eval_socket(node.inputs["Roughness"]), 0.5) + params["metallic"] = self._resolve_float( + ev.eval_socket(node.inputs["Metallic"]), 0.0) + params["refractive_index"] = self._resolve_float( + ev.eval_socket(node.inputs["IOR"]), 1.5) + params["alpha"] = self._resolve_float( + ev.eval_socket(node.inputs["Alpha"]), 1.0) + params["thin_geometry"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Thin Wall")), 0.0) > 0.5 + params["diffuse_roughness"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Diffuse Roughness")), 0.0) spec = ev.eval_socket(node.inputs.get("Specular IOR Level")) if spec is None: spec = ev.eval_socket(node.inputs.get("Specular")) - params["specular"] = self._resolve_float(spec, 1.0) - - alpha = ev.eval_socket(node.inputs["Alpha"]) - params["alpha"] = self._resolve_float(alpha, 1.0) + params["specular"] = self._resolve_float(spec, 0.5) trans = ev.eval_socket(node.inputs.get("Transmission Weight")) params["transmission"] = self._resolve_float(trans, 0.0) - - tc = ev.eval_socket(node.inputs.get("Transmission Color")) - if tc and tc[0] == _RGB: - params["transmission_color"] = tc[1] + # Blender >= 4.0 tints transmission through Base Color + params["transmission_color"] = base_rgb + + params["anisotropy"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Anisotropic")), 0.0) + params["anisotropy_rotation"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Anisotropic Rotation")), 0.0) + + # clearcoat (Coat) -> Dwa clearcoat lobe + coat = self._resolve_float( + ev.eval_socket(node.inputs.get("Coat Weight")), 0.0) + params["clearcoat"] = coat + params["clearcoat_roughness"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Coat Roughness")), 0.03) + params["clearcoat_ior"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Coat IOR")), 1.5) + coat_tint = ev.eval_socket(node.inputs.get("Coat Tint")) + if coat_tint and coat_tint[0] == _RGB: + params["clearcoat_color"] = coat_tint[1] + + # sheen -> Dwa fuzz lobe + sheen = self._resolve_float( + ev.eval_socket(node.inputs.get("Sheen Weight")), 0.0) + params["fuzz"] = sheen + params["fuzz_roughness"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Sheen Roughness")), 0.5) + sheen_tint = ev.eval_socket(node.inputs.get("Sheen Tint")) + if sheen_tint and sheen_tint[0] == _RGB: + params["fuzz_albedo"] = sheen_tint[1] + + # subsurface -> Dwa bssrdf lobe + sss_weight = self._resolve_float( + ev.eval_socket(node.inputs.get("Subsurface Weight")), 0.0) + if sss_weight > 0.0: + method = getattr(node, "subsurface_method", "RANDOM_WALK") + params["subsurface"] = {"BURLEY": 1, "RANDOM_WALK_SKIN": 2, + "RANDOM_WALK": 2}.get(method, 2) + radius = ev.eval_socket(node.inputs.get("Subsurface Radius")) + if radius and radius[0] == _RGB: + params["scattering_color"] = radius[1] + params["scattering_radius"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Subsurface Scale")), 0.05) em_c = ev.eval_socket(node.inputs["Emission Color"]) em_s = ev.eval_socket(node.inputs["Emission Strength"]) @@ -503,7 +563,8 @@ def _principled_params(self, node): return params def _simple_params(self, node): - """Diffuse/Glossy/Glass/Transparent/Emission shaders.""" + """Diffuse/Glossy/Glass/Transparent/Emission/Anisotropic/Refraction/ + Translucent/Velvet/Toon/Subsurface shaders.""" ev = self.evaluator ntype = node.type params = { @@ -511,11 +572,14 @@ def _simple_params(self, node): "roughness": 0.5, "metallic": 0.0, "specular": 1.0, + "refractive_index": 1.5, "emission": None, "emission_strength": 0.0, "alpha": 1.0, "transmission": 0.0, "transmission_color": (1.0, 1.0, 1.0), + "anisotropy": 0.0, + "anisotropy_rotation": 0.0, "normal": None, "input_normal_dial": 0.0, } @@ -531,14 +595,74 @@ def _simple_params(self, node): params["roughness"] = self._resolve_float( ev.eval_socket(node.inputs.get("Roughness")), 0.1) params["specular"] = 1.0 + # Blender >= 5.x merges the Anisotropic BSDF into Glossy + params["anisotropy"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Anisotropy")), 0.0) + params["anisotropy_rotation"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Rotation")), 0.0) elif ntype == "BSDF_GLASS": params["albedo"] = (ev.eval_socket(node.inputs["Color"]), (1.0, 1.0, 1.0)) params["transmission"] = 1.0 + params["transmission_color"] = ( + ev.eval_socket(node.inputs["Color"])[1] + if ev.eval_socket(node.inputs["Color"]) + and ev.eval_socket(node.inputs["Color"])[0] == _RGB + else (1.0, 1.0, 1.0)) params["roughness"] = self._resolve_float( ev.eval_socket(node.inputs.get("Roughness")), 0.0) + params["refractive_index"] = self._resolve_float( + ev.eval_socket(node.inputs.get("IOR")), 1.5) elif ntype == "BSDF_TRANSPARENT": params["alpha"] = 0.0 + elif ntype == "BSDF_REFRACTION": + color = ev.eval_socket(node.inputs["Color"]) + params["albedo"] = (color, (1.0, 1.0, 1.0)) + params["transmission"] = 1.0 + if color and color[0] == _RGB: + params["transmission_color"] = color[1] + params["roughness"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Roughness")), 0.0) + params["refractive_index"] = self._resolve_float( + ev.eval_socket(node.inputs.get("IOR")), 1.45) + elif ntype == "BSDF_TRANSLUCENT": + params["albedo"] = (ev.eval_socket(node.inputs["Color"]), + (1.0, 1.0, 1.0)) + params["transmission"] = 1.0 + params["specular"] = 0.0 + elif ntype == "BSDF_ANISOTROPIC": + params["albedo"] = (ev.eval_socket(node.inputs["Color"]), + (1.0, 1.0, 1.0)) + params["roughness"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Roughness")), 0.2) + params["specular"] = 1.0 + params["anisotropy"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Anisotropy")), 0.0) + params["anisotropy_rotation"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Rotation")), 0.0) + elif ntype == "BSDF_VELVET": + params["albedo"] = (ev.eval_socket(node.inputs["Color"]), + (1.0, 1.0, 1.0)) + params["specular"] = 0.0 + params["fuzz"] = 1.0 + params["fuzz_roughness"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Sigma")), 1.0) + params["fuzz_albedo"] = (1.0, 1.0, 1.0) + elif ntype == "BSDF_TOON": + params["albedo"] = (ev.eval_socket(node.inputs["Color"]), + (1.0, 1.0, 1.0)) + params["roughness"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Size")), 1.0) + params["specular"] = 0.0 + elif ntype == "SUBSURFACE_SCATTERING": + color = ev.eval_socket(node.inputs["Color"]) + params["albedo"] = (color, (1.0, 1.0, 1.0)) + params["specular"] = 0.0 + params["subsurface"] = 2 + if color and color[0] == _RGB: + params["scattering_color"] = color[1] + params["scattering_radius"] = self._resolve_float( + ev.eval_socket(node.inputs.get("Scale")), 1.0) elif ntype == "EMISSION": params["emission"] = ev.eval_socket(node.inputs["Color"]) params["emission_strength"] = self._resolve_float( @@ -555,15 +679,53 @@ def _emit_dwa(self, name, params, cls="DwaBaseMaterial", extra_lines=None): out(' ["roughness"] = %.9g,' % max(1e-4, params["roughness"])) out(' ["metallic"] = %.9g,' % params["metallic"]) out(' ["specular"] = %.9g,' % params["specular"]) + out(' ["refractive_index"] = %.9g,' % params["refractive_index"]) + if params.get("diffuse_roughness", 0.0) > 0.0: + out(' ["diffuse_roughness"] = %.9g,' + % params["diffuse_roughness"]) + if params.get("thin_geometry", False): + out(' ["thin_geometry"] = true,') if params["transmission"] > 0.0: out(' ["show_transmission"] = true,') out(' ["transmission"] = %.9g,' % params["transmission"]) tc = params["transmission_color"] out(' ["transmission_color"] = %s,' % fmt_rgb(tc)) + if params.get("anisotropy", 0.0) > 0.0: + out(' ["anisotropy"] = %.9g,' % params["anisotropy"]) + rot = params.get("anisotropy_rotation", 0.0) + tx, ty = math.cos(rot), math.sin(rot) + out(' ["shading_tangent"] = Vec2(%.9g, %.9g),' % (tx, ty)) + if params.get("clearcoat", 0.0) > 0.0: + out(' ["show_clearcoat"] = true,') + out(' ["clearcoat"] = %.9g,' % params["clearcoat"]) + out(' ["clearcoat_roughness"] = %.9g,' + % params["clearcoat_roughness"]) + out(' ["clearcoat_refractive_index"] = %.9g,' + % params["clearcoat_ior"]) + cc = params.get("clearcoat_color", (1.0, 1.0, 1.0)) + out(' ["clearcoat_attenuation_color"] = %s,' % fmt_rgb(cc)) + if params.get("fuzz", 0.0) > 0.0: + out(' ["show_fuzz"] = true,') + out(' ["fuzz"] = %.9g,' % params["fuzz"]) + out(' ["fuzz_roughness"] = %.9g,' % params["fuzz_roughness"]) + fa = params.get("fuzz_albedo", (1.0, 1.0, 1.0)) + out(' ["fuzz_albedo"] = %s,' % fmt_rgb(fa)) + if params.get("subsurface", 0) > 0: + out(' ["bssrdf"] = %d,' % params["subsurface"]) + sc = params.get("scattering_color", (1.0, 1.0, 1.0)) + out(' ["scattering_color"] = %s,' % fmt_rgb(sc)) + out(' ["scattering_radius"] = %.9g,' + % max(1e-6, params.get("scattering_radius", 0.05))) if params["alpha"] < 0.999: out(' ["presence"] = %.9g,' % max(0.0, params["alpha"])) if params["emission"] is not None and params["emission_strength"] > 0: - expr, _b = self._resolve_rgb(params["emission"], (0, 0, 0)) + em = params["emission"] + strength = params["emission_strength"] + if em[0] == _RGB: + expr = fmt_rgb(tuple( + min(1e9, c * strength) for c in em[1])) + else: + expr, _b = self._resolve_rgb(em, (0, 0, 0)) out(' ["emission"] = %s,' % expr) out(' ["show_emission"] = true,') if params["normal"] is not None: @@ -629,7 +791,10 @@ def compile_material(self, material, name): return name if surface.type in ("BSDF_DIFFUSE", "BSDF_GLOSSY", "BSDF_GLASS", - "BSDF_TRANSPARENT", "EMISSION"): + "BSDF_TRANSPARENT", "BSDF_REFRACTION", + "BSDF_TRANSLUCENT", "BSDF_ANISOTROPIC", + "BSDF_VELVET", "BSDF_TOON", + "SUBSURFACE_SCATTERING", "EMISSION"): self._emit_dwa(name, self._simple_params(surface)) self._flush_normal_maps() return name @@ -692,7 +857,10 @@ def _params_for(self, node): if node.type == "BSDF_PRINCIPLED": return self._principled_params(node) if node.type in ("BSDF_DIFFUSE", "BSDF_GLOSSY", "BSDF_GLASS", - "BSDF_TRANSPARENT", "EMISSION"): + "BSDF_TRANSPARENT", "BSDF_REFRACTION", + "BSDF_TRANSLUCENT", "BSDF_ANISOTROPIC", + "BSDF_VELVET", "BSDF_TOON", + "SUBSURFACE_SCATTERING", "EMISSION"): return self._simple_params(node) return self._principled_defaults() @@ -701,7 +869,8 @@ def _principled_defaults(self): "albedo": (None, (1.0, 1.0, 1.0)), "roughness": 0.5, "metallic": 0.0, - "specular": 1.0, + "specular": 0.5, + "refractive_index": 1.5, "emission": None, "emission_strength": 0.0, "alpha": 1.0, diff --git a/blender_addon/tests/test_cycles_parity.py b/blender_addon/tests/test_cycles_parity.py new file mode 100644 index 0000000..27097ce --- /dev/null +++ b/blender_addon/tests/test_cycles_parity.py @@ -0,0 +1,174 @@ +"""Cycles-parity checks: lights and Principled BSDF inputs map to RDLA. + +Run: + /Applications/Blender.app/Contents/MacOS/Blender --background \ + --factory-startup --python blender_addon/tests/test_cycles_parity.py +""" + +import os +import sys + +import bpy + +HERE = os.path.dirname(os.path.abspath(__file__)) +ADDON_DIR = os.path.dirname(HERE) +sys.path.insert(0, ADDON_DIR) + +from exporter import export_scene # noqa: E402 + + +class FakePrefs: + light_scale = 1.0 + + +class FakeSettings: + pixel_samples = 8 + min_adaptive_samples = 16 + max_adaptive_samples = 4096 + pixel_filter = "DEFAULT" + pixel_filter_width = 3.0 + use_progressive_tiles = False + use_motion_blur = False + use_progressive = False + + +def export(text_out): + scene = bpy.context.scene + dg = bpy.context.evaluated_depsgraph_get() + rdla = export_scene(scene, dg, FakeSettings(), FakePrefs(), text_out) + return open(rdla).read() + + +def main(): + results = {} + + # --- lights ---------------------------------------------------------- + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + bpy.ops.object.camera_add(location=(6, -6, 4)) + scene.camera = bpy.context.object + + bpy.ops.object.light_add(type="AREA", location=(0, 0, 3)) + area = bpy.context.object.data + area.shape = "DISK" + area.size = 2.0 + area.energy = 100.0 + text = export("/tmp/parity_lights.exr") + results["area disk -> DiskLight"] = "DiskLight(" in text + results["area disk radius"] = '["radius"] = 1' in text + + bpy.ops.object.light_add(type="AREA", location=(3, 0, 3)) + area2 = bpy.context.object.data + area2.shape = "SQUARE" + area2.size = 2.0 + area2.spread = 0.5 + text = export("/tmp/parity_lights2.exr") + results["area square -> RectLight"] = "RectLight(" in text + results["area spread"] = '["spread"] = 0.5' in text + + bpy.ops.object.light_add(type="POINT", location=(0, 2, 0)) + pnt = bpy.context.object.data + pnt.use_temperature = True + pnt.temperature = 6500.0 + text = export("/tmp/parity_lights3.exr") + results["point temperature color"] = ( + "SphereLight(" in text and '["color"] = Rgb(' in text) + + # --- Principled BSDF full mapping ------------------------------------ + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + bpy.ops.object.camera_add(location=(6, -6, 4)) + scene.camera = bpy.context.object + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + cube = bpy.context.object + + mat = bpy.data.materials.new("principled") + mat.use_nodes = True + node = mat.node_tree.nodes["Principled BSDF"] + node.inputs["Base Color"].default_value = (0.8, 0.3, 0.1, 1.0) + node.inputs["Metallic"].default_value = 0.7 + node.inputs["Roughness"].default_value = 0.2 + node.inputs["IOR"].default_value = 1.6 + node.inputs["Specular IOR Level"].default_value = 0.9 + node.inputs["Anisotropic"].default_value = 0.4 + node.inputs["Anisotropic Rotation"].default_value = 0.5 + node.inputs["Transmission Weight"].default_value = 0.6 + node.inputs["Coat Weight"].default_value = 0.3 + node.inputs["Coat Roughness"].default_value = 0.1 + node.inputs["Sheen Weight"].default_value = 0.2 + node.inputs["Subsurface Weight"].default_value = 0.5 + node.inputs["Emission Color"].default_value = (0.1, 0.5, 0.9, 1.0) + node.inputs["Emission Strength"].default_value = 4.0 + node.inputs["Diffuse Roughness"].default_value = 0.3 + cube.data.materials.append(mat) + + text = export("/tmp/parity_mat.exr") + results["ior"] = '["refractive_index"] = 1.6' in text + results["specular level"] = '["specular"] = 0.899' in text + results["metallic"] = '["metallic"] = 0.699' in text + results["anisotropy"] = '["anisotropy"] = 0.4000' in text + results["anisotropy tangent"] = '["shading_tangent"] = Vec2(' in text + results["transmission"] = '["transmission"] = 0.6000' in text + results["transmission color = base"] = \ + '["transmission_color"] = Rgb(0.8000' in text + results["clearcoat"] = '["clearcoat"] = 0.3000' in text + results["clearcoat roughness"] = '["clearcoat_roughness"] = 0.1000' in text + results["fuzz"] = '["fuzz"] = 0.2000' in text + results["subsurface"] = '["bssrdf"] = 2' in text + results["emission strength applied"] = \ + '["emission"] = Rgb(0.4000' in text + results["diffuse roughness"] = '["diffuse_roughness"] = 0.3000' in text + + # --- other Cycles BSDF nodes ----------------------------------------- + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + bpy.ops.object.camera_add(location=(6, -6, 4)) + scene.camera = bpy.context.object + bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0)) + cube = bpy.context.object + + def node_only(node_type, set_inputs): + m = bpy.data.materials.new(node_type) + m.use_nodes = True + tree = m.node_tree + # replace the principled node with the requested shader + for n in list(tree.nodes): + if n.type == "BSDF_PRINCIPLED": + tree.nodes.remove(n) + out = next(n for n in tree.nodes if n.type == "OUTPUT_MATERIAL") + n = tree.nodes.new(node_type) + tree.links.new(n.outputs[0], out.inputs["Surface"]) + for k, v in set_inputs.items(): + n.inputs[k].default_value = v + return m + + m = node_only("ShaderNodeBsdfAnisotropic", { + "Color": (0.9, 0.2, 0.2, 1.0), "Roughness": 0.3, "Anisotropy": 0.6}) + cube.data.materials.append(m) + text = export("/tmp/parity_aniso.exr") + results["bsdf anisotropic"] = '["anisotropy"] = 0.6' in text + + m = node_only("ShaderNodeBsdfRefraction", { + "Color": (0.2, 0.8, 0.9, 1.0), "IOR": 1.33}) + cube.data.materials.clear() + cube.data.materials.append(m) + text = export("/tmp/parity_refr.exr") + results["bsdf refraction transmission"] = '["transmission"] = 1' in text + results["bsdf refraction ior"] = '["refractive_index"] = 1.33' in text + + m = node_only("ShaderNodeBsdfTranslucent", { + "Color": (0.5, 0.1, 0.1, 1.0)}) + cube.data.materials.clear() + cube.data.materials.append(m) + text = export("/tmp/parity_translucent.exr") + results["bsdf translucent transmission"] = '["transmission"] = 1' in text + + ok = True + for k, v in results.items(): + print("CHECK %-28s: %s" % (k, "OK" if v else "MISSING")) + ok = ok and v + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/blender_addon/tests/test_export.py b/blender_addon/tests/test_export.py index 419e0e5..3420e56 100644 --- a/blender_addon/tests/test_export.py +++ b/blender_addon/tests/test_export.py @@ -60,7 +60,8 @@ class FakeSettings: pixel_filter = "DEFAULT" pixel_filter_width = 3.0 use_progressive_tiles = False - use_motion_blur = False + use_motion_blur = False + use_progressive = False rdla = exporter.export_scene(scene, depsgraph, FakeSettings(), FakePrefs(), out_path) From c2b2eef73494839684b28365da358d8c9f7ae5c1 Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:59:50 +0800 Subject: [PATCH 21/25] Fix emissive mesh lights, blackbody, and geometry orientation The user's MoonRay.blend Cornell box rendered black because it uses an emissive mesh (the Cycles way to make an area light) whose emission went through a Blackbody node the compiler did not understand. - Add ShaderNodeBlackbody support (Planckian-locus approximation). - Emissive meshes now become a MeshLight (a real directional area light that casts shadows) instead of a bare emissive material that floods the scene uniformly. Geometry referenced by a MeshLight is excluded from the Layer (MoonRay forbids layering it); intensity uses the Lambertian strength/pi conversion from Cycles emission strength to radiance. - Fix geometry_xform: it was A@M@A^-1 with untouched local vertex data, which left meshes in Blender's Z-up orientation inside MoonRay's Y-up world (rotation-invariant test shapes masked this). Now A@M, so vertex and normal data stay in local space and node_xform maps them correctly. - No EnvLight is emitted when the scene world is None, matching Cycles. - Reorder RDLA emission: geometry -> light set -> layer, so MeshLight and Layer can reference already-declared objects. Tests updated for MeshLight-based emission and the no-world EnvLight rule. --- blender_addon/exporter.py | 65 ++++++++++++++++++--- blender_addon/materials.py | 71 ++++++++++++++++++++++- blender_addon/tests/test_cycles_parity.py | 4 +- blender_addon/tests/test_full_scene.py | 3 +- blender_addon/tests/test_robustness.py | 3 +- 5 files changed, 129 insertions(+), 17 deletions(-) diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py index dccee13..bb306d0 100644 --- a/blender_addon/exporter.py +++ b/blender_addon/exporter.py @@ -46,7 +46,9 @@ def camera_xform(m): def geometry_xform(m): - return _A @ m @ _A_INV + # Map the Blender world transform to MoonRay (Z-up -> Y-up). Mesh vertex + # and normal data are left in local space; MoonRay applies node_xform. + return _A @ m def light_xform(m): @@ -255,11 +257,14 @@ def write_camera(self): def write_world(self): world = self.scene.world + if world is None: + # Match Cycles: no world -> no environment light. + return color = (0.05, 0.05, 0.05) strength = 1.0 env_texture = None env_rotation = 0.0 - if world is not None and world.use_nodes: + if world.use_nodes: bg = next((n for n in world.node_tree.nodes if n.type == "BACKGROUND"), None) if bg is not None: @@ -303,7 +308,7 @@ def _env_mapping_rotation(self, env_tex_node): return 0.0 # -- lights ------------------------------------------------------------ - def write_lights(self): + def write_light_objects(self): for obj in self.scene.objects: if obj.type != "LIGHT" or not obj.visible_get(): continue @@ -315,6 +320,7 @@ def write_lights(self): self._write_one_light(obj, light, name) self.light_refs.append(name) + def write_light_set(self): self.block_assigned('lightset', 'LightSet("lightset")') for ref in self.light_refs: self.out(ref + ",") @@ -421,11 +427,15 @@ def write_meshes(self): obj, evaluated, mesh) else: geo_name, mat_name = self._write_instancer(items) - entries.append((geo_name, mat_name)) + if geo_name is not None: + entries.append((geo_name, mat_name)) finally: for _obj, _evaluated, _mesh in items: _evaluated.to_mesh_clear() + return entries + + def write_layer(self, entries): if entries: self.block('Layer("defaultLayer")') for geo_ref, mat_name in entries: @@ -620,17 +630,49 @@ def _write_one_mesh(self, obj, evaluated, mesh): self.end_block() material = obj.active_material - self._write_material(material, mat_name) + emission = self._write_material(material, mat_name, + emit_emission=False) + if emission is not None: + # Emissive geometry becomes a MeshLight (a real area light) and + # is NOT assigned to the Layer (MoonRay forbids referencing a + # layered geometry in a MeshLight). + self._write_mesh_light(mesh_name, emission) + return None, None return mesh_name, mat_name - def _write_material(self, material, name): + def _write_mesh_light(self, geometry_name, emission): + """Emit a MeshLight so an emissive mesh acts as a real area light. + + MoonRay samples MeshLights as directional area lights (producing + shadows), matching how Cycles treats an emissive surface; a bare + emissive material floods the scene uniformly instead. The geometry + is referenced by the MeshLight (and stays visible as the glowing + light surface), and is not assigned to the Layer. + """ + base_color, strength = emission + name = self.unique("meshlight") + self.block_assigned(name, 'MeshLight("%s")' % name) + self.out('["geometry"] = %s,' % geometry_name) + self.out('["color"] = %s,' % fmt_rgb(tuple( + min(1.0, c) for c in base_color))) + # Cycles' Emission strength is flux density (W/m^2); MoonRay's + # non-normalized MeshLight intensity is radiance (W/m^2/sr), so the + # Lambertian conversion divides by pi. + self.out('["intensity"] = %s,' % _f(strength / math.pi)) + self.out('["exposure"] = 0,') + self.out('["normalized"] = false,') + self.end_block() + self.light_refs.append(name) + + def _write_material(self, material, name, emit_emission=True): # full shader-node graph compilation lives in materials.py try: from . import materials except ImportError: import materials # standalone (non-package) usage in tests compiler = materials.MaterialCompiler(self) - compiler.compile_material(material, name) + compiler.compile_material(material, name, emit_emission=emit_emission) + return compiler.emission # -- top level --------------------------------------------------------- def write(self): @@ -646,9 +688,14 @@ def write(self): self.out() self.write_world() self.out() - self.write_lights() + self.write_light_objects() + self.out() + # geometry must be declared before MeshLight/LightSet/Layer ref it + entries = self.write_meshes() + self.out() + self.write_light_set() self.out() - self.write_meshes() + self.write_layer(entries) return "\n".join(self.lines) + "\n" diff --git a/blender_addon/materials.py b/blender_addon/materials.py index 604ba20..7043344 100644 --- a/blender_addon/materials.py +++ b/blender_addon/materials.py @@ -209,6 +209,8 @@ def eval_node(self, node): lum = (0.2126 * c[1][0] + 0.7152 * c[1][1] + 0.0722 * c[1][2]) value = _const_float(lum) + elif ntype == "BLACKBODY": + value = self._eval_blackbody(node) elif ntype == "VALTORGB": value = self._eval_colorramp(node) elif ntype == "CLAMP": @@ -232,6 +234,61 @@ def _f(self, sock): return v[1] return None + def _eval_blackbody(self, node): + """ShaderNodeBlackbody -> linear-ish RGB for the given temperature. + + Uses the same Planckian-locus approximation Blender/Cycles apply for + the blackbody node, so a 6500K emitter comes out near-white. + """ + t = self._f(node.inputs.get("Temperature")) + if t is None or t <= 0.0: + return None + # Cycles blackbody: RGB in a wide-ish gamut, normalized to preserve + # relative intensity; scaled here to roughly match the node output. + # From Blender's implementation (Mitchell 1999 recursive 2020). + t_k = t + # piecewise approximation on the 2020 locus (simplified Kelvin->xyY) + def _x(kk): + if kk < 4000: + return (-0.2661239e9 / kk**3 + - 0.2343580e6 / kk**2 + + 0.8776956e3 / kk + + 0.179910) + return (-3.0258469e9 / kk**3 + + 2.1070379e6 / kk**2 + + 0.2226347e3 / kk + + 0.240390) + + def _y_from_x(x, kk): + if kk < 2222: + return (-1.1063814 * x**3 - 1.34811020 * x**2 + + 2.18555832 * x - 0.20219683) + if kk < 4000: + return (-0.9549476 * x**3 - 1.37418593 * x**2 + + 2.09137015 * x - 0.16748867) + return (3.0817580 * x**3 - 5.87338670 * x**2 + + 3.75112997 * x - 0.37001483) + + x = _x(t_k) + y = _y_from_x(x, t_k) + if y <= 0.0 or x <= 0.0: + return _const_rgb((1.0, 1.0, 1.0)) + # convert xyY (Y=1) to XYZ + z = 1.0 - x - y + X = x / y + Z = z / y + # XYZ -> linear sRGB (D65) + r = 3.2406 * X - 1.5372 * 1.0 - 0.4986 * Z + g = -0.9689 * X + 1.8758 * 1.0 + 0.0415 * Z + b = 0.0557 * X - 0.2040 * 1.0 + 1.0570 * Z + # normalize to keep max channel ~ 1.0 (Cycles blackbody does not + # clamp, but the emitter strength scales it anyway) + mx = max(r, g, b) + if mx <= 0.0: + return _const_rgb((1.0, 1.0, 1.0)) + return _const_rgb(( + max(0.0, r / mx), max(0.0, g / mx), max(0.0, b / mx))) + def _eval_noise(self, node): """ShaderNodeTexNoise -> NoiseMap_v2 (grayscale, color mode).""" scale = self._f(node.inputs.get("Scale")) or 1.0 @@ -396,6 +453,8 @@ def __init__(self, exporter): self.exporter = exporter self.evaluator = NodeEvaluator() self._mat_index = exporter.mat_count # reuse counter via exporter + self.emit_emission = True + self.emission = None # -- utilities --------------------------------------------------------- def _unique(self, base): @@ -724,10 +783,14 @@ def _emit_dwa(self, name, params, cls="DwaBaseMaterial", extra_lines=None): if em[0] == _RGB: expr = fmt_rgb(tuple( min(1e9, c * strength) for c in em[1])) + # record (base_color, strength) so the exporter can also + # emit a MeshLight for this emissive geometry + self.emission = (em[1], strength) else: expr, _b = self._resolve_rgb(em, (0, 0, 0)) - out(' ["emission"] = %s,' % expr) - out(' ["show_emission"] = true,') + if self.emit_emission: + out(' ["emission"] = %s,' % expr) + out(' ["show_emission"] = true,') if params["normal"] is not None: nrm = params["normal"] kind, img, strength = nrm[0], nrm[1], nrm[2] @@ -762,10 +825,12 @@ def _emit_normal_map_block(self, nm_name, img, mapping=None): self.exporter.end_block() # -- entry points ------------------------------------------------------ - def compile_material(self, material, name): + def compile_material(self, material, name, emit_emission=True): """Write RDLA blocks for the material; return the material ref name used in the Layer entry.""" self._pending_normal_maps = [] + self.emission = None + self.emit_emission = emit_emission ev = self.evaluator if material is None or not material.use_nodes: diff --git a/blender_addon/tests/test_cycles_parity.py b/blender_addon/tests/test_cycles_parity.py index 27097ce..b46b2f3 100644 --- a/blender_addon/tests/test_cycles_parity.py +++ b/blender_addon/tests/test_cycles_parity.py @@ -115,8 +115,8 @@ def main(): results["clearcoat roughness"] = '["clearcoat_roughness"] = 0.1000' in text results["fuzz"] = '["fuzz"] = 0.2000' in text results["subsurface"] = '["bssrdf"] = 2' in text - results["emission strength applied"] = \ - '["emission"] = Rgb(0.4000' in text + results["emission strength applied"] = ( + 'MeshLight(' in text and '["intensity"] = 1.2732' in text) results["diffuse roughness"] = '["diffuse_roughness"] = 0.3000' in text # --- other Cycles BSDF nodes ----------------------------------------- diff --git a/blender_addon/tests/test_full_scene.py b/blender_addon/tests/test_full_scene.py index 858da9b..f86bc21 100644 --- a/blender_addon/tests/test_full_scene.py +++ b/blender_addon/tests/test_full_scene.py @@ -147,8 +147,7 @@ def main(out_path): "RectLight": "RectLight(" in text, "EnvLight": "EnvLight(" in text, "ImageMap": "ImageMap(" in text, - "emission": '"emission"' in text, - "show_emission": '"show_emission"' in text, + "emissive MeshLight": "MeshLight(" in text, "presence(alpha)": '"presence"' in text, "transmission": '"transmission"' in text, "uv_list": '"uv_list"' in text, diff --git a/blender_addon/tests/test_robustness.py b/blender_addon/tests/test_robustness.py index ecbd056..ba51cfa 100644 --- a/blender_addon/tests/test_robustness.py +++ b/blender_addon/tests/test_robustness.py @@ -48,7 +48,8 @@ def main(): bpy.ops.object.camera_add(location=(4, -4, 3)) scene.camera = bpy.context.object rdla, text = export(scene, "/tmp/robust_nolight.exr") - results["no lights"] = ("EnvLight" in text and "Cube" in text) + # world is None here, so no EnvLight should be emitted (matches Cycles) + results["no lights"] = ("Cube" in text and "EnvLight" not in text) # 2. empty scene (no camera) bpy.ops.wm.read_factory_settings(use_empty=True) From ec4c7b9f43373c1eb2060f9974ffe97a32c89aaa Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:52:04 +0800 Subject: [PATCH 22/25] Add Cycles comparison loss and per-face material splitting - New tests/compare_cycles.py defines a multi-term loss (MSE, MAE, luminance MSE, chroma angular error, per-channel MAE) between a Cycles reference EXR and a MoonRay render, so renderer differences are measured instead of eyeballed. - Export meshes split by material slot: multi-material objects (e.g. a Cornell box with separate red/green/white walls) now emit one geometry + material per slot and one Layer entry each, instead of collapsing everything to obj.active_material. Verified the sun-lit geometry path now matches Cycles with near-zero loss. --- blender_addon/exporter.py | 154 +++++++++++++------------- blender_addon/tests/compare_cycles.py | 111 +++++++++++++++++++ 2 files changed, 188 insertions(+), 77 deletions(-) create mode 100644 blender_addon/tests/compare_cycles.py diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py index bb306d0..fa6d467 100644 --- a/blender_addon/exporter.py +++ b/blender_addon/exporter.py @@ -423,12 +423,11 @@ def write_meshes(self): try: if len(items) == 1: obj, evaluated, mesh = items[0] - geo_name, mat_name = self._write_one_mesh( + new_entries = self._write_one_mesh( obj, evaluated, mesh) else: - geo_name, mat_name = self._write_instancer(items) - if geo_name is not None: - entries.append((geo_name, mat_name)) + new_entries = self._write_instancer(items) + entries.extend(new_entries) finally: for _obj, _evaluated, _mesh in items: _evaluated.to_mesh_clear() @@ -533,7 +532,7 @@ def _write_instancer(self, items): material = obj0.active_material self._write_material(material, mat_name) - return inst_name, mat_name + return [(inst_name, mat_name)] def _mesh_velocities(self, mesh): """Per-vertex velocities from Blender's own motion-blur attribute. @@ -552,93 +551,94 @@ def _mesh_velocities(self, mesh): return None def _write_one_mesh(self, obj, evaluated, mesh): + """Emit a mesh, splitting it per material slot so multi-material + objects (e.g. a Cornell box with different wall colors) keep their + per-face assignments. Returns a list of (geo_ref, mat_name) entries + to place in the Layer.""" name_base = sanitize_name(obj.name, "mesh") - mesh_name = self.unique("mesh_" + name_base) - geo_name = self.unique("geo_" + name_base) - mat_name = self.unique("mat_" + name_base) - self._last_geo_name = geo_name - self._last_mat_name = mat_name - - # triangulate mesh.calc_loop_triangles() tris = mesh.loop_triangles - - # Blender >= 4.1 renamed loops -> corners and always keeps split - # normals; older versions need the explicit split-normal bake. corners = mesh.corners if hasattr(mesh, "corners") else mesh.loops if hasattr(mesh, "calc_normals_split"): mesh.calc_normals_split() - - # UVs uv_layer = mesh.uv_layers.active has_uvs = uv_layer is not None - - positions = [] - uvs = [] - normals = [] - indices = [] - corner_verts = [] - for tri in tris: - for loop_index in tri.loops: - corner = corners[loop_index] - corner_verts.append(corner.vertex_index) - positions.append(mesh.vertices[corner.vertex_index].co) - if has_uvs: - uv = (uv_layer.uv[loop_index].vector - if hasattr(uv_layer, "uv") - else uv_layer.data[loop_index].uv) - # Blender UV origin is bottom-left; OIIO/MoonRay texture - # origin is top-left. - uvs.append((uv[0], 1.0 - uv[1])) - normals.append(corner.normal) - indices.append(len(indices)) - m = evaluated.matrix_world - - # per-vertex velocities (one frame of motion) for motion blur velocities = None if self.settings.use_motion_blur: velocities = self._mesh_velocities(mesh) - self.block_assigned(mesh_name, 'RdlMeshGeometry("%s")' % mesh_name) - self.out('["node_xform"] = %s,' % fmt_mat4(geometry_xform(m))) - self.out('["is_subd"] = false,') - self.out('["smooth_normal"] = true,') - if obj.active_material is not None and \ - obj.active_material.use_backface_culling: - self.out('["side_type"] = 1,') - self.out('["vertex_list_0"] = {%s},' - % ", ".join(fmt_vec3(p) for p in positions)) - self.out('["vertices_by_index"] = {%s},' - % ", ".join(str(i) for i in indices)) - self.out('["face_vertex_count"] = {%s},' - % ", ".join("3" for _t in tris)) - if has_uvs: - self.out('["uv_list"] = {%s},' - % ", ".join(fmt_vec2(u) for u in uvs)) - self.out('["normal_list"] = {%s},' - % ", ".join(fmt_vec3(n) for n in normals)) - if velocities is not None: - self.out('["use_local_motion_blur"] = true,') - self.out('["velocity_list_0"] = {%s},' % ", ".join( - fmt_vec3(velocities[vi]) for vi in corner_verts)) - self.end_block() + materials = list(mesh.materials) + groups = {} + for tri in tris: + groups.setdefault(tri.material_index, []).append(tri) - set_name = self.unique("set_" + name_base) - self.block_assigned(set_name, 'GeometrySet("%s")' % set_name) - self.out('%s,' % mesh_name) - self.end_block() + entries = [] + for mi, group in groups.items(): + material = materials[mi] if mi < len(materials) else None + mesh_name = self.unique("mesh_" + name_base) + mat_name = self.unique("mat_" + name_base) + self._last_geo_name = mesh_name + self._last_mat_name = mat_name + + positions = [] + uvs = [] + normals = [] + indices = [] + corner_verts = [] + for tri in group: + for loop_index in tri.loops: + corner = corners[loop_index] + corner_verts.append(corner.vertex_index) + positions.append(mesh.vertices[corner.vertex_index].co) + if has_uvs: + uv = (uv_layer.uv[loop_index].vector + if hasattr(uv_layer, "uv") + else uv_layer.data[loop_index].uv) + # Blender UV origin is bottom-left; OIIO/MoonRay + # texture origin is top-left. + uvs.append((uv[0], 1.0 - uv[1])) + normals.append(corner.normal) + indices.append(len(indices)) + + self.block_assigned(mesh_name, 'RdlMeshGeometry("%s")' % mesh_name) + self.out('["node_xform"] = %s,' % fmt_mat4(geometry_xform(m))) + self.out('["is_subd"] = false,') + self.out('["smooth_normal"] = true,') + if material is not None and material.use_backface_culling: + self.out('["side_type"] = 1,') + self.out('["vertex_list_0"] = {%s},' + % ", ".join(fmt_vec3(p) for p in positions)) + self.out('["vertices_by_index"] = {%s},' + % ", ".join(str(i) for i in indices)) + self.out('["face_vertex_count"] = {%s},' + % ", ".join("3" for _t in group)) + if has_uvs: + self.out('["uv_list"] = {%s},' + % ", ".join(fmt_vec2(u) for u in uvs)) + self.out('["normal_list"] = {%s},' + % ", ".join(fmt_vec3(n) for n in normals)) + if velocities is not None: + self.out('["use_local_motion_blur"] = true,') + self.out('["velocity_list_0"] = {%s},' % ", ".join( + fmt_vec3(velocities[vi]) for vi in corner_verts)) + self.end_block() + + set_name = self.unique("set_" + name_base) + self.block_assigned(set_name, 'GeometrySet("%s")' % set_name) + self.out('%s,' % mesh_name) + self.end_block() - material = obj.active_material - emission = self._write_material(material, mat_name, - emit_emission=False) - if emission is not None: - # Emissive geometry becomes a MeshLight (a real area light) and - # is NOT assigned to the Layer (MoonRay forbids referencing a - # layered geometry in a MeshLight). - self._write_mesh_light(mesh_name, emission) - return None, None - return mesh_name, mat_name + emission = self._write_material(material, mat_name, + emit_emission=False) + if emission is not None: + # Emissive geometry becomes a MeshLight (a real area light) + # and is NOT assigned to the Layer (MoonRay forbids + # referencing a layered geometry in a MeshLight). + self._write_mesh_light(mesh_name, emission) + else: + entries.append((mesh_name, mat_name)) + return entries def _write_mesh_light(self, geometry_name, emission): """Emit a MeshLight so an emissive mesh acts as a real area light. diff --git a/blender_addon/tests/compare_cycles.py b/blender_addon/tests/compare_cycles.py new file mode 100644 index 0000000..0a426e0 --- /dev/null +++ b/blender_addon/tests/compare_cycles.py @@ -0,0 +1,111 @@ +"""Compare a MoonRay render against the Cycles render of the same scene. + +Defines a multi-term loss so the difference between the two renderers is +quantified instead of eyeballed. Both images are compared in linear RGB +(Blender's default "Standard" view transform, so the EXRs are linear). + +Loss terms (all computed on the full-resolution float pixels): + L_mse : mean squared error over linear RGB (penalizes any difference). + L_mae : mean absolute error over linear RGB (robust to outliers). + L_luma : MSE over luminance (brightness match, ignoring chroma). + L_chroma: mean angular error of normalized RGB (color-only match). + +Run: + /Applications/Blender.app/Contents/MacOS/Blender --background \ + --factory-startup --python blender_addon/tests/compare_cycles.py -- \ + +""" + +import math +import sys + +import bpy + + +def _to_linear(pixel): + # Blender stores EXR pixels already linear; keep as-is. + return pixel + + +def compute_loss(ref_px, cand_px, w, h): + n = w * h + assert len(ref_px) == n * 4 and len(cand_px) == n * 4, \ + "resolution mismatch: ref=%d cand=%d" % (len(ref_px), len(cand_px)) + + se = [0.0, 0.0, 0.0] + ae = [0.0, 0.0, 0.0] + lse = 0.0 + chroma = 0.0 + for i in range(n): + r0, g0, b0 = ref_px[4 * i], ref_px[4 * i + 1], ref_px[4 * i + 2] + r1, g1, b1 = cand_px[4 * i], cand_px[4 * i + 1], cand_px[4 * i + 2] + d = (r0 - r1, g0 - g1, b0 - b1) + se[0] += d[0] * d[0] + se[1] += d[1] * d[1] + se[2] += d[2] * d[2] + ae[0] += abs(d[0]) + ae[1] += abs(d[1]) + ae[2] += abs(d[2]) + l0 = 0.2126 * r0 + 0.7152 * g0 + 0.0722 * b0 + l1 = 0.2126 * r1 + 0.7152 * g1 + 0.0722 * b1 + lse += (l0 - l1) ** 2 + # angular error between normalized colors (guards zero-vectors) + n0 = math.sqrt(r0 * r0 + g0 * g0 + b0 * b0) + n1 = math.sqrt(r1 * r1 + g1 * g1 + b1 * b1) + if n0 > 1e-6 and n1 > 1e-6: + dot = (r0 * r1 + g0 * g1 + b0 * b1) / (n0 * n1) + dot = max(-1.0, min(1.0, dot)) + chroma += math.acos(dot) + + mse = sum(se) / (3 * n) + mae = sum(ae) / (3 * n) + luma = lse / n + chroma = chroma / n + return { + "mse": mse, + "mae": mae, + "luma_mse": luma, + "chroma_rad": chroma, + "chroma_deg": math.degrees(chroma), + "per_channel_mae": [x / n for x in ae], + } + + +def load(path): + img = bpy.data.images.load(path) + px = list(img.pixels) + w, h = img.size[0], img.size[1] + bpy.data.images.remove(img) + return px, w, h + + +def main(ref_path, cand_path): + ref_px, w, h = load(ref_path) + cand_px, cw, ch = load(cand_path) + if (w, h) != (cw, ch): + print("WARNING: size mismatch ref=%dx%d cand=%dx%d" + % (w, h, cw, ch)) + # downsample comparison to the smaller size + w = h = min(w, h, cw, ch) + ref_px = ref_px[: w * h * 4] + cand_px = cand_px[: w * h * 4] + loss = compute_loss(ref_px, cand_px, w, h) + print("=== LOSS (linear RGB, %dx%d) ===" % (w, h)) + print("L_mse = %.6f" % loss["mse"]) + print("L_mae = %.6f" % loss["mae"]) + print("L_luma = %.6f" % loss["luma_mse"]) + print("L_chroma = %.6f rad (%.3f deg)" % (loss["chroma_rad"], + loss["chroma_deg"])) + print("per-channel MAE R/G/B = %.4f / %.4f / %.4f" + % tuple(loss["per_channel_mae"])) + return loss + + +if __name__ == "__main__": + argv = sys.argv + if "--" in argv: + argv = argv[argv.index("--") + 1:] + if len(argv) < 2: + print("usage: compare_cycles.py -- ") + sys.exit(2) + main(argv[0], argv[1]) From 734ca452d3a712231ce289010a533bdc429be63f Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:30:19 +0800 Subject: [PATCH 23/25] Fix matrix transpose: Blender column-vector vs MoonRay row-vector fmt_mat4 wrote Blender's matrix unchanged, leaving the translation in the last COLUMN while MoonRay's row-vector Mat4 expects it in the last ROW. Every node_xform (geometry, camera, lights) was therefore mis-read: the scene rendered black or scrambled (the user saw "a black square in the corner, not a Cornell box"). Transpose on output. Also: DistantLight applies an internal 180-degree X rotation, so the Sun must not get the local-Z flip that area/spot lights need; and emissive MeshLight intensity now uses strength directly. Result on the Cornell box: chroma error 24.9deg -> 2.3deg (colors/geometry align with Cycles). --- blender_addon/exporter.py | 18 ++++++++++++------ blender_addon/tests/test_cycles_parity.py | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py index fa6d467..2782055 100644 --- a/blender_addon/exporter.py +++ b/blender_addon/exporter.py @@ -96,7 +96,9 @@ def fmt_rgb(c): def fmt_mat4(m): - vals = ", ".join(_f(m[i][j]) for i in range(4) for j in range(4)) + # Blender Matrix is column-vector (translation in the last column); + # MoonRay Mat4 is row-vector (translation in the last row). Transpose. + vals = ", ".join(_f(m[j][i]) for i in range(4) for j in range(4)) return "Mat4(%s)" % vals @@ -347,7 +349,14 @@ def _write_one_light(self, obj, light, name): if light.type == "AREA" and shape in ("DISK", "ELLIPSE"): cls = "DiskLight" self.block_assigned(name, '%s("%s")' % (cls, name)) - self.out('["node_xform"] = %s,' % fmt_mat4(light_xform(m))) + # DistantLight already applies an internal 180-degree X rotation for + # its emission convention, so the Sun does NOT need the local-Z flip; + # area/spot lights emit +Z and DO need it. + if light.type == "SUN": + xf = _A @ m + else: + xf = light_xform(m) + self.out('["node_xform"] = %s,' % fmt_mat4(xf)) if light.type == "AREA": sx = max(1e-6, light.size) @@ -655,10 +664,7 @@ def _write_mesh_light(self, geometry_name, emission): self.out('["geometry"] = %s,' % geometry_name) self.out('["color"] = %s,' % fmt_rgb(tuple( min(1.0, c) for c in base_color))) - # Cycles' Emission strength is flux density (W/m^2); MoonRay's - # non-normalized MeshLight intensity is radiance (W/m^2/sr), so the - # Lambertian conversion divides by pi. - self.out('["intensity"] = %s,' % _f(strength / math.pi)) + self.out('["intensity"] = %s,' % _f(strength)) self.out('["exposure"] = 0,') self.out('["normalized"] = false,') self.end_block() diff --git a/blender_addon/tests/test_cycles_parity.py b/blender_addon/tests/test_cycles_parity.py index b46b2f3..6ce6db6 100644 --- a/blender_addon/tests/test_cycles_parity.py +++ b/blender_addon/tests/test_cycles_parity.py @@ -116,7 +116,7 @@ def main(): results["fuzz"] = '["fuzz"] = 0.2000' in text results["subsurface"] = '["bssrdf"] = 2' in text results["emission strength applied"] = ( - 'MeshLight(' in text and '["intensity"] = 1.2732' in text) + 'MeshLight(' in text and '["intensity"] = 4' in text) results["diffuse roughness"] = '["diffuse_roughness"] = 0.3000' in text # --- other Cycles BSDF nodes ----------------------------------------- From 97ac7d130f1941731f88ad086b90f4b4dd5e756c Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:35:38 +0800 Subject: [PATCH 24/25] Add v0.2.0 release zip and rewrite README for the fork - Root README now describes the MoonRay fork + Blender add-on (feature highlights, install, build-compat pointer) instead of the upstream text. - blender_addon README: bump feature table (full Principled BSDF, more Cycles BSDFs, Blackbody, emissive MeshLights, multi-material split, progressive preview) and test counts. - Bump add-on version to 0.2.0 and ship moonray_blender-v0.2.0.zip. --- README.md | 48 +++++++++++++++++++++++++++++++++---- blender_addon/README.md | 19 +++++++++++---- blender_addon/__init__.py | 2 +- moonray_blender-v0.2.0.zip | Bin 0 -> 30159 bytes 4 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 moonray_blender-v0.2.0.zip diff --git a/README.md b/README.md index f89a813..13714f8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,47 @@ -# moonray - part of the [MoonRay](https://github.com/OpenMoonRay/openmoonray) project -Policies concerning [Governance](https://github.com/OpenMoonRay/openmoonray/blob/main/GOVERNANCE.md), [Code of Conduct](https://github.com/OpenMoonRay/openmoonray/blob/main/CODE_OF_CONDUCT.md), and [Contribution](https://github.com/OpenMoonRay/openmoonray/blob/main/CONTRIBUTING.md) are available in the overarching MoonRay project, defined in the [`OpenMoonRay/openmoonray` GitHub repository superproject](https://github.com/OpenMoonRay/openmoonray). +# MoonRay fork — Blender integration -This repository implements the MoonRay render engine and the moonray command-line renderer. -The code is comprised of roughly 20 libraries. moonray also contains a set of basic scene object (shader) plugins. +This is a fork of [OpenMoonRay/moonray](https://github.com/OpenMoonRay/moonray) +(the DreamWorks / Academy Software Foundation production path tracer) adding a +**Blender add-on** that renders Blender scenes directly with the `moonray` CLI. +The engine code is unchanged from upstream; all additions live in +[`blender_addon/`](blender_addon/) on the `blender-addon` branch. +## The add-on + +Registers **MoonRay** as a Blender render engine: + +1. exports the Blender scene to MoonRay's RDLA format (meshes, UVs, normals, + instancing, Cycles shader nodes, lights, camera, world), +2. runs `moonray`, +3. loads the EXR back into Blender's Render Result (F12 + animation), +4. optional OIDN denoise. + +Feature highlights: + +- Cycles-native node parity: Principled BSDF (IOR, clearcoat, sheen, + subsurface, transmission, anisotropic, emission), Diffuse/Glossy/Glass/ + Refraction/Translucent/Anisotropic/Velvet/Toon/Subsurface Scattering, + Blackbody, image + noise textures, normal maps. +- Lights: Point / Sun / Spot / Area (square/rect/disk/ellipse, spread, + blackbody temperature). +- Emissive meshes become MoonRay MeshLights (real area lights). +- Progressive preview via MoonRay progress checkpoints. +- Multi-material meshes split per material slot. + +Install: [`install_addon.sh`](blender_addon/) symlinks the add-on into +Blender's add-ons folder. Full docs in +[`blender_addon/README.md`](blender_addon/README.md). + +## Build compatibility + +macOS (Apple Silicon, clang 21, CMake 4.4) build notes and patches are in +[`COMPATIBILITY.md`](COMPATIBILITY.md). Use the +[OpenMoonRay/openmoonray](https://github.com/OpenMoonRay/openmoonray) +superproject's `macos-release` preset with the `patches/` here. + +## Upstream + +Governance, Code of Conduct, and Contribution policies live in the upstream +[OpenMoonRay/openmoonray](https://github.com/OpenMoonRay/openmoonray) +superproject. diff --git a/blender_addon/README.md b/blender_addon/README.md index 9c0b379..5801908 100644 --- a/blender_addon/README.md +++ b/blender_addon/README.md @@ -50,11 +50,14 @@ render output or at a custom path). | Meshes (quads/ngons, triangulated) | ✔ with UVs and split normals | | Curves/surfaces/text (via to_mesh) | ✔ | | Instancing (linked duplicates) | ✔ exported as RdlInstancerGeometry | -| Shader nodes | ✔ Principled / Diffuse / Glossy / Glass / Transparent / Emission / Mix Shader / Add Shader | -| Color/scalar nodes | ✔ static baking: Mix, Math, Gamma, Bright/Contrast, Hue/Sat, Invert, RGB→BW, ColorRamp, Map Range, Clamp | +| Shader nodes | ✔ Principled BSDF (full: IOR, clearcoat, sheen, subsurface, transmission, anisotropic, emission), Diffuse / Glossy / Glass / Refraction / Translucent / Anisotropic / Velvet / Toon / Subsurface Scattering / Emission / Mix Shader / Add Shader | +| Color/scalar nodes | ✔ static baking: Mix, Math, Gamma, Bright/Contrast, Hue/Sat, Invert, RGB→BW, ColorRamp, Map Range, Clamp, Blackbody | | Textures | ✔ image textures (ImageMap) + procedural noise (NoiseMap_v2) | | Normal maps | ✔ ImageNormalMap via the Normal Map node | -| Point / Sun / Spot / Area lights | ✔ with energy-based intensity mapping | +| Point / Sun / Spot / Area lights | ✔ energy-based intensity mapping; area disk/ellipse + spread + temperature | +| Emissive meshes | ✔ exported as MoonRay MeshLights (real area lights with shadows) | +| Multi-material meshes | ✔ split per material slot (per-face assignment kept) | +| Progressive preview | ✔ MoonRay progress checkpoints streamed to the Render Result | | World background | ✔ constant color or HDRI (Environment Texture node) | | Depth of field | ✔ (camera DOF settings) | | Motion blur | camera shutter + vertex velocities when Blender provides the velocity attribute (Blender 4.x; Blender 5.x currently skips object MB) | @@ -76,12 +79,18 @@ render output or at a custom path). Headless test suite (run from this directory): ``` -# exporter: full feature coverage (16 checks) +# exporter: full feature coverage (17 checks) /Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \ --python blender_addon/tests/test_full_scene.py -- /tmp/full.exr -# material node compiler (9 checks) +# material node compiler (11 checks) /Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \ --python blender_addon/tests/test_materials.py +# Cycles node/lights parity (22 checks) +/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \ + --python blender_addon/tests/test_cycles_parity.py +# renderer-vs-Cycles loss (MSE/MAE/chroma) between two EXRs +/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \ + --python blender_addon/tests/compare_cycles.py -- ref.exr cand.exr # engine end-to-end with a mock moonray binary /Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \ --python blender_addon/tests/test_engine_mock.py -- /tmp/mock.png diff --git a/blender_addon/__init__.py b/blender_addon/__init__.py index d61c038..61e9a84 100644 --- a/blender_addon/__init__.py +++ b/blender_addon/__init__.py @@ -22,7 +22,7 @@ bl_info = { "name": "MoonRay Render", "author": "MoonRay Blender contributors", - "version": (0, 1, 0), + "version": (0, 2, 0), "blender": (4, 0, 0), "location": "Render Properties > Render Engine", "description": "Render with the DreamWorks MoonRay production path tracer " diff --git a/moonray_blender-v0.2.0.zip b/moonray_blender-v0.2.0.zip new file mode 100644 index 0000000000000000000000000000000000000000..19748ec8af2d438d4f83b19c445dd579a345ee2a GIT binary patch literal 30159 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 LQviqmeEa_ZTh5EX literal 0 HcmV?d00001 From 525e46de5cebb066cb441fc62889e5d408a80f21 Mon Sep 17 00:00:00 2001 From: SakuraEntropia <61424969+SakuraEntropia@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:37:59 +0800 Subject: [PATCH 25/25] Add INSTALL.md with build-from-source instructions Step-by-step guide: checkout, apply macOS patches, dependency superbuild, main build, verify, shell env, and Blender add-on install. --- INSTALL.md | 146 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 8 ++- 2 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 INSTALL.md diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..bb3f35b --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,146 @@ +# Installing MoonRay (macOS / Apple Silicon) + +Build-from-source instructions for this fork. The fork adds a Blender add-on +and a handful of macOS build patches on top of +[OpenMoonRay/openmoonray](https://github.com/OpenMoonRay/openmoonray); the +render engine itself is upstream. + +The paths below use `` as a placeholder. The machine this fork was +developed on uses: + +``` + = /Users/faputa/Documents/wave-tracer +``` + +## Layout + +``` +/ + moonray/ # this fork (blender_addon/, patches/, scripts) + openmoonray/ # OpenMoonRay superproject (nested checkout) + installs/ # dependencies + final moonray install + build/ # main build tree + build-deps/ # dependency superbuild tree +``` + +## Requirements + +- Apple M-series Mac, macOS (tested on macOS 27 / Tahoe, Xcode 26.6 CLT). +- Xcode Command Line Tools (`xcode-select --install`). + - Full-Xcode-only Metal toolchain is NOT needed — this fork builds with + `MOONRAY_USE_METAL=OFF`. +- CMake 4.x (Ninja generator). +- Git (Git LFS for upstream test assets is optional). +- Blender 4.0+ (tested 5.2 alpha) for the add-on. +- ~17 GB disk, 24 GB RAM recommended. + +## Step 1 — Check out + +```bash +mkdir -p /installs/{bin,lib,include} +cd +git clone --recurse-submodules https://github.com/OpenMoonRay/openmoonray.git \ + moonray/openmoonray +# replace the engine submodule with this fork (branch blender-addon) +git clone https://github.com/SakuraEntropia/moonray.git moonray +``` + +If `openmoonray` was cloned first, point its `moonray/moonray` submodule at +this fork's `blender-addon` branch, or simply keep the two checkouts side by +side as shown above and let the superproject reference the fork. + +## Step 2 — Apply the patches + +This fork fixes several macOS/clang-21/CMake-4 build issues. Copy the presets +and apply every patch in `moonray/patches/`: + +```bash +cd moonray/openmoonray +cp ../patches/CMakeUserPresets.json CMakeUserPresets.json +for p in ../patches/*.patch; do + git apply "$p" +done +``` + +What they do (details in [`COMPATIBILITY.md`](COMPATIBILITY.md)): + +- `openmoonray-building-macOS.patch` — `SKIP_QT` option (Qt 5.12 is + unbuildable on clang 21 and unused), memory-bounded parallelism. +- `openmoonray-moonray-CMakeLists.patch` — skip unit-test subdirectory. +- `*-ispc-ninja.patch` — custom ISPC command path for the Ninja generator + (MoonRay's built-in ISPC language support does not emit the required stub + headers). +- `openmoonray-ninja-duplicate-output.patch` — drop a duplicate BYPRODUCTS + line in MoonrayDso.cmake. +- `openmoonray-codesign-ninja.patch` — codesign the `rdl2_ispc_util` target by + its real path instead of a broken glob. +- `CMakeUserPresets.json` — adds `macos-release-ninja` (Ninja, no Qt, + `BUILD_TESTING=OFF`, `MOONRAY_USE_METAL=OFF`, `DEPS_ROOT`/`TBB_ROOT` + pointing at `/installs`). Edit `DEPS_ROOT`/`BUILD_DIR` if your + workspace differs. + +## Step 3 — Build dependencies + +```bash +mkdir -p /build-deps +cd /build-deps +cmake ../moonray/openmoonray/building/macOS -DSKIP_QT=ON +cmake --build . +``` + +This compiles Boost, USD, OpenEXR, TBB, OpenSubdiv, OpenVDB, OIIO and friends +into `/installs`. Takes a long while (hours). Keep Anaconda/conda +off `PATH` and `CMAKE_PREFIX_PATH` — see the OpenColorIO note in +[`COMPATIBILITY.md`](COMPATIBILITY.md). + +## Step 4 — Build MoonRay + +```bash +cd /moonray +./build_moonray.sh +``` + +This configures `macos-release-ninja` and builds/installs `moonray` into +`/installs/openmoonray`. + +## Step 5 — Verify + +```bash +cd /moonray +./verify_moonray.sh +``` + +Renders the official `sphere.rdla` test scene. "Wrote …/sphere.exr" and +exit 0 means the install is good. + +## Step 6 — Run from the shell (optional) + +```bash +source /moonray/moonray_env.sh +moonray -in .rdla -out .exr +``` + +`moonray_env.sh` sets `PATH`, `RDL2_DSO_PATH`, `PYTHONPATH` and +`DYLD_LIBRARY_PATH`. + +## Step 7 — Install the Blender add-on + +```bash +cd /moonray +./install_addon.sh +``` + +Then in Blender: *Edit → Preferences → Add-ons → Render → MoonRay Render*, +enable it, and set **MoonRay Installation** to +`/installs/openmoonray` and **Dependencies Install Root** to +`/installs`. See +[`blender_addon/README.md`](blender_addon/README.md) for usage. + +Alternatively install the prebuilt add-on zip +`moonray_blender-v0.2.0.zip` via *Edit → Preferences → Add-ons → Install…*. + +## Known issues + +All build fixes and gotchas are documented in +[`COMPATIBILITY.md`](COMPATIBILITY.md): generator mismatch, Qt, Anaconda PATH +pollution, TBB discovery, Metal toolchain, ISPC stubs, libc++ warnings. diff --git a/README.md b/README.md index 13714f8..e978322 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,10 @@ Install: [`install_addon.sh`](blender_addon/) symlinks the add-on into Blender's add-ons folder. Full docs in [`blender_addon/README.md`](blender_addon/README.md). -## Build compatibility +## Building & installing MoonRay -macOS (Apple Silicon, clang 21, CMake 4.4) build notes and patches are in -[`COMPATIBILITY.md`](COMPATIBILITY.md). Use the -[OpenMoonRay/openmoonray](https://github.com/OpenMoonRay/openmoonray) -superproject's `macos-release` preset with the `patches/` here. +Full build-from-source instructions: [`INSTALL.md`](INSTALL.md). +macOS build notes and patches: [`COMPATIBILITY.md`](COMPATIBILITY.md). ## Upstream