"""Light3D, DirectionalLight3D, PointLight3D, SpotLight3D -- 3D light nodes."""
import math
import numpy as np
from ..descriptors import Property
from ..math.types import Quat, Vec3
from ..properties import Bitmask, Colour, get_mask_bit, set_mask_bit
from .node3d import Node3D
[docs]
class Light3D(Node3D):
"""Base class for the 3D light nodes.
Holds the properties every light shares: ``colour``, ``intensity``,
``shadows`` and ``light_cull_mask``. Add one of the concrete subclasses
(:class:`DirectionalLight3D`, :class:`PointLight3D`, :class:`SpotLight3D`)
to a scene rather than this class.
The renderer multiplies ``colour`` by ``intensity`` to get the light's
radiance and feeds that into the Cook-Torrance BRDF, so ``intensity`` is a
plain linear multiplier: it is not lumens, watts or any photometric unit,
and 2.0 is exactly twice as bright as 1.0. Values stay linear all the way
to the tonemapper, so overdriving a light past 1.0 is normal and produces
bloom rather than clipping.
A light only lights a mesh when ``light_cull_mask`` and the mesh's render
layer share a bit, and only when the active camera's cull mask also
overlaps ``light_cull_mask``: use this to keep, say, a UI-only rim light
off the world geometry.
"""
colour = Colour((1.0, 1.0, 1.0), has_alpha=False, group="Light")
intensity = Property(
1.0,
range=(0.0, 20.0),
clamp=False,
hint="Linear radiance multiplier on colour (not a photometric unit); values above 1 are allowed",
group="Light",
)
shadows = Property(False, hint="Cast shadows from this light (opt-in: costs a shadow pass)", group="Light")
light_cull_mask = Bitmask(0xFFFFFFFF, hint="Which render layers this light affects")
[docs]
def set_light_cull_mask_layer(self, index: int, enabled: bool = True) -> None:
"""Enable or disable a specific light cull mask layer (0-31)."""
self.light_cull_mask = set_mask_bit(self.light_cull_mask, index, enabled, label="Light cull mask layer")
[docs]
def is_light_cull_mask_layer_enabled(self, index: int) -> bool:
"""Check if a specific light cull mask layer is enabled (0-31)."""
return get_mask_bit(self.light_cull_mask, index, label="Light cull mask layer")
class _DirectionProperty(Property):
"""The declared Property behind :attr:`DirectionalLight3D.direction`.
The direction owns no storage of its own: reading returns the owning
node's forward vector and writing rotates the node so forward matches,
exactly as the plain ``@property`` it replaces did. Declaring it as a
Property is what makes it a constructor kwarg and lets the inspector and
the scene emitter see it.
"""
__slots__ = ()
storage_backed = False
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.forward
def __set__(self, obj, value):
"""Set light direction by rotating the node so forward matches *value*.
Raises ``ValueError`` on zero-length input: a zero direction would
normalize to NaN and propagate into the shadow-cascade projection
matrices (``compute_cascades`` in the renderer's shadow pass),
producing RuntimeWarnings per frame and undefined shadow output. The
caller must supply a non-zero direction explicitly.
"""
v_arr = np.asarray(value, dtype=np.float64)
length = float(np.linalg.norm(v_arr))
if length < 1e-6:
raise ValueError(
"DirectionalLight3D.direction must be a non-zero vector "
f"(got {tuple(v_arr)}, length={length:.3e}). "
"Explicit direction avoids NaN propagation into shadow cascades."
)
d = Vec3(value).normalized()
obj.world_rotation = Quat.look_at(d)
[docs]
class DirectionalLight3D(Light3D):
"""Infinitely distant light: a sun. Every fragment is lit from one direction.
The light has no position and no falloff: ``position`` only moves the
editor gizmo. The direction is the node's forward vector, so aim it either
by rotating the node or by assigning :attr:`direction` (which rotates the
node for you). ``range`` does not apply.
With ``shadows=True`` the light drives the cascaded shadow maps; the
cascade count is set globally by ``WorldEnvironment.shadow_cascade_count``.
A ``WorldEnvironment`` with ``sky_mode="procedural"`` also treats the first
``DirectionalLight3D`` in the scene as the sun that generates the sky.
"""
gizmo_colour = Colour((1.0, 0.95, 0.5, 0.4))
direction = _DirectionProperty(
(0.0, 0.0, -1.0),
hint="World-space light direction: reads the forward vector; assigning rotates the node to match",
group="Light",
)
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Return direction arrow and parallel rays showing light direction."""
p = self.world_position
fwd = self.forward
lines: list[tuple[Vec3, Vec3]] = [(p, p + fwd * 2.5)]
# Two parallel side rays
up = Vec3(0, 1, 0) if abs(fwd.y) < 0.9 else Vec3(1, 0, 0)
right = Vec3(*np.cross(fwd, up))
rn = np.linalg.norm(right)
if rn > 1e-6:
right = right * (0.4 / rn)
lines.append((p + right, p + right + fwd * 2.0))
lines.append((p - right, p - right + fwd * 2.0))
return lines
[docs]
class PointLight3D(Light3D):
"""Omnidirectional light at the node's world position, with a hard range.
Attenuation is the windowed linear falloff ``max(1 - distance/range, 0)``
squared, so brightness reaches exactly zero at ``range`` and the light
contributes nothing beyond it. That cutoff is what lets the renderer cull
the light per tile, so keep ``range`` as tight as the look allows rather
than using one huge light. It is not inverse-square: doubling ``range``
stretches the falloff instead of dimming the light, and ``intensity`` is
the only brightness dial.
With ``shadows=True`` the light casts a cubemap shadow. The Vulkan backend
renders ``WorldEnvironment.shadow_caster_count`` point-light shadow cubes
per frame (default 1); when more point lights ask than the budget holds,
the highest :attr:`shadow_priority` wins, then the strongest influence at
the camera (``intensity * range / distance``, so reach counts as much as
closeness), and a light already casting keeps its map unless a challenger
clearly beats it. Lights past that budget still light the scene, cast
nothing, and are reported once. Each caster re-renders every shadow-casting
mesh six times per frame, so raise the budget deliberately rather than
lighting every lamp.
"""
range = Property(
10.0,
range=(0.0, 1000.0),
clamp=False,
hint="Cutoff distance in world units: brightness falls to zero here (0 = light contributes nothing)",
group="Light",
)
shadow_priority = Property(
0,
range=(-100, 100),
hint="Claim on the shadow caster budget: higher wins, before influence at the camera",
group="Light",
)
gizmo_colour = Colour((1.0, 0.95, 0.5, 0.4))
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Return 3 circles showing the light range sphere."""
from ..gizmo import circle_lines_3d as _circle_lines_3d
p = self.world_position
r = float(self.range)
lines: list[tuple[Vec3, Vec3]] = []
lines.extend(_circle_lines_3d(p, Vec3(1, 0, 0), Vec3(0, 1, 0), r))
lines.extend(_circle_lines_3d(p, Vec3(1, 0, 0), Vec3(0, 0, 1), r))
lines.extend(_circle_lines_3d(p, Vec3(0, 1, 0), Vec3(0, 0, 1), r))
return lines
[docs]
class SpotLight3D(Light3D):
"""Cone-shaped light aimed along the node's forward vector.
Combines the same distance falloff as :class:`PointLight3D` with a cone
mask. Both cone angles are HALF-angles in degrees, measured from the cone
axis to its edge, so the default ``outer_cone=45`` is a 90-degree-wide
beam. Fragments inside ``inner_cone`` get the full beam, fragments between
``inner_cone`` and ``outer_cone`` fade out linearly in cosine space, and
fragments past ``outer_cone`` get nothing. Keep ``inner_cone`` below
``outer_cone``: the gap between them is the soft edge, and narrowing it
towards zero sharpens the beam.
Aim the light by rotating the node (the beam follows ``forward``); the
``position`` is the cone apex.
With ``shadows=True`` the light casts a projected shadow map. The Vulkan
backend renders ``WorldEnvironment.shadow_caster_count`` spot shadows per
frame (default 1); when more spot lights ask than the budget holds, the
highest :attr:`shadow_priority` wins, then the strongest influence at the
camera (``intensity * range / distance``, so reach counts as much as
closeness), and a light already casting keeps its map unless a challenger
clearly beats it. Lights past that budget still light the scene and cast
nothing. The shadow frustum is ``2 * outer_cone`` wide, so a cone
approaching 90 degrees loses shadow precision.
"""
range = Property(
10.0,
range=(0.0, 1000.0),
clamp=False,
hint="Cutoff distance in world units: brightness falls to zero here (0 = light contributes nothing)",
group="Light",
)
inner_cone = Property(
30.0,
range=(0.0, 90.0),
clamp=False,
hint="Half-angle in degrees of the fully-lit core; the gap up to outer_cone is the soft edge",
group="Light",
)
outer_cone = Property(
45.0,
range=(0.0, 90.0),
clamp=False,
hint="Half-angle in degrees of the cone edge: the beam is twice this wide and is dark beyond it",
group="Light",
)
shadow_priority = Property(
0,
range=(-100, 100),
hint="Claim on the shadow caster budget: higher wins, before influence at the camera",
group="Light",
)
gizmo_colour = Colour((1.0, 0.95, 0.5, 0.4))
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Return cone wireframe showing spot light direction and angle."""
from ..gizmo import circle_lines_3d as _circle_lines_3d
p = self.world_position
fwd = self.forward
r = float(self.range)
# ``outer_cone`` is the cone HALF-angle (matches the shader's spot cone
# test: cos(radians(outer_cone)) against dot(-L, axis) in cube_textured.frag).
cone_angle = math.radians(float(self.outer_cone))
base_radius = r * math.tan(cone_angle)
base_center = p + fwd * r
# Compute orthonormal basis
up = Vec3(0, 1, 0) if abs(fwd.y) < 0.9 else Vec3(1, 0, 0)
right_raw = np.cross(fwd, up)
rn = np.linalg.norm(right_raw)
if rn < 1e-6:
return [(p, base_center)]
right = Vec3(*(right_raw / rn))
u = Vec3(*np.cross(right, fwd))
lines: list[tuple[Vec3, Vec3]] = []
# Base circle
lines.extend(_circle_lines_3d(base_center, right, u, base_radius, 16))
# 4 lines from apex to base circle
for angle in [0.0, math.pi * 0.5, math.pi, math.pi * 1.5]:
edge = base_center + right * (base_radius * math.cos(angle)) + u * (base_radius * math.sin(angle))
lines.append((p, edge))
return lines