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).
"""

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]