Newer
Older
SmTIAS-LightSim / src / sm_tias_lightsim / visualize / baker.py
@rintoHasegawa rintoHasegawa on 28 May 25 KB [add] Speed-1 LLVM JIT 自動検出と診断ログ強化 — runtime/llvm_init.py を新規実装.ensure_drjit_libllvm_path() で DRJIT_LIBLLVM_PATH 未設定時に Windows 標準 4 候補 (C:\Program Files\LLVM 等) + POSIX glob + LLVM_DIR + extra_candidates を順次探索して os.environ 設定(既設定なら上書きしない).log_variant_status() で llvm_/cuda_/scalar_ 経路を INFO 明示,要求 llvm_* で実 scalar_* なら WARNING 追加.render/runner.py で variant=llvm_* 時に自動検出を呼び ImportError 時は原因明示ログ→ re-raise,mitsuba を関数内遅延 import 化.visualize/baker.py の _set_variant_once に LLVM 自動探索分岐を追加し direct/path 両方で log_variant_status を呼ぶ.cli/render.py で import 順を制御し sensors.py/lights.py のモジュールレベル mitsuba import 前に検出を確実に通す.docs/02_ENV/ENV_02 に LLVM インストール手順・DRJIT_LIBLLVM_PATH 説明・動作確認コマンドを追記.新規テスト 51 件 (runtime/test_llvm_init.py 25, render/test_runner_jit_init.py 14, visualize/test_baker_jit_init.py 8, cli/test_render_jit_init.py 4) で仕様 A1-E1 完全カバー,DRJIT_LIBLLVM_PATH 設定下で 606 件全通過.速度は不変(目的は再発防止+診断容易化).数値同等性は MC 内部シードで SHA256 異なるが平均相対誤差 7.23e-05 で統計同等性パス.これで環境変数消失によるサイレントクラッシュが再発しない土台が整備された.TEST_04 Speed-1 セクションを実施結果で更新.CLAUDE.md 開発進捗に Speed-1 完了追記+実装済みモジュール一覧に runtime/llvm_init.py 追加
# pyright: reportCallIssue=none
# Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が
# mi.* を object 型と推論し reportCallIssue を誤検知する(GUIDE_06 の方針).
"""各メッシュ face 中心での照度ベイクモジュール.

2 つのモードを提供する:

- mode="direct": shadow ray + コサイン項 + 距離減衰による直接照度(W/m²).
  多重反射を無視する.高速(face ×光源数の積分).

- mode="path": コサイン重点サンプリング + Mitsuba path tracer による多重反射込み照度(W/m²).
  E = ∫_hemisphere L_i(ω)·cosθ dω を Monte Carlo 積分で評価する.
  コサイン重点サンプリングの場合 E ≈ (π/N)·Σ L_i(PDF=cosθ/π のため cosθ キャンセル).
  完全シーン(emitter + BSDF を含む)を使うため多重反射経路もすべて積分される.
  scalar_rgb variant を使用(LLVM 不要).per-ray スカラ評価のため遅いが正確に完走する.
  計算コストは face 数 × samples 倍(直接より大幅に重い).

{mesh_id: irradiance_per_face (N_f,) float32} の形式で返す.
"""

from __future__ import annotations

import logging
import shutil
from pathlib import Path
from typing import Literal

import numpy as np

from sm_tias_lightsim.geometry.loader import load_stl
from sm_tias_lightsim.geometry.transforms import scale_vertices
from sm_tias_lightsim.runtime.llvm_init import ensure_drjit_libllvm_path, log_variant_status
from sm_tias_lightsim.scene.schema import (
    AreaLight,
    DirectionalLight,
    PointLight,
    SceneConfig,
)

_logger = logging.getLogger(__name__)

# YAML 座標 (mm) → m 変換係数
_MM_TO_M = 0.001

# shadow ray の原点オフセット(face 法線方向に少しずらして自己交差を防ぐ)
_SHADOW_RAY_EPSILON = 1e-4

# DirectionalLight の shadow ray の最大距離(無限遠をシミュレート)
_DIRECTIONAL_MAXT = 1e9

# 方向ベクトルの長さがこれ以下なら縮退とみなしてスキップする
_DIR_NORM_EPSILON = 1e-9


def _to_scalar_intensity(value: float | tuple[float, float, float]) -> float:
    """スカラまたは RGB タプルを輝度スカラに変換する.

    RGB の場合は Rec.709 係数で輝度に変換する.
    スカラの場合はそのまま返す.

    Args:
        value: スカラまたは RGB タプル.

    Returns:
        輝度スカラ値(float).
    """
    if isinstance(value, (int, float)):
        return float(value)
    r, g, b = value
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


