class_name BathymetricSonarRenderer extends SubViewport const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd") const SonarReliefMeshBuilder = preload( "res://game/presentation/sonar/sonar_relief_mesh_builder.gd" ) const SonarReliefStyle = preload("res://game/presentation/sonar/sonar_relief_style.gd") ## Matches the glass of the cockpit sonar monitor (0.690 × 0.434, ratio 1.59). const RENDER_SIZE := Vector2i(960, 604) ## Ground width framed by the orthographic camera, independent of the ratio. const VIEW_WIDTH_M := 178.0 ## Table-like oblique view from behind the vehicle. The aim point sits right ## of the scan axis so the relief leaves the right edge to the height scale. const CAMERA_AIM_POINT := Vector3(14.0, -46.0, 50.0) const CAMERA_ELEVATION_DEGREES := 44.0 const CAMERA_DISTANCE_M := 120.0 const IDLE_PROMPT_SIZE := Vector2(600.0, 64.0) ## Height scale drawn at the right edge of the screen, in viewport pixels. const SCALE_BAR_RECT := Rect2(920.0, 70.0, 12.0, 464.0) const SCALE_TICK_COUNT := 3 const SCALE_LABEL_SIZE := Vector2(112.0, 40.0) const SCALE_FONT_SIZE := 34 var _current_mesh_instance: MeshInstance3D var _previous_mesh_instance: MeshInstance3D var _contact_root: Node3D var _idle_prompt: Label var _current_material: ShaderMaterial var _previous_material: ShaderMaterial var _depth_scale: TextureRect var _scale_labels: Array[Label] = [] var _current_scan_id: StringName = &"" var _previous_scan_id: StringName = &"" var _built_current_rows := 0 var _built_current_triangle_count := 0 var _built_current_grid_segment_count := 0 func _ready() -> void: size = RENDER_SIZE render_target_update_mode = SubViewport.UPDATE_ALWAYS own_world_3d = true _build_render_world() func present( current_scan: SonarScan, previous_scan: SonarScan, visible_rows: int, previous_opacity: float ) -> void: if current_scan == null: return _idle_prompt.visible = false if current_scan.scan_id != _current_scan_id: _current_scan_id = current_scan.scan_id _current_mesh_instance.mesh = ArrayMesh.new() _built_current_rows = 0 _built_current_triangle_count = 0 _built_current_grid_segment_count = 0 _rebuild_contacts(current_scan) _update_depth_scale(current_scan) _build_current_rows(current_scan, clampi(visible_rows, 0, current_scan.samples.rows)) _update_contact_visibility(current_scan, visible_rows) if previous_scan == null: _previous_scan_id = &"" _previous_mesh_instance.mesh = ArrayMesh.new() _previous_mesh_instance.visible = false else: _previous_mesh_instance.visible = previous_opacity > 0.0 if previous_scan.scan_id != _previous_scan_id: _previous_scan_id = previous_scan.scan_id _previous_mesh_instance.mesh = SonarReliefMeshBuilder.build_full_mesh( previous_scan, _previous_material ) SonarReliefStyle.set_opacity(_previous_material, previous_opacity) func render_texture() -> Texture2D: return get_texture() func built_row_count() -> int: return _built_current_rows func built_triangle_count() -> int: return _built_current_triangle_count func built_grid_segment_count() -> int: return _built_current_grid_segment_count func idle_prompt_visible() -> bool: return _idle_prompt != null and _idle_prompt.visible ## Elevation labels of the height scale, top (highest) to bottom; empty until ## a scan has been presented. func depth_scale_texts() -> PackedStringArray: var texts := PackedStringArray() if _depth_scale != null and _depth_scale.visible: for label: Label in _scale_labels: texts.append(label.text) return texts func visible_contact_count() -> int: var count := 0 for child: Node in _contact_root.get_children(): if child is Node3D and (child as Node3D).visible: count += 1 return count func _build_render_world() -> void: var environment_node := WorldEnvironment.new() var environment := Environment.new() environment.background_mode = Environment.BG_COLOR environment.background_color = SonarReliefStyle.COLOR_BACKGROUND environment.ambient_light_source = Environment.AMBIENT_SOURCE_DISABLED environment_node.environment = environment add_child(environment_node) _current_material = SonarReliefStyle.relief_material(false) _previous_material = SonarReliefStyle.relief_material(true) _current_mesh_instance = MeshInstance3D.new() _current_mesh_instance.name = "CurrentRelief" _current_mesh_instance.mesh = ArrayMesh.new() add_child(_current_mesh_instance) _previous_mesh_instance = MeshInstance3D.new() _previous_mesh_instance.name = "PreviousRelief" _previous_mesh_instance.mesh = ArrayMesh.new() _previous_mesh_instance.visible = false add_child(_previous_mesh_instance) _contact_root = Node3D.new() _contact_root.name = "Contacts" add_child(_contact_root) _add_submarine_marker() var camera := Camera3D.new() camera.name = "SonarCamera" camera.projection = Camera3D.PROJECTION_ORTHOGONAL camera.keep_aspect = Camera3D.KEEP_WIDTH camera.size = VIEW_WIDTH_M camera.far = 240.0 var elevation := deg_to_rad(CAMERA_ELEVATION_DEGREES) camera.position = CAMERA_AIM_POINT + CAMERA_DISTANCE_M * Vector3( 0.0, sin(elevation), -cos(elevation) ) add_child(camera) camera.look_at(CAMERA_AIM_POINT, Vector3.UP) camera.current = true _build_depth_scale() _build_idle_prompt() func _build_idle_prompt() -> void: _idle_prompt = Label.new() _idle_prompt.name = "IdlePrompt" _idle_prompt.position = (Vector2(RENDER_SIZE) - IDLE_PROMPT_SIZE) * 0.5 _idle_prompt.size = IDLE_PROMPT_SIZE _idle_prompt.text = "SONAR PRÊT · F POUR ÉMETTRE" _idle_prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _idle_prompt.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _idle_prompt.add_theme_font_size_override("font_size", 26) _idle_prompt.add_theme_color_override("font_color", SonarReliefStyle.COLOR_MIDDLE) _idle_prompt.add_theme_color_override("font_outline_color", Color(0.0, 0.04, 0.05)) _idle_prompt.add_theme_constant_override("outline_size", 6) add_child(_idle_prompt) ## Height legend: the relief palette is relative to the scan, so the bar shows ## the real elevations of its two ends and of the steps in between. func _build_depth_scale() -> void: var gradient := GradientTexture2D.new() gradient.gradient = SonarReliefStyle.height_gradient() gradient.fill_from = Vector2(0.0, 1.0) gradient.fill_to = Vector2(0.0, 0.0) gradient.width = int(SCALE_BAR_RECT.size.x) gradient.height = int(SCALE_BAR_RECT.size.y) _depth_scale = TextureRect.new() _depth_scale.name = "DepthScale" _depth_scale.texture = gradient _depth_scale.position = SCALE_BAR_RECT.position _depth_scale.size = SCALE_BAR_RECT.size _depth_scale.visible = false add_child(_depth_scale) for tick in SCALE_TICK_COUNT: var label := Label.new() label.size = SCALE_LABEL_SIZE label.position = Vector2( -label.size.x - 8.0, SCALE_BAR_RECT.size.y * tick / float(SCALE_TICK_COUNT - 1) - label.size.y * 0.5 ) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.add_theme_font_size_override("font_size", SCALE_FONT_SIZE) label.add_theme_color_override("font_color", SonarReliefStyle.COLOR_MIDDLE) _depth_scale.add_child(label) _scale_labels.append(label) func _update_depth_scale(scan: SonarScan) -> void: for tick in _scale_labels.size(): var elevation := lerpf( scan.maximum_elevation_m, scan.minimum_elevation_m, tick / float(_scale_labels.size() - 1) ) _scale_labels[tick].text = "%.0f m" % elevation _depth_scale.visible = true func _build_current_rows(scan: SonarScan, visible_rows: int) -> void: var mesh := _current_mesh_instance.mesh as ArrayMesh while _built_current_rows < visible_rows: if _built_current_rows > 0: var triangles := SonarReliefMeshBuilder.add_row_band( mesh, scan, _built_current_rows, _current_material ) _built_current_triangle_count += triangles _built_current_grid_segment_count += ( triangles / 2 * SonarReliefMeshBuilder.GRID_SEGMENTS_PER_CELL ) _built_current_rows += 1 func _rebuild_contacts(scan: SonarScan) -> void: for child: Node in _contact_root.get_children(): _contact_root.remove_child(child) child.queue_free() for contact in scan.contacts: var marker := MeshInstance3D.new() var box := BoxMesh.new() box.size = Vector3(4.0, 2.25, 4.0) marker.mesh = box marker.position = contact.center_m + Vector3.UP * 1.4 marker.visible = false marker.set_meta("forward_distance_m", contact.center_m.z) marker.material_override = SonarReliefStyle.marker_material( SonarReliefStyle.COLOR_METAL ) _contact_root.add_child(marker) func _update_contact_visibility(scan: SonarScan, visible_rows: int) -> void: var maximum_visible_distance := -INF if visible_rows > 0: var row_ratio := float(visible_rows - 1) / float(scan.samples.rows - 1) maximum_visible_distance = lerpf( scan.minimum_range_m, scan.maximum_range_m, row_ratio ) for child: Node in _contact_root.get_children(): if child is Node3D: (child as Node3D).visible = ( float(child.get_meta("forward_distance_m", INF)) <= maximum_visible_distance ) func _add_submarine_marker() -> void: var marker := MeshInstance3D.new() marker.name = "SubmarineMarker" var box := BoxMesh.new() box.size = Vector3(3.2, 1.1, 5.8) marker.mesh = box marker.position = Vector3(0.0, 0.0, 1.0) marker.material_override = SonarReliefStyle.marker_material( SonarReliefStyle.COLOR_VEHICLE ) add_child(marker)