Source code for simvx.core.light2d

"""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]