Tool

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.

Works with blender

91
Spark score
out of 100
Updated 2 months ago
Source checked Oct 14, 2026
Version 0.1.1

Add 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

01

Trace light paths through 3D scenes with BVH-accelerated ray intersection

02

Export Blender scenes to custom format and render with multithreaded CPU

03

Denoise Monte Carlo renders using feature-buffer filtering techniques

04

Benchmark rendering performance with deterministic test harness

Source

Get it from source

Spark does not host a copy of it.

Open source

Reports

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

cloud-render

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 .luzvol with 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 .luz scene 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
stormtroopers
by @ScottGraham on BlendSwap
Rhetorician statue
bust-statue
by @geoffreymarchal on BlendSwap
Clouds and a golden sunset
golden-clouds
Cornell Box
cornell
Simulated Earth atmosphere - space view + moon
atmosphere
10 Million bananas
bananas
Ray Tracing in One Weekend main scene
rtionw

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.