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`. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md new file mode 100644 index 0000000..f6c80dd --- /dev/null +++ b/COMPATIBILITY.md @@ -0,0 +1,127 @@ +# 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 +- 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 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 +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: 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/README.md b/blender_addon/README.md new file mode 100644 index 0000000..9c0b379 --- /dev/null +++ b/blender_addon/README.md @@ -0,0 +1,93 @@ +# 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, 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. + +## 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 (tested with Blender 5.2 alpha). + +## Installation + +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. 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 + +| Feature | Status | +|--------------------|----------------------------------------------------| +| 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 | +| 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 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) | +| Volumetrics | 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. +- 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/__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..2e14458 --- /dev/null +++ b/blender_addon/engine.py @@ -0,0 +1,229 @@ +"""Blender RenderEngine integration: exports to RDLA and runs moonray. + +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 +import shutil +import tempfile +import time + +import bpy + +from . import exporter +from .renderer import MoonRayProcess, resolve_moonray_root + +ADDON_ID = __package__.split(".")[0] + + +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 + 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 = 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 + + if settings.export_only: + engine.report({"INFO"}, "Exported scene to %s" % rdla_path) + _keep_rdla(engine, rdla_path, scene, settings) + cleanup() + return + + # optionally persist the intermediate RDLA scene + _keep_rdla(engine, rdla_path, scene, settings) + + # 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)] + + def on_progress(pct): + engine.update_progress(pct / 100.0) + engine.update_stats("Rendering", "MoonRay: %d%%" % pct) + + 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 + + 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) + + 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: + proc.run_denoise(out_exr, denoised) + final = denoised + except Exception as e: + 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() + + +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: + _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 diff --git a/blender_addon/exporter.py b/blender_addon/exporter.py new file mode 100644 index 0000000..ed9e951 --- /dev/null +++ b/blender_addon/exporter.py @@ -0,0 +1,606 @@ +"""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 + + +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", + "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 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("}") + + 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 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,') + 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 + 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) + 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 + env_rotation = self._env_mapping_rotation(src) + try: + color = tuple(color_in.default_value)[:3] + strength = float(bg.inputs["Strength"].default_value) + except Exception: + pass + self.block_assigned('envlight', 'EnvLight("envlight")') + 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') + + 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: + 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(name) + + self.block_assigned('lightset', '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_assigned(name, '%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", + "CURVES", "POINTCLOUD"): + 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_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): + """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) + 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 + 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_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,') + 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},' + % ", ".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_assigned(inst_name, 'RdlInstancerGeometry("%s")' % inst_name) + self.out('["node_xform"] = %s,' % fmt_mat4(Matrix.Identity(4))) + self.out('["references"] = {%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() + + 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 inst_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") + 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() + + 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 mesh_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..5ad5cd2 --- /dev/null +++ b/blender_addon/materials.py @@ -0,0 +1,712 @@ +"""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) +_MAP = "map" # ("map", rdl2_class, {attr: expr}) + + +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, mapping) when usable.""" + img = getattr(node, "image", None) + if img is None or not img.filepath: + return None + 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 +_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): + 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] in (_RGB, _FLOAT, _IMG, _MAP): + 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 == "TEX_NOISE": + value = self._eval_noise(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_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) + 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() + 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, 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 + + 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], value[2]) + 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): + 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, _MAP) 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, + img_val[2]) + 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("%s") {' % (name, 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: + 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")) + 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, mapping)) + 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, 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 ------------------------------------------------------ + 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"] = %s,' % b_name, + '["mix"] = %.9g,' % fac, + ]) + + def _flush_normal_maps(self): + 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): + 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..99a31de --- /dev/null +++ b/blender_addon/operators.py @@ -0,0 +1,64 @@ +"""Operators for the MoonRay add-on.""" + +import os +import subprocess + +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..82f79f5 --- /dev/null +++ b/blender_addon/properties.py @@ -0,0 +1,194 @@ +"""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 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 + 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, + ) + 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 " + "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..7315f26 --- /dev/null +++ b/blender_addon/renderer.py @@ -0,0 +1,152 @@ +"""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.""" + 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=""): + """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 + 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_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) 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..419e0e5 --- /dev/null +++ b/blender_addon/tests/test_export.py @@ -0,0 +1,82 @@ +"""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 + use_motion_blur = 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..858da9b --- /dev/null +++ b/blender_addon/tests/test_full_scene.py @@ -0,0 +1,176 @@ +"""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 + use_motion_blur = 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 + + # 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 + 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 + 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, + "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(): + 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..ac62626 --- /dev/null +++ b/blender_addon/tests/test_materials.py @@ -0,0 +1,137 @@ +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 + use_motion_blur = 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 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 +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) + +# --- 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) +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, + "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(): + 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_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/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..8c703e2 --- /dev/null +++ b/blender_addon/tests/test_render.py @@ -0,0 +1,100 @@ +"""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): + # 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 + 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 + 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 + # 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 + 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 + # 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 + + +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/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()) diff --git a/blender_addon/ui.py b/blender_addon/ui.py new file mode 100644 index 0000000..e7f57ea --- /dev/null +++ b/blender_addon/ui.py @@ -0,0 +1,66 @@ +"""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() + col = layout.column(align=True) + col.prop(settings, "use_denoise") + col.prop(settings, "use_motion_blur") + + 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/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" diff --git a/patches/CMakeUserPresets.json b/patches/CMakeUserPresets.json new file mode 100644 index 0000000..c118eb3 --- /dev/null +++ b/patches/CMakeUserPresets.json @@ -0,0 +1,29 @@ +{ + "version": 4, + "configurePresets": [ + { + "name": "macos-release-ninja", + "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", + "MOONRAY_USE_METAL": "OFF" + } + } + ], + "buildPresets": [ + { + "name": "macos-release-ninja", + "displayName": "macOS Release (Ninja, no Qt)", + "configurePreset": "macos-release-ninja", + "jobs": 6 + } + ] +} 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-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/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 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