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