def _shadow_ray_blocked(
    scene: object,
    origin: np.ndarray,
    direction: np.ndarray,
    maxt: float,
) -> bool:
    """shadow ray が遮蔽されているかを判定する.

    origin → direction 方向に距離 maxt まで ray を飛ばし,
    途中に交差があれば True(遮蔽あり)を返す.

    Args:
        scene: Mitsuba の Scene オブジェクト(load_dict 済み).
        origin: ray の起点(m 単位).
        direction: ray の方向(単位ベクトル).
        maxt: 最大ヒット距離(m 単位).

    Returns:
        遮蔽あり(途中に交差あり)なら True.
    """
    import mitsuba as mi

    o = mi.Point3f(float(origin[0]), float(origin[1]), float(origin[2]))
    d = mi.Vector3f(float(direction[0]), float(direction[1]), float(direction[2]))
    ray = mi.Ray3f(o, d)  # type: ignore[call-arg]
    ray.maxt = maxt  # type: ignore[assignment]
    si = scene.ray_intersect_preliminary(ray)  # type: ignore[attr-defined]
    return bool(si.is_valid())


def _compute_face_centroids_and_normals(
    vertices: np.ndarray, faces: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
    """face の重心と法線ベクトルを計算する.

    Args:
        vertices: 頂点配列 (N_v, 3),m 単位.
        faces: 面配列 (N_f, 3),頂点インデックス.

    Returns:
        (centroids, normals) のタプル.
        - centroids: face 重心 (N_f, 3).
        - normals: 単位法線ベクトル (N_f, 3).長さ 0 の面はゼロベクトルになる.
    """
    v0 = vertices[faces[:, 0]]
    v1 = vertices[faces[:, 1]]
    v2 = vertices[faces[:, 2]]

    centroids = (v0 + v1 + v2) / 3.0

    edge1 = v1 - v0
    edge2 = v2 - v0
    raw_normals = np.cross(edge1, edge2)  # (N_f, 3)

    norms = np.linalg.norm(raw_normals, axis=1, keepdims=True)  # (N_f, 1)
    norms = np.where(norms == 0.0, 1.0, norms)
    normals = raw_normals / norms

    return centroids, normals


def _accumulate_finite_light(
    irradiance: np.ndarray,
    centroids: np.ndarray,
    normals: np.ndarray,
    scene: object,
    light_pos_m: np.ndarray,
    intensity: float,
    emit_dir: np.ndarray | None = None,
) -> None:
    """距離減衰のある光源(PointLight / AreaLight)の照度を face ごとに累積する.

    PointLight と AreaLight は「光源位置 → face 距離」「cos_theta」「shadow ray」の
    計算構造が共通のため,このヘルパーに集約する.
    AreaLight に固有の「裏面スキップ」は emit_dir が指定されたときのみ評価する.

    Args:
        irradiance: 累積先の照度配列 (N_f,),in-place で更新する.
        centroids: face 重心 (N_f, 3).
        normals: face 単位法線 (N_f, 3).
        scene: Mitsuba Scene オブジェクト.
        light_pos_m: 光源位置(m 単位).
        intensity: 光源の等価スカラ強度(W/sr 相当).
        emit_dir: AreaLight の放射方向単位ベクトル(正規化済み).
            None のとき裏面チェックをスキップする(PointLight 用).
    """
    for fi in range(len(centroids)):
        centroid = centroids[fi]
        normal = normals[fi]

        to_light = light_pos_m - centroid
        distance = float(np.linalg.norm(to_light))
        if distance < _SHADOW_RAY_EPSILON:
            continue

        dir_n = to_light / distance
        cos_theta = float(np.dot(normal, dir_n))
        if cos_theta <= 0.0:
            continue

        # AreaLight の裏面には放射しない(放射方向との角度チェック)
        # emit_dir が face から光源への方向と逆向きの場合はスキップ
        if emit_dir is not None:
            emit_cos = float(np.dot(-dir_n, emit_dir))
            if emit_cos <= 0.0:
                continue

        # shadow ray: origin を法線方向にわずかにオフセットし,
        # maxt は光源手前までに制限して自己遮蔽と光源貫通を防ぐ
        origin = centroid + _SHADOW_RAY_EPSILON * normal
        if _shadow_ray_blocked(scene, origin, dir_n, distance - _SHADOW_RAY_EPSILON):
            continue

        irradiance[fi] += float(intensity * cos_theta / (distance**2))


def bake_irradiance(
    scene_config: SceneConfig,
    project_root: Path,
    *,
    mode: Literal["direct", "path"] = "direct",
    samples: int = 64,
    exclude_mesh_ids: set[str] | None = None,
    max_depth: int | None = None,
) -> dict[str, np.ndarray]:
    """各メッシュの face 中心で照度(W/m²)を計算する.

    2 つのモードを提供する:

    - mode="direct"(既定): shadow ray + コサイン項 + 距離減衰による直接照度のみ.
      多重反射を無視する.高速.scalar_rgb variant を使用.

    - mode="path": コサイン重点サンプリング + Mitsuba path tracer による多重反射込み照度.
      完全シーン(emitter + BSDF を含む)に対してパス積分を評価するため,
      内壁の多重反射で均一化された照度分布が得られる(MiniTIAS の設計目標と整合).
      **評価板は直接照度ベイクでは真っ暗になるが,path ベイクでは多重反射で非ゼロになる.**
      scalar_rgb variant を使用(LLVM 不要・DRJIT_LIBLLVM_PATH 不要).
      計算コストは face 数 × samples 倍(直接ベイクより大幅に重い).
      目安: 100k face × samples=64 ≈ 数分(face 数依存).

    Args:
        scene_config: 検証済み SceneConfig.
        project_root: STL 相対パスの基点ディレクトリ.
        mode: 計算モード."direct"(直接照度, 後方互換)または "path"(多重反射込み).
        samples: mode="path" 時の face あたりサンプル数.大きいほど低ノイズだが重い.
            既定 64.高速確認は 16 程度,本番は 256 以上推奨.mode="direct" では無視.
        exclude_mesh_ids: ベイク計算からスキップする mesh_id の集合(任意).
            ハイブリッドモードで評価板を投影で着色する場合に path ベイクから除外して
            計算量を節約するために使う.除外された mesh_id はキーとして返さない.
        max_depth: mode="path" 時の path integrator の最大反射回数(任意).
            None のとき scene_config.render.max_depth を使用(後方互換).
            指定時はその値を使う(render 設定から独立して制御できる).
            mode="direct" では無視される(direct ベイクは path integrator を使わない).
            SPEC_03: depth≥12 推奨(depth=8 は後段多重反射を取りこぼし勾配を過大評価する).

    Returns:
        {mesh_id: irradiance_per_face (N_f,) float32} の辞書.
        irradiance の単位は W/m²(light の radiance / intensity の単位に依存).
        exclude_mesh_ids に指定した id はキーに含まれない.

    Raises:
        ValueError: mode が不正な値の場合.
        FileNotFoundError: STL ファイルが見つからない場合.
    """
    if mode not in ("direct", "path"):
        msg = f"mode={mode!r} は不正です.'direct' または 'path' を指定してください."
        raise ValueError(msg)

    if exclude_mesh_ids is None:
        exclude_mesh_ids = set()

    import mitsuba as mi

    from sm_tias_lightsim.scene.builder import build_scene

    if mode == "direct":
        # Mitsuba variant を初期化(scalar_rgb を使う:shadow ray 評価に最適)
        # scalar_rgb は LLVM 不要なので ensure_drjit_libllvm_path() は呼ばない
        # runner.py と同じ variant が既に設定されている場合はそのまま続行する
        desired_variant = "scalar_rgb"
        _variant_set_succeeded = False
        try:
            mi.set_variant(desired_variant)
            _variant_set_succeeded = True
        except Exception:
            _logger.warning(
                "Mitsuba variant の切り替えに失敗しました.現在の variant で続行します."
            )
        if _variant_set_succeeded:
            log_variant_status(_logger, desired_variant, mi.variant())

        # shadow ray 評価専用シーン(AreaLight の rectangle 形状を除外)を構築する.
        # AreaLight を含む通常シーンでは,面光源自身の rectangle にヒットして
        # 正面メッシュへの shadow ray が常に遮蔽されてしまう問題を回避するため,
        # exclude_emitters=True で AreaLight をシーンから除外する.
        scene_dict, tempdir = build_scene(
            scene_config, project_root=project_root, exclude_emitters=True
        )
        try:
            scene = mi.load_dict(scene_dict)
            return _bake_direct(
                scene_config, project_root, scene, exclude_mesh_ids=exclude_mesh_ids
            )
        finally:
            shutil.rmtree(tempdir, ignore_errors=True)

    else:  # mode == "path"
        return _bake_path(
            scene_config,
            project_root,
            samples=samples,
            exclude_mesh_ids=exclude_mesh_ids,
            max_depth=max_depth,
        )


def _set_variant_once(desired_variant: str) -> None:
    """Mitsuba variant をプロセス内で 1 度だけ設定する.

    既に同じ variant が設定されている場合は何もしない.
    異なる variant が設定されている場合は警告を出して続行する(変更しない).

    llvm_* variant 要求時は DRJIT_LIBLLVM_PATH を自動探索して設定する
    (Speed-2 wavefront 化で llvm_ad_rgb 等を要求するケースに備えた前準備).
    scalar_* / cuda_* は LLVM-C.dll 不要なので探索しない.

    Args:
        desired_variant: 設定したい variant 文字列.
    """
    import mitsuba as mi

    current = mi.variant()
    if current == desired_variant:
        return
    if current is None:
        # Speed-2 wavefront 化用の前準備: llvm_* variant の場合は DRJIT_LIBLLVM_PATH を自動探索する
        if desired_variant.startswith("llvm_"):
            ensure_drjit_libllvm_path()
        mi.set_variant(desired_variant)
        log_variant_status(_logger, desired_variant, mi.variant())
    else:
        _logger.warning(
            "Mitsuba variant は既に %r に設定されています."
            "要求された variant %r は無視されます."
            "variant を変更するにはプロセスを再起動してください.",
            current,
            desired_variant,
        )


def _bake_path(
    scene_config: SceneConfig,
    project_root: Path,
    *,
    samples: int,
    exclude_mesh_ids: set[str] | None = None,
    max_depth: int | None = None,
) -> dict[str, np.ndarray]:
    """多重反射込み path ベイクの内部実装.

    コサイン重点サンプリングで hemisphere 方向を N 本生成し,
    各レイを Mitsuba path integrator で評価して入射照度を積分する.

    積分式(コサイン重点サンプリング,PDF = cosθ/π):

        E = ∫_H² L_i(ω) cosθ dω
          ≈ (1/N) Σ L_i(ωk) cosθk / pdf(ωk)
          = (π/N) Σ L_i(ωk)      ← cosθ と PDF のコサインがキャンセル

    完全シーン(emitter + BSDF を含む)を使うため内壁の多重反射も積分される.
    scalar_rgb variant を使用(LLVM 不要).per-ray スカラ評価のため遅いが正確に完走する.

    Args:
        scene_config: 検証済み SceneConfig.
        project_root: STL 相対パスの基点ディレクトリ.
        samples: face あたりサンプル数.大きいほど低ノイズ・高コスト.
        exclude_mesh_ids: ベイク計算からスキップする mesh_id の集合(任意).
        max_depth: path integrator の最大反射回数(任意).
            None のとき scene_config.render.max_depth を使用(後方互換).
            指定時はその値で scene_dict["integrator"]["max_depth"] を上書きしてから
            mi.load_dict を呼ぶ(render 設定から独立して制御できる).

    Returns:
        {mesh_id: irradiance_per_face (N_f,) float32}.
    """
    if exclude_mesh_ids is None:
        exclude_mesh_ids = set()
    import mitsuba as mi

    from sm_tias_lightsim.scene.builder import build_scene

    # path ベイクは scalar_rgb variant を使う(LLVM 不要).
    # llvm_ad_rgb 等の LLVM variant を per-ray スカラループで使うと
    # LLVM JIT の megakernel シンボル解決が破綻してクラッシュするため,
    # scalar_rgb(JIT 無し)で per-ray 評価を行う(_bake_direct と同方式).
    _set_variant_once("scalar_rgb")

    # 完全シーン(emitter と BSDF を含む)を構築する.
    # exclude_emitters=False にすることで AreaLight の rectangle も含まれ,
    # path tracer が emitter を光源として正しくサンプリングできる.
    scene_dict, tempdir = build_scene(
        scene_config, project_root=project_root, exclude_emitters=False
    )

    # max_depth を render 設定から独立して上書きする.
    # build_scene は scene_dict["integrator"] = {"type": "path", "max_depth": ...} を構築する.
    # None のとき scene_config.render.max_depth(YAML 値)をそのまま使う(後方互換).
    # 指定時は mi.load_dict 前に dict を上書きして integrator に反映させる.
    effective_max_depth = max_depth if max_depth is not None else scene_config.render.max_depth
    scene_dict["integrator"]["max_depth"] = effective_max_depth
    _logger.info(
        "path ベイク: max_depth=%d (render.max_depth=%d, 引数指定=%s)",
        effective_max_depth,
        scene_config.render.max_depth,
        str(max_depth) if max_depth is not None else "なし(render 設定を継承)",
    )

    try:
        scene = mi.load_dict(scene_dict)
        integrator = scene.integrator()
        sampler = mi.load_dict({"type": "independent"})
        # wavefront_size=1: 1 レイずつスカラで評価する(face ループ内で毎回呼ぶため)
        sampler.seed(0, wavefront_size=1)  # type: ignore[call-arg]

        result: dict[str, np.ndarray] = {}
        n_meshes = len(scene_config.geometry.meshes)

        for mesh_idx, mesh_cfg in enumerate(scene_config.geometry.meshes):
            if mesh_cfg.id in exclude_mesh_ids:
                _logger.info(
                    "path ベイク: メッシュ %d/%d '%s' はスキップします(exclude_mesh_ids)",
                    mesh_idx + 1,
                    n_meshes,
                    mesh_cfg.id,
                )
                continue

            raw_path = Path(mesh_cfg.path)
            stl_path = raw_path if raw_path.is_absolute() else project_root / mesh_cfg.path

            loaded = load_stl(stl_path, mesh_cfg.unit)
            scaled = scale_vertices(loaded, target_unit="m")

            centroids, normals = _compute_face_centroids_and_normals(scaled.vertices, scaled.faces)
            n_faces = len(centroids)
            irradiance = np.zeros(n_faces, dtype=np.float64)

            _logger.info(
                "path ベイク: メッシュ %d/%d '%s'  faces=%d  samples=%d",
                mesh_idx + 1,
                n_meshes,
                mesh_cfg.id,
                n_faces,
                samples,
            )

            rng = np.random.default_rng(seed=mesh_idx)

            for fi in range(n_faces):
                centroid = centroids[fi]
                normal = normals[fi]

                # face 法線座標系を構築(コサイン重点サンプリングのローカル→ワールド変換用)
                n_mi = mi.Vector3f(float(normal[0]), float(normal[1]), float(normal[2]))
                frame = mi.Frame3f(n_mi)

                # origin を法線方向にオフセット(自己交差回避)
                origin = centroid + _SHADOW_RAY_EPSILON * normal

                total_radiance = np.zeros(3, dtype=np.float64)

                for _ in range(samples):
                    # コサイン重点サンプリング: [0,1)² → ローカル hemisphere 方向
                    u0, u1 = float(rng.random()), float(rng.random())
                    local_dir = mi.warp.square_to_cosine_hemisphere(mi.Point2f(u0, u1))
                    world_dir = frame.to_world(local_dir)
                    # scalar_rgb では Vector3f は通常の Mitsuba 型なので numpy 変換で取り出す
                    wd = np.array(world_dir).flatten()

                    ray = mi.Ray3f(  # type: ignore[call-arg]
                        mi.Point3f(float(origin[0]), float(origin[1]), float(origin[2])),
                        mi.Vector3f(float(wd[0]), float(wd[1]), float(wd[2])),
                    )

                    # path integrator で多重反射込みの入射放射輝度を評価する
                    # 返り値(Color3f, Bool)を numpy 変換して取り出す
                    radiance_rgb, valid, _aovs = integrator.sample(  # type: ignore[call-arg]
                        scene, sampler, ray
                    )
                    radiance_arr = np.array(radiance_rgb).flatten()  # (3,) float32
                    valid_arr = np.array(valid).flatten()  # (1,) bool

                    if bool(valid_arr[0]):
                        total_radiance[0] += float(radiance_arr[0])
                        total_radiance[1] += float(radiance_arr[1])
                        total_radiance[2] += float(radiance_arr[2])

                # E = (π/N) Σ L_i  (コサイン重点サンプリングの正規化)
                # Σ は valid サンプルのみ.N はサンプル総数(valid 比率で bias なし)
                if samples > 0:
                    radiance_mean = total_radiance / samples
                    irradiance_rgb = np.pi * radiance_mean
                    # スカラ照度として Rec.709 輝度に変換
                    irradiance[fi] = (
                        0.2126 * irradiance_rgb[0]
                        + 0.7152 * irradiance_rgb[1]
                        + 0.0722 * irradiance_rgb[2]
                    )

            result[mesh_cfg.id] = irradiance.astype(np.float32)
            _logger.info(
                "  '%s': min=%.4e  max=%.4e  std=%.4e  (valid_faces=%d/%d)",
                mesh_cfg.id,
                float(irradiance.min()),
                float(irradiance.max()),
                float(irradiance.std()),
                int((irradiance > 0).sum()),
                n_faces,
            )

    finally:
        shutil.rmtree(tempdir, ignore_errors=True)

    return result


def _bake_direct(
    scene_config: SceneConfig,
    project_root: Path,
    scene: object,
    *,
    exclude_mesh_ids: set[str] | None = None,
) -> dict[str, np.ndarray]:
    """直接照度ベイクの内部実装.

    各メッシュの face 中心に対して,全光源からの直接照度を shadow ray で計算する.

    Args:
        scene_config: 検証済み SceneConfig.
        project_root: STL 相対パスの基点ディレクトリ.
        scene: Mitsuba Scene オブジェクト(load_dict 済み).
        exclude_mesh_ids: ベイク計算からスキップする mesh_id の集合(任意).

    Returns:
        {mesh_id: irradiance_per_face (N_f,) float32}.
    """
    if exclude_mesh_ids is None:
        exclude_mesh_ids = set()

    result: dict[str, np.ndarray] = {}

    for mesh_cfg in scene_config.geometry.meshes:
        if mesh_cfg.id in exclude_mesh_ids:
            _logger.info(
                "direct ベイク: メッシュ '%s' はスキップします(exclude_mesh_ids)", mesh_cfg.id
            )
            continue
        raw_path = Path(mesh_cfg.path)
        stl_path = raw_path if raw_path.is_absolute() else project_root / mesh_cfg.path

        loaded = load_stl(stl_path, mesh_cfg.unit)
        scaled = scale_vertices(loaded, target_unit="m")

        centroids, normals = _compute_face_centroids_and_normals(scaled.vertices, scaled.faces)
        n_faces = len(centroids)
        irradiance = np.zeros(n_faces, dtype=np.float32)

        for light in scene_config.lights:
            if isinstance(light, PointLight):
                intensity = _to_scalar_intensity(light.intensity)
                light_pos_m = np.array(light.position, dtype=np.float64) * _MM_TO_M
                _accumulate_finite_light(
                    irradiance, centroids, normals, scene, light_pos_m, intensity
                )

            elif isinstance(light, AreaLight):
                # 面光源: 中心点を点光源として近似する(TODO: 面サンプリングで精度向上可能)
                # 近似の精度を上げるには center を N 点サンプリングして平均する方法がある.
                # 現時点ではシンプルに中心点のみで計算する.
                radiance = _to_scalar_intensity(light.radiance)
                area = (light.size[0] * _MM_TO_M) * (light.size[1] * _MM_TO_M)
                # 点光源近似の等価強度: radiance × area(Lambertian 放射源の近似)
                equiv_intensity = radiance * area

                center_m = np.array(light.center, dtype=np.float64) * _MM_TO_M
                dir_vec = np.array(light.direction, dtype=np.float64)
                dir_norm = np.linalg.norm(dir_vec)
                if dir_norm < _DIR_NORM_EPSILON:
                    continue
                emit_dir = dir_vec / dir_norm

                _accumulate_finite_light(
                    irradiance, centroids, normals, scene, center_m, equiv_intensity, emit_dir
                )

            elif isinstance(light, DirectionalLight):
                # YAML の direction は「光の進行方向」なので,face への入射方向は逆転する
                irradiance_val = _to_scalar_intensity(light.irradiance)
                dir_vec = np.array(light.direction, dtype=np.float64)
                dir_norm = np.linalg.norm(dir_vec)
                if dir_norm < _DIR_NORM_EPSILON:
                    continue
                incident_dir = -dir_vec / dir_norm

                for fi in range(n_faces):
                    centroid = centroids[fi]
                    normal = normals[fi]

                    cos_theta = float(np.dot(normal, incident_dir))
                    if cos_theta <= 0.0:
                        continue

                    origin = centroid + _SHADOW_RAY_EPSILON * normal
                    if _shadow_ray_blocked(scene, origin, incident_dir, float(_DIRECTIONAL_MAXT)):
                        continue

                    irradiance[fi] += float(irradiance_val * cos_theta)

        result[mesh_cfg.id] = irradiance

    return result