diff --git a/gamms/VisualizationEngine/__init__.py b/gamms/VisualizationEngine/__init__.py index 2ad7f14..3f982a4 100644 --- a/gamms/VisualizationEngine/__init__.py +++ b/gamms/VisualizationEngine/__init__.py @@ -31,6 +31,13 @@ class Shape(Enum): Circle = auto() Rectangle = auto() + +SHORT_EDGE_PIXEL_THRESHOLD = 3.0 +SKIP_EDGE_PIXEL_THRESHOLD = 1.0 +SKIP_NODE_PIXEL_THRESHOLD = 1.0 +CACHE_ZOOM_MAX = 1.2 + + import sys import importlib.util diff --git a/gamms/VisualizationEngine/artist.py b/gamms/VisualizationEngine/artist.py index fd61bd4..020af04 100644 --- a/gamms/VisualizationEngine/artist.py +++ b/gamms/VisualizationEngine/artist.py @@ -17,7 +17,6 @@ def __init__(self, ctx: IContext, drawer: Union[Callable[[IContext, Dict[str, An self._layer = layer self._layer_dirty = False self._visible = True - self._will_draw = True self._artist_type = ArtistType.DYNAMIC self._render_mode: RenderMode = RenderMode.NON_CACHED if isinstance(drawer, Shape): @@ -60,12 +59,6 @@ def set_drawer(self, drawer: Callable[[IContext, Dict[str, Any]], None]): def get_drawer(self) -> Callable[[IContext, Dict[str, Any]], None]: return self._drawer - def get_will_draw(self) -> bool: - return self._will_draw - - def set_will_draw(self, will_draw: bool): - self._will_draw = will_draw - def get_artist_type(self) -> ArtistType: return self._artist_type diff --git a/gamms/VisualizationEngine/default_drawers.py b/gamms/VisualizationEngine/default_drawers.py index 5614fd5..d29b8be 100644 --- a/gamms/VisualizationEngine/default_drawers.py +++ b/gamms/VisualizationEngine/default_drawers.py @@ -1,5 +1,10 @@ from gamms.AgentEngine.agent_engine import AerialAgent -from gamms.VisualizationEngine import Color +from gamms.VisualizationEngine import ( + Color, + SHORT_EDGE_PIXEL_THRESHOLD, + SKIP_EDGE_PIXEL_THRESHOLD, + SKIP_NODE_PIXEL_THRESHOLD, +) from gamms.VisualizationEngine.builtin_artists import AgentData, GraphData from gamms.typing import IContext, OSMEdge, Node, ColorType, AgentType @@ -7,7 +12,6 @@ import math - def render_circle(ctx: IContext, data: Dict[str, Any]): """ Render a circle at the specified position with the specified radius and color. @@ -63,23 +67,59 @@ def render_agent(ctx: IContext, data: Dict[str, Any]): prev_node = ctx.graph.graph.get_node(agent.prev_node_id) prev_position = (prev_node.x, prev_node.y) target_position = (target_node.x, target_node.y) - current_edge = None - for edge_id in ctx.graph.graph.get_edges(): - edge = ctx.graph.graph.get_edge(edge_id) - if edge.source == agent.prev_node_id and edge.target == agent.current_node_id: - current_edge = edge - alpha = cast(float, data.get('_alpha')) - if current_edge is not None: - point = current_edge.linestring.interpolate(alpha, True) - position = (point.x, point.y) - else: - position = (prev_position[0] + alpha * (target_position[0] - prev_position[0]), - prev_position[1] + alpha * (target_position[1] - prev_position[1])) - agent_data.current_position = position + viewport = ctx.visual.get_viewport() + if viewport is None: + return + + _, _, _, _, scale = viewport + dx = target_position[0] - prev_position[0] + dy = target_position[1] - prev_position[1] + d_sq = dx * dx + dy * dy + short_sq = _pixel_thresh_sq(SHORT_EDGE_PIXEL_THRESHOLD, scale) + skip_sq = _pixel_thresh_sq(SKIP_EDGE_PIXEL_THRESHOLD, scale) + + if skip_sq > 0.0 and d_sq <= skip_sq: + position = ((1 - alpha) * prev_position[0] + alpha * target_position[0], + (1 - alpha) * prev_position[1] + alpha * target_position[1]) + elif short_sq > 0.0 and d_sq <= short_sq: + position = ((1 - alpha) * prev_position[0] + alpha * target_position[0], + (1 - alpha) * prev_position[1] + alpha * target_position[1]) + else: + current_edge = None + cache_key = (agent.prev_node_id, agent.current_node_id) + cached_edge_entry = data.get('_current_edge_cache') + if cached_edge_entry is not None: + cached_edge_key, cached_edge = cached_edge_entry + if cached_edge_key == cache_key: + current_edge = cached_edge + + if current_edge is None: + neighbors = set(ctx.graph.graph.get_neighbors(agent.prev_node_id)) + if agent.current_node_id in neighbors: + midpoint_x = (prev_position[0] + target_position[0]) / 2 + midpoint_y = (prev_position[1] + target_position[1]) / 2 + max_dist = math.sqrt((target_position[0] - prev_position[0])**2 + + (target_position[1] - prev_position[1])**2) / 2 + 1.0 + for edge_id in ctx.graph.graph.get_edges(max_dist, midpoint_x, midpoint_y): + edge = ctx.graph.graph.get_edge(edge_id) + if edge.source == agent.prev_node_id and edge.target == agent.current_node_id: + current_edge = edge + data['_current_edge_cache'] = (cache_key, edge) + break + + if current_edge is not None: + point = current_edge.linestring.interpolate(alpha, True) + position = (point.x, point.y) + else: + position = ((1 - alpha) * prev_position[0] + alpha * target_position[0], + (1 - alpha) * prev_position[1] + alpha * target_position[1]) else: position = (target_node.x, target_node.y) + data.pop('_current_edge_cache', None) + + agent_data.current_position = position # Draw each agent as a triangle at its current position angle = math.radians(45) @@ -142,6 +182,23 @@ def render_aerial_agent(ctx: IContext, position: tuple[float, float], angle: flo ctx.visual.render_polygon(points, color) +def _pixel_thresh_sq(pixel_thresh: float, scale: float) -> float: + """ + Return the squared world-space distance that corresponds to a given number of screen pixels at the current camera zoom. + + Args: + pixel_thresh (float): Length in screen pixels to convert. + scale (float): Pixels-per-world-unit factor from the current viewport. + + Returns: + float: The squared world-space distance. Returns 0.0 if scale is non-positive. + """ + if scale <= 0: + return 0.0 + thresh_world = pixel_thresh / scale + return thresh_world * thresh_world + + def render_graph(ctx: IContext, data: Dict[str, Any]): """ Render the graph by drawing its nodes and edges on the screen. This is the default rendering method for graphs. @@ -156,13 +213,25 @@ def render_graph(ctx: IContext, data: Dict[str, Any]): edge_color = graph_data.edge_color draw_id = graph_data.draw_id - for edge_id in ctx.graph.graph.get_edges(): - edge = ctx.graph.graph.get_edge(edge_id) - _render_graph_edge(ctx, graph_data, edge, edge_color) - - for node_id in ctx.graph.graph.get_nodes(): - node = ctx.graph.graph.get_node(node_id) - _render_graph_node(ctx, node, node_color, node_size, draw_id) + graph = ctx.graph.graph + + viewport = ctx.visual.get_viewport() + if viewport is None: + return + _, _, _, _, scale = viewport + + short_sq = _pixel_thresh_sq(SHORT_EDGE_PIXEL_THRESHOLD, scale) + skip_sq = _pixel_thresh_sq(SKIP_EDGE_PIXEL_THRESHOLD, scale) + + for edge_id in graph.get_edges(): + edge = graph.get_edge(edge_id) + _render_graph_edge(ctx, graph_data, edge, edge_color, short_sq, skip_sq) + + node_pixel_radius = node_size * scale + if node_pixel_radius >= SKIP_NODE_PIXEL_THRESHOLD: + for node_id in graph.get_nodes(): + node = graph.get_node(node_id) + _render_graph_node(ctx, node, node_color, node_size, draw_id) def render_input_overlay(ctx: IContext, data: Dict[str, Any]): """ @@ -195,8 +264,15 @@ def render_input_overlay(ctx: IContext, data: Dict[str, Any]): draw_id = graph_data.draw_id target_node_id_set = set(input_options.values()) - for node in target_node_id_set: - _render_graph_node(ctx, graph.get_node(node), node_color, node_size, draw_id) + viewport = ctx.visual.get_viewport() + if viewport is None: + return + _, _, _, _, scale = viewport + + node_pixel_radius = node_size * scale + if node_pixel_radius >= SKIP_NODE_PIXEL_THRESHOLD: + for node in target_node_id_set: + _render_graph_node(ctx, graph.get_node(node), node_color, node_size, draw_id) active_edges: List[OSMEdge] = [] for edge_id in graph.get_edges(): @@ -204,26 +280,56 @@ def render_input_overlay(ctx: IContext, data: Dict[str, Any]): if edge.source == current_waiting_agent.current_node_id and edge.target in target_node_id_set: active_edges.append(edge) + short_sq = _pixel_thresh_sq(SHORT_EDGE_PIXEL_THRESHOLD, scale) + skip_sq = _pixel_thresh_sq(SKIP_EDGE_PIXEL_THRESHOLD, scale) for edge in active_edges: - _render_graph_edge(ctx, graph_data, edge, edge_color) + _render_graph_edge(ctx, graph_data, edge, edge_color, short_sq, skip_sq) + +def _render_graph_edge(ctx: IContext, graph_data: GraphData, edge: OSMEdge, color: ColorType, + short_edge_thresh_sq: float = 0.0, + skip_edge_thresh_sq: float = 0.0): + """ + Draw an edge as a curve or straight line based on the linestring. + + The squared world-space distance between the edge's endpoints drives + three mutually exclusive paths: -def _render_graph_edge(ctx: IContext, graph_data: GraphData, edge: OSMEdge, color: ColorType): - """Draw an edge as a curve or straight line based on the linestring.""" + * endpoints within sqrt(skip_edge_thresh_sq) world units -> drop + the edge entirely (sub-pixel on screen). + * linestring edge within sqrt(short_edge_thresh_sq) world units -> + draw as a single straight segment, skipping Shapely deserialization + and the multi-segment renderer call. + * otherwise -> draw the full linestring. + """ source = ctx.graph.graph.get_node(edge.source) target = ctx.graph.graph.get_node(edge.target) - if edge.linestring: - edge_line_points = graph_data.edge_line_points - if edge.id not in edge_line_points: - # linestring[1:-1] - linestring = ([(source.x, source.y)] + [(x, y) for (x, y) in edge.linestring.coords] + - [(target.x, target.y)]) - edge_line_points[edge.id] = linestring + dx = target.x - source.x + dy = target.y - source.y + d_sq = dx * dx + dy * dy - line_points = edge_line_points[edge.id] - ctx.visual.render_linestring(line_points, color, is_aa=True, perform_culling_test=False) - else: - ctx.visual.render_line(source.x, source.y, target.x, target.y, color, 2, perform_culling_test=False, is_aa=False) + if skip_edge_thresh_sq > 0.0 and d_sq <= skip_edge_thresh_sq: + return + + if short_edge_thresh_sq > 0.0 and d_sq <= short_edge_thresh_sq: + ctx.visual.render_line(source.x, source.y, target.x, target.y, color, 2, + perform_culling_test=False, is_aa=False) + return + + edge_line_points = graph_data.edge_line_points + line_points = edge_line_points.get(edge.id) + + if line_points is None: + linestring = edge.linestring + if not linestring: + ctx.visual.render_line(source.x, source.y, target.x, target.y, color, 2, + perform_culling_test=False, is_aa=False) + return + line_points = ([(source.x, source.y)] + [(x, y) for (x, y) in linestring.coords] + + [(target.x, target.y)]) + edge_line_points[edge.id] = line_points + + ctx.visual.render_linestring(line_points, color, is_aa=True, perform_culling_test=False) def _render_graph_node(ctx: IContext, node: Node, color: ColorType, radius: float, draw_id: bool): @@ -269,11 +375,30 @@ def render_map_sensor(ctx: IContext, data: Dict[str, Any]): edge_color = data.get('edge_color', Color.Cyan) sensed_edges = sensor_data.get('edges', []) - + + viewport = ctx.visual.get_viewport() + if viewport is None: + return + _, _, _, _, scale = viewport + short_sq = _pixel_thresh_sq(SHORT_EDGE_PIXEL_THRESHOLD, scale) + skip_sq = _pixel_thresh_sq(SKIP_EDGE_PIXEL_THRESHOLD, scale) + for edge in sensed_edges: source = ctx.graph.graph.get_node(edge.source) target = ctx.graph.graph.get_node(edge.target) + dx = target.x - source.x + dy = target.y - source.y + d_sq = dx * dx + dy * dy + + if skip_sq > 0.0 and d_sq <= skip_sq: + continue + + if short_sq > 0.0 and d_sq <= short_sq: + ctx.visual.render_line(source.x, source.y, target.x, target.y, edge_color, 4, + perform_culling_test=False, is_aa=False) + continue + if edge.linestring: # linestring[1:-1] line_points = ([(source.x, source.y)] + [(x, y) for (x, y) in edge.linestring.coords] + diff --git a/gamms/VisualizationEngine/no_engine.py b/gamms/VisualizationEngine/no_engine.py index d5b8f0e..d563ec0 100644 --- a/gamms/VisualizationEngine/no_engine.py +++ b/gamms/VisualizationEngine/no_engine.py @@ -9,7 +9,7 @@ from gamms.VisualizationEngine.artist import Artist from gamms.VisualizationEngine import Color -from typing import Dict, Any, List, Tuple, Callable, cast, Union +from typing import Dict, Any, List, Tuple, Callable, Optional, cast, Union class NoEngine(IVisualizationEngine): def __init__(self, ctx: IContext, **kwargs: Dict[str, Any]) -> None: @@ -78,4 +78,7 @@ def render_polygon(self, points: List[Tuple[float, float]], color: ColorType = C return def render_layer(self, layer_id: int) -> None: - return \ No newline at end of file + return + + def get_viewport(self) -> Optional[Tuple[float, float, float, float, float]]: + return None \ No newline at end of file diff --git a/gamms/VisualizationEngine/pygame_engine.py b/gamms/VisualizationEngine/pygame_engine.py index 681056b..46409f6 100644 --- a/gamms/VisualizationEngine/pygame_engine.py +++ b/gamms/VisualizationEngine/pygame_engine.py @@ -1,5 +1,12 @@ from gamms.AgentEngine.agent_engine import AerialAgent -from gamms.VisualizationEngine import Color, Space, Shape, Artist, lazy +from gamms.VisualizationEngine import ( + Color, + Space, + Shape, + Artist, + lazy, + CACHE_ZOOM_MAX, +) from gamms.VisualizationEngine.render_manager import RenderManager from gamms.VisualizationEngine.builtin_artists import AgentData, GraphData from gamms.VisualizationEngine.default_drawers import ( @@ -18,7 +25,18 @@ ColorType, AgentType ) -from typing import Dict, Any, List, Tuple, Union, cast, Optional, Iterator, Set +from typing import Dict, Any, List, NamedTuple, Tuple, Union, cast, Optional, Iterator, Set + + +class _LayerCache(NamedTuple): + surface: Any + artist_names: Tuple[str, ...] + camera_x: float + camera_y: float + camera_size: float + screen_width: int + screen_height: int + class PygameVisualizationEngine(IVisualizationEngine): def __init__(self, ctx: IContext, width: int = 1280, height: int = 720, simulation_time_constant: float = 2.0, **kwargs : Dict[str, Any]): @@ -42,8 +60,10 @@ def __init__(self, ctx: IContext, width: int = 1280, height: int = 720, simulati self._simulation_time = 0 self._will_quit = False self._render_manager = RenderManager(ctx, 0, 0, 15, width, height) - self._surface_dict : Dict[int, self._pygame.Surface ] = {} - self._static_artists: Dict[str, IArtist] = {} + self._render_manager.set_cached_layer_handler(self._blit_layer_cache) + self._render_surface = self._pygame.Surface((width, height), self._pygame.SRCALPHA) + self._layer_caches: Dict[int, _LayerCache] = {} + self._building_layer_surface: Optional[Any] = None self._dynamic_artists: Dict[str, IArtist] = {} self._dynamic_agent_artist_names: Set[str] = set() @@ -51,13 +71,6 @@ def __init__(self, ctx: IContext, width: int = 1280, height: int = 720, simulati self._input_overlay_artist = self._set_input_overlay_artist(input_overlay_args) def create_layer(self, layer_id: int, width : int, height : int) -> int: - if layer_id not in self._surface_dict: - surface = self._pygame.Surface((width, height), self._pygame.SRCALPHA) - self._surface_dict[layer_id] = surface - - # Order layers by ascending order - self._surface_dict = {id: self._surface_dict[id] for id in sorted(self._surface_dict.keys())} - return layer_id def set_graph_visual(self, **kwargs: Dict[str, Any]) -> IArtist: @@ -89,15 +102,11 @@ def set_graph_visual(self, **kwargs: Dict[str, Any]) -> IArtist: artist = Artist(self.ctx, render_graph, 10) artist.data['graph_data'] = graph_data - artist.set_will_draw(False) artist.set_artist_type(ArtistType.STATIC) #Add data for node ID and Color self.add_artist('graph', artist) - # Trigger the redraw of static artists after it has been added. - self._redraw_static_artists() - return artist def _set_input_overlay_artist(self, args: Dict[str, Any]) -> IArtist: @@ -191,12 +200,11 @@ def add_artist(self, name: str, artist: Union[IArtist, Dict[str, Any]]) -> IArti artist_to_add.data = artist if artist_to_add.get_artist_type() == ArtistType.STATIC: - self._static_artists[name] = artist_to_add self._dynamic_artists.pop(name, None) self._dynamic_agent_artist_names.discard(name) + self._layer_caches.pop(artist_to_add.get_layer(), None) elif artist_to_add.get_artist_type() == ArtistType.DYNAMIC: self._dynamic_artists[name] = artist_to_add - self._static_artists.pop(name, None) if 'agent_data' in artist_to_add.data: self._dynamic_agent_artist_names.add(name) else: @@ -206,7 +214,9 @@ def add_artist(self, name: str, artist: Union[IArtist, Dict[str, Any]]) -> IArti return artist_to_add def remove_artist(self, name: str): - self._static_artists.pop(name, None) + artist = self._render_manager.get_artist(name) + if artist is not None and artist.get_artist_type() == ArtistType.STATIC: + self._layer_caches.pop(artist.get_layer(), None) self._dynamic_artists.pop(name, None) self._dynamic_agent_artist_names.discard(name) self._render_manager.remove_artist(name) @@ -216,19 +226,15 @@ def handle_input(self): scroll_speed = self._render_manager.camera_size / 2 if pressed_keys[self._pygame.K_a] or pressed_keys[self._pygame.K_LEFT]: self._render_manager.camera_x -= (scroll_speed * self._clock.get_time() / 1000) - self._redraw_static_artists() if pressed_keys[self._pygame.K_d] or pressed_keys[self._pygame.K_RIGHT]: self._render_manager.camera_x += (scroll_speed * self._clock.get_time() / 1000) - self._redraw_static_artists() if pressed_keys[self._pygame.K_w] or pressed_keys[self._pygame.K_UP]: self._render_manager.camera_y += (scroll_speed * self._clock.get_time() / 1000) - self._redraw_static_artists() if pressed_keys[self._pygame.K_s] or pressed_keys[self._pygame.K_DOWN]: self._render_manager.camera_y -= (scroll_speed * self._clock.get_time() / 1000) - self._redraw_static_artists() for event in self._pygame.event.get(): if event.type == self._pygame.MOUSEWHEEL: @@ -237,21 +243,18 @@ def handle_input(self): self._render_manager.camera_size /= 1.05 else: self._render_manager.camera_size *= 1.05 - - self._redraw_static_artists() if event.type == self._pygame.QUIT: self._will_quit = True self._input_option_result = -1 self._input_position_result = -1 if event.type == self._pygame.VIDEORESIZE: - self._render_manager.screen_width = event.w - self._render_manager.screen_height = event.h - self._screen = self._pygame.display.set_mode((event.w, event.h), self._pygame.RESIZABLE) - for layer_id in self._surface_dict.keys(): - self._surface_dict[layer_id] = self._pygame.Surface((event.w, event.h), self._pygame.SRCALPHA) - - self._redraw_static_artists() + event_w = max(1, event.w) + event_h = max(1, event.h) + self._render_manager.set_screen_size(event_w, event_h) + self._screen = self._pygame.display.set_mode((event_w, event_h), self._pygame.RESIZABLE) + self._render_surface = self._pygame.Surface((event_w, event_h), self._pygame.SRCALPHA) + self._layer_caches.clear() if self._waiting_user_input: if self._waiting_agent_name is None: @@ -293,11 +296,13 @@ def handle_tick(self): def handle_single_draw(self): self._screen.fill(Color.White) + self._render_surface.fill((0, 0, 0, 0)) # Note: Draw in layer order of back layer -> front layer # self._draw_grid() - + self._render_manager.handle_render() + self._screen.blit(self._render_surface, (0, 0)) self.draw_input_overlay() self.draw_hud() @@ -353,10 +358,121 @@ def _render_text_internal(self, text: str, x: float, y: float, coord_space: Spac else: raise ValueError("Invalid coord_space value. Must be one of the values in the Space enum.") - def _redraw_static_artists(self): - for artist_name, static_artist in self._static_artists.items(): - self.clear_layer(static_artist.get_layer()) - self._render_manager.render_single_artist(artist_name) + def _rebuild_layer_cache(self, layer: int, names: List[str]) -> None: + rm = self._render_manager + surface = self._pygame.Surface( + (rm.screen_width, rm.screen_height), self._pygame.SRCALPHA + ) + surface.fill((0, 0, 0, 0)) + + self._building_layer_surface = surface + try: + for name in names: + self._render_manager.render_single_artist(name) + finally: + self._building_layer_surface = None + + self._layer_caches[layer] = _LayerCache( + surface=surface, + artist_names=tuple(names), + camera_x=rm.camera_x, + camera_y=rm.camera_y, + camera_size=rm.camera_size, + screen_width=rm.screen_width, + screen_height=rm.screen_height, + ) + + def _blit_layer_cache(self, layer: int, names: List[str]) -> None: + rm = self._render_manager + cache = self._layer_caches.get(layer) + expected_names = tuple(names) + + if (cache is None + or cache.artist_names != expected_names + or cache.screen_width != rm.screen_width + or cache.screen_height != rm.screen_height): + self._rebuild_layer_cache(layer, names) + cache = self._layer_caches[layer] + + zoom_ratio = cache.camera_size / rm.camera_size + if zoom_ratio < 1.0 or zoom_ratio > CACHE_ZOOM_MAX: + self._rebuild_layer_cache(layer, names) + cache = self._layer_caches[layer] + zoom_ratio = 1.0 + + dx_px = int(round(rm.world_to_screen_scale(rm.camera_x - cache.camera_x))) + dy_px = int(round(rm.world_to_screen_scale(rm.camera_y - cache.camera_y))) + + if (abs(dx_px) >= rm.screen_width // 4 + or abs(dy_px) >= rm.screen_height // 4): + self._rebuild_layer_cache(layer, names) + cache = self._layer_caches[layer] + dx_px = 0 + dy_px = 0 + zoom_ratio = 1.0 + + if zoom_ratio == 1.0: + self._render_surface.blit(cache.surface, (-dx_px, dy_px)) + cover_x0 = -dx_px + cover_y0 = dy_px + cover_w = rm.screen_width + cover_h = rm.screen_height + else: + W = rm.screen_width + H = rm.screen_height + scaled_w = max(1, int(round(W * zoom_ratio))) + scaled_h = max(1, int(round(H * zoom_ratio))) + scaled = self._pygame.transform.scale( + cache.surface, (scaled_w, scaled_h) + ) + offset_x = -dx_px + (W - scaled_w) // 2 + offset_y = dy_px + (H - scaled_h) // 2 + self._render_surface.blit(scaled, (offset_x, offset_y)) + cover_x0 = offset_x + cover_y0 = offset_y + cover_w = scaled_w + cover_h = scaled_h + + self._redraw_layer_strips(names, cover_x0, cover_y0, cover_w, cover_h) + + def _redraw_layer_strips(self, names: List[str], cover_x0: int, cover_y0: int, cover_w: int, cover_h: int) -> None: + rm = self._render_manager + W = rm.screen_width + H = rm.screen_height + + cx_min = max(0, cover_x0) + cy_min = max(0, cover_y0) + cx_max = min(W, cover_x0 + cover_w) + cy_max = min(H, cover_y0 + cover_h) + + strips: List[Tuple[int, int, int, int]] = [] + if cy_min > 0: + strips.append((0, 0, W, cy_min)) + if cy_max < H: + strips.append((0, cy_max, W, H - cy_max)) + middle_h = max(0, cy_max - cy_min) + if middle_h > 0: + if cx_min > 0: + strips.append((0, cy_min, cx_min, middle_h)) + if cx_max < W: + strips.append((cx_max, cy_min, W - cx_max, middle_h)) + + if not strips: + return + + for sx, sy, sw, sh in strips: + if sw <= 0 or sh <= 0: + continue + wl, wb = rm.screen_to_world(sx, sy) + wr, wt = rm.screen_to_world(sx + sw, sy + sh) + rm.set_culling_bounds(wl, wr, wt, wb) + self._render_surface.set_clip(self._pygame.Rect(sx, sy, sw, sh)) + try: + for name in names: + self._render_manager.render_single_artist(name) + finally: + self._render_surface.set_clip(None) + rm.reset_culling_bounds() def _iter_agent_artists(self) -> Iterator[IArtist]: stale_names: List[str] = [] @@ -392,11 +508,18 @@ def _toggle_waiting_user_input(self, waiting_user_input: bool): self._waiting_user_input = waiting_user_input self._input_overlay_artist.data['_waiting_user_input'] = waiting_user_input - def _get_target_surface(self, layer: int): - if layer >= 0: - return self._surface_dict.get(layer, self._screen) - else: - return self._screen + def _get_target_surface(self): + if self._building_layer_surface is not None: + return self._building_layer_surface + return self._render_surface + + def get_viewport(self) -> Optional[Tuple[float, float, float, float, float]]: + rm = self._render_manager + if rm.screen_width <= 0 or rm.screen_height <= 0 or rm.camera_size <= 0: + self.ctx.logger.warning("Invalid viewport state") + return None + scale = rm.world_to_screen_scale(1.0) + return (rm.bound_left, rm.bound_right, rm.bound_top, rm.bound_bottom, scale) def render_text(self, text: str, x: float, y: float, color: ColorType = Color.Black, perform_culling_test: bool=True, font_size: Optional[int]=None): if font_size is not None: @@ -415,8 +538,7 @@ def render_text(self, text: str, x: float, y: float, color: ColorType = Color.Bl if self._render_manager.current_drawing_artist is None: raise ValueError("No current drawing artist set.") - layer = self._render_manager.current_drawing_artist.get_layer() - surface = self._get_target_surface(layer) + surface = self._get_target_surface() surface.blit(text_surface, text_rect) def render_rectangle(self, x: float, y: float, width: float, height: float, color: ColorType = Color.Black, @@ -429,8 +551,7 @@ def render_rectangle(self, x: float, y: float, width: float, height: float, colo if self._render_manager.current_drawing_artist is None: raise ValueError("No current drawing artist set.") - layer = self._render_manager.current_drawing_artist.get_layer() - surface = self._get_target_surface(layer) + surface = self._get_target_surface() self._pygame.draw.rect(surface, color, self._pygame.Rect(x, y, width, height)) def render_circle(self, x: float, y: float, radius: float, color: ColorType = Color.Black, width: int = 0, @@ -445,9 +566,8 @@ def render_circle(self, x: float, y: float, radius: float, color: ColorType = Co if self._render_manager.current_drawing_artist is None: raise ValueError("No current drawing artist set.") - - layer = self._render_manager.current_drawing_artist.get_layer() - surface = self._get_target_surface(layer) + + surface = self._get_target_surface() self._pygame.draw.circle(surface, color, (x, y), radius, width) def render_line(self, start_x: float, start_y: float, end_x: float, end_y: float, color: ColorType = Color.Black, @@ -461,8 +581,7 @@ def render_line(self, start_x: float, start_y: float, end_x: float, end_y: float if self._render_manager.current_drawing_artist is None: raise ValueError("No current drawing artist set.") - layer = self._render_manager.current_drawing_artist.get_layer() - surface = self._get_target_surface(layer) + surface = self._get_target_surface() if is_aa: self._pygame.draw.aaline(surface, color, (start_x, start_y), (end_x, end_y)) else: @@ -478,8 +597,7 @@ def render_linestring(self, points: List[Tuple[float, float]], color: ColorType if self._render_manager.current_drawing_artist is None: raise ValueError("No current drawing artist set.") - layer = self._render_manager.current_drawing_artist.get_layer() - surface = self._get_target_surface(layer) + surface = self._get_target_surface() if is_aa: self._pygame.draw.aalines(surface, color, closed, points) else: @@ -495,22 +613,17 @@ def render_polygon(self, points: List[Tuple[float, float]], color: ColorType = C if self._render_manager.current_drawing_artist is None: raise ValueError("No current drawing artist set.") - layer = self._render_manager.current_drawing_artist.get_layer() - surface = self._get_target_surface(layer) + surface = self._get_target_surface() self._pygame.draw.polygon(surface, color, points, width) def clear_layer(self, layer_id: int): - if layer_id in self._surface_dict: - self._surface_dict[layer_id].fill((0, 0, 0, 0)) + return def fill_layer(self, layer_id: int, color: ColorType): - if layer_id in self._surface_dict: - self._surface_dict[layer_id].fill(color) + return def render_layer(self, layer_id: int): - if layer_id in self._surface_dict: - surface = self._surface_dict[layer_id] - self._screen.blit(surface, (0, 0)) + return def _draw_grid(self): x_min = self._render_manager.camera_x - self._render_manager.camera_size * 4 @@ -562,7 +675,6 @@ def human_input(self, agent_name: str, state: Dict[str, Any]) -> Union[int, Tupl self._input_overlay_artist.data['_input_options'] = self._input_options self._input_overlay_artist.set_visible(True) - self._redraw_static_artists() while self._waiting_user_input: # still need to update the render @@ -613,7 +725,6 @@ def end_handle_human_input(self): self._input_option_result = None self._input_position_result = None self._waiting_agent_name = None - self._redraw_static_artists() def simulate(self): if self.ctx.record.record(): diff --git a/gamms/VisualizationEngine/render_manager.py b/gamms/VisualizationEngine/render_manager.py index 95984e3..db135b0 100644 --- a/gamms/VisualizationEngine/render_manager.py +++ b/gamms/VisualizationEngine/render_manager.py @@ -1,7 +1,7 @@ from gamms.VisualizationEngine import RenderMode from gamms.typing import ArtistType, IContext, IArtist -from typing import Set, Dict, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple class RenderManager: @@ -10,30 +10,54 @@ def __init__(self, ctx: IContext, camera_x: float, camera_y: float, camera_size: self._screen_width = screen_width self._screen_height = screen_height - self._aspect_ratio = self._screen_width / self._screen_height self._camera_x = int(camera_x) self._camera_y = int(camera_y) self._camera_size = camera_size - self._camera_size_y = camera_size / self.aspect_ratio - - self._update_bounds() + self._update_projection() self._artists: Dict[str, IArtist] = {} # This will call drawer on all artists in the respective layer self._layer_artists: Dict[int, List[str]] = {} - self._static_layers: Set[int] = set() self._current_drawing_artist: Optional[IArtist] = None + self._cached_layer_handler: Optional[Callable[[int, List[str]], None]] = None self._default_origin = (0, 0) self._surface_size = 0 + def set_cached_layer_handler(self, handler: Optional[Callable[[int, List[str]], None]]) -> None: + """ + Register a callable invoked once per layer for cached static artists. + Passing None clears the hook. + + Args: + handler (Optional[Callable[[int, List[str]], None]]): Callable taking + the layer id and static artist names, or None to clear any hook. + """ + self._cached_layer_handler = handler + def _update_bounds(self): self._bound_left = -self.camera_size + self.camera_x self._bound_right = self.camera_size + self.camera_x self._bound_top = -self.camera_size_y + self.camera_y self._bound_bottom = self.camera_size_y + self.camera_y + def _update_projection(self): + self._screen_width = max(1, self._screen_width) + self._screen_height = max(1, self._screen_height) + self._aspect_ratio = self._screen_width / self._screen_height + self._camera_size_y = self._camera_size / self._aspect_ratio + self._update_bounds() + + def set_culling_bounds(self, left: float, right: float, top: float, bottom: float) -> None: + self._bound_left = left + self._bound_right = right + self._bound_top = top + self._bound_bottom = bottom + + def reset_culling_bounds(self) -> None: + self._update_bounds() + def set_origin(self, x: float, y: float, graph_width: float, graph_height: float): self.camera_x = int(x) self.camera_y = int(y) @@ -71,8 +95,7 @@ def camera_size(self): @camera_size.setter def camera_size(self, value: float): self._camera_size = value - self._camera_size_y = self.camera_size / self.aspect_ratio - self._update_bounds() + self._update_projection() @property def camera_size_y(self): @@ -91,7 +114,7 @@ def screen_width(self): @screen_width.setter def screen_width(self, value: int): self._screen_width = value - self._aspect_ratio = self._screen_width / self._screen_height + self._update_projection() @property def screen_height(self): @@ -100,7 +123,12 @@ def screen_height(self): @screen_height.setter def screen_height(self, value: int): self._screen_height = value - self._aspect_ratio = self._screen_width / self._screen_height + self._update_projection() + + def set_screen_size(self, width: int, height: int) -> None: + self._screen_width = width + self._screen_height = height + self._update_projection() @property def aspect_ratio(self): @@ -109,6 +137,22 @@ def aspect_ratio(self): @property def current_drawing_artist(self): return self._current_drawing_artist + + @property + def bound_left(self): + return self._bound_left + + @property + def bound_right(self): + return self._bound_right + + @property + def bound_top(self): + return self._bound_top + + @property + def bound_bottom(self): + return self._bound_bottom def world_to_screen_scale(self, world_size: float) -> float: """ @@ -209,9 +253,6 @@ def add_artist(self, name: str, artist: IArtist) -> None: else: self._layer_artists[artist.get_layer()].append(name) - if artist.get_artist_type() == ArtistType.STATIC: - self._static_layers.add(artist.get_layer()) - def remove_artist(self, name: str): """ Remove an artist from the render manager. @@ -227,18 +268,17 @@ def remove_artist(self, name: str): else: print(f"Warning: Artist {name} not found.") + def get_artist(self, name: str) -> Optional[IArtist]: + return self._artists.get(name) + def rebuild_artist_layer(self): self._layer_artists.clear() - self._static_layers.clear() for name, artist in self._artists.items(): if artist.get_layer() not in self._layer_artists: self._layer_artists[artist.get_layer()] = [name] else: self._layer_artists[artist.get_layer()].append(name) - if artist.get_artist_type() == ArtistType.STATIC: - self._static_layers.add(artist.get_layer()) - self._layer_artists = {k: self._layer_artists[k] for k in sorted(self._layer_artists.keys())} def render_single_artist(self, artist_name: str): @@ -264,20 +304,27 @@ def handle_render(self): for artist in self._artists.values(): artist.layer_dirty = False - rendered_layers: Set[int] = set() for layer, artist_name_list in self._layer_artists.items(): + cached_static_names: List[str] = [] + non_cached_artists: List[IArtist] = [] for artist_name in artist_name_list: artist = self._artists[artist_name] if not artist.get_visible(): continue - if not artist.get_will_draw(): - if artist.get_artist_type() == ArtistType.STATIC and layer not in rendered_layers: - artist.set_render_mode(RenderMode.CACHED) - self.ctx.visual.render_layer(layer) - rendered_layers.add(layer) - continue + if (artist.get_artist_type() == ArtistType.STATIC + and self._cached_layer_handler is not None): + cached_static_names.append(artist_name) + else: + non_cached_artists.append(artist) + + if cached_static_names: + for artist_name in cached_static_names: + self._artists[artist_name].set_render_mode(RenderMode.CACHED) + assert self._cached_layer_handler is not None + self._cached_layer_handler(layer, cached_static_names) + for artist in non_cached_artists: artist.set_render_mode(RenderMode.NON_CACHED) self._current_drawing_artist = artist artist.draw() diff --git a/gamms/typing/artist.py b/gamms/typing/artist.py index f78fed0..dd6d4e0 100644 --- a/gamms/typing/artist.py +++ b/gamms/typing/artist.py @@ -77,26 +77,6 @@ def get_drawer(self) -> Optional[Callable[["IContext", Dict[str, Any]], None]]: """ pass - @abstractmethod - def get_will_draw(self) -> bool: - """ - Get whether the artist will draw. - - Returns: - bool: True if the artist will draw, False otherwise. - """ - pass - - @abstractmethod - def set_will_draw(self, will_draw: bool) -> None: - """ - Set whether the artist will draw. - - Args: - will_draw (bool): The will_draw state to set. - """ - pass - @abstractmethod def get_artist_type(self) -> ArtistType: """ diff --git a/gamms/typing/visualization_engine.py b/gamms/typing/visualization_engine.py index eccae6e..722020a 100644 --- a/gamms/typing/visualization_engine.py +++ b/gamms/typing/visualization_engine.py @@ -270,3 +270,16 @@ def render_layer(self, layer_id: int) -> None: layer_id (int): The layer number to render. """ pass + + @abstractmethod + def get_viewport(self) -> Optional[Tuple[float, float, float, float, float]]: + """ + Return the current viewport as (left, right, top, bottom, scale) in world + coordinates, where scale is the pixels-per-world-unit factor at the + current camera zoom. + + Returns: + Optional[Tuple[float, float, float, float, float]]: (left, right, top, + bottom, scale) in world coordinates, or None if there is no valid viewport. + """ + pass