Add production bathymetric sonar slice

This commit is contained in:
chindit committed 2026-10-02 15:52:11 +02:00
1 parent a18e82eb44
commit 7613daee84
28 files changed
+1825 -87

No files matched your search

@@ -0,0 +1,13 @@
class_name SonarAcquisitionPort
extends RefCounted
const SonarCaptureRequest = preload(
"res://game/application/sonar/sonar_capture_request.gd"
)
const SonarCaptureResult = preload(
"res://game/application/sonar/sonar_capture_result.gd"
)
func capture(_request: SonarCaptureRequest) -> SonarCaptureResult:
return SonarCaptureResult.new(false, [], "sonar acquisition is not implemented")
@@ -0,0 +1,48 @@
class_name SonarCaptureRequest
extends RefCounted
var scan_id: StringName
var session_id: StringName
var sensor_transform: Transform3D
var minimum_range_m: float
var maximum_range_m: float
var horizontal_opening_radians: float
var vertical_opening_radians: float
var columns: int
var rows: int
func _init(
p_scan_id: StringName,
p_session_id: StringName,
p_sensor_transform: Transform3D,
p_minimum_range_m: float,
p_maximum_range_m: float,
p_horizontal_opening_radians: float,
p_vertical_opening_radians: float,
p_columns: int,
p_rows: int
) -> void:
scan_id = p_scan_id
session_id = p_session_id
sensor_transform = p_sensor_transform
minimum_range_m = p_minimum_range_m
maximum_range_m = p_maximum_range_m
horizontal_opening_radians = p_horizontal_opening_radians
vertical_opening_radians = p_vertical_opening_radians
columns = p_columns
rows = p_rows
func validation_error() -> String:
if scan_id.is_empty() or session_id.is_empty():
return "sonar capture identifiers must not be empty"
if minimum_range_m < 0.0 or maximum_range_m <= minimum_range_m:
return "sonar capture range is invalid"
if horizontal_opening_radians <= 0.0 or horizontal_opening_radians >= PI:
return "sonar capture horizontal opening is invalid"
if vertical_opening_radians <= 0.0 or vertical_opening_radians >= PI:
return "sonar capture vertical opening is invalid"
if columns < 2 or rows < 2:
return "sonar capture dimensions must be at least 2 by 2"
return ""
@@ -0,0 +1,20 @@
class_name SonarCaptureResult
extends RefCounted
const SonarCaptureSample = preload(
"res://game/application/sonar/sonar_capture_sample.gd"
)
var accepted: bool
var samples: Array[SonarCaptureSample]
var message: String
func _init(
p_accepted: bool,
p_samples: Array[SonarCaptureSample] = [],
p_message: String = ""
) -> void:
accepted = p_accepted
samples = p_samples.duplicate()
message = p_message
@@ -0,0 +1,50 @@
class_name SonarCaptureSample
extends RefCounted
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
var row: int
var column: int
var position_m: Vector3
var normal: Vector3
var intensity: float
var elevation_m: float
var material: SonarMaterial.Value
func _init(
p_row: int,
p_column: int,
p_position_m: Vector3,
p_normal: Vector3,
p_intensity: float,
p_elevation_m: float,
p_material: SonarMaterial.Value
) -> void:
row = p_row
column = p_column
position_m = p_position_m
normal = p_normal
intensity = p_intensity
elevation_m = p_elevation_m
material = p_material
func validation_error(columns: int, rows: int) -> String:
if row < 0 or row >= rows or column < 0 or column >= columns:
return "sonar capture sample coordinates are invalid"
if not _vector_is_finite(position_m) or not _vector_is_finite(normal):
return "sonar capture sample vectors are not finite"
if normal.length_squared() < 0.25:
return "sonar capture sample normal is invalid"
if not is_finite(intensity) or intensity < 0.0 or intensity > 1.0:
return "sonar capture sample intensity is invalid"
if not is_finite(elevation_m):
return "sonar capture sample elevation is invalid"
if material == SonarMaterial.Value.UNKNOWN or not SonarMaterial.is_valid(material):
return "sonar capture sample material is invalid"
return ""
func _vector_is_finite(value: Vector3) -> bool:
return is_finite(value.x) and is_finite(value.y) and is_finite(value.z)
@@ -0,0 +1,18 @@
class_name SonarCommandResult
extends RefCounted
const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd")
var accepted: bool
var message: String
var scan: SonarScan
func _init(
p_accepted: bool,
p_message: String = "",
p_scan: SonarScan = null
) -> void:
accepted = p_accepted
message = p_message
scan = p_scan
+298
View File
@@ -0,0 +1,298 @@
class_name SonarService
extends RefCounted
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
const SonarScanState = preload("res://game/domain/sonar/sonar_scan_state.gd")
const SonarSample = preload("res://game/domain/sonar/sonar_sample.gd")
const SonarSampleBuffer = preload(
"res://game/domain/sonar/sonar_sample_buffer.gd"
)
const SonarContact = preload("res://game/domain/sonar/sonar_contact.gd")
const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd")
const SonarCaptureRequest = preload(
"res://game/application/sonar/sonar_capture_request.gd"
)
const SonarCaptureResult = preload(
"res://game/application/sonar/sonar_capture_result.gd"
)
const SonarAcquisitionPort = preload(
"res://game/application/interfaces/sonar_acquisition_port.gd"
)
const SonarCommandResult = preload(
"res://game/application/sonar/sonar_command_result.gd"
)
const MINIMUM_RANGE_M := 5.0
const MAXIMUM_RANGE_M := 95.0
const HORIZONTAL_OPENING_RADIANS := deg_to_rad(76.0)
const VERTICAL_OPENING_RADIANS := deg_to_rad(64.0)
const COLUMNS := 49
const ROWS := 31
const REVEAL_DURATION_SECONDS := 2.5
const RECOVERY_DURATION_SECONDS := 0.35
const FIRST_RETURN_PROGRESS := 0.04
const PREVIOUS_SCAN_STARTING_OPACITY := 0.52
const MINIMUM_COLOR_SPAN_M := 6.0
var _acquisition: SonarAcquisitionPort
var _session_id: StringName
var _scan_sequence := 0
var _phase_elapsed_seconds := 0.0
var _current_scan: SonarScan
var _previous_scan: SonarScan
func _init(p_acquisition: SonarAcquisitionPort, p_session_id: StringName) -> void:
_acquisition = p_acquisition
_session_id = p_session_id
func request_scan(sensor_transform: Transform3D) -> SonarCommandResult:
if _acquisition == null:
return SonarCommandResult.new(false, "sonar acquisition port is missing")
if _session_id.is_empty():
return SonarCommandResult.new(false, "sonar session id is empty")
if _current_scan != null and _current_scan.state != SonarScanState.Value.READY:
return SonarCommandResult.new(false, "sonar is already scanning")
var scan_id := StringName("%s-scan-%d" % [_session_id, _scan_sequence])
var request := SonarCaptureRequest.new(
scan_id,
_session_id,
sensor_transform,
MINIMUM_RANGE_M,
MAXIMUM_RANGE_M,
HORIZONTAL_OPENING_RADIANS,
VERTICAL_OPENING_RADIANS,
COLUMNS,
ROWS
)
var request_error := request.validation_error()
if not request_error.is_empty():
return SonarCommandResult.new(false, request_error)
var capture: SonarCaptureResult = _acquisition.capture(request)
if capture == null or not capture.accepted:
return SonarCommandResult.new(
false,
"sonar acquisition failed" if capture == null else capture.message
)
var built = _build_scan(request, capture)
if built is String:
return SonarCommandResult.new(false, built)
_previous_scan = _current_scan
_current_scan = built as SonarScan
_phase_elapsed_seconds = 0.0
_scan_sequence += 1
return SonarCommandResult.new(true, "", _current_scan)
func advance(elapsed_seconds: float) -> SonarScan:
if _current_scan == null or not is_finite(elapsed_seconds) or elapsed_seconds < 0.0:
return _current_scan
if _current_scan.state == SonarScanState.Value.REVEALING:
_phase_elapsed_seconds += elapsed_seconds
var progress := clampf(
_phase_elapsed_seconds / REVEAL_DURATION_SECONDS,
0.0,
1.0
)
if progress >= 1.0:
_current_scan = _current_scan.with_phase(
SonarScanState.Value.RECOVERY,
1.0
)
_phase_elapsed_seconds = 0.0
_previous_scan = null
else:
_current_scan = _current_scan.with_phase(
SonarScanState.Value.REVEALING,
progress
)
elif _current_scan.state == SonarScanState.Value.RECOVERY:
_phase_elapsed_seconds += elapsed_seconds
if _phase_elapsed_seconds >= RECOVERY_DURATION_SECONDS:
_current_scan = _current_scan.with_phase(
SonarScanState.Value.READY,
1.0
)
_phase_elapsed_seconds = 0.0
return _current_scan
func current_scan() -> SonarScan:
return _current_scan
func previous_scan() -> SonarScan:
return _previous_scan
func visible_row_count() -> int:
if _current_scan == null:
return 0
if _current_scan.state != SonarScanState.Value.REVEALING:
return _current_scan.samples.rows
if _current_scan.reveal_progress < FIRST_RETURN_PROGRESS:
return 0
var normalized := inverse_lerp(
FIRST_RETURN_PROGRESS,
1.0,
_current_scan.reveal_progress
)
return clampi(
floori(normalized * float(_current_scan.samples.rows - 1)) + 1,
1,
_current_scan.samples.rows
)
func previous_scan_opacity() -> float:
if _previous_scan == null or _current_scan == null:
return 0.0
return PREVIOUS_SCAN_STARTING_OPACITY * (1.0 - _current_scan.reveal_progress)
func _build_scan(request: SonarCaptureRequest, capture: SonarCaptureResult) -> Variant:
var buffer := SonarSampleBuffer.new(request.columns, request.rows)
var minimum_elevation := INF
var maximum_elevation := -INF
for captured_sample in capture.samples:
if captured_sample == null:
return "sonar acquisition returned a missing sample"
var capture_error: String = captured_sample.validation_error(
request.columns,
request.rows
)
if not capture_error.is_empty():
return capture_error
var sample := SonarSample.new(
captured_sample.position_m,
captured_sample.normal,
captured_sample.intensity,
captured_sample.elevation_m,
captured_sample.material,
true
)
var write_error := buffer.set_sample(
captured_sample.row,
captured_sample.column,
sample
)
if not write_error.is_empty():
return write_error
minimum_elevation = minf(minimum_elevation, sample.elevation_m)
maximum_elevation = maxf(maximum_elevation, sample.elevation_m)
var seal_error := buffer.seal()
if not seal_error.is_empty():
return seal_error
if buffer.observed_count() == 0:
minimum_elevation = -MINIMUM_COLOR_SPAN_M * 0.5
maximum_elevation = MINIMUM_COLOR_SPAN_M * 0.5
elif maximum_elevation - minimum_elevation < MINIMUM_COLOR_SPAN_M:
var center := (minimum_elevation + maximum_elevation) * 0.5
minimum_elevation = center - MINIMUM_COLOR_SPAN_M * 0.5
maximum_elevation = center + MINIMUM_COLOR_SPAN_M * 0.5
var scan := SonarScan.new(
request.scan_id,
request.session_id,
request.sensor_transform,
request.minimum_range_m,
request.maximum_range_m,
request.horizontal_opening_radians,
request.vertical_opening_radians,
REVEAL_DURATION_SECONDS,
RECOVERY_DURATION_SECONDS,
buffer,
_cluster_contacts(request.scan_id, buffer),
minimum_elevation,
maximum_elevation,
SonarScanState.Value.REVEALING,
0.0
)
var scan_error := scan.validation_error()
return scan if scan_error.is_empty() else scan_error
func _cluster_contacts(
scan_id: StringName,
buffer: SonarSampleBuffer
) -> Array[SonarContact]:
var metal_indices: Dictionary = {}
for row in buffer.rows:
for column in buffer.columns:
var sample: SonarSample = buffer.sample_at(row, column)
if sample.observed and sample.material == SonarMaterial.Value.METAL:
metal_indices[row * buffer.columns + column] = true
var contacts: Array[SonarContact] = []
var visited: Dictionary = {}
for seed_index: int in metal_indices:
if visited.has(seed_index):
continue
var queue: Array[int] = [seed_index]
var cluster_indices: Array[int] = []
visited[seed_index] = true
while not queue.is_empty():
var index: int = queue.pop_front()
cluster_indices.append(index)
var row: int = index / buffer.columns
var column: int = index % buffer.columns
for row_offset: int in range(-1, 2):
for column_offset: int in range(-1, 2):
var neighbor_row: int = row + row_offset
var neighbor_column: int = column + column_offset
if (
neighbor_row < 0
or neighbor_row >= buffer.rows
or neighbor_column < 0
or neighbor_column >= buffer.columns
):
continue
var neighbor_index: int = neighbor_row * buffer.columns + neighbor_column
if metal_indices.has(neighbor_index) and not visited.has(neighbor_index):
visited[neighbor_index] = true
queue.append(neighbor_index)
contacts.append(
_contact_from_indices(scan_id, contacts.size(), buffer, cluster_indices)
)
return contacts
func _contact_from_indices(
scan_id: StringName,
contact_index: int,
buffer: SonarSampleBuffer,
indices: Array[int]
) -> SonarContact:
var center := Vector3.ZERO
var minimum := Vector3(INF, INF, INF)
var maximum := Vector3(-INF, -INF, -INF)
var maximum_intensity := 0.0
for index: int in indices:
var sample: SonarSample = buffer.sample_at(
index / buffer.columns,
index % buffer.columns
)
center += sample.position_m
minimum = Vector3(
minf(minimum.x, sample.position_m.x),
minf(minimum.y, sample.position_m.y),
minf(minimum.z, sample.position_m.z)
)
maximum = Vector3(
maxf(maximum.x, sample.position_m.x),
maxf(maximum.y, sample.position_m.y),
maxf(maximum.z, sample.position_m.z)
)
maximum_intensity = maxf(maximum_intensity, sample.intensity)
center /= float(indices.size())
return SonarContact.new(
StringName("%s-contact-%d" % [scan_id, contact_index]),
SonarMaterial.Value.METAL,
center,
AABB(minimum, maximum - minimum),
maximum_intensity,
indices.size()
)
@@ -18,6 +18,7 @@ const VehicleCollisionSample = preload(
"res://game/application/vehicle/vehicle_collision_sample.gd"
)
const PilotUseCase = preload("res://game/application/vehicle/pilot_use_case.gd")
const SonarService = preload("res://game/application/sonar/sonar_service.gd")
const SubmarineActor = preload("res://game/world/submarine/submarine_actor.gd")
const SubmarineMotionAdapter = preload(
"res://game/world/submarine/submarine_motion_adapter.gd"
@@ -25,6 +26,9 @@ const SubmarineMotionAdapter = preload(
const SubmarineTelemetryAdapter = preload(
"res://game/world/submarine/submarine_telemetry_adapter.gd"
)
const ReferenceBathymetryAcquisition = preload(
"res://game/world/sonar/reference_bathymetry_acquisition.gd"
)
const ProductionInputAdapter = preload(
"res://game/infrastructure/input/production_input_adapter.gd"
)
@@ -35,6 +39,7 @@ const CockpitInstrumentPanel = preload(
"res://game/presentation/cockpit/cockpit_instrument_panel.gd"
)
const CockpitHud = preload("res://game/presentation/cockpit/cockpit_hud.gd")
const SonarDisplay = preload("res://game/presentation/sonar/sonar_display.gd")
const MainMenuScreen = preload("res://game/presentation/main_menu/main_menu_screen.gd")
const PauseMenu = preload("res://game/presentation/pause/pause_menu.gd")
const ControlsSettingsScreen = preload(
@@ -50,6 +55,9 @@ const VEHICLE_TEST_WORLD_SCENE_PATH := (
"res://game/composition/vertical_slice/vehicle_test_world.tscn"
)
const COCKPIT_HUD_SCENE = preload("res://game/presentation/cockpit/cockpit_hud.tscn")
const SONAR_DISPLAY_SCENE = preload(
"res://game/presentation/sonar/sonar_display.tscn"
)
const PAUSE_MENU_SCENE = preload("res://game/presentation/pause/pause_menu.tscn")
const CONTROLS_SETTINGS_SCENE = preload(
"res://game/presentation/settings/controls_settings_screen.tscn"
@@ -75,6 +83,8 @@ var _actor: SubmarineActor
var _camera_controller: CockpitCameraController
var _instrument_panel: CockpitInstrumentPanel
var _hud: CockpitHud
var _sonar_service: SonarService
var _sonar_display: SonarDisplay
var _latest_update: DiveUpdate
var _active_world: Node3D
var _active_screen: Control
@@ -90,6 +100,8 @@ var _dive_terminal := false
func _ready() -> void:
_input_adapter.pause_requested.connect(_on_pause_requested)
_input_adapter.rebind_result_available.connect(_on_rebind_result_available)
_input_adapter.sonar_scan_requested.connect(_on_sonar_scan_requested)
_input_adapter.sonar_view_toggle_requested.connect(_on_sonar_view_toggle_requested)
_show_main_menu()
@@ -115,10 +127,18 @@ func _start_vehicle_test() -> void:
_instrument_panel = world.get_node("%CockpitInterior") as CockpitInstrumentPanel
_hud = COCKPIT_HUD_SCENE.instantiate() as CockpitHud
_replace_active_screen(_hud)
_sonar_display = SONAR_DISPLAY_SCENE.instantiate() as SonarDisplay
_hud.add_child(_sonar_display)
_instrument_panel.attach_sonar_texture(_sonar_display.sonar_texture())
var motion_adapter := SubmarineMotionAdapter.new(_actor)
_pilot_use_case = PilotUseCase.new(motion_adapter)
_telemetry_adapter = SubmarineTelemetryAdapter.new(_actor)
var sonar_acquisition := ReferenceBathymetryAcquisition.new(
_actor.get_world_3d(),
_actor
)
_sonar_service = SonarService.new(sonar_acquisition, DIVE_SESSION_ID)
_runner = DiveTickRunner.new(session)
_latest_update = _runner.initial_update()
_vehicle_command_sequence = 0
@@ -177,6 +197,8 @@ func _physics_process(delta: float) -> void:
)
_hud.present(_latest_update, telemetry)
_instrument_panel.present(_latest_update, telemetry)
_sonar_service.advance(delta)
_present_sonar()
if _latest_update.snapshot.is_terminal():
_finish_dive_motion()
@@ -213,6 +235,14 @@ func active_overlay() -> Control:
return _active_overlay
func sonar_service() -> SonarService:
return _sonar_service
func sonar_display() -> SonarDisplay:
return _sonar_display
func _finish_dive_motion() -> void:
_dive_terminal = true
_input_adapter.set_gameplay_enabled(false)
@@ -242,6 +272,41 @@ func _on_collision_sampled(sample: VehicleCollisionSample) -> void:
_pending_collision_samples.append(sample)
func _on_sonar_scan_requested() -> void:
if _sonar_service == null or _actor == null or _dive_terminal:
return
var result = _sonar_service.request_scan(_sonar_sensor_transform())
if result.accepted:
_present_sonar()
func _on_sonar_view_toggle_requested() -> void:
if _sonar_display != null and not _dive_terminal:
_sonar_display.toggle_expanded()
func _present_sonar() -> void:
if _sonar_service == null or _sonar_display == null:
return
_sonar_display.present(
_sonar_service.current_scan(),
_sonar_service.previous_scan(),
_sonar_service.visible_row_count(),
_sonar_service.previous_scan_opacity()
)
func _sonar_sensor_transform() -> Transform3D:
var world_basis := _actor.global_basis
var contract_basis := Basis(
world_basis.x,
world_basis.y,
-world_basis.z
)
var sensor_origin := _actor.global_transform * Vector3(0.0, -0.75, -2.2)
return Transform3D(contract_basis, sensor_origin)
func _on_pause_requested() -> void:
if _runner == null:
return
@@ -382,6 +447,8 @@ func _show_main_menu() -> void:
_camera_controller = null
_instrument_panel = null
_hud = null
_sonar_service = null
_sonar_display = null
_latest_update = null
_pending_collision_samples.clear()
_dive_terminal = false
+39
View File
@@ -0,0 +1,39 @@
class_name SonarContact
extends RefCounted
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
var contact_id: StringName
var material: SonarMaterial.Value
var center_m: Vector3
var bounds: AABB
var maximum_intensity: float
var sample_count: int
func _init(
p_contact_id: StringName,
p_material: SonarMaterial.Value,
p_center_m: Vector3,
p_bounds: AABB,
p_maximum_intensity: float,
p_sample_count: int
) -> void:
contact_id = p_contact_id
material = p_material
center_m = p_center_m
bounds = p_bounds
maximum_intensity = p_maximum_intensity
sample_count = p_sample_count
func validation_error() -> String:
if contact_id.is_empty():
return "sonar contact id is empty"
if material == SonarMaterial.Value.UNKNOWN or not SonarMaterial.is_valid(material):
return "sonar contact material is invalid"
if sample_count <= 0:
return "sonar contact sample count must be positive"
if not is_finite(maximum_intensity) or maximum_intensity < 0.0 or maximum_intensity > 1.0:
return "sonar contact intensity must be between 0 and 1"
return ""
+12
View File
@@ -0,0 +1,12 @@
class_name SonarMaterial
extends RefCounted
enum Value {
UNKNOWN,
ROCK,
METAL,
}
static func is_valid(value: int) -> bool:
return value >= Value.UNKNOWN and value <= Value.METAL
+53
View File
@@ -0,0 +1,53 @@
class_name SonarSample
extends RefCounted
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
var position_m: Vector3
var normal: Vector3
var intensity: float
var elevation_m: float
var material: SonarMaterial.Value
var observed: bool
func _init(
p_position_m: Vector3 = Vector3.ZERO,
p_normal: Vector3 = Vector3.UP,
p_intensity: float = 0.0,
p_elevation_m: float = 0.0,
p_material: SonarMaterial.Value = SonarMaterial.Value.UNKNOWN,
p_observed: bool = false
) -> void:
position_m = p_position_m
normal = p_normal
intensity = p_intensity
elevation_m = p_elevation_m
material = p_material
observed = p_observed
static func unknown() -> SonarSample:
return new()
func validation_error() -> String:
if not observed:
if material != SonarMaterial.Value.UNKNOWN:
return "unobserved sonar sample has a material"
return ""
if not _vector_is_finite(position_m):
return "sonar sample position is not finite"
if not _vector_is_finite(normal) or normal.length_squared() < 0.25:
return "sonar sample normal is invalid"
if not is_finite(intensity) or intensity < 0.0 or intensity > 1.0:
return "sonar sample intensity must be between 0 and 1"
if not is_finite(elevation_m):
return "sonar sample elevation is not finite"
if material == SonarMaterial.Value.UNKNOWN or not SonarMaterial.is_valid(material):
return "observed sonar sample material is invalid"
return ""
func _vector_is_finite(value: Vector3) -> bool:
return is_finite(value.x) and is_finite(value.y) and is_finite(value.z)
+115
View File
@@ -0,0 +1,115 @@
class_name SonarSampleBuffer
extends RefCounted
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
const SonarSample = preload("res://game/domain/sonar/sonar_sample.gd")
var columns: int
var rows: int
var _positions := PackedVector3Array()
var _normals := PackedVector3Array()
var _intensities := PackedFloat32Array()
var _elevations_m := PackedFloat32Array()
var _materials := PackedInt32Array()
var _observed := PackedByteArray()
var _sealed := false
func _init(p_columns: int, p_rows: int) -> void:
columns = p_columns
rows = p_rows
var sample_count := maxi(columns, 0) * maxi(rows, 0)
_positions.resize(sample_count)
_normals.resize(sample_count)
_intensities.resize(sample_count)
_elevations_m.resize(sample_count)
_materials.resize(sample_count)
_observed.resize(sample_count)
for index in sample_count:
_normals[index] = Vector3.UP
_materials[index] = SonarMaterial.Value.UNKNOWN
func set_sample(row: int, column: int, sample: SonarSample) -> String:
if _sealed:
return "sonar sample buffer is sealed"
var index := _index_of(row, column)
if index < 0:
return "sonar sample coordinates are outside the buffer"
if sample == null:
return "sonar sample is missing"
var error := sample.validation_error()
if not error.is_empty():
return error
if not sample.observed:
return "cannot write an unobserved sonar sample"
if _observed[index] == 1:
return "sonar sample coordinates are duplicated"
_positions[index] = sample.position_m
_normals[index] = sample.normal.normalized()
_intensities[index] = sample.intensity
_elevations_m[index] = sample.elevation_m
_materials[index] = sample.material
_observed[index] = 1
return ""
func seal() -> String:
if columns < 2 or rows < 2:
return "sonar sample buffer dimensions must be at least 2 by 2"
_sealed = true
return ""
func is_sealed() -> bool:
return _sealed
func sample_count() -> int:
return columns * rows
func observed_count() -> int:
var count := 0
for value: int in _observed:
count += value
return count
func sample_at(row: int, column: int) -> SonarSample:
var index := _index_of(row, column)
if index < 0 or _observed[index] == 0:
return SonarSample.unknown()
return SonarSample.new(
_positions[index],
_normals[index],
_intensities[index],
_elevations_m[index],
_materials[index],
true
)
func validation_error() -> String:
if columns < 2 or rows < 2:
return "sonar sample buffer dimensions must be at least 2 by 2"
if not _sealed:
return "sonar sample buffer must be sealed before publication"
var expected_size := columns * rows
if (
_positions.size() != expected_size
or _normals.size() != expected_size
or _intensities.size() != expected_size
or _elevations_m.size() != expected_size
or _materials.size() != expected_size
or _observed.size() != expected_size
):
return "sonar sample buffer arrays are not aligned"
return ""
func _index_of(row: int, column: int) -> int:
if row < 0 or row >= rows or column < 0 or column >= columns:
return -1
return row * columns + column
+119
View File
@@ -0,0 +1,119 @@
class_name SonarScan
extends RefCounted
const SonarScanState = preload("res://game/domain/sonar/sonar_scan_state.gd")
const SonarSampleBuffer = preload("res://game/domain/sonar/sonar_sample_buffer.gd")
const SonarContact = preload("res://game/domain/sonar/sonar_contact.gd")
var scan_id: StringName
var session_id: StringName
var sensor_transform: Transform3D
var minimum_range_m: float
var maximum_range_m: float
var horizontal_opening_radians: float
var vertical_opening_radians: float
var reveal_duration_seconds: float
var recovery_duration_seconds: float
var samples: SonarSampleBuffer
var contacts: Array[SonarContact]
var minimum_elevation_m: float
var maximum_elevation_m: float
var state: SonarScanState.Value
var reveal_progress: float
func _init(
p_scan_id: StringName,
p_session_id: StringName,
p_sensor_transform: Transform3D,
p_minimum_range_m: float,
p_maximum_range_m: float,
p_horizontal_opening_radians: float,
p_vertical_opening_radians: float,
p_reveal_duration_seconds: float,
p_recovery_duration_seconds: float,
p_samples: SonarSampleBuffer,
p_contacts: Array[SonarContact],
p_minimum_elevation_m: float,
p_maximum_elevation_m: float,
p_state: SonarScanState.Value,
p_reveal_progress: float
) -> void:
scan_id = p_scan_id
session_id = p_session_id
sensor_transform = p_sensor_transform
minimum_range_m = p_minimum_range_m
maximum_range_m = p_maximum_range_m
horizontal_opening_radians = p_horizontal_opening_radians
vertical_opening_radians = p_vertical_opening_radians
reveal_duration_seconds = p_reveal_duration_seconds
recovery_duration_seconds = p_recovery_duration_seconds
samples = p_samples
contacts = p_contacts.duplicate()
minimum_elevation_m = p_minimum_elevation_m
maximum_elevation_m = p_maximum_elevation_m
state = p_state
reveal_progress = p_reveal_progress
func with_phase(next_state: SonarScanState.Value, next_progress: float) -> SonarScan:
return new(
scan_id,
session_id,
sensor_transform,
minimum_range_m,
maximum_range_m,
horizontal_opening_radians,
vertical_opening_radians,
reveal_duration_seconds,
recovery_duration_seconds,
samples,
contacts,
minimum_elevation_m,
maximum_elevation_m,
next_state,
next_progress
)
func validation_error() -> String:
if scan_id.is_empty() or session_id.is_empty():
return "sonar scan identifiers must not be empty"
if not is_finite(minimum_range_m) or minimum_range_m < 0.0:
return "sonar minimum range is invalid"
if not is_finite(maximum_range_m) or maximum_range_m <= minimum_range_m:
return "sonar maximum range must exceed minimum range"
if (
not is_finite(horizontal_opening_radians)
or horizontal_opening_radians <= 0.0
or horizontal_opening_radians >= PI
):
return "sonar horizontal opening is invalid"
if (
not is_finite(vertical_opening_radians)
or vertical_opening_radians <= 0.0
or vertical_opening_radians >= PI
):
return "sonar vertical opening is invalid"
if reveal_duration_seconds <= 0.0 or recovery_duration_seconds < 0.0:
return "sonar phase durations are invalid"
if samples == null:
return "sonar sample buffer is missing"
var buffer_error := samples.validation_error()
if not buffer_error.is_empty():
return buffer_error
for contact: SonarContact in contacts:
if contact == null:
return "sonar contact is missing"
var contact_error := contact.validation_error()
if not contact_error.is_empty():
return contact_error
if not is_finite(minimum_elevation_m) or not is_finite(maximum_elevation_m):
return "sonar elevation scale is not finite"
if maximum_elevation_m <= minimum_elevation_m:
return "sonar elevation scale is empty"
if not SonarScanState.is_valid(state):
return "sonar scan state is invalid"
if not is_finite(reveal_progress) or reveal_progress < 0.0 or reveal_progress > 1.0:
return "sonar reveal progress must be between 0 and 1"
return ""
+13
View File
@@ -0,0 +1,13 @@
class_name SonarScanState
extends RefCounted
enum Value {
READY,
CAPTURE,
REVEALING,
RECOVERY,
}
static func is_valid(value: int) -> bool:
return value >= Value.READY and value <= Value.RECOVERY
@@ -3,6 +3,8 @@ extends Node
signal pause_requested
signal rebind_result_available(result: RebindResult)
signal sonar_scan_requested
signal sonar_view_toggle_requested
const VehicleCommand = preload("res://game/application/vehicle/vehicle_command.gd")
const CameraLookCommand = preload("res://game/application/vehicle/camera_look_command.gd")
@@ -20,6 +22,8 @@ const ACTION_DESCEND := &"vehicle_descend"
const ACTION_HIGH_THRUST := &"vehicle_high_thrust"
const ACTION_EJECT := &"dive_eject"
const ACTION_RECENTER_CAMERA := &"camera_recenter"
const ACTION_SONAR_SCAN := &"sonar_scan"
const ACTION_SONAR_VIEW_TOGGLE := &"sonar_view_toggle"
const ACTION_PAUSE := &"pause_game"
const BINDABLE_ACTIONS: Array[StringName] = [
@@ -32,6 +36,8 @@ const BINDABLE_ACTIONS: Array[StringName] = [
ACTION_HIGH_THRUST,
ACTION_EJECT,
ACTION_RECENTER_CAMERA,
ACTION_SONAR_SCAN,
ACTION_SONAR_VIEW_TOGGLE,
]
const ACTION_LABELS := {
@@ -44,6 +50,8 @@ const ACTION_LABELS := {
ACTION_HIGH_THRUST: "Forte poussée",
ACTION_EJECT: "Éjection maintenue",
ACTION_RECENTER_CAMERA: "Recentrer le regard",
ACTION_SONAR_SCAN: "Émettre un relevé sonar",
ACTION_SONAR_VIEW_TOGGLE: "Agrandir le sonar",
}
const DEFAULT_KEYS := {
@@ -56,6 +64,8 @@ const DEFAULT_KEYS := {
ACTION_HIGH_THRUST: KEY_SHIFT,
ACTION_EJECT: KEY_X,
ACTION_RECENTER_CAMERA: KEY_R,
ACTION_SONAR_SCAN: KEY_F,
ACTION_SONAR_VIEW_TOGGLE: KEY_TAB,
ACTION_PAUSE: KEY_ESCAPE,
}
@@ -175,6 +185,15 @@ func _unhandled_input(event: InputEvent) -> void:
pause_requested.emit()
get_viewport().set_input_as_handled()
return
if _gameplay_enabled and not get_tree().paused:
if event.is_action_pressed(ACTION_SONAR_SCAN):
sonar_scan_requested.emit()
get_viewport().set_input_as_handled()
return
if event.is_action_pressed(ACTION_SONAR_VIEW_TOGGLE):
sonar_view_toggle_requested.emit()
get_viewport().set_input_as_handled()
return
if (
_gameplay_enabled
and not get_tree().paused
@@ -15,6 +15,10 @@ const COLOR_CRITICAL := Color(1.0, 0.42, 0.31, 1.0)
@onready var _integrity_label: Label3D = %IntegrityLabel
@onready var _return_label: Label3D = %ReturnLabel
@onready var _status_label: Label3D = %StatusLabel
@onready var _sonar_screen: MeshInstance3D = %SonarScreen
@onready var _sonar_placeholder: Label3D = %SonarPlaceholder
var _sonar_texture: Texture2D
func present(update: DiveUpdate, telemetry: VehicleTelemetry) -> void:
@@ -79,6 +83,22 @@ func return_text() -> String:
return _return_label.text
func attach_sonar_texture(texture: Texture2D) -> void:
_sonar_texture = texture
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.albedo_texture = texture
material.emission_enabled = true
material.emission_texture = texture
material.emission_energy_multiplier = 1.7
_sonar_screen.material_override = material
_sonar_placeholder.visible = false
func sonar_texture() -> Texture2D:
return _sonar_texture
func _return_text(estimate: ReturnEstimate) -> String:
match estimate.status:
ReturnEstimate.Status.SAFE:
@@ -395,6 +395,7 @@ mesh = SubResource("ScreenQuadMesh")
material_override = SubResource("ScreenMaterial")
[node name="SonarPlaceholder" type="Label3D" parent="SonarMonitor"]
unique_name_in_owner = true
position = Vector3(0, 0.025, 0.068)
pixel_size = 0.0009
font_size = 16
+68
View File
@@ -0,0 +1,68 @@
class_name SonarDisplay
extends Control
const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd")
const SonarScanState = preload("res://game/domain/sonar/sonar_scan_state.gd")
const BathymetricSonarRendererScript = preload(
"res://game/presentation/sonar/sonar_relief_renderer.gd"
)
@onready var _renderer: Node = %SonarReliefRenderer
@onready var _expanded_panel: PanelContainer = %ExpandedPanel
@onready var _expanded_view: TextureRect = %ExpandedView
@onready var _status_label: Label = %StatusLabel
func _ready() -> void:
_expanded_view.texture = _renderer.render_texture()
_expanded_panel.visible = false
func present(
current_scan: SonarScan,
previous_scan: SonarScan,
visible_rows: int,
previous_opacity: float
) -> void:
if current_scan == null:
_status_label.text = "SONAR PRÊT · F POUR ÉMETTRE"
return
_renderer.present(current_scan, previous_scan, visible_rows, previous_opacity)
_status_label.text = "%s · %.0f %% · %.0f–%.0f m · %d CONTACT(S)" % [
_state_text(current_scan.state),
current_scan.reveal_progress * 100.0,
current_scan.minimum_elevation_m,
current_scan.maximum_elevation_m,
_renderer.visible_contact_count(),
]
func toggle_expanded() -> void:
_expanded_panel.visible = not _expanded_panel.visible
func is_expanded() -> bool:
return _expanded_panel.visible
func sonar_texture() -> Texture2D:
return _renderer.render_texture()
func built_row_count() -> int:
return _renderer.built_row_count()
func status_text() -> String:
return _status_label.text
func _state_text(state: SonarScanState.Value) -> String:
match state:
SonarScanState.Value.REVEALING:
return "RELEVÉ EN COURS"
SonarScanState.Value.RECOVERY:
return "RÉCUPÉRATION"
SonarScanState.Value.READY:
return "RELEVÉ DISPONIBLE"
return "CAPTURE"
@@ -0,0 +1,80 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://game/presentation/sonar/sonar_display.gd" id="1_display"]
[ext_resource type="Script" path="res://game/presentation/sonar/sonar_relief_renderer.gd" id="2_renderer"]
[sub_resource type="StyleBoxFlat" id="ExpandedStyle"]
bg_color = Color(0.008, 0.02, 0.025, 0.98)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.18, 0.68, 0.7, 1)
content_margin_left = 10.0
content_margin_top = 10.0
content_margin_right = 10.0
content_margin_bottom = 10.0
[node name="SonarDisplay" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_display")
[node name="SonarReliefRenderer" type="SubViewport" parent="."]
unique_name_in_owner = true
size = Vector2i(960, 540)
render_target_update_mode = 4
script = ExtResource("2_renderer")
[node name="ExpandedPanel" type="PanelContainer" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -530.0
offset_top = -326.0
offset_right = 530.0
offset_bottom = 326.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme_override_styles/panel = SubResource("ExpandedStyle")
[node name="Layout" type="VBoxContainer" parent="ExpandedPanel"]
layout_mode = 2
mouse_filter = 2
[node name="Title" type="Label" parent="ExpandedPanel/Layout"]
layout_mode = 2
theme_override_colors/font_color = Color(0.42, 0.9, 0.91, 1)
theme_override_font_sizes/font_size = 22
text = "SONAR BATHYMÉTRIQUE · CAP VERS LE HAUT"
[node name="ExpandedView" type="TextureRect" parent="ExpandedPanel/Layout"]
unique_name_in_owner = true
custom_minimum_size = Vector2(1038, 556)
layout_mode = 2
texture_filter = 1
expand_mode = 1
stretch_mode = 5
mouse_filter = 2
[node name="StatusLabel" type="Label" parent="ExpandedPanel/Layout"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.7, 0.82, 0.78, 1)
theme_override_font_sizes/font_size = 16
text = "SONAR PRÊT · F POUR ÉMETTRE"
[node name="Legend" type="Label" parent="ExpandedPanel/Layout"]
layout_mode = 2
theme_override_colors/font_color = Color(0.58, 0.7, 0.71, 1)
text = "BLEU PROFOND · VERT/JAUNE HAUTEUR · ORANGE ANOMALIE · NOIR INCONNU · TAB FERMER"
@@ -0,0 +1,295 @@
class_name BathymetricSonarRenderer
extends SubViewport
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
const SonarSample = preload("res://game/domain/sonar/sonar_sample.gd")
const SonarScan = preload("res://game/domain/sonar/sonar_scan.gd")
const COLOR_DEEP := Color("173f91")
const COLOR_LOW := Color("168fc1")
const COLOR_MIDDLE := Color("36b979")
const COLOR_HIGH := Color("d7d93d")
const COLOR_PEAK := Color("ee5734")
const COLOR_METAL := Color("ff6b22")
var _current_mesh_instance: MeshInstance3D
var _previous_mesh_instance: MeshInstance3D
var _contact_root: Node3D
var _current_material: StandardMaterial3D
var _previous_material: StandardMaterial3D
var _current_scan_id: StringName = &""
var _previous_scan_id: StringName = &""
var _built_current_rows := 0
func _ready() -> void:
size = Vector2i(960, 540)
render_target_update_mode = SubViewport.UPDATE_ALWAYS
positional_shadow_atlas_size = 1024
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
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
_rebuild_contacts(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 = _build_full_mesh(
previous_scan,
_previous_material
)
_previous_material.albedo_color.a = clampf(previous_opacity, 0.0, 1.0)
func render_texture() -> Texture2D:
return get_texture()
func built_row_count() -> int:
return _built_current_rows
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 = Color("020407")
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color(0.18, 0.31, 0.34)
environment.ambient_light_energy = 0.42
environment_node.environment = environment
add_child(environment_node)
_current_material = _relief_material(false)
_previous_material = _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.size = 116.0
camera.far = 240.0
camera.position = Vector3(0.0, 65.0, -30.0)
add_child(camera)
camera.look_at(Vector3(0.0, -10.0, 60.0), Vector3.UP)
camera.current = true
var key_light := DirectionalLight3D.new()
key_light.rotation_degrees = Vector3(-52.0, -28.0, 0.0)
key_light.light_energy = 1.25
key_light.shadow_enabled = true
add_child(key_light)
var fill_light := DirectionalLight3D.new()
fill_light.rotation_degrees = Vector3(-28.0, 145.0, 18.0)
fill_light.light_energy = 0.38
add_child(fill_light)
func _relief_material(transparent: bool) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.vertex_color_use_as_albedo = true
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.cull_mode = BaseMaterial3D.CULL_DISABLED
if transparent:
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.albedo_color = Color(1.0, 1.0, 1.0, 0.0)
return material
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:
_add_row_band(mesh, scan, _built_current_rows, _current_material)
_built_current_rows += 1
func _build_full_mesh(
scan: SonarScan,
material: StandardMaterial3D
) -> ArrayMesh:
var mesh := ArrayMesh.new()
for row in range(1, scan.samples.rows):
_add_row_band(mesh, scan, row, material)
return mesh
func _add_row_band(
mesh: ArrayMesh,
scan: SonarScan,
row: int,
material: StandardMaterial3D
) -> void:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var colors := PackedColorArray()
for column in range(scan.samples.columns - 1):
var top_left: SonarSample = scan.samples.sample_at(row - 1, column)
var top_right: SonarSample = scan.samples.sample_at(row - 1, column + 1)
var bottom_left: SonarSample = scan.samples.sample_at(row, column)
var bottom_right: SonarSample = scan.samples.sample_at(row, column + 1)
if not (
top_left.observed
and top_right.observed
and bottom_left.observed
and bottom_right.observed
):
continue
_append_triangle(
vertices, normals, colors, scan,
top_left, bottom_left, top_right
)
_append_triangle(
vertices, normals, colors, scan,
top_right, bottom_left, bottom_right
)
if vertices.is_empty():
return
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_COLOR] = colors
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
mesh.surface_set_material(mesh.get_surface_count() - 1, material)
func _append_triangle(
vertices: PackedVector3Array,
normals: PackedVector3Array,
colors: PackedColorArray,
scan: SonarScan,
a: SonarSample,
b: SonarSample,
c: SonarSample
) -> void:
for sample: SonarSample in [a, b, c]:
vertices.append(sample.position_m)
normals.append(sample.normal)
colors.append(_sample_color(scan, sample))
func _sample_color(scan: SonarScan, sample: SonarSample) -> Color:
if sample.material == SonarMaterial.Value.METAL:
return COLOR_METAL
var height := inverse_lerp(
scan.minimum_elevation_m,
scan.maximum_elevation_m,
sample.elevation_m
)
var color: Color
if height <= 0.28:
color = COLOR_DEEP.lerp(COLOR_LOW, height / 0.28)
elif height <= 0.52:
color = COLOR_LOW.lerp(COLOR_MIDDLE, (height - 0.28) / 0.24)
elif height <= 0.76:
color = COLOR_MIDDLE.lerp(COLOR_HIGH, (height - 0.52) / 0.24)
else:
color = COLOR_HIGH.lerp(COLOR_PEAK, (height - 0.76) / 0.24)
var light_direction := Vector3(-0.35, 0.82, -0.45).normalized()
var slope_light := clampf(sample.normal.dot(light_direction), 0.0, 1.0)
var shade := lerpf(0.68, 1.0, slope_light)
var lit_color := color.lerp(Color.WHITE, sample.intensity * 0.08)
return Color(
lit_color.r * shade,
lit_color.g * shade,
lit_color.b * shade,
lit_color.a
)
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)
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.albedo_color = COLOR_METAL
material.emission_enabled = true
material.emission = COLOR_METAL
material.emission_energy_multiplier = 4.0
marker.material_override = material
_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)
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.albedo_color = Color.WHITE
material.emission_enabled = true
material.emission = Color.WHITE
material.emission_energy_multiplier = 2.4
marker.material_override = material
add_child(marker)
@@ -1,8 +1,20 @@
class_name VehicleTestEnvironment
extends Node3D
const TERRAIN_COLUMNS := 61
const TERRAIN_ROWS := 69
const TERRAIN_WIDTH_M := 180.0
const TERRAIN_LENGTH_M := 200.0
const TERRAIN_NEAR_Z := 28.0
func _ready() -> void:
_add_bathymetric_terrain()
_place_calibration_target()
_add_suspended_particles()
func _add_suspended_particles() -> void:
var particles := GPUParticles3D.new()
particles.name = "SuspendedParticles"
particles.position = Vector3(0.0, -34.0, -42.0)
@@ -31,3 +43,111 @@ func _ready() -> void:
particle_mesh.height = 0.07
particles.draw_pass_1 = particle_mesh
add_child(particles)
func _add_bathymetric_terrain() -> void:
var surface := SurfaceTool.new()
surface.begin(Mesh.PRIMITIVE_TRIANGLES)
for row in range(TERRAIN_ROWS - 1):
for column in range(TERRAIN_COLUMNS - 1):
var top_left := _terrain_vertex(row, column)
var top_right := _terrain_vertex(row, column + 1)
var bottom_left := _terrain_vertex(row + 1, column)
var bottom_right := _terrain_vertex(row + 1, column + 1)
_add_terrain_triangle(surface, top_left, top_right, bottom_left)
_add_terrain_triangle(surface, top_right, bottom_right, bottom_left)
surface.generate_normals()
var mesh := surface.commit()
var body := StaticBody3D.new()
body.name = "BathymetricTerrain"
body.set_meta("sonar_material", 1)
body.set_meta("sonar_reflectivity", 0.72)
var mesh_instance := MeshInstance3D.new()
mesh_instance.name = "Mesh"
mesh_instance.mesh = mesh
var material := StandardMaterial3D.new()
material.albedo_color = Color(0.075, 0.19, 0.19)
material.roughness = 0.92
mesh_instance.material_override = material
body.add_child(mesh_instance)
var collision := CollisionShape3D.new()
collision.name = "Collision"
var height_map := HeightMapShape3D.new()
height_map.map_width = TERRAIN_COLUMNS
height_map.map_depth = TERRAIN_ROWS
var height_data := PackedFloat32Array()
height_data.resize(TERRAIN_COLUMNS * TERRAIN_ROWS)
for row in TERRAIN_ROWS:
for column in TERRAIN_COLUMNS:
var vertex := _terrain_vertex(row, column)
height_data[row * TERRAIN_COLUMNS + column] = vertex.y
height_map.map_data = height_data
collision.shape = height_map
collision.position = Vector3(
0.0,
0.0,
TERRAIN_NEAR_Z - TERRAIN_LENGTH_M * 0.5
)
collision.scale = Vector3(
TERRAIN_WIDTH_M / float(TERRAIN_COLUMNS - 1),
1.0,
TERRAIN_LENGTH_M / float(TERRAIN_ROWS - 1)
)
body.add_child(collision)
add_child(body)
func _terrain_vertex(row: int, column: int) -> Vector3:
var x := lerpf(
-TERRAIN_WIDTH_M * 0.5,
TERRAIN_WIDTH_M * 0.5,
float(column) / float(TERRAIN_COLUMNS - 1)
)
var z := TERRAIN_NEAR_Z - (
TERRAIN_LENGTH_M * float(row) / float(TERRAIN_ROWS - 1)
)
return Vector3(x, _terrain_height(x, z), z)
func _terrain_height(x: float, z: float) -> float:
var distance_ratio := clampf(
(TERRAIN_NEAR_Z - z) / TERRAIN_LENGTH_M,
0.0,
1.0
)
var base := lerpf(-40.0, -78.0, distance_ratio)
var left_ridge := 13.0 * exp(
-(pow(x + 24.0, 2.0) / 210.0 + pow(z + 48.0, 2.0) / 980.0)
)
var right_ridge := 15.0 * exp(
-(pow(x - 25.0, 2.0) / 250.0 + pow(z + 58.0, 2.0) / 1150.0)
)
var channel := -8.5 * exp(
-(pow(x, 2.0) / 120.0 + pow(z + 66.0, 2.0) / 2500.0)
)
var rear_rise := 8.0 * exp(
-(pow(x + 5.0, 2.0) / 900.0 + pow(z + 125.0, 2.0) / 620.0)
)
var undulation := 1.8 * sin(x * 0.075) * cos(z * 0.048)
return base + left_ridge + right_ridge + channel + rear_rise + undulation
func _add_terrain_triangle(
surface: SurfaceTool,
a: Vector3,
b: Vector3,
c: Vector3
) -> void:
surface.set_uv(Vector2(a.x, a.z) * 0.02)
surface.add_vertex(a)
surface.set_uv(Vector2(b.x, b.z) * 0.02)
surface.add_vertex(b)
surface.set_uv(Vector2(c.x, c.z) * 0.02)
surface.add_vertex(c)
func _place_calibration_target() -> void:
var target := get_node_or_null("SonarCalibrationTarget") as Node3D
if target != null:
target.position.y = _terrain_height(target.position.x, target.position.z) + 2.5
+24 -81
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=12 format=3]
[gd_scene load_steps=8 format=3]
[ext_resource type="Script" path="res://game/world/sites/vehicle_test_environment.gd" id="1_environment"]
@@ -18,36 +18,21 @@ volumetric_fog_enabled = true
volumetric_fog_density = 0.025
volumetric_fog_length = 72.0
[sub_resource type="StandardMaterial3D" id="RockMaterial"]
albedo_color = Color(0.075, 0.19, 0.19, 1)
roughness = 0.92
[sub_resource type="StandardMaterial3D" id="RockMaterialLight"]
albedo_color = Color(0.105, 0.25, 0.24, 1)
roughness = 0.88
[sub_resource type="BoxMesh" id="SeafloorMesh"]
size = Vector3(220, 8, 250)
[sub_resource type="BoxShape3D" id="SeafloorShape"]
size = Vector3(220, 8, 250)
[sub_resource type="BoxMesh" id="RockMeshA"]
size = Vector3(12, 30, 14)
[sub_resource type="BoxShape3D" id="RockShapeA"]
size = Vector3(12, 30, 14)
[sub_resource type="BoxMesh" id="RockMeshB"]
size = Vector3(18, 42, 16)
[sub_resource type="BoxShape3D" id="RockShapeB"]
size = Vector3(18, 42, 16)
[sub_resource type="SphereMesh" id="BeaconMesh"]
radius = 0.55
height = 1.1
[sub_resource type="StandardMaterial3D" id="MetalTargetMaterial"]
albedo_color = Color(0.24, 0.18, 0.08, 1)
metallic = 0.88
roughness = 0.27
[sub_resource type="BoxMesh" id="MetalTargetMesh"]
size = Vector3(8, 5, 7)
[sub_resource type="BoxShape3D" id="MetalTargetShape"]
size = Vector3(8, 5, 7)
[sub_resource type="StandardMaterial3D" id="BeaconMaterial"]
shading_mode = 0
albedo_color = Color(0.2, 0.78, 0.9, 1)
@@ -67,60 +52,6 @@ light_color = Color(0.39, 0.65, 0.7, 1)
light_energy = 0.38
shadow_enabled = true
[node name="Seafloor" type="StaticBody3D" parent="."]
position = Vector3(0, -84, -58)
[node name="Mesh" type="MeshInstance3D" parent="Seafloor"]
mesh = SubResource("SeafloorMesh")
material_override = SubResource("RockMaterial")
[node name="Collision" type="CollisionShape3D" parent="Seafloor"]
shape = SubResource("SeafloorShape")
[node name="RockNearLeft" type="StaticBody3D" parent="."]
position = Vector3(-10, -18, -18)
rotation_degrees = Vector3(0, -14, 8)
[node name="Mesh" type="MeshInstance3D" parent="RockNearLeft"]
mesh = SubResource("RockMeshA")
material_override = SubResource("RockMaterialLight")
[node name="Collision" type="CollisionShape3D" parent="RockNearLeft"]
shape = SubResource("RockShapeA")
[node name="RockNearRight" type="StaticBody3D" parent="."]
position = Vector3(12, -22, -32)
rotation_degrees = Vector3(0, 18, -5)
[node name="Mesh" type="MeshInstance3D" parent="RockNearRight"]
mesh = SubResource("RockMeshB")
material_override = SubResource("RockMaterial")
[node name="Collision" type="CollisionShape3D" parent="RockNearRight"]
shape = SubResource("RockShapeB")
[node name="RockFarLeft" type="StaticBody3D" parent="."]
position = Vector3(-18, -48, -68)
rotation_degrees = Vector3(0, 9, 4)
[node name="Mesh" type="MeshInstance3D" parent="RockFarLeft"]
mesh = SubResource("RockMeshA")
material_override = SubResource("RockMaterialLight")
[node name="Collision" type="CollisionShape3D" parent="RockFarLeft"]
shape = SubResource("RockShapeA")
[node name="RockFarRight" type="StaticBody3D" parent="."]
position = Vector3(20, -58, -94)
rotation_degrees = Vector3(0, -11, -7)
[node name="Mesh" type="MeshInstance3D" parent="RockFarRight"]
mesh = SubResource("RockMeshB")
material_override = SubResource("RockMaterial")
[node name="Collision" type="CollisionShape3D" parent="RockFarRight"]
shape = SubResource("RockShapeB")
[node name="NavigationBeacon" type="MeshInstance3D" parent="."]
position = Vector3(0, -15, -22)
mesh = SubResource("BeaconMesh")
@@ -130,3 +61,15 @@ material_override = SubResource("BeaconMaterial")
omni_range = 12.0
light_color = Color(0.2, 0.78, 0.9, 1)
light_energy = 4.0
[node name="SonarCalibrationTarget" type="StaticBody3D" parent="."]
position = Vector3(0, -60, -58)
metadata/sonar_material = 2
metadata/sonar_reflectivity = 1.0
[node name="Mesh" type="MeshInstance3D" parent="SonarCalibrationTarget"]
mesh = SubResource("MetalTargetMesh")
material_override = SubResource("MetalTargetMaterial")
[node name="Collision" type="CollisionShape3D" parent="SonarCalibrationTarget"]
shape = SubResource("MetalTargetShape")
@@ -0,0 +1,89 @@
class_name ReferenceBathymetryAcquisition
extends "res://game/application/interfaces/sonar_acquisition_port.gd"
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
const SonarCaptureSample = preload(
"res://game/application/sonar/sonar_capture_sample.gd"
)
const PROBE_HEIGHT_M := 48.0
const PROBE_DEPTH_M := 92.0
const COLLISION_MASK := 1
var _world: World3D
var _excluded_rids: Array[RID] = []
func _init(p_world: World3D, excluded_body: CollisionObject3D = null) -> void:
_world = p_world
if excluded_body != null:
_excluded_rids.append(excluded_body.get_rid())
func capture(request: SonarCaptureRequest) -> SonarCaptureResult:
if _world == null:
return SonarCaptureResult.new(false, [], "sonar world is missing")
var request_error := request.validation_error()
if not request_error.is_empty():
return SonarCaptureResult.new(false, [], request_error)
var samples: Array[SonarCaptureSample] = []
var inverse_sensor := request.sensor_transform.affine_inverse()
for row in request.rows:
var distance_ratio := float(row) / float(request.rows - 1)
var forward_distance := lerpf(
request.minimum_range_m,
request.maximum_range_m,
distance_ratio
)
var half_width := tan(request.horizontal_opening_radians * 0.5) * forward_distance
for column in request.columns:
var lateral_ratio := float(column) / float(request.columns - 1)
var lateral_offset := lerpf(-half_width, half_width, lateral_ratio)
var ray_start := request.sensor_transform * Vector3(
lateral_offset,
PROBE_HEIGHT_M,
forward_distance
)
var ray_end := request.sensor_transform * Vector3(
lateral_offset,
-PROBE_DEPTH_M,
forward_distance
)
var query := PhysicsRayQueryParameters3D.create(
ray_start,
ray_end,
COLLISION_MASK,
_excluded_rids
)
query.hit_back_faces = true
var hit := _world.direct_space_state.intersect_ray(query)
if hit.is_empty():
continue
var world_position: Vector3 = hit["position"]
var world_normal: Vector3 = hit["normal"]
var local_position := inverse_sensor * world_position
var local_normal := (inverse_sensor.basis * world_normal).normalized()
var collider := hit.get("collider") as Object
var material := SonarMaterial.Value.ROCK
var reflectivity := 0.72
if collider != null:
material = int(collider.get_meta("sonar_material", material))
reflectivity = float(
collider.get_meta("sonar_reflectivity", reflectivity)
)
var incidence := clampf(absf(world_normal.dot(Vector3.UP)), 0.18, 1.0)
var attenuation := lerpf(1.0, 0.55, distance_ratio)
var intensity := clampf(incidence * reflectivity * attenuation, 0.0, 1.0)
samples.append(
SonarCaptureSample.new(
row,
column,
local_position,
local_normal,
intensity,
local_position.y,
material
)
)
return SonarCaptureResult.new(true, samples)