"""2D lighting nodes — Light2D, PointLight2D, DirectionalLight2D, LightOccluder2D.
Backend-agnostic light description nodes. The Vulkan backend renders these
via Light2DPass (additive light accumulation with optional shadow casting).
"""
from __future__ import annotations
import logging
import math
from typing import Any
from .nodes_2d.node2d import Node2D
from .descriptors import Property, Signal
log = logging.getLogger(__name__)
__all__ = [
"Light2D",
"PointLight2D",
"DirectionalLight2D",
"LightOccluder2D",
]
[docs]
class Light2D(Node2D):
"""Base class for 2D lights.
Lights contribute additive (or mixed) colour to a light accumulation
buffer that modulates the final 2D scene output. Attach as children
of any Node2D to have their position follow the parent.
Attributes:
colour: RGB light colour, each component in [0, 1].
energy: Intensity multiplier applied to the light colour.
range: Radius of the light in pixels.
blend_mode: ``"add"`` for additive blending, ``"mix"`` for
alpha-based mix with ambient.
enabled: Toggle the light on/off without removing it.
shadow_enabled: When ``True``, occluders in the scene cast
shadows for this light.
shadow_colour: RGBA colour used to tint shadow regions.
texture_scale: Scale multiplier for the light texture/gradient.
"""
colour = Property((1.0, 1.0, 1.0), group="Light")
energy = Property(1.0, group="Light")
range = Property(200.0, group="Light")
blend_mode = Property("add", enum=["add", "mix"], group="Light")
enabled = Property(True)
shadow_enabled = Property(False, group="Shadow")
shadow_colour = Property((0.0, 0.0, 0.0, 0.5), group="Shadow")
texture_scale = Property(1.0)
light_cull_mask = Property(
0xFFFFFFFF, range=(0, 0xFFFFFFFF), hint="Light cull mask — 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)."""
if not 0 <= index < 32:
raise ValueError(f"Light cull mask layer index must be 0-31, got {index}")
if enabled:
self.light_cull_mask = self.light_cull_mask | (1 << index)
else:
self.light_cull_mask = self.light_cull_mask & ~(1 << index)
[docs]
def is_light_cull_mask_layer_enabled(self, index: int) -> bool:
"""Check if a specific light cull mask layer is enabled (0-31)."""
if not 0 <= index < 32:
raise ValueError(f"Light cull mask layer index must be 0-31, got {index}")
return bool(self.light_cull_mask & (1 << index))
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.light_changed = Signal()
def _get_light_data(self) -> dict[str, Any]:
"""Return a dict of light parameters for the renderer."""
gp = self.world_position
return {
"position": (gp.x, gp.y),
"colour": tuple(self.colour[:3]) if len(self.colour) >= 3 else (1.0, 1.0, 1.0),
"energy": self.energy,
"range": self.range * self.texture_scale,
"blend_mode": self.blend_mode,
"shadow_enabled": self.shadow_enabled,
"shadow_colour": tuple(self.shadow_colour),
}
[docs]
class PointLight2D(Light2D):
"""Radial point light with configurable falloff curve.
The ``falloff`` exponent controls the attenuation shape:
- ``1.0`` = linear falloff (default)
- ``2.0`` = quadratic (more concentrated center)
- ``0.5`` = square-root (softer, wider spread)
Example::
light = PointLight2D(
colour=(1.0, 0.8, 0.3),
energy=1.5,
range=300.0,
falloff=2.0,
position=Vec2(400, 300),
)
"""
falloff = Property(1.0, range=(0.1, 10.0))
def _get_light_data(self) -> dict[str, Any]:
data = super()._get_light_data()
data["falloff"] = self.falloff
data["type"] = "point"
return data
[docs]
class DirectionalLight2D(Light2D):
"""Global directional light that illuminates the entire scene.
Unlike point lights, directional lights have no position-based
attenuation. The ``direction`` vector determines the light angle
for shadow casting (if enabled). The ``range`` setting is ignored
for illumination but still used to size the shadow map.
Example::
sun = DirectionalLight2D(
direction=(0.5, -1.0),
colour=(1.0, 1.0, 0.9),
energy=0.8,
)
"""
direction = Property((0.0, -1.0))
def _get_light_data(self) -> dict[str, Any]:
data = super()._get_light_data()
d = self.direction
ln = math.sqrt(d[0] ** 2 + d[1] ** 2) or 1.0
data["direction"] = (d[0] / ln, d[1] / ln)
data["type"] = "directional"
return data
[docs]
class LightOccluder2D(Node2D):
"""Shadow-casting obstacle defined by a convex polygon.
The ``polygon`` attribute is a sequence of ``(x, y)`` tuples
defining the occluder shape in local coordinates. The occluder
follows its parent's transform (position, rotation, scale).
When ``one_way`` is ``True``, shadows are only cast in the
direction of the polygon's winding normal.
Example::
wall = LightOccluder2D(
polygon=[(-50, -10), (50, -10), (50, 10), (-50, 10)],
position=Vec2(400, 400),
)
"""
polygon = Property(())
one_way = Property(False)
[docs]
def get_global_polygon(self) -> list[tuple[float, float]]:
"""Return polygon vertices transformed to global coordinates.
Applies the node's global position, rotation, and scale to
each vertex in the polygon.
"""
if not self.polygon:
return []
gp = self.world_position
angle = self.world_rotation
gs = self.world_scale
c, s = math.cos(angle), math.sin(angle)
result = []
for vx, vy in self.polygon:
sx, sy = vx * gs.x, vy * gs.y
rx = sx * c - sy * s + gp.x
ry = sx * s + sy * c + gp.y
result.append((rx, ry))
return result
[docs]
def get_edge_segments(self) -> list[tuple[tuple[float, float], tuple[float, float]]]:
"""Return a list of edge segments ``((x0, y0), (x1, y1))`` in global space."""
verts = self.get_global_polygon()
if len(verts) < 2:
return []
edges = []
for i, v in enumerate(verts):
edges.append((v, verts[(i + 1) % len(verts)]))
return edges
# ---------------------------------------------------------------------------
# Utility: collect lights and occluders from a scene tree
# ---------------------------------------------------------------------------
[docs]
def collect_lights(root: Node2D) -> list[Light2D]:
"""Walk the subtree and return all enabled Light2D nodes."""
return [n for n in root.find_all(Light2D) if n.enabled]
[docs]
def collect_occluders(root: Node2D) -> list[LightOccluder2D]:
"""Walk the subtree and return all LightOccluder2D nodes with non-empty polygons."""
return [n for n in root.find_all(LightOccluder2D) if n.polygon]