Render photorealistic 3D scenes with Monte Carlo path tracing
C++20 path tracer written from scratch with no third-party dependencies: global illumination, volumes and clouds, denoising and a Blender exporter.
0.1.1Add to Favorites
Why it matters
Generate high-quality photorealistic images from 3D scenes using physically-based rendering. Luz enables developers and artists to produce global illumination renders with advanced features like adaptive sampling, denoising, and atmospheric effects without external dependencies.
Outcomes
What it gets done
Trace light paths through 3D scenes with BVH-accelerated ray intersection
Export Blender scenes to custom format and render with multithreaded CPU
Denoise Monte Carlo renders using feature-buffer filtering techniques
Benchmark rendering performance with deterministic test harness
Source
Get it from source
Spark does not host a copy of it.
Open sourceReports
Agent outcome reports
No reports yet
Overview
Luz is a C++20 path tracer developed from scratch with zero third-party dependencies. It renders on the CPU with Monte Carlo path tracing, global illumination, BVH acceleration, adaptive sampling, denoising, atmospheric scattering, physically parameterized scene files and a Blender-to-Luz exporter. Use it to render CPU path-traced scenes, or to convert Blender scenes to .luz scene files. Builds tuned with -march=native may not run on older or different CPUs, and Disney cloud scenes need a separately downloaded dataset.
What it does
Luz is a C++20 path tracer developed from scratch with zero third-party dependencies. It renders on the CPU with multithreading and supports Monte Carlo path tracing, global illumination, BVH acceleration, adaptive sampling, denoising, atmospheric scattering and physically parameterized scene files.
Geometry covers spheres, planes, rectangles, triangles, cubes, volumes and OBJ meshes. BVH acceleration includes packed mesh BVHs with binned SAH construction and near-first traversal. Materials include Lambertian, GGX metal, rough dielectric, a layered principled material with subsurface scattering, emissive, isotropic and Henyey-Greenstein phase materials, plus measured conductor, glass and volume presets and IES lamp profiles. Lights are area, point, sphere and directional with physical units, and PPM and HDR equirectangular environment maps come with calibrated lighting and MIS. Sampling features are importance sampling with PDFs, MIS, optional caustic photon mapping and optional volume path guiding. There is an atmospheric simulation with scattering.
Procedural clouds render with no extra assets, using cumulus, stratus, cirrus and storm presets. Clouds can also be loaded from a custom .luzvol volume format, with an optional VDB converter.
Rendering is scene-linear ACEScg with sRGB input and output transforms. Scene units are set with meters_per_unit. Numeric color triples are ACEScg values by default, and explicit functions cover other sources: sRGB colors, wavelength(550nm), blackbody(3000K), solar and reflectance curves. Lights take physical quantities such as lumens=12000 on an area light or solar=1 on a directional light. Cameras take real lens controls: focal_length_mm, sensor_width_mm, sensor_height_mm, f_stop, focus_distance, shutter and iso. Post-processing covers photographic exposure, antialiasing, contrast, tone mapping, sRGB encoding and bloom.
When to use - and when NOT to
Use --samples and --resolution to balance render time and quality.
Adaptive sampling is on by default: --samples sets the maximum samples per pixel and converged pixels stop early. Lowering --adaptive-threshold keeps more detail at more cost, and --no-adaptive renders every pixel at the full sample count. For final renders, start with a high maximum sample count and tune with values like --samples 4096 --adaptive-min-samples 512 --adaptive-check-interval 64 --adaptive-threshold 0.005.
Denoising is on by default. The NFOR-style feature-buffer denoiser has no hard minimum resolution or sample count, but it needs enough signal to estimate color and feature statistics. One sample per pixel is mainly a stress test: there is no per-pixel variance estimate, and the denoised image can look almost unchanged or can smooth the wrong details. Use at least a few samples per pixel for previews and roughly 16 or more when judging denoiser quality. Very low resolutions also make evaluation misleading because each local filter window covers too much of the image.
The raw view transform is for debugging, measurement and compositing data, not normal viewing.
Release builds are tuned for the machine that builds them: -O3, native CPU tuning with -march=native, link-time optimization with -flto, and a fast floating-point mode where the compiler and platform support it. Binaries built with -march=native may not run on older or different CPUs, and LTO can expose toolchain-specific linker issues. After an illegal-instruction crash or a linker error, or when you need a portable binary, rebuild from clean objects with make clean and make NATIVE=0 LTO=0. For CMake, configure with cmake -S . -B build -DLUZ_NATIVE_OPTIMIZATIONS=OFF -DLUZ_ENABLE_LTO=OFF and build with cmake --build build --clean-first.
The Disney cloud scene needs a separately downloaded dataset, used to create assets/volumes/wdas_cloud_half.luzvol before rendering. Only the optional VDB converter requires OpenVDB.
Inputs and outputs
The input is a .luz scene file, passed as a positional path or with -f, --file. Scene files define materials, meshes, objects, lights and cameras. The CLI overrides common render settings: -r, --resolution WxH, -s, --samples N, -t, --threads N, --seed N, --max-light-bounces N, --exposure EV, --contrast F, --bloom true|false, the --adaptive-* family and --view-transform standard|agx|aces|raw. --adaptive-background-min-samples N and --adaptive-volume-min-samples N set the background and volume sample floors, where 0 inherits the adaptive minimum. --max-light-bounces N is an alias for --maxLightBounces N, which is also spelled -mlb.
The primary output is render.bmp, and render_denoised.bmp is written by default as a companion image. Scene files can set outputfilename=..., and -o, --output PATH.EXT overrides it. A .bmp, .png or .tiff path selects the format. PNG output is post-processed 8-bit RGB SDR. TIFF output stores RGB as uncompressed 32-bit IEEE floating-point samples, and --output render.tiff --view-transform raw preserves scene-linear ACEScg HDR values above 1.0. --denoise-output PATH.EXT chooses the denoised path, --no-denoise or --denoise false skips the companion render, and --render-times writes renderTime.bmp.
In .luz files, absolute object paths are used as-is and relative paths resolve from the directory containing the .luz file. OBJ meshes are declared in a [meshes] section with file=, then transformed and assigned named scene materials such as a metal with preset=gold.
A deterministic benchmark harness reports render, denoise, post-process and score breakdowns. Run it with --benchmark and select a case with --benchmark-case: default, many-objects, mesh-bvh, diffuse, postprocess, atmosphere, lights, emissive-geometry, primitives-materials, volumes or obj-mesh. The Makefile route is make benchmark with BENCH_CPUS=1 BENCH_THREADS=1, redirecting output to before.csv and after.csv, then make benchmark-compare BEFORE=before.csv AFTER=after.csv.
Integrations
Luz needs a C++20 compiler and Make or CMake 3.16 or newer. Python 3 is needed only for optional tools and scripts. Supported platforms are macOS, Linux and Windows. On macOS and Linux the Makefile is the primary path; on Windows use CMake with MSVC or the MinGW-based make windows target. WSL is supported as a Linux build environment. Build and render the bundled Cornell box:
make
./luz examples/scenes/cornell.luz --samples 50 --resolution 300x300
make test runs the test suite. CMake users run cmake -S . -B build, cmake --build build and ctest --test-dir build.
Blender scenes are exported through Blender's Python API, by running Blender in background mode (-b) with --python tools/blender_export_luz.py -- --output exports/scene.luz. The exporter writes a .luz file plus OBJ meshes. Its options include --blend, --mesh-dir (default meshes), --texture-dir (default textures), --selected-only, --include-hidden, --sky linear|none|atmosphere|environment, --global-scale, --light-power-scale, --sun-power-scale, --min-point-light-radius (default 0.1), --default-focus-distance (default 10.0 Blender units) and --texture-max-size (default 1024). It also takes --resolution as WIDTHxHEIGHT, --samples, --max-light-bounces, --view-transform auto|standard|agx|aces|raw, --exposure in stops, --render-output for the Luz render output filename, --subsurface-scale-multiplier, --camera-f-stop, --no-texture-colors to skip image texture color approximation, --texture-sample-size (default 64) for the temporary thumbnail size used for image color averaging, and --profile to print per-stage progress and timings. --adaptive and --denoise default to on and accept auto to mirror Blender. Procedural base-color materials are baked to PPM textures by default, and --no-bake-procedural-textures disables that. Environment images in .hdr or .pic form are copied as-is.
Luz is MIT licensed.
Source README
Luz
Luz is a C++20 Path Tracer developed from scratch with zero third-party dependencies.
It supports Monte Carlo path tracing, global illumination, BVH acceleration, adaptive sampling, denoising, atmospheric scattering, physically parameterized scene files, and a Blender-to-Luz exporter.
https://github.com/user-attachments/assets/7dc03485-9418-47af-a7e7-c4c4c53b6b70
Features
- Monte Carlo path tracing
- Global illumination
- Multithreaded CPU rendering
- Adaptive sampling
- Denoiser (NFOR-style)
- Spheres, planes, rectangles, triangles, cubes, volumes, and OBJ meshes
- Procedural cloud rendering and generation with cumulus, stratus, cirrus, and storm presets
- Custom cloud volume format
.luzvolwith optional VDB converter - Scene-linear ACEScg rendering with sRGB input/output transforms
- Spectral authoring helpers: wavelength, blackbody, solar, and reflectance curves
- Lambertian, GGX metal, rough dielectric, layered principled with subsurface scattering, emissive, isotropic, and Henyey-Greenstein phase materials
- Measured conductor, glass, and volume presets, plus IES lamp profiles
- Area, point, sphere and directional lights with physical units
- PPM and HDR equirectangular environment maps with calibrated lighting and MIS
- Custom
.luzscene files - .blend to .luz converter
- Fully customizable render parameters via CLI or scene file
- Importance sampling with PDFs, MIS, and optional caustic photon mapping
- Optional volume path guiding
- BVH acceleration, including packed mesh BVHs with binned SAH construction and near-first traversal
- Atmospheric simulation w/ scattering
- Physical camera focal length, sensor size, aperture/f-stop, focus distance, photographic exposure, antialiasing, contrast, tone mapping, sRGB encoding, and bloom
- BMP, PNG, and 32-bit floating-point TIFF output
- Deterministic benchmark harness with render, denoise, post-process, and score breakdowns
Requirements
- C++20 compiler
- Make or CMake 3.16+
- Python 3, only for optional tools/scripts
Quick Start
Build with the Makefile:
make
Render a bundled example scene:
./luz examples/scenes/cornell.luz --samples 50 --resolution 300x300
The primary output is render.bmp, with render_denoised.bmp written by
default. Scene files can set outputfilename=..., and the CLI can override
common render settings. Use a .bmp, .png, or .tiff output path to select
the format.
The volumetric fog and godrays sample is:
./luz --file examples/scenes/volumetric_godrays.luz --threads 8
Run the test suite:
make test
Clouds
Render procedural clouds with no extra assets:
./luz examples/scenes/procedural-cumulus-daylight.luz --samples 32 --resolution 960x540
More presets and a layered cloudscape are in examples/scenes/.
Use --samples and --resolution to balance render time and quality.
For the Disney golden-hour hero, first follow the
volume import instructions
to create assets/volumes/wdas_cloud_half.luzvol, then render:
./luz examples/scenes/disney-cloud-golden-hero.luz --samples 32 --resolution 960x540
Disney scenes need a separately downloaded dataset. Only the optional VDB converter requires OpenVDB; Luz itself has no third-party dependencies. See the cloud reference for customization.
Showcase
Stormtroopers shot
by @ScottGraham on BlendSwap
Rhetorician statue
by @geoffreymarchal on BlendSwap
Clouds and a golden sunset
Cornell Box
Simulated Earth atmosphere - space view + moon
10 Million bananas
Ray Tracing in One Weekend main scene
Benchmarking
Luz includes deterministic benchmarks for render, denoise, post-process, and overall score comparisons.
make benchmark BENCH_CPUS=1 BENCH_THREADS=1 > before.csv
make benchmark BENCH_CPUS=1 BENCH_THREADS=1 > after.csv
make benchmark-compare BEFORE=before.csv AFTER=after.csv
For details, see docs/benchmarks.md.
CMake
A CMake build is also available:
cmake -S . -B build
cmake --build build
ctest --test-dir build
Platform Support
Supported platforms:
- macOS
- Linux
- Windows
On macOS and Linux, the Makefile is the primary path. On Windows, use CMake with MSVC or the MinGW-based Makefile target:
make windows
WSL is also supported as a Linux build environment.
Build Optimizations
Release builds are tuned for the machine doing the build by default. The
Makefile enables -O3, native CPU tuning with -march=native, and link-time
optimization with -flto. It also enables a fast floating-point mode where the
compiler/platform supports it. CMake uses the same release intent: -O3, native
CPU tuning, and interprocedural optimization/LTO when supported.
These defaults produce faster local renders, but binaries built with
-march=native may not run on older or different CPUs, and LTO can expose
toolchain-specific linker issues. If you hit an illegal-instruction crash,
linker error, or need a more portable binary, disable the aggressive options and
rebuild from clean objects:
make clean
make NATIVE=0 LTO=0
For CMake builds, configure with the optimization toggles off:
cmake -S . -B build -DLUZ_NATIVE_OPTIMIZATIONS=OFF -DLUZ_ENABLE_LTO=OFF
cmake --build build --clean-first
CLI
Usage: ./luz [scene.luz] [options]
Arguments:
PATH Load a .luz scene file
Options:
-f, --file PATH Load a .luz scene file
-r, --resolution WxH Override render resolution
-s, --samples N Override samples per pixel
--adaptive [true|false] Toggle adaptive sampling (default: true)
--no-adaptive Disable adaptive sampling
--adaptive-min-samples N Minimum samples before adaptive stopping
--adaptive-background-min-samples N
Background floor (0 inherits adaptive minimum)
--adaptive-volume-min-samples N
Volume floor (0 inherits adaptive minimum)
--adaptive-threshold F Relative adaptive noise threshold
--adaptive-check-interval N Adaptive convergence check interval
-mlb, --maxLightBounces N Override maximum light bounces
--max-light-bounces N Alias for --maxLightBounces
-t, --threads N Render with N worker threads
--seed N Seed random sampling
--view-transform standard|agx|aces|raw
Select display transform; raw is debugging/HDR data, not viewing
--bloom true|false Toggle bloom
--exposure EV Exposure compensation in stops
--contrast F Display contrast multiplier
--denoise [true|false] Toggle denoised companion render (default: true)
--no-denoise Disable denoising
-o, --output PATH.EXT Override render output path
--denoise-output PATH.EXT Override denoised output path
--render-times Write renderTime.bmp
--benchmark Run the built-in benchmark scene
--benchmark-case NAME Benchmark case: default, many-objects, mesh-bvh, diffuse, postprocess, atmosphere, lights, emissive-geometry, primitives-materials, volumes, obj-mesh
TIFF output stores RGB as uncompressed 32-bit IEEE floating-point samples. Use
--output render.tiff --view-transform raw to preserve scene-linear ACEScg HDR
values above 1.0. Raw output is for debugging, measurement, and compositing data,
not normal viewing.
Physically Based Authoring
Luz scene units are controlled with meters_per_unit, and color values are
converted into scene-linear ACEScg before rendering. Numeric triples are ACEScg
values by default; use explicit functions for other sources:
color=srgb(0.8,0.2,0.1)
color=wavelength(550nm)
color=blackbody(3000K)
color=solar
color=reflectance(materials/red_paint.spd)
Lights can be authored with physical quantities:
area_light softbox {
position=(0,3,0)
size=(2,1)
normal=(0,-1,0)
color=blackbody(3200K)
lumens=12000
}
directional_light sun {
direction=(-0.2,-1,-0.1)
color=solar
solar=1
}
Cameras can use real lens controls:
camera main {
position=(0,1.5,5)
direction=(0,0,-1)
focal_length_mm=50
sensor_width_mm=36
sensor_height_mm=24
f_stop=2.8
focus_distance=4
shutter=0.0166667
iso=400
}
The complete scene-file reference is in
docs/scene-files.md.
Adaptive Sampling
Adaptive sampling is enabled by default: --samples sets the maximum samples
per pixel, and converged pixels stop early. Lower --adaptive-threshold for
more detail, or use --no-adaptive to render every pixel at the full sample count.
See the scene-file reference for advanced settings.
Lower thresholds keep more detail and cost more time. For final renders, start with a high max sample count and tune with values like:
./luz exports/stormtroopers.luz --samples 4096 --adaptive-min-samples 512 --adaptive-check-interval 64 --adaptive-threshold 0.005
Denoising
Denoising is enabled by default. Luz's NFOR-style feature-buffer denoiser writes
a separate companion image: by default, render.bmp becomes
render_denoised.bmp; use --denoise-output PATH.EXT to choose the exact
path. Use --no-denoise or --denoise false to skip the companion render.
PNG output writes post-processed 8-bit RGB SDR images using stored DEFLATE blocks for dependency-free writing.
The denoiser has no hard minimum resolution or sample count, but it needs enough signal to estimate useful color and feature statistics. One sample per pixel is mainly a stress test: there is no per-pixel variance estimate, so the denoised image can look almost unchanged or can smooth the wrong details. Use at least a few samples per pixel for previews, and prefer roughly 16+ samples per pixel when judging denoiser quality. Very low resolutions also make evaluation misleading because each local filter window covers too much of the image.
Scene Files
Example scenes live in examples/scenes/. The scene-file format is documented in docs/scene-files.md.
Object paths in .luz files use the path provided by the scene: absolute paths are used as-is, and relative paths are resolved from the directory containing the .luz file. Keep large or generated OBJ assets local unless they are intentionally reviewed for inclusion.
OBJ meshes can be declared, transformed, and assigned named scene materials:
[materials]
material gold {
type=metal
preset=gold
roughness=0.15
}
[meshes]
mesh statue_mesh {
file=objects/statue.obj
}
[scene]
object statue {
mesh=statue_mesh
position=(0,0,0)
material=gold
}
Blender Exporter
Blender scenes can be exported through Blender's Python API:
"/Applications/Blender.app/Contents/MacOS/Blender" -b scene.blend --python tools/blender_export_luz.py -- --output exports/scene.luz
./luz --file exports/scene.luz --threads 8
The exporter writes a .luz file plus OBJ meshes. Usage and current fidelity
limits are documented in docs/blender-exporter.md.
Repository Layout
include/luz/ Public headers
src/core/ Math, geometry, materials, image, and sampling code
src/renderer/ Rendering implementation
src/scene/ Scene model and scene helpers
src/io/ Scene-file, OBJ, BMP, PNG, and TIFF loading/writing
src/cli/ Command-line entry point and flags
examples/scenes/ Example .luz scene files
examples/objects/ Example OBJ assets
docs/images/ Compressed showcase images
tools/ Export and utility scripts
tests/ Standard-library-only test program
docker/ Benchmark container
Personal Note
Special thanks to the Ray Tracing in One Weekend book series. It was a great source of inspiration and information during a big part of the development of Luz, specially since those were times before AI.
tools/blender_export_luz.py
#!/usr/bin/env python3
"""
Export the current Blender scene to a Luz .luz file plus simple OBJ meshes.
Run from Blender:
blender -b scene.blend --python tools/blender_export_luz.py -- --output exports/scene.luz
"""
from __future__ import annotations
import argparse
from array import array
from collections.abc import Iterable
import math
import os
import re
import shutil
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TextIO
try:
import bpy
from mathutils import Vector
except ImportError as error:
bpy = None
_BLENDER_IMPORT_ERROR = error
class Vector: # type: ignore[no-redef]
def __init__(self, *_args: object, **_kwargs: object) -> None:
raise RuntimeError("mathutils.Vector is only available inside Blender.")
SAMPLE_TEXTURE_COLORS = True
TEXTURE_SAMPLE_SIZE = 64
DEFAULT_QUALITY_MODE = "on"
DEFAULT_VIEW_TRANSFORM_MODE = "auto"
IMAGE_AVERAGE_CACHE: dict[tuple[int, int, int, int], tuple[float, float, float] | None] = {}
@dataclass
class LuzMaterial:
name: str
material_type: str
base_color: tuple[float, float, float]
metallic: float
roughness: float
alpha: float
transmission: float
refractive_index: float
clearcoat: float
clearcoat_roughness: float
sheen: float
subsurface: float
subsurface_radius: tuple[float, float, float]
subsurface_scale: float
subsurface_color: tuple[float, float, float]
subsurface_method: str
glossy_color: tuple[float, float, float]
glossy_weight: float
emission_color: tuple[float, float, float]
emission_strength: float
texture_path: str | None = None
@dataclass
class LuzMesh:
name: str
file_path: Path
scene_file_path: str
material_name: str
object_name: str
triangle_count: int
@dataclass
class LuzRectangle:
object_name: str
material_name: str
position: Vector
normal: Vector
width_axis: Vector
height_axis: Vector
width: float
height: float
uvs: tuple[tuple[float, float], tuple[float, float], tuple[float, float], tuple[float, float]]
@dataclass
class LuzCamera:
name: str
position: Vector
direction: Vector
up: Vector
focal_length_mm: float
sensor_width_mm: float
sensor_height_mm: float
f_stop: float
pinhole: bool
focus_distance_meters: float
@dataclass
class LuzLight:
kind: str
name: str
color: tuple[float, float, float]
quantity: float
unit_key: str
position: Vector | None = None
normal: Vector | None = None
width: float = 1.0
height: float = 1.0
radius: float = 0.1
atmosphere_angle: float | None = None
@dataclass
class LuzEnvironment:
path: str
strength: float
rotation: float = 0.0
class Bounds:
def __init__(self) -> None:
self.minimum: Vector | None = None
self.maximum: Vector | None = None
def include(self, point: Vector) -> None:
if self.minimum is None or self.maximum is None:
self.minimum = point.copy()
self.maximum = point.copy()
return
self.minimum.x = min(self.minimum.x, point.x)
self.minimum.y = min(self.minimum.y, point.y)
self.minimum.z = min(self.minimum.z, point.z)
self.maximum.x = max(self.maximum.x, point.x)
self.maximum.y = max(self.maximum.y, point.y)
self.maximum.z = max(self.maximum.z, point.z)
def center(self) -> Vector:
if self.minimum is None or self.maximum is None:
return Vector((0.0, 0.75, 0.0))
return (self.minimum + self.maximum) * 0.5
def radius(self) -> float:
if self.minimum is None or self.maximum is None:
return 1.0
return max((self.maximum - self.minimum).length * 0.5, 1.0)
def parse_args(argv: list[str]) -> argparse.Namespace:
if "--" in argv:
argv = argv[argv.index("--") + 1:]
else:
argv = []
parser = argparse.ArgumentParser(description="Export a Blender scene to Luz.")
parser.add_argument("--blend", help="Optional .blend file to open before exporting.")
parser.add_argument("-o", "--output", required=True, help="Output .luz scene file.")
parser.add_argument("--mesh-dir", default="meshes", help="OBJ output directory, relative to the .luz file by default.")
parser.add_argument("--texture-dir", default="textures", help="Texture output directory, relative to the .luz file by default.")
parser.add_argument("--selected-only", action="store_true", help="Export selected objects only.")
parser.add_argument("--include-hidden", action="store_true", help="Include objects hidden from render.")
parser.add_argument("--resolution", help="Override resolution as WIDTHxHEIGHT.")
parser.add_argument("--samples", type=int, help="Override Luz samples per pixel.")
parser.add_argument("--adaptive", choices=("auto", "on", "off", "true", "false", "1", "0"), default=DEFAULT_QUALITY_MODE, help="Enable Luz adaptive sampling by default; use auto to mirror Blender or off to disable.")
parser.add_argument("--adaptive-min-samples", type=int, help="Override exported Luz adaptive minimum samples.")
parser.add_argument("--adaptive-threshold", type=float, help="Override exported Luz adaptive noise threshold.")
parser.add_argument("--adaptive-check-interval", type=int, help="Override exported Luz adaptive check interval.")
parser.add_argument("--max-light-bounces", type=int, help="Override Luz max light bounces.")
parser.add_argument("--denoise", choices=("auto", "on", "off", "true", "false", "1", "0"), default=DEFAULT_QUALITY_MODE, help="Enable Luz denoising by default; use auto to mirror Blender or off to disable.")
parser.add_argument("--view-transform", choices=("auto", "standard", "agx", "aces", "raw"), default=DEFAULT_VIEW_TRANSFORM_MODE, help="Luz view transform. Raw is for debugging/HDR data, not display viewing.")
parser.add_argument("--exposure", type=float, help="Override exported Luz exposure compensation in stops.")
parser.add_argument("--sky", choices=("linear", "none", "atmosphere", "environment"), help="Override Luz sky mode.")
parser.add_argument("--render-output", help="Luz render output filename. .bmp is appended by Luz when omitted.")
parser.add_argument("--global-scale", type=float, default=1.0, help="Scale all exported positions and mesh vertices.")
parser.add_argument("--light-power-scale", type=float, default=1.0, help="Additional multiplier for exported area and point light power.")
parser.add_argument("--subsurface-scale-multiplier", type=float, default=1.0, help="Additional multiplier for exported Principled subsurface scale.")
parser.add_argument("--min-point-light-radius", type=float, default=0.1, help="Minimum Blender-unit radius for point and spot lights. Defaults to 0.1.")
parser.add_argument("--sun-power-scale", type=float, default=1.0, help="Multiplier from Blender sun energy to Luz directional irradiance.")
parser.add_argument("--camera-f-stop", type=float, help="Override exported camera f-number.")
parser.add_argument("--default-focus-distance", type=float, default=10.0, help="Fallback focus distance in Blender units.")
parser.add_argument("--no-texture-colors", action="store_false", dest="sample_texture_colors", help="Skip image texture color approximation.")
parser.add_argument("--texture-sample-size", type=int, default=64, help="Temporary texture thumbnail size used for image color averaging. Defaults to 64.")
parser.add_argument("--texture-max-size", type=int, default=1024, help="Maximum exported texture side length. Defaults to 1024.")
parser.add_argument("--bake-procedural-textures", action="store_true", dest="bake_procedural_textures", default=True, help="Bake linked procedural base-color materials to PPM textures. Enabled by default.")
parser.add_argument("--no-bake-procedural-textures", action="store_false", dest="bake_procedural_textures", help="Disable procedural base-color texture baking.")
parser.add_argument("--profile", action="store_true", help="Print per-stage exporter progress and timings.")
parser.set_defaults(sample_texture_colors=True)
args = parser.parse_args(argv)
if not math.isfinite(args.global_scale) or args.global_scale <= 0.0:
parser.error("--global-scale must be a finite positive value.")
if not math.isfinite(args.subsurface_scale_multiplier) or args.subsurface_scale_multiplier <= 0.0:
parser.error("--subsurface-scale-multiplier must be a finite positive value.")
if not math.isfinite(args.min_point_light_radius) or args.min_point_light_radius < 0.0:
parser.error("--min-point-light-radius must be a finite non-negative value.")
if args.camera_f_stop is not None and (not math.isfinite(args.camera_f_stop) or args.camera_f_stop <= 0.0):
parser.error("--camera-f-stop must be a finite positive value.")
if args.exposure is not None and not math.isfinite(args.exposure):
parser.error("--exposure must be finite.")
if args.adaptive_min_samples is not None and args.adaptive_min_samples <= 0:
parser.error("--adaptive-min-samples must be positive.")
if args.adaptive_threshold is not None and (not math.isfinite(args.adaptive_threshold) or args.adaptive_threshold <= 0.0):
parser.error("--adaptive-threshold must be a finite positive value.")
if args.adaptive_check_interval is not None and args.adaptive_check_interval <= 0:
parser.error("--adaptive-check-interval must be positive.")
return args
def slugify(value: str, fallback: str) -> str:
slug = re.sub(r"[^A-Za-z0-9_]+", "_", value).strip("_").lower()
if not slug:
slug = fallback
if slug[0].isdigit():
slug = f"{fallback}_{slug}"
return slug
def unique_name(base: str, used: set[str]) -> str:
name = base
index = 2
while name in used:
name = f"{base}_{index}"
index += 1
used.add(name)
return name
def fmt_float(value: float) -> str:
if abs(value) < 1e-12:
value = 0.0
return f"{value:.8g}"
def fmt_vector(value: Vector) -> str:
return f"({fmt_float(value.x)},{fmt_float(value.y)},{fmt_float(value.z)})"
def fmt_color(value: tuple[float, float, float]) -> str:
return f"({fmt_float(value[0])},{fmt_float(value[1])},{fmt_float(value[2])})"
def fmt_uv(value: tuple[float, float]) -> str:
return f"({fmt_float(value[0])},{fmt_float(value[1])})"
def fmt_blender_color(value: tuple[float, float, float]) -> str:
return f"linear_srgb{fmt_color(value)}"
def log_profile(args: argparse.Namespace, message: str) -> None:
if args.profile:
print(f"[luz-export] {message}", flush=True)
def blender_point_to_luz(point: Vector, global_scale: float) -> Vector:
return Vector((point.x * global_scale, point.z * global_scale, -point.y * global_scale))
def blender_meters_per_unit() -> float:
try:
scale_length = float(bpy.context.scene.unit_settings.scale_length)
except (AttributeError, TypeError, ValueError):
return 1.0
if not math.isfinite(scale_length) or scale_length <= 0.0:
return 1.0
return scale_length
def luz_meters_per_unit(args: argparse.Namespace) -> float:
return blender_meters_per_unit() / args.global_scale
def blender_vector_to_luz(vector: Vector) -> Vector:
result = Vector((vector.x, vector.z, -vector.y))
if result.length == 0.0:
return Vector((0.0, 0.0, -1.0))
return result.normalized()
def blender_normal_to_luz(normal: Vector) -> Vector:
return blender_vector_to_luz(normal)
RECTANGLE_EPSILON = 1e-6
MAX_NATIVE_RECTANGLE_POLYGONS_PER_MATERIAL = 2
def vector_dot(a: Vector, b: Vector) -> float:
return (a.x * b.x) + (a.y * b.y) + (a.z * b.z)
def vector_cross(a: Vector, b: Vector) -> Vector:
return Vector((
(a.y * b.z) - (a.z * b.y),
(a.z * b.x) - (a.x * b.z),
(a.x * b.y) - (a.y * b.x),
))
def vector_length(value: Vector) -> float:
return math.sqrt(max(vector_dot(value, value), 0.0))
def normalized_vector(value: Vector) -> Vector | None:
length = vector_length(value)
if length <= RECTANGLE_EPSILON:
return None
return value / length
def vector_almost_zero(value: Vector, scale: float) -> bool:
return vector_length(value) <= (RECTANGLE_EPSILON * max(scale, 1.0))
def luz_rectangle_from_quad(
object_name: str,
material_name: str,
vertices: tuple[Vector, Vector, Vector, Vector],
uvs: tuple[tuple[float, float], tuple[float, float], tuple[float, float], tuple[float, float]],
) -> LuzRectangle | None:
edge_width = vertices[1] - vertices[0]
edge_height = vertices[2] - vertices[1]
opposite_width = vertices[2] - vertices[3]
opposite_height = vertices[3] - vertices[0]
width = vector_length(edge_width)
height = vector_length(edge_height)
scale = max(width, height, 1.0)
if width <= RECTANGLE_EPSILON or height <= RECTANGLE_EPSILON:
return None
if not vector_almost_zero(edge_width - opposite_width, scale):
return None
if not vector_almost_zero(edge_height - opposite_height, scale):
return None
if abs(vector_dot(edge_width, edge_height)) > (RECTANGLE_EPSILON * scale * scale):
return None
width_axis = normalized_vector(edge_width)
height_axis = normalized_vector(edge_height)
if width_axis is None or height_axis is None:
return None
normal = normalized_vector(vector_cross(width_axis, height_axis))
if normal is None:
return None
if abs(vector_dot(vertices[3] - vertices[0], normal)) > (RECTANGLE_EPSILON * scale):
return None
return LuzRectangle(
object_name=object_name,
material_name=material_name,
position=(vertices[0] + vertices[1] + vertices[2] + vertices[3]) * 0.25,
normal=normal,
width_axis=width_axis,
height_axis=height_axis,
width=width,
height=height,
uvs=uvs,
)
def can_export_native_rectangle_group(polygon_count: int) -> bool:
return 0 < polygon_count <= MAX_NATIVE_RECTANGLE_POLYGONS_PER_MATERIAL
def effective_camera_sensor_mm(
camera_data: object,
scene: object,
focal_length_mm: float,
fallback_width_mm: float,
fallback_height_mm: float,
) -> tuple[float, float]:
if getattr(camera_data, "type", "PERSP") != "PERSP":
return (fallback_width_mm, fallback_height_mm)
try:
frame = camera_data.view_frame(scene=scene)
except Exception:
return (fallback_width_mm, fallback_height_mm)
if len(frame) < 4:
return (fallback_width_mm, fallback_height_mm)
try:
min_x = min(float(point.x) for point in frame)
max_x = max(float(point.x) for point in frame)
min_y = min(float(point.y) for point in frame)
max_y = max(float(point.y) for point in frame)
depth = sum(abs(float(point.z)) for point in frame) / len(frame)
except (AttributeError, TypeError, ValueError):
return (fallback_width_mm, fallback_height_mm)
frame_width = max_x - min_x
frame_height = max_y - min_y
if (
not math.isfinite(frame_width)
or not math.isfinite(frame_height)
or not math.isfinite(depth)
or frame_width <= 0.0
or frame_height <= 0.0
or depth <= 1e-12
):
return (fallback_width_mm, fallback_height_mm)
return (
max(frame_width * focal_length_mm / depth, 1e-6),
max(frame_height * focal_length_mm / depth, 1e-6),
)
def color_from_value(value: object, fallback: tuple[float, float, float]) -> tuple[float, float, float]:
try:
return (float(value[0]), float(value[1]), float(value[2])) # type: ignore[index]
except (TypeError, IndexError, ValueError):
return fallback
def scalar_from_value(value: object, fallback: float) -> float:
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return fallback
def channel_index_from_name(name: str) -> int | None:
channel = name.strip().lower()
if channel in {"red", "r", "x"}:
return 0
if channel in {"green", "g", "y"}:
return 1
if channel in {"blue", "b", "z"}:
return 2
if channel in {"alpha", "a"}:
return 3
return None
def channel_from_value(value: object, channel_index: int, fallback: float) -> float:
try:
return float(value[channel_index]) # type: ignore[index]
except (TypeError, IndexError, ValueError):
return fallback
def blend_color(
first: tuple[float, float, float],
second: tuple[float, float, float],
factor: float,
) -> tuple[float, float, float]:
factor = max(0.0, min(1.0, factor))
return (
first[0] * (1.0 - factor) + second[0] * factor,
first[1] * (1.0 - factor) + second[1] * factor,
first[2] * (1.0 - factor) + second[2] * factor,
)
def blend_scalar(first: float, second: float, factor: float) -> float:
factor = max(0.0, min(1.0, factor))
return first * (1.0 - factor) + second * factor
def color_has_energy(color: tuple[float, float, float]) -> bool:
return max(color[0], color[1], color[2]) > 0.0
def image_dimensions(image: object) -> tuple[int, int] | None:
try:
width = int(image.size[0])
height = int(image.size[1])
except (AttributeError, TypeError, ValueError):
return None
if width <= 0 or height <= 0:
return None
return (width, height)
def image_pixels(image: object, width: int, height: int) -> object | None:
expected_values = width * height * 4
if expected_values <= 0:
return None
try:
pixels = image.pixels
foreach_get = getattr(pixels, "foreach_get", None)
if foreach_get is not None:
buffer = array("f", [0.0]) * expected_values
foreach_get(buffer)
return buffer
values = pixels[:]
except (AttributeError, TypeError, ValueError, RuntimeError):
return None
try:
if len(values) < expected_values:
return None
except TypeError:
return None
return values
def remove_blender_image(image: object) -> None:
try:
bpy.data.images.remove(image)
except Exception:
pass
def average_image_pixels(image: object) -> tuple[float, float, float] | None:
dimensions = image_dimensions(image)
if dimensions is None:
return None
width, height = dimensions
pixels = image_pixels(image, width, height)
if pixels is None:
return None
pixel_count = width * height
if pixel_count <= 0:
return None
red = 0.0
green = 0.0
blue = 0.0
for offset in range(0, pixel_count * 4, 4):
red += float(pixels[offset])
green += float(pixels[offset + 1])
blue += float(pixels[offset + 2])
return (red / pixel_count, green / pixel_count, blue / pixel_count)
def average_image_color(image: object | None) -> tuple[float, float, float] | None:
if image is None:
return None
dimensions = image_dimensions(image)
if dimensions is None:
return None
width, height = dimensions
sample_size = max(int(TEXTURE_SAMPLE_SIZE), 1)
cache_key = (id(image), width, height, sample_size)
if cache_key in IMAGE_AVERAGE_CACHE:
return IMAGE_AVERAGE_CACHE[cache_key]
if width <= sample_size and height <= sample_size:
result = average_image_pixels(image)
IMAGE_AVERAGE_CACHE[cache_key] = result
return result
scale = min(sample_size / width, sample_size / height)
sample_width = max(int(width * scale), 1)
sample_height = max(int(height * scale), 1)
sampled_image = None
try:
sampled_image = image.copy()
sampled_image.scale(sample_width, sample_height)
result = average_image_pixels(sampled_image)
except Exception:
result = None
finally:
if sampled_image is not None:
remove_blender_image(sampled_image)
IMAGE_AVERAGE_CACHE[cache_key] = result
return result
def image_copy_scaled(image: object, max_size: int) -> object | None:
dimensions = image_dimensions(image)
if dimensions is None:
return None
width, height = dimensions
max_size = max(int(max_size), 1)
if width <= max_size and height <= max_size:
return image
scale = min(max_size / width, max_size / height)
sample_width = max(int(width * scale), 1)
sample_height = max(int(height * scale), 1)
try:
sampled_image = image.copy()
sampled_image.scale(sample_width, sample_height)
return sampled_image
except Exception:
return None
def channel_byte(value: float) -> int:
try:
channel = float(value)
except (TypeError, ValueError):
return 0
if math.isnan(channel) or channel <= 0.0:
return 0
if channel >= 1.0:
return 255
return int(channel * 255.0 + 0.5)
def ppm_rgb_bytes(pixels: object, width: int, height: int) -> bytearray:
output = bytearray(width * height * 3)
output_offset = 0
to_byte = channel_byte
for y in range(height - 1, -1, -1):
pixel_offset = y * width * 4
row_end = pixel_offset + width * 4
while pixel_offset < row_end:
output[output_offset] = to_byte(pixels[pixel_offset]) # type: ignore[index]
output[output_offset + 1] = to_byte(pixels[pixel_offset + 1]) # type: ignore[index]
output[output_offset + 2] = to_byte(pixels[pixel_offset + 2]) # type: ignore[index]
output_offset += 3
pixel_offset += 4
return output
def write_image_as_ppm(image: object, texture_path: Path, max_size: int) -> bool:
sampled_image = image_copy_scaled(image, max_size)
if sampled_image is None:
return False
remove_sampled_image = sampled_image is not image
dimensions = image_dimensions(sampled_image)
if dimensions is None:
if remove_sampled_image:
remove_blender_image(sampled_image)
return False
width, height = dimensions
pixels = image_pixels(sampled_image, width, height)
if pixels is None:
if remove_sampled_image:
remove_blender_image(sampled_image)
return False
try:
texture_path.parent.mkdir(parents=True, exist_ok=True)
with texture_path.open("wb") as stream:
stream.write(f"P6\n{width} {height}\n255\n".encode("ascii"))
stream.write(ppm_rgb_bytes(pixels, width, height))
except (IndexError, OSError, TypeError, ValueError):
return False
finally:
if remove_sampled_image:
remove_blender_image(sampled_image)
return True
def node_socket(node: object, name: str) -> object | None:
try:
return node.inputs.get(name) # type: ignore[attr-defined]
except AttributeError:
return None
def socket_default(socket: object | None, fallback: object) -> object:
if socket is not None and hasattr(socket, "default_value"):
return socket.default_value
return fallback
def resolve_output_value(output_socket: object, fallback: object, visited: set[object], depth: int) -> object:
node = getattr(output_socket, "node", None)
if node is None or depth > 16 or node in visited:
return socket_default(output_socket, fallback)
visited.add(node)
node_type = getattr(node, "type", "")
output_name = getattr(output_socket, "name", "")
if node_type == "RGB":
return socket_default(node.outputs.get("Color"), socket_default(output_socket, fallback)) # type: ignore[attr-defined]
if node_type == "VALUE":
return socket_default(node.outputs.get("Value"), socket_default(output_socket, fallback)) # type: ignore[attr-defined]
if node_type in {"TEX_IMAGE", "TEX_ENVIRONMENT"} and output_name == "Color":
if not SAMPLE_TEXTURE_COLORS:
return socket_default(output_socket, fallback)
return average_image_color(getattr(node, "image", None)) or socket_default(output_socket, fallback)
if node_type in {"SEPARATE_COLOR", "SEPRGB"}:
channel_index = channel_index_from_name(output_name)
if channel_index is None:
return socket_default(output_socket, fallback)
color = resolve_socket_value(
node_socket(node, "Color") or node_socket(node, "Image"),
fallback,
visited,
depth + 1,
)
return channel_from_value(
color,
channel_index,
scalar_from_value(socket_default(output_socket, fallback), scalar_from_value(fallback, 0.0)),
)
if node_type == "VALTORGB" and output_name == "Color":
factor = scalar_from_value(resolve_socket_value(node_socket(node, "Fac") or node_socket(node, "Factor"), 0.5, visited, depth + 1), 0.5)
try:
return node.color_ramp.evaluate(max(0.0, min(1.0, factor)))
except Exception:
return socket_default(output_socket, fallback)
if node_type in {"MIX_RGB", "MIX"} and output_name in {"Color", "Result"}:
factor = scalar_from_value(resolve_socket_value(node_socket(node, "Fac") or node_socket(node, "Factor"), 0.5, visited, depth + 1), 0.5)
color_a = color_from_value(
resolve_socket_value(node_socket(node, "Color1") or node_socket(node, "A"), fallback, visited, depth + 1),
color_from_value(fallback, (0.0, 0.0, 0.0)),
)
color_b = color_from_value(
resolve_socket_value(node_socket(node, "Color2") or node_socket(node, "B"), fallback, visited, depth + 1),
color_from_value(fallback, (0.0, 0.0, 0.0)),
)
return blend_color(color_a, color_b, factor)
if node_type == "MATH" and output_name in {"Value", "Result"}:
first = scalar_from_value(resolve_socket_value(node_socket(node, "Value"), 0.0, visited, depth + 1), 0.0)
second = scalar_from_value(resolve_socket_value(node_socket(node, "Value_001"), 0.0, visited, depth + 1), 0.0)
operation = getattr(node, "operation", "")
if operation == "ADD":
return first + second
if operation == "SUBTRACT":
return first - second
if operation == "MULTIPLY":
return first * second
if operation == "DIVIDE":
return first / second if abs(second) > 1e-12 else first
if operation == "POWER":
return first ** second
if operation == "MINIMUM":
return min(first, second)
if operation == "MAXIMUM":
return max(first, second)
return first
if node_type in {"EMISSION", "BSDF_DIFFUSE", "BSDF_GLOSSY", "BSDF_PRINCIPLED"}:
if output_name in {"Color", "BSDF", "Emission"}:
return resolve_socket_value(
node_socket(node, "Base Color") or node_socket(node, "Color") or node_socket(node, "Emission Color"),
fallback,
visited,
depth + 1,
)
if output_name == "Strength":
return resolve_socket_value(node_socket(node, "Strength"), fallback, visited, depth + 1)
return socket_default(output_socket, fallback)
def resolve_socket_value(socket: object | None, fallback: object, visited: set[object] | None = None, depth: int = 0) -> object:
if socket is None:
return fallback
if visited is None:
visited = set()
try:
if socket.links:
return resolve_output_value(socket.links[0].from_socket, fallback, visited, depth + 1)
except AttributeError:
pass
return socket_default(socket, fallback)
def node_input_value(node: object, names: tuple[str, ...], fallback: object) -> object:
for name in names:
socket = node_socket(node, name)
if socket is not None:
return resolve_socket_value(socket, fallback)
return fallback
def socket_default_value(node: object, names: tuple[str, ...], fallback: object) -> object:
for name in names:
socket = node_socket(node, name)
if socket is not None:
return resolve_socket_value(socket, fallback)
return fallback
def texture_image_from_output(output_socket: object, visited: set[object], depth: int = 0) -> object | None:
node = getattr(output_socket, "node", None)
if node is None or node in visited or depth > 8:
return None
visited.add(node)
output_name = getattr(output_socket, "name", "")
node_type = getattr(node, "type", "")
if node_type in {"TEX_IMAGE", "TEX_ENVIRONMENT"} and output_name == "Color":
return getattr(node, "image", None)
if node_type in {"MIX_RGB", "MIX"} and output_name in {"Color", "Result"}:
for input_name in ("Color1", "Color2", "A", "B"):
socket = node_socket(node, input_name)
if socket is None:
continue
for link in getattr(socket, "links", []):
image = texture_image_from_output(link.from_socket, visited.copy(), depth + 1)
if image is not None:
return image
return None
if node_type == "VALTORGB" and output_name == "Color":
socket = node_socket(node, "Fac") or node_socket(node, "Factor")
for link in getattr(socket, "links", []) if socket is not None else []:
image = texture_image_from_output(link.from_socket, visited.copy(), depth + 1)
if image is not None:
return image
return None
if node_type in {"RGB", "VALUE"}:
return None
for socket in getattr(node, "inputs", []):
for link in getattr(socket, "links", []):
image = texture_image_from_output(link.from_socket, visited.copy(), depth + 1)
if image is not None:
return image
return None
def texture_image_from_socket(socket: object | None) -> object | None:
if socket is None:
return None
try:
if not socket.links:
return None
except AttributeError:
return None
return texture_image_from_output(socket.links[0].from_socket, set())
def find_principled_node(material: object) -> object | None:
if not material or not getattr(material, "use_nodes", False) or not getattr(material, "node_tree", None):
return None
for node in material.node_tree.nodes:
if node.type == "BSDF_PRINCIPLED":
return node
return None
def emission_from_node(node: object, visited: set[object]) -> tuple[tuple[float, float, float], float] | None:
if node in visited:
return None
visited.add(node)
if node.type == "EMISSION":
color = color_from_value(socket_default_value(node, ("Color",), (1.0, 1.0, 1.0)), (1.0, 1.0, 1.0))
strength = scalar_from_value(socket_default_value(node, ("Strength",), 1.0), 1.0)
if strength > 0.0 and color_has_energy(color):
return (color, strength)
return None
if node.type == "BSDF_PRINCIPLED":
color = color_from_value(
socket_default_value(node, ("Emission Color", "Emission"), (1.0, 1.0, 1.0)),
(1.0, 1.0, 1.0),
)
strength = scalar_from_value(socket_default_value(node, ("Emission Strength",), 0.0), 0.0)
if strength > 0.0 and color_has_energy(color):
return (color, strength)
for socket in node.inputs:
for link in socket.links:
emission = emission_from_node(link.from_node, visited)
if emission:
return emission
return None
def material_output_surface_node(material: object) -> object | None:
if not material or not getattr(material, "use_nodes", False) or not getattr(material, "node_tree", None):
return None
def surface_node_from_output(output_node: object) -> object | None:
surface = output_node.inputs.get("Surface")
if surface and surface.links:
return surface.links[0].from_node
return None
output_nodes: list[object] = []
for node in material.node_tree.nodes:
if node.type != "OUTPUT_MATERIAL":
continue
if surface_node_from_output(node) is not None:
output_nodes.append(node)
if not output_nodes:
return None
try:
render_engine = str(bpy.context.scene.render.engine).upper()
except AttributeError:
render_engine = ""
target = "CYCLES" if render_engine == "CYCLES" else "EEVEE" if "EEVEE" in render_engine else "ALL"
def node_target(node: object) -> str:
return str(getattr(node, "target", "ALL") or "ALL").upper()
def active_output(node: object) -> bool:
return bool(getattr(node, "is_active_output", False))
preference_groups = []
if target != "ALL":
preference_groups.append(lambda candidate: node_target(candidate) == target)
preference_groups.extend(
[
lambda candidate: node_target(candidate) == "ALL" and active_output(candidate),
lambda candidate: node_target(candidate) == "ALL",
active_output,
lambda candidate: True,
]
)
for matches in preference_groups:
for node in output_nodes:
if matches(node):
return surface_node_from_output(node)
return None
def material_emission(material: object) -> tuple[tuple[float, float, float], float] | None:
surface_node = material_output_surface_node(material)
if surface_node:
emission = emission_from_node(surface_node, set())
if emission:
return emission
if material and getattr(material, "use_nodes", False) and getattr(material, "node_tree", None):
for node in material.node_tree.nodes:
if node.type == "EMISSION":
emission = emission_from_node(node, set())
if emission:
return emission
return None
def material_defaults(
material_type: str,
base_color: tuple[float, float, float],
metallic: float,
roughness: float,
alpha: float,
transmission: float,
refractive_index: float,
clearcoat: float,
clearcoat_roughness: float,
sheen: float,
subsurface: float,
subsurface_radius: tuple[float, float, float],
subsurface_scale: float,
subsurface_color: tuple[float, float, float],
subsurface_method: str,
glossy_color: tuple[float, float, float],
glossy_weight: float,
emission_color: tuple[float, float, float],
emission_strength: float,
) -> dict[str, object]:
return {
"material_type": material_type,
"base_color": base_color,
"metallic": metallic,
"roughness": roughness,
"alpha": alpha,
"transmission": transmission,
"refractive_index": refractive_index,
"clearcoat": clearcoat,
"clearcoat_roughness": clearcoat_roughness,
"sheen": sheen,
"subsurface": subsurface,
"subsurface_radius": subsurface_radius,
"subsurface_scale": subsurface_scale,
"subsurface_color": subsurface_color,
"subsurface_method": subsurface_method,
"glossy_color": glossy_color,
"glossy_weight": glossy_weight,
"emission_color": emission_color,
"emission_strength": emission_strength,
}
def export_texture_image(
image: object,
material_name: str,
texture_dir: Path,
scene_texture_dir: Path,
used_texture_names: set[str],
exported_textures: dict[str, str],
args: argparse.Namespace,
) -> str | None:
image_key = getattr(image, "name_full", None) or getattr(image, "name", None)
if image_key and image_key in exported_textures:
return exported_textures[image_key]
base_name = slugify(getattr(image, "name", material_name), "texture")
texture_name = unique_name(base_name, used_texture_names) + ".ppm"
texture_path = texture_dir / texture_name
scene_path = (scene_texture_dir / texture_name).as_posix()
if not write_image_as_ppm(image, texture_path, int(args.texture_max_size)):
return None
if image_key:
exported_textures[image_key] = scene_path
return scene_path
def image_source_path(image: object) -> Path | None:
raw_path = getattr(image, "filepath", None) or getattr(image, "filepath_raw", None)
if not raw_path:
return None
try:
absolute_path = Path(bpy.path.abspath(raw_path)).expanduser()
except Exception:
return None
if not absolute_path.exists() or not absolute_path.is_file():
return None
return absolute_path
def export_environment_image(
image: object,
texture_dir: Path,
scene_texture_dir: Path,
used_texture_names: set[str],
exported_textures: dict[str, str],
args: argparse.Namespace,
) -> str | None:
image_key = "environment::" + str(getattr(image, "name_full", None) or getattr(image, "name", "environment"))
if image_key in exported_textures:
return exported_textures[image_key]
source_path = image_source_path(image)
if source_path is not None and source_path.suffix.lower() in {".hdr", ".pic"}:
base_name = slugify(source_path.stem, "environment")
texture_name = unique_name(base_name, used_texture_names) + source_path.suffix.lower()
texture_path = texture_dir / texture_name
scene_path = (scene_texture_dir / texture_name).as_posix()
texture_path.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.copyfile(source_path, texture_path)
except OSError:
return None
exported_textures[image_key] = scene_path
return scene_path
base_name = slugify(getattr(image, "name", "environment"), "environment")
texture_name = unique_name(base_name, used_texture_names) + ".ppm"
texture_path = texture_dir / texture_name
scene_path = (scene_texture_dir / texture_name).as_posix()
if not write_image_as_ppm(image, texture_path, int(args.texture_max_size)):
return None
exported_textures[image_key] = scene_path
return scene_path
def blend_material_values(
first: dict[str, object],
second: dict[str, object],
factor: float,
) -> dict[str, object]:
first_type = str(first["material_type"])
second_type = str(second["material_type"])
if {first_type, second_type} == {"lambertian", "glossy"}:
diffuse = first if first_type == "lambertian" else second
glossy = first if first_type == "glossy" else second
glossy_weight = 1.0 - factor if first_type == "glossy" else factor
return {
"material_type": "diffuse_glossy",
"base_color": diffuse["base_color"],
"metallic": 0.0,
"roughness": glossy["roughness"],
"alpha": blend_scalar(float(first["alpha"]), float(second["alpha"]), factor),
"transmission": 0.0,
"refractive_index": blend_scalar(float(first["refractive_index"]), float(second["refractive_index"]), factor),
"clearcoat": 0.0,
"clearcoat_roughness": glossy["roughness"],
"sheen": 0.0,
"subsurface": 0.0,
"subsurface_radius": (1.0, 1.0, 1.0),
"subsurface_scale": 0.001,
"subsurface_color": (1.0, 1.0, 1.0),
"subsurface_method": "burley",
"glossy_color": glossy["glossy_color"],
"glossy_weight": max(0.0, min(1.0, glossy_weight)),
"emission_color": blend_color(first["emission_color"], second["emission_color"], factor), # type: ignore[arg-type]
"emission_strength": blend_scalar(float(first["emission_strength"]), float(second["emission_strength"]), factor),
}
return {
"material_type": first["material_type"] if first_type == second_type else "principled",
"base_color": blend_color(first["base_color"], second["base_color"], factor), # type: ignore[arg-type]
"metallic": blend_scalar(float(first["metallic"]), float(second["metallic"]), factor),
"roughness": blend_scalar(float(...
FAQ
Common questions
Discussion
Questions & comments · 0
Sign In Sign in to leave a comment.