Source code for simvx.core.nodes_3d.lights

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