"""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).
"""
import logging
import math
from typing import Any
from .descriptors import Property, Signal
from .math.types import Vec2
from .nodes_2d.node2d import Node2D
from .properties import Bitmask, Colour
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 = Colour((1.0, 1.0, 1.0), has_alpha=False, 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 = Colour((0.0, 0.0, 0.0, 0.5), group="Shadow")
texture_scale = Property(1.0)
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)."""
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))
gizmo_colour = Colour((1.0, 0.9, 0.3, 0.6))
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.light_changed = Signal()
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec2, Vec2]]:
"""Return a circle representing the light radius plus a centre crosshair."""
from .math.types import Vec2
p = self.world_position
r = float(self.range) * float(self.texture_scale)
segments = 32
step = math.tau / segments
lines: list[tuple[Vec2, Vec2]] = []
prev = Vec2(p.x + r, p.y)
for i in range(1, segments + 1):
angle = i * step
cur = Vec2(p.x + r * math.cos(angle), p.y + r * math.sin(angle))
lines.append((prev, cur))
prev = cur
# Crosshair at centre
ch = r * 0.1
lines.append((Vec2(p.x - ch, p.y), Vec2(p.x + ch, p.y)))
lines.append((Vec2(p.x, p.y - ch), Vec2(p.x, p.y + ch)))
return lines
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)
gizmo_colour = Colour((0.8, 0.5, 1.0, 0.6))
[docs]
@property
def global_polygon(self) -> list[tuple[float, float]]:
"""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]
@property
def edge_segments(self) -> list[tuple[tuple[float, float], tuple[float, float]]]:
"""Edge segments ``((x0, y0), (x1, y1))`` in global space."""
verts = self.global_polygon
if len(verts) < 2:
return []
edges = []
for i, v in enumerate(verts):
edges.append((v, verts[(i + 1) % len(verts)]))
return edges
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec2, Vec2]]:
"""Return the occluder polygon outline as line segments in world space."""
from .math.types import Vec2
verts = self.global_polygon
if len(verts) < 2:
return []
return [
(Vec2(verts[i][0], verts[i][1]), Vec2(verts[(i + 1) % len(verts)][0], verts[(i + 1) % len(verts)][1]))
for i in range(len(verts))
]
# ---------------------------------------------------------------------------
# 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]