Source code for simvx.graphics.scene_adapter

"""Adapter to bridge SceneTree nodes to ForwardRenderer submissions."""


from __future__ import annotations

import logging
from typing import Any

import numpy as np

from simvx.core import (
    Camera2D,
    Camera3D,
    GPUParticles2D,
    GPUParticles3D,
    Light2D,
    Light3D,
    LightOccluder2D,
    Material,
    MeshInstance3D,
    MultiMeshInstance3D,
    NinePatchRect,
    Node,
    ParticleEmitter,
    PointLight3D,
    SceneTree,
    SpotLight3D,
    Sprite2D,
    Text2D,
)
from simvx.core.tilemap import TileMap

from ._types import (
    ALPHA_BLEND,
    ALPHA_OPAQUE,
    LIGHT_DTYPE,
    MATERIAL_DTYPE,
    SKINNED_VERTEX_DTYPE,
    VERTEX_DTYPE,
    Feature,
)
from .renderer.tilemap_pass import TILE_INSTANCE_DTYPE

__all__ = ["SceneAdapter"]

log = logging.getLogger(__name__)


[docs] class SceneAdapter: """Bridges SceneTree to ForwardRenderer (Vulkan). Responsibilities: - Register meshes with the GPU - Convert materials to Vulkan format - Traverse scene tree and submit instances """ def __init__(self, engine: Any, renderer: Any): self._engine = engine self._renderer = renderer self._mesh_cache: dict[int, Any] = {} # mesh id -> MeshHandle self._material_registry: dict[int, int] = {} # material id -> material index self._material_dedup: dict[tuple, int] = {} # content_key -> material index self._material_array = np.zeros(256, dtype=MATERIAL_DTYPE) self._next_material_id = 0 # Cached node collection (invalidated by tree structure changes) self._cached_nodes: tuple | None = None self._cached_structure_version: int = -1
[docs] def register_mesh(self, mesh: Any) -> Any | None: """Register mesh with Vulkan engine, return MeshHandle. Returns None if the mesh has no vertex data (skipped gracefully). """ mesh_id = getattr(mesh, "_uid", id(mesh)) if mesh_id in self._mesh_cache: return self._mesh_cache[mesh_id] # Validate mesh has renderable data if not hasattr(mesh, "positions") or mesh.positions is None or mesh.vertex_count == 0: log.debug("Skipping mesh registration: no vertex data") return None if not hasattr(mesh, "indices") or mesh.indices is None or len(mesh.indices) == 0: log.debug("Skipping mesh registration: no index data") return None # Ensure required attributes if mesh.normals is None: mesh.generate_normals() if not hasattr(mesh, "texcoords") or mesh.texcoords is None: mesh.texcoords = np.zeros((mesh.vertex_count, 2), dtype=np.float32) # Convert to structured array (VERTEX_DTYPE) vertices = np.zeros(mesh.vertex_count, dtype=VERTEX_DTYPE) vertices["position"] = mesh.positions vertices["normal"] = mesh.normals vertices["uv"] = mesh.texcoords handle = self._engine.mesh_registry.register(vertices, mesh.indices) self._mesh_cache[mesh_id] = handle return handle
[docs] def register_skinned_mesh(self, mesh: Any) -> Any | None: """Register skinned mesh with Vulkan engine, return MeshHandle. Returns None if the mesh has no vertex data. """ mesh_id = getattr(mesh, "_uid", id(mesh)) if mesh_id in self._mesh_cache: return self._mesh_cache[mesh_id] # Validate mesh has renderable data if not hasattr(mesh, "indices") or mesh.indices is None or len(mesh.indices) == 0: log.debug("Skipping skinned mesh registration: no index data") return None # Use pre-built skinned vertex data if available skinned_verts = getattr(mesh, "_skinned_vertices", None) if skinned_verts is not None: vertices = skinned_verts else: if not hasattr(mesh, "positions") or mesh.positions is None or mesh.vertex_count == 0: log.debug("Skipping skinned mesh registration: no vertex data") return None # Build SKINNED_VERTEX_DTYPE from mesh data if mesh.normals is None: mesh.generate_normals() if not hasattr(mesh, "texcoords") or mesh.texcoords is None: mesh.texcoords = np.zeros((mesh.vertex_count, 2), dtype=np.float32) vertices = np.zeros(mesh.vertex_count, dtype=SKINNED_VERTEX_DTYPE) vertices["position"] = mesh.positions vertices["normal"] = mesh.normals vertices["uv"] = mesh.texcoords # joints and weights default to zero handle = self._engine.mesh_registry.register(vertices, mesh.indices) self._mesh_cache[mesh_id] = handle return handle
def _load_texture(self, source: str | bytes) -> int: """Load texture and return texture index (bindless). Accepts a file path (str) or embedded image bytes. Returns -1 if texture loading fails. """ try: if isinstance(source, bytes): return self._engine.texture_manager.load_from_bytes(source) return self._engine.texture_manager.load(source) except (OSError, ValueError, RuntimeError) as exc: log.warning("Failed to load texture %s: %s", type(source).__name__, exc) return -1
[docs] def register_material(self, material: Material | None) -> int: """Convert simvx.core Material to Vulkan material index. Deduplicates materials by content: two Material objects with identical rendering properties share the same SSBO slot. """ if material is None: return 0 # Default material mat_id = getattr(material, "_uid", id(material)) if mat_id in self._material_registry: # Update in case colour/properties changed at runtime idx = self._material_registry[mat_id] colour = material.colour if hasattr(material, "colour") else (1, 1, 1, 1) self._material_array[idx]["albedo"] = colour self._material_array[idx]["metallic"] = getattr(material, "metallic", 0.0) self._material_array[idx]["roughness"] = getattr(material, "roughness", 0.5) # Update alpha mode on existing materials blend = getattr(material, "blend", "opaque") if blend in ("alpha", "additive"): self._material_array[idx]["alpha_mode"] = ALPHA_BLEND elif colour[3] < 1.0: self._material_array[idx]["alpha_mode"] = ALPHA_BLEND else: self._material_array[idx]["alpha_mode"] = ALPHA_OPAQUE return idx # Content-based deduplication: reuse slot if identical material already registered content_key = material.content_key if hasattr(material, "content_key") else None if content_key is not None and content_key in self._material_dedup: idx = self._material_dedup[content_key] self._material_registry[mat_id] = idx return idx idx = self._next_material_id if idx >= len(self._material_array): # Grow array if needed new_array = np.zeros(idx + 128, dtype=MATERIAL_DTYPE) new_array[: len(self._material_array)] = self._material_array self._material_array = new_array self._next_material_id += 1 # Convert Material.colour (RGBA tuple) to Vulkan format colour = material.colour if hasattr(material, "colour") else (1, 1, 1, 1) self._material_array[idx]["albedo"] = colour self._material_array[idx]["metallic"] = getattr(material, "metallic", 0.0) self._material_array[idx]["roughness"] = getattr(material, "roughness", 0.5) # Load textures if URIs or direct indices are provided features = Feature.NONE albedo_tex = -1 normal_tex = -1 mr_tex = -1 emissive_tex = -1 ao_tex = -1 # Direct GPU texture index (bypasses URI loading) if getattr(material, "albedo_tex_index", -1) >= 0: albedo_tex = material.albedo_tex_index features |= Feature.HAS_ALBEDO elif hasattr(material, "albedo_uri") and material.albedo_uri: albedo_tex = self._load_texture(material.albedo_uri) if albedo_tex >= 0: features |= Feature.HAS_ALBEDO if hasattr(material, "normal_uri") and material.normal_uri: normal_tex = self._load_texture(material.normal_uri) if normal_tex >= 0: features |= Feature.HAS_NORMAL if hasattr(material, "metallic_roughness_uri") and material.metallic_roughness_uri: mr_tex = self._load_texture(material.metallic_roughness_uri) if mr_tex >= 0: features |= Feature.HAS_METALLIC_ROUGHNESS if hasattr(material, "emissive_uri") and material.emissive_uri: emissive_tex = self._load_texture(material.emissive_uri) if emissive_tex >= 0: features |= Feature.HAS_EMISSIVE if hasattr(material, "ao_uri") and material.ao_uri: ao_tex = self._load_texture(material.ao_uri) if ao_tex >= 0: features |= Feature.HAS_AO # Emissive colour ec = getattr(material, "emissive_colour", None) if ec is not None: self._material_array[idx]["emissive_colour"] = ec[:4] if len(ec) >= 4 else (*ec[:3], 1.0) features |= Feature.HAS_EMISSIVE_COLOR self._material_array[idx]["albedo_tex"] = albedo_tex self._material_array[idx]["normal_tex"] = normal_tex self._material_array[idx]["metallic_roughness_tex"] = mr_tex self._material_array[idx]["emissive_tex"] = emissive_tex self._material_array[idx]["ao_tex"] = ao_tex self._material_array[idx]["features"] = int(features) # Alpha mode from Material.blend blend = getattr(material, "blend", "opaque") if blend == "alpha": self._material_array[idx]["alpha_mode"] = ALPHA_BLEND self._material_array[idx]["alpha_cutoff"] = 0.0 elif blend == "additive": # Additive blending also uses the transparent pipeline self._material_array[idx]["alpha_mode"] = ALPHA_BLEND self._material_array[idx]["alpha_cutoff"] = 0.0 else: # Check if the albedo alpha implies transparency if colour[3] < 1.0: self._material_array[idx]["alpha_mode"] = ALPHA_BLEND else: self._material_array[idx]["alpha_mode"] = ALPHA_OPAQUE self._material_array[idx]["alpha_cutoff"] = 0.5 # Double-sided flag (disables backface culling) self._material_array[idx]["double_sided"] = 1 if getattr(material, "double_sided", False) else 0 self._material_registry[mat_id] = idx if content_key is not None: self._material_dedup[content_key] = idx return idx
[docs] def upload_materials(self) -> None: """Upload material array to renderer.""" if self._next_material_id == 0: # No materials registered, skip upload to avoid size-0 buffer error return used = self._material_array[: self._next_material_id] self._renderer.set_materials(used)
def _upload_tileset_atlas(self, ts: Any) -> int: """Upload tileset atlas pixels to GPU, return bindless texture index. The TileSet must have ``_atlas_pixels`` (RGBA uint8 ndarray, shape HxWx4) and ``_atlas_width`` / ``_atlas_height`` attributes. """ pixels = ts._atlas_pixels w, h = ts._atlas_width, ts._atlas_height tex_idx = self._engine.upload_texture_pixels(pixels, w, h) ts._gpu_texture_id = tex_idx log.debug("TileSet atlas uploaded (%dx%d) -> texture %d", w, h, tex_idx) return tex_idx def _collect_nodes( self, root: Node, ) -> tuple[list, list, list, list, list, list, list, list, list, list, list]: """Single-pass collection of all renderable node types. Walks the tree iteratively (stack-based) to classify every node into one of: cameras, meshes, sprites, texts, particles, gpu_particles, nine_patches, lights3d, lights2d, occluders, tilemaps. """ cameras: list[Camera3D] = [] meshes: list[MeshInstance3D] = [] sprites: list[Sprite2D] = [] texts: list[Text2D] = [] particles: list[ParticleEmitter] = [] gpu_particles: list = [] nine_patches: list[NinePatchRect] = [] lights3d: list[Light3D] = [] lights2d: list[Light2D] = [] occluders: list[LightOccluder2D] = [] tilemaps: list[TileMap] = [] stack = [root] while stack: node = stack.pop() if not node.visible: continue if isinstance(node, Camera3D): cameras.append(node) elif isinstance(node, MeshInstance3D): meshes.append(node) elif isinstance(node, NinePatchRect): nine_patches.append(node) elif isinstance(node, Sprite2D): sprites.append(node) elif isinstance(node, Text2D): texts.append(node) elif isinstance(node, GPUParticles2D | GPUParticles3D): gpu_particles.append(node) elif isinstance(node, ParticleEmitter): particles.append(node) elif isinstance(node, Light3D): lights3d.append(node) elif isinstance(node, Light2D): lights2d.append(node) elif isinstance(node, LightOccluder2D): occluders.append(node) elif isinstance(node, TileMap): tilemaps.append(node) stack.extend(node.children) return (cameras, meshes, sprites, texts, particles, lights3d, lights2d, occluders, tilemaps, gpu_particles, nine_patches) def _submit_tilemaps(self, tilemaps: list, tree: SceneTree) -> None: """Submit tile layers from pre-collected TileMap nodes to the tilemap pass.""" tilemap_pass = getattr(self._renderer, "_tilemap_pass", None) if not tilemap_pass: return if not tilemaps: return for tilemap in tilemaps: ts = tilemap.tile_set if ts is None: continue # Lazy-upload atlas pixels to GPU on first encounter tex_id = getattr(ts, "_gpu_texture_id", -1) if tex_id < 0: if hasattr(ts, "_atlas_pixels") and ts._atlas_pixels is not None: tex_id = self._upload_tileset_atlas(ts) else: continue # Atlas dimensions for UV computation atlas_w = getattr(ts, "_atlas_width", 0) atlas_h = getattr(ts, "_atlas_height", 0) if atlas_w <= 0 or atlas_h <= 0: continue cell_w, cell_h = tilemap.cell_size for layer_idx in range(tilemap.layer_count): layer = tilemap.get_layer(layer_idx) if not layer.visible: continue cells = layer.get_used_cells() if not cells: continue tile_data = np.zeros(len(cells), dtype=TILE_INSTANCE_DTYPE) valid = 0 for gx, gy in cells: tile_id = layer.get_cell(gx, gy) if tile_id < 0: continue td = ts.get_tile(tile_id) if td is None: continue rx, ry, rw, rh = td.texture_region tile_data[valid]["position"] = (gx * cell_w, gy * cell_h) tile_data[valid]["tile_uv_offset"] = (rx / atlas_w, ry / atlas_h) tile_data[valid]["tile_uv_size"] = (rw / atlas_w, rh / atlas_h) tile_data[valid]["flip_h"] = 0 tile_data[valid]["flip_v"] = 0 valid += 1 if valid > 0: tilemap_pass.submit_layer( tile_data[:valid], tex_id, (float(cell_w), float(cell_h)), ) def _submit_lights(self, lights: list, cull_mask: int = 0xFFFFFFFF) -> None: """Upload pre-collected Light3D nodes to GPU SSBO, filtered by light_cull_mask vs camera cull_mask.""" if not lights: return # Filter: skip lights whose light_cull_mask has no overlap with the camera's cull_mask visible_lights = [n for n in lights if getattr(n, "light_cull_mask", 0xFFFFFFFF) & cull_mask] if not visible_lights: return light_data = np.zeros(len(visible_lights), dtype=LIGHT_DTYPE) for i, node in enumerate(visible_lights): pos = node.world_position fwd = node.forward colour = node.colour intensity = node.intensity if isinstance(node, SpotLight3D): light_data[i]["position"] = (pos.x, pos.y, pos.z, 2.0) light_data[i]["direction"] = (fwd.x, fwd.y, fwd.z, 0.0) light_data[i]["params"] = ( node.range, node.inner_cone, node.outer_cone, 0.0, ) elif isinstance(node, PointLight3D): light_data[i]["position"] = (pos.x, pos.y, pos.z, 1.0) light_data[i]["direction"] = (0.0, 0.0, 0.0, 0.0) light_data[i]["params"] = (node.range, 0.0, 0.0, 0.0) else: # DirectionalLight3D or base Light3D light_data[i]["position"] = (fwd.x, fwd.y, fwd.z, 0.0) light_data[i]["direction"] = (fwd.x, fwd.y, fwd.z, 0.0) light_data[i]["params"] = (0.0, 0.0, 0.0, 0.0) light_data[i]["colour"] = (colour[0], colour[1], colour[2], intensity) # Shadow flag in params[3] — 1.0 = casts shadows if getattr(node, "shadows", False): light_data[i]["params"][3] = 1.0 self._renderer.set_lights(light_data) def _submit_mesh_nodes(self, meshes: list, cull_mask: int = 0xFFFFFFFF) -> None: """Submit pre-collected MeshInstance3D nodes to the renderer, filtered by cull_mask.""" for node in meshes: if node.mesh is None: continue # Filter by render layer vs camera cull mask if not (getattr(node, "render_layer", 1) & cull_mask): continue model_mat = node.model_matrix if not isinstance(model_mat, np.ndarray): model_mat = np.ascontiguousarray(np.array(model_mat, dtype=np.float32).reshape(4, 4)) material_id = self.register_material(node.material) if node.material else 0 skeleton = getattr(node, "skeleton", None) is_skinned = getattr(node, "_is_skinned", False) if is_skinned and skeleton and hasattr(skeleton, "joint_matrices"): handle = self.register_skinned_mesh(node.mesh) if handle is None: continue self._renderer.submit_skinned_instance( mesh_handle=handle, transform=model_mat, material_id=material_id, joint_matrices=skeleton.joint_matrices, ) else: handle = self.register_mesh(node.mesh) if handle is None: continue self._renderer.submit_instance( mesh_handle=handle, transform=model_mat, material_id=material_id, viewport_id=0 ) def _submit_multimesh_nodes(self, root: Node, cull_mask: int = 0xFFFFFFFF) -> None: """Submit all MultiMeshInstance3D descendants — vectorized batch submission.""" for node in root.find_all(MultiMeshInstance3D): mm = node.multi_mesh if mm is None or mm.mesh is None or mm.instance_count == 0: continue if not (getattr(node, "render_layer", 1) & cull_mask): continue handle = self.register_mesh(mm.mesh) if handle is None: continue material_id = self.register_material(node.material) if node.material else 0 # Node's own global transform (applied to all instances) node_mat = node.model_matrix if not isinstance(node_mat, np.ndarray): node_mat = np.ascontiguousarray(np.array(node_mat, dtype=np.float32).reshape(4, 4)) # Respect visible_instance_count (-1 means all) vis = node.visible_instance_count count = mm.instance_count if vis < 0 else min(vis, mm.instance_count) if count == 0: continue # Batch compose: node_global @ each instance transform (vectorized matmul) inst_transforms = mm.transforms[:count] # (count, 4, 4) is_identity = np.allclose(node_mat, np.eye(4, dtype=np.float32)) if is_identity: final = np.ascontiguousarray(inst_transforms) else: final = np.ascontiguousarray(node_mat @ inst_transforms) # broadcast (4,4) @ (count,4,4) # Per-instance colour support has_colours = mm.colours is not None mat_ids = None if has_colours: if mm._colour_materials is None or mm._dirty: mm._colour_materials = [Material(colour=tuple(mm.colours[i])) for i in range(mm.instance_count)] mat_ids = np.array( [self.register_material(mm._colour_materials[i]) for i in range(count)], dtype=np.uint32, ) mm._dirty = False self._renderer.submit_multimesh( mesh_handle=handle, transforms=final, material_id=material_id, material_ids=mat_ids, viewport_id=0, )
[docs] def submit_scene(self, tree: SceneTree) -> None: """Walk scene tree and submit instances to renderer. Performs a single tree traversal to collect all renderable node types, then passes the pre-collected lists to individual submission methods. Gracefully handles scenes with no Camera3D (e.g. editor UI-only scenes) by skipping 3D submission while still processing 2D overlays. """ if not tree.root: return # Cached single-pass collection — only re-walk on tree structure changes version = tree._structure_version if self._cached_nodes is not None and self._cached_structure_version == version: (cameras, meshes, sprites, texts, particles, lights3d, lights2d, occluders, tilemaps, gpu_particles, nine_patches) = self._cached_nodes else: collected = self._collect_nodes(tree.root) self._cached_nodes = collected self._cached_structure_version = version (cameras, meshes, sprites, texts, particles, lights3d, lights2d, occluders, tilemaps, gpu_particles, nine_patches) = collected if not cameras: # No camera — still process Text2D overlays and particles below self._submit_2d_overlays( tree, sprites, texts, particles, lights2d, occluders, tilemaps, gpu_particles, nine_patches, ) return camera = cameras[0] # Setup viewport — use play_viewport_rect if set (editor play mode) vp_rect = getattr(tree, "play_viewport_rect", None) if vp_rect is not None: # Logical coords → physical pixels (HiDPI) sx, sy = self._engine._content_scale vp_x, vp_y, w, h = ( vp_rect[0] * sx, vp_rect[1] * sy, vp_rect[2] * sx, vp_rect[3] * sy, ) else: w, h = self._engine.extent vp_x, vp_y = 0, 0 aspect = w / h if h > 0 else 1.0 # Camera matrices (always numpy arrays now) try: view_mat = camera.view_matrix except (AttributeError, TypeError): # Camera parent chain may be incomplete during scene transitions self._submit_2d_overlays( tree, sprites, texts, particles, lights2d, occluders, tilemaps, gpu_particles, nine_patches, ) return if not isinstance(view_mat, np.ndarray): view_mat = np.ascontiguousarray(np.array(view_mat, dtype=np.float32).reshape(4, 4)) proj_mat = camera.projection_matrix(aspect) if not isinstance(proj_mat, np.ndarray): proj_mat = np.ascontiguousarray(np.array(proj_mat, dtype=np.float32).reshape(4, 4)) # Ensure the 3x3 rotation submatrix has positive determinant (right-handed, no mirroring). # A negative determinant causes geometry to render horizontally flipped. rot_det = np.linalg.det(view_mat[:3, :3]) if rot_det < 0: log.warning("View matrix 3x3 det=%.4f (mirrored) — negating right vector to fix", rot_det) view_mat[0, :3] = -view_mat[0, :3] view_mat[0, 3] = -view_mat[0, 3] # Clear and create viewport self._renderer.viewport_manager.clear() self._renderer.viewport_manager.create_viewport( x=int(vp_x), y=int(vp_y), width=int(w), height=int(h), camera_view=view_mat, camera_proj=proj_mat, ) # Submit all MeshInstance3D nodes (filtered by camera cull_mask) cull_mask = getattr(camera, "cull_mask", 0xFFFFFFFF) self._submit_mesh_nodes(meshes, cull_mask) # Submit all MultiMeshInstance3D nodes self._submit_multimesh_nodes(tree.root, cull_mask) # Submit 2D overlays, particles, lights, materials self._submit_2d_overlays( tree, sprites, texts, particles, lights2d, occluders, tilemaps, gpu_particles, nine_patches, ) # Collect and upload lights (filtered by light_cull_mask vs camera cull_mask) self._submit_lights(lights3d, cull_mask) # Upload materials self.upload_materials()
def _submit_2d_overlays( self, tree: SceneTree, sprites: list, texts: list, particles: list, lights2d: list, occluders: list, tilemaps: list, gpu_particles: list | None = None, nine_patches: list | None = None, ) -> None: """Submit Text2D overlays, sprite textures, and particle emitters from pre-collected lists.""" # Load sprite textures lazily (sets _texture_id so Sprite2D.draw() works) for node in sprites: if node._texture_id < 0 and node.texture: tex_id = self._engine.texture_manager.load_if_exists(node.texture) if tex_id >= 0: node._texture_id = tex_id # Load NinePatchRect textures lazily (sets _texture_id and texture_size) for node in (nine_patches or []): if node._texture_id < 0 and node.texture: tex_id = self._engine.texture_manager.load_if_exists(node.texture) if tex_id >= 0: node._texture_id = tex_id w, h = self._engine.texture_manager.get_texture_size(tex_id) node.texture_size = (w, h) # Submit Text2D overlay nodes via MSDF text renderer ox, oy = getattr(tree, "overlay_offset", (0, 0)) for node in texts: if not node.text: continue fc = node.font_colour # Normalize 0-255 int colours to 0.0-1.0 float range if any(c > 1.0 for c in fc[:3]): fc = tuple(c / 255.0 for c in fc) colour = (fc[0], fc[1], fc[2], fc[3] if len(fc) > 3 else 1.0) self._renderer.submit_text( node.text, x=node.x + ox, y=node.y + oy, size=node.font_scale * 16.0, colour=colour, ) # Submit particle emitters for node in particles: data = node.particle_data if data is not None and len(data) > 0: self._renderer.submit_particles(data) # Submit GPU particle emitters (compute-shader driven) if gpu_particles: for node in gpu_particles: if node.emitting or not getattr(node, '_cycle_complete', False): self._renderer.submit_gpu_particles(node.emitter_config) # Submit TileMap layers self._submit_tilemaps(tilemaps, tree) # Collect and submit 2D lights and occluders self._submit_lights_2d(tree, lights2d, occluders) def _submit_lights_2d(self, tree: SceneTree, lights2d: list, occluders: list) -> None: """Submit pre-collected Light2D and LightOccluder2D nodes to renderer. Uses viewport/camera culling to skip lights and occluders that are entirely outside the visible area, reducing GPU and CPU work. """ if not lights2d: return # Compute viewport bounds from 2D camera cam: Camera2D | None = tree._current_camera_2d sw, sh = tree._screen_size if cam is not None: zoom = cam.zoom if cam.zoom > 0 else 1.0 cx, cy = cam._current.x, cam._current.y half_w, half_h = (sw / zoom) * 0.5, (sh / zoom) * 0.5 else: cx, cy = sw * 0.5, sh * 0.5 half_w, half_h = cx, cy vp_min_x, vp_max_x = cx - half_w, cx + half_w vp_min_y, vp_max_y = cy - half_h, cy + half_h # Collect visible lights (viewport-culled, layer-filtered) cam_cull_mask = getattr(cam, "cull_mask", 0xFFFFFFFF) if cam is not None else 0xFFFFFFFF max_light_range = 0.0 lights_to_submit: list[dict] = [] for node in lights2d: if not node.enabled: continue # Filter by light_cull_mask vs camera cull_mask if not (getattr(node, "light_cull_mask", 0xFFFFFFFF) & cam_cull_mask): continue data = node._get_light_data() lx, ly = data["position"] lr = data["range"] if lx + lr < vp_min_x or lx - lr > vp_max_x: continue if ly + lr < vp_min_y or ly - lr > vp_max_y: continue lights_to_submit.append(data) if lr > max_light_range: max_light_range = lr if not lights_to_submit: return # Expanded viewport for occluder culling (viewport + max light range) exp_min_x = vp_min_x - max_light_range exp_max_x = vp_max_x + max_light_range exp_min_y = vp_min_y - max_light_range exp_max_y = vp_max_y + max_light_range # Submit occluders within expanded viewport (quick position pre-check) for node in occluders: if not node.polygon: continue # Quick AABB check using node world_position before expensive polygon transform gp = node.world_position px, py = float(gp[0]), float(gp[1]) # Estimate occluder extent from polygon local bounds max_ext = max(abs(v[0]) + abs(v[1]) for v in node.polygon) + 1 if px + max_ext < exp_min_x or px - max_ext > exp_max_x: continue if py + max_ext < exp_min_y or py - max_ext > exp_max_y: continue self._renderer.submit_occluder2d(node.get_global_polygon()) # Submit visible lights for data in lights_to_submit: self._renderer.submit_light2d( position=data["position"], colour=data["colour"], energy=data["energy"], light_range=data["range"], falloff=data.get("falloff", 1.0), blend_mode=data.get("blend_mode", "add"), shadow_enabled=data.get("shadow_enabled", False), shadow_colour=data.get("shadow_colour", (0.0, 0.0, 0.0, 0.5)), )