"""FogVolume3D — Localised volumetric fog node for 3D scenes."""
from __future__ import annotations
import logging
import math
from enum import IntEnum
from .descriptors import Property
from .math.types import Vec3
from .nodes_3d.node3d import Node3D
log = logging.getLogger(__name__)
[docs]
class FogVolumeShape(IntEnum):
"""Shape of a FogVolume3D region."""
BOX = 0
SPHERE = 1
CYLINDER = 2
[docs]
class FogMaterial:
"""Custom material controlling fog appearance inside a FogVolume3D.
Attributes:
albedo: Base colour of the fog (RGBA).
density: Override density (applied multiplicatively with the volume's density).
emission: Emissive colour contribution (RGB + intensity in alpha).
scattering: Anisotropy of light scattering (-1 backward, 0 isotropic, +1 forward).
absorption: How much light the fog absorbs per unit distance.
"""
__slots__ = ("albedo", "density", "emission", "scattering", "absorption")
def __init__(
self,
*,
albedo: tuple[float, ...] = (1.0, 1.0, 1.0, 1.0),
density: float = 1.0,
emission: tuple[float, ...] = (0.0, 0.0, 0.0, 0.0),
scattering: float = 0.0,
absorption: float = 0.0,
) -> None:
self.albedo = albedo
self.density = density
self.emission = emission
self.scattering = scattering
self.absorption = absorption
[docs]
def __repr__(self) -> str:
return f"<FogMaterial density={self.density} scattering={self.scattering}>"
[docs]
class FogVolume3D(Node3D):
"""Localised volumetric fog region in 3D space.
Places a shaped fog volume (box, sphere, or cylinder) into the scene.
The renderer samples this volume during the lighting pass to produce
volumetric fog effects such as god rays, ground mist, and dust clouds.
Attributes:
shape: Geometric shape of the volume (box, sphere, cylinder).
size: Extents of the volume in local space (full width/height/depth for box,
diameter on each axis for sphere, diameter + height for cylinder).
density: Fog density inside the volume (0 = transparent, 1 = fully opaque).
albedo: Fog colour as RGBA tuple.
falloff: Rate at which fog density drops off near the volume boundary.
height_falloff: Vertical density gradient — higher values concentrate fog at the bottom.
priority: Rendering priority when volumes overlap (higher wins).
Example::
fog = FogVolume3D(position=(0, 1, 0))
fog.shape = FogVolumeShape.SPHERE
fog.size = Vec3(5, 5, 5)
fog.density = 0.3
fog.albedo = (0.8, 0.85, 0.9, 1.0)
scene.add_child(fog)
"""
shape = Property(FogVolumeShape.BOX, enum=[s.value for s in FogVolumeShape], hint="Volume shape",
group="Fog Volume")
size = Property((4.0, 4.0, 4.0), hint="Volume extents (x, y, z)", group="Fog Volume")
density = Property(0.5, range=(0.0, 1.0), hint="Fog density", group="Fog Volume")
albedo = Property((1.0, 1.0, 1.0, 1.0), hint="Fog colour (RGBA)", group="Fog Volume")
falloff = Property(1.0, range=(0.0, 10.0), hint="Edge density falloff exponent", group="Fog Volume")
height_falloff = Property(0.0, range=(0.0, 10.0), hint="Vertical density gradient", group="Fog Volume")
priority = Property(0, range=(-100, 100), hint="Overlap priority (higher wins)", group="Fog Volume")
gizmo_colour = Property((0.4, 0.6, 1.0, 0.5), hint="Editor gizmo colour")
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._material: FogMaterial | None = None
@property
def material(self) -> FogMaterial | None:
"""Optional FogMaterial for advanced fog appearance control."""
return self._material
@material.setter
def material(self, mat: FogMaterial | None) -> None:
self._material = mat
@property
def effective_density(self) -> float:
"""Density after applying material multiplier."""
d = float(self.density)
if self._material is not None:
d *= self._material.density
return d
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Return wireframe lines for the editor gizmo based on current shape."""
s = self.size
sx, sy, sz = float(s[0]) * 0.5, float(s[1]) * 0.5, float(s[2]) * 0.5
p = self.world_position
shape_val = int(self.shape)
if shape_val == FogVolumeShape.BOX:
return self._box_lines(p, sx, sy, sz)
elif shape_val == FogVolumeShape.SPHERE:
return self._sphere_lines(p, sx, sy, sz)
else: # CYLINDER
return self._cylinder_lines(p, sx, sy, sz)
# -- Private gizmo helpers ------------------------------------------------
@staticmethod
def _box_lines(p: Vec3, hx: float, hy: float, hz: float) -> list[tuple[Vec3, Vec3]]:
"""12-edge wireframe box centred at *p* with half-extents."""
corners = [
Vec3(p.x + dx * hx, p.y + dy * hy, p.z + dz * hz)
for dx in (-1, 1) for dy in (-1, 1) for dz in (-1, 1)
]
# Corner indices: 0=(-,-,-) 1=(-,-,+) 2=(-,+,-) 3=(-,+,+)
# 4=(+,-,-) 5=(+,-,+) 6=(+,+,-) 7=(+,+,+)
edges = [
(0, 1), (2, 3), (4, 5), (6, 7), # Z-aligned
(0, 2), (1, 3), (4, 6), (5, 7), # Y-aligned
(0, 4), (1, 5), (2, 6), (3, 7), # X-aligned
]
return [(corners[a], corners[b]) for a, b in edges]
@staticmethod
def _sphere_lines(p: Vec3, hx: float, hy: float, hz: float) -> list[tuple[Vec3, Vec3]]:
"""Three orthogonal circle outlines approximating a sphere (or ellipsoid)."""
from .physics_nodes import _circle_lines_3d
lines: list[tuple[Vec3, Vec3]] = []
# XY plane (radius uses hx, hy via separate circles for true ellipsoid support)
rx, ry, rz = hx, hy, hz
lines.extend(_circle_lines_3d(p, Vec3(1, 0, 0), Vec3(0, 1, 0), (rx + ry) * 0.5))
lines.extend(_circle_lines_3d(p, Vec3(1, 0, 0), Vec3(0, 0, 1), (rx + rz) * 0.5))
lines.extend(_circle_lines_3d(p, Vec3(0, 1, 0), Vec3(0, 0, 1), (ry + rz) * 0.5))
return lines
@staticmethod
def _cylinder_lines(p: Vec3, hx: float, hy: float, hz: float) -> list[tuple[Vec3, Vec3]]:
"""Top and bottom circles plus vertical edge lines for a Y-axis cylinder."""
from .physics_nodes import _circle_lines_3d
radius = (hx + hz) * 0.5 # Average XZ radii
top = Vec3(p.x, p.y + hy, p.z)
bottom = Vec3(p.x, p.y - hy, p.z)
lines: list[tuple[Vec3, Vec3]] = []
lines.extend(_circle_lines_3d(top, Vec3(1, 0, 0), Vec3(0, 0, 1), radius))
lines.extend(_circle_lines_3d(bottom, Vec3(1, 0, 0), Vec3(0, 0, 1), radius))
# 4 vertical edge lines connecting top and bottom circles
for angle_frac in (0.0, 0.25, 0.5, 0.75):
angle = angle_frac * math.tau
dx = radius * math.cos(angle)
dz = radius * math.sin(angle)
lines.append((
Vec3(top.x + dx, top.y, top.z + dz),
Vec3(bottom.x + dx, bottom.y, bottom.z + dz),
))
return lines