Files
Ransom-s-wake/src/sonar_relief_renderer.gd
T

431 lines
13 KiB
GDScript

class_name SonarReliefRenderer
extends SubViewport
const SonarModelScript = preload("res://src/sonar_model.gd")
@export var terrain_skirt_depth := 4.5
var _camera: Camera3D
var _current_mesh: MeshInstance3D
var _previous_mesh: MeshInstance3D
var _current_anomaly: MeshInstance3D
var _previous_anomaly: MeshInstance3D
var _submarine_marker: Node3D
var _current_cells: Array[Dictionary] = []
var _previous_cells: Array[Dictionary] = []
var _last_built_cell_count := -1
func _ready() -> void:
size = Vector2i(960, 540)
disable_3d = false
render_target_update_mode = SubViewport.UPDATE_ALWAYS
world_3d = World3D.new()
_build_world()
func start_scan() -> void:
_previous_cells = _current_cells.duplicate(true)
_current_cells.clear()
_last_built_cell_count = -1
_rebuild_terrain(_previous_mesh, _previous_cells)
_rebuild_terrain(_current_mesh, _current_cells)
_update_anomaly(_previous_anomaly, _previous_cells)
_update_anomaly(_current_anomaly, _current_cells)
_previous_mesh.transparency = 0.48 if not _previous_cells.is_empty() else 1.0
_previous_anomaly.transparency = _previous_mesh.transparency
func update_scan(
current_cells: Array[Dictionary],
previous_cells: Array[Dictionary],
progress: float
) -> void:
_current_cells = current_cells.duplicate(true)
_previous_cells = previous_cells.duplicate(true)
if _current_cells.size() != _last_built_cell_count:
_last_built_cell_count = _current_cells.size()
_rebuild_terrain(_current_mesh, _current_cells)
_update_anomaly(_current_anomaly, _current_cells)
if _previous_mesh.mesh == null and not _previous_cells.is_empty():
_rebuild_terrain(_previous_mesh, _previous_cells)
_update_anomaly(_previous_anomaly, _previous_cells)
var previous_alpha := lerpf(0.48, 1.0, clampf(progress, 0.0, 1.0))
_previous_mesh.transparency = previous_alpha
_previous_anomaly.transparency = previous_alpha
func finish_scan(current_cells: Array[Dictionary]) -> void:
_current_cells = current_cells.duplicate(true)
_last_built_cell_count = _current_cells.size()
_rebuild_terrain(_current_mesh, _current_cells)
_update_anomaly(_current_anomaly, _current_cells)
_previous_cells.clear()
_previous_mesh.mesh = null
_previous_mesh.transparency = 1.0
_previous_anomaly.visible = false
_previous_anomaly.transparency = 1.0
func update_submarine_pose(submarine_transform: Transform3D) -> void:
if _camera == null:
return
var forward := -submarine_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
var camera_position := submarine_transform.origin - forward * 30.0 + Vector3.UP * 65.0
var focus := submarine_transform.origin + forward * 43.0 + Vector3.DOWN * 8.0
_camera.global_position = camera_position
_camera.look_at(focus, Vector3.UP)
_submarine_marker.global_position = submarine_transform.origin
_submarine_marker.rotation = Vector3(0.0, submarine_transform.basis.get_euler().y, 0.0)
func current_face_count() -> int:
if _current_mesh.mesh == null:
return 0
return int(_current_mesh.mesh.get_faces().size() / 3)
func _build_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("8fc9d1")
environment.ambient_light_energy = 0.42
environment.tonemap_mode = Environment.TONE_MAPPER_FILMIC
environment_node.environment = environment
add_child(environment_node)
_previous_mesh = _make_terrain_instance("PreviousBathymetry")
_current_mesh = _make_terrain_instance("CurrentBathymetry")
_previous_anomaly = _make_anomaly_instance("PreviousAnomaly")
_current_anomaly = _make_anomaly_instance("CurrentAnomaly")
var key_light := DirectionalLight3D.new()
key_light.name = "ReliefKeyLight"
key_light.rotation_degrees = Vector3(-58.0, -34.0, 0.0)
key_light.light_color = Color("d8fbff")
key_light.light_energy = 1.25
key_light.shadow_enabled = true
add_child(key_light)
var fill_light := DirectionalLight3D.new()
fill_light.name = "ReliefFillLight"
fill_light.rotation_degrees = Vector3(-35.0, 145.0, 0.0)
fill_light.light_color = Color("3d86ad")
fill_light.light_energy = 0.38
add_child(fill_light)
_camera = Camera3D.new()
_camera.name = "BathymetryCamera"
_camera.projection = Camera3D.PROJECTION_ORTHOGONAL
_camera.size = 116.0
_camera.far = 240.0
_camera.current = true
add_child(_camera)
_build_submarine_marker()
func _make_terrain_instance(node_name: String) -> MeshInstance3D:
var instance := MeshInstance3D.new()
instance.name = node_name
var material := StandardMaterial3D.new()
material.vertex_color_use_as_albedo = true
material.roughness = 0.84
material.metallic = 0.0
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
# The scan may be inspected while the submarine pitches or passes above it.
# Rendering both sides keeps the reconstructed sheet readable from every
# permitted sonar-camera angle.
material.cull_mode = BaseMaterial3D.CULL_DISABLED
instance.material_override = material
add_child(instance)
return instance
func _make_anomaly_instance(node_name: String) -> MeshInstance3D:
var instance := MeshInstance3D.new()
instance.name = node_name
var crate := BoxMesh.new()
crate.size = Vector3(8.0, 4.5, 8.0)
instance.mesh = crate
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.albedo_color = Color("ff9d1f")
material.emission_enabled = true
material.emission = Color("ff6800")
material.emission_energy_multiplier = 4.0
material.roughness = 0.32
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
instance.material_override = material
instance.visible = false
add_child(instance)
return instance
func _build_submarine_marker() -> void:
_submarine_marker = Node3D.new()
_submarine_marker.name = "SubmarineReference"
add_child(_submarine_marker)
var body := MeshInstance3D.new()
var body_mesh := CapsuleMesh.new()
body_mesh.radius = 1.15
body_mesh.height = 5.8
body_mesh.radial_segments = 16
body_mesh.rings = 6
body.mesh = body_mesh
body.rotation_degrees.x = 90.0
var body_material := StandardMaterial3D.new()
body_material.albedo_color = Color("eaffff")
body_material.emission_enabled = true
body_material.emission = Color("a7f8ff")
body_material.emission_energy_multiplier = 1.45
body.material_override = body_material
_submarine_marker.add_child(body)
var nose := MeshInstance3D.new()
var nose_mesh := CylinderMesh.new()
nose_mesh.top_radius = 0.0
nose_mesh.bottom_radius = 0.72
nose_mesh.height = 1.6
nose_mesh.radial_segments = 16
nose.mesh = nose_mesh
nose.position.z = -3.5
nose.rotation_degrees.x = -90.0
nose.material_override = body_material
_submarine_marker.add_child(nose)
func _rebuild_terrain(instance: MeshInstance3D, cells: Array[Dictionary]) -> void:
var samples: Dictionary = {}
for cell: Dictionary in cells:
if String(cell.get("sample_type", "bathymetry")) != "bathymetry":
continue
samples[Vector2i(int(cell["column"]), int(cell["row"]))] = cell
if samples.size() < 4:
instance.mesh = null
return
var keys: Array[Vector2i] = []
for key: Vector2i in samples:
keys.append(key)
keys.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
return a.y < b.y or (a.y == b.y and a.x < b.x)
)
var vertices: Array[Vector3] = []
var colors: Array[Color] = []
var normals: Array[Vector3] = []
var indices: Array[int] = []
var vertex_for_key: Dictionary = {}
for key: Vector2i in keys:
var cell: Dictionary = samples[key]
vertex_for_key[key] = vertices.size()
vertices.append(cell["position"])
colors.append(_bathymetry_color(cell))
normals.append(Vector3.ZERO)
for key: Vector2i in keys:
var right_key := key + Vector2i.RIGHT
var far_key := key + Vector2i.DOWN
var far_right_key := key + Vector2i(1, 1)
if not (
vertex_for_key.has(right_key)
and vertex_for_key.has(far_key)
and vertex_for_key.has(far_right_key)
):
continue
_append_top_triangle(
int(vertex_for_key[key]),
int(vertex_for_key[right_key]),
int(vertex_for_key[far_key]),
vertices,
normals,
indices
)
_append_top_triangle(
int(vertex_for_key[right_key]),
int(vertex_for_key[far_right_key]),
int(vertex_for_key[far_key]),
vertices,
normals,
indices
)
if indices.is_empty():
instance.mesh = null
return
for normal_index in normals.size():
normals[normal_index] = normals[normal_index].normalized()
_append_terrain_skirt(samples, vertices, normals, colors, indices)
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = PackedVector3Array(vertices)
arrays[Mesh.ARRAY_NORMAL] = PackedVector3Array(normals)
arrays[Mesh.ARRAY_COLOR] = PackedColorArray(colors)
arrays[Mesh.ARRAY_INDEX] = PackedInt32Array(indices)
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
instance.mesh = mesh
func _append_top_triangle(
first: int,
second: int,
third: int,
vertices: Array[Vector3],
normals: Array[Vector3],
indices: Array[int]
) -> void:
var face_normal := (vertices[second] - vertices[first]).cross(
vertices[third] - vertices[first]
).normalized()
if face_normal.y < 0.0:
var swapped := second
second = third
third = swapped
face_normal = -face_normal
indices.append_array([first, second, third])
normals[first] += face_normal
normals[second] += face_normal
normals[third] += face_normal
func _append_terrain_skirt(
samples: Dictionary,
vertices: Array[Vector3],
normals: Array[Vector3],
colors: Array[Color],
indices: Array[int]
) -> void:
var row_bounds := _sample_bounds(samples)
if row_bounds.is_empty():
return
var minimum_row: int = row_bounds["min_row"]
var maximum_row: int = row_bounds["max_row"]
var minimum_column: int = row_bounds["min_column"]
var maximum_column: int = row_bounds["max_column"]
for column in range(minimum_column, maximum_column):
_append_skirt_between(
samples,
Vector2i(column, minimum_row),
Vector2i(column + 1, minimum_row),
vertices,
normals,
colors,
indices
)
_append_skirt_between(
samples,
Vector2i(column + 1, maximum_row),
Vector2i(column, maximum_row),
vertices,
normals,
colors,
indices
)
for row in range(minimum_row, maximum_row):
_append_skirt_between(
samples,
Vector2i(minimum_column, row + 1),
Vector2i(minimum_column, row),
vertices,
normals,
colors,
indices
)
_append_skirt_between(
samples,
Vector2i(maximum_column, row),
Vector2i(maximum_column, row + 1),
vertices,
normals,
colors,
indices
)
func _append_skirt_between(
samples: Dictionary,
first_key: Vector2i,
second_key: Vector2i,
vertices: Array[Vector3],
normals: Array[Vector3],
colors: Array[Color],
indices: Array[int]
) -> void:
if not samples.has(first_key) or not samples.has(second_key):
return
var first: Vector3 = samples[first_key]["position"]
var second: Vector3 = samples[second_key]["position"]
var first_bottom := first + Vector3.DOWN * terrain_skirt_depth
var second_bottom := second + Vector3.DOWN * terrain_skirt_depth
var side_normal := (second - first).cross(first_bottom - first).normalized()
var base_index := vertices.size()
vertices.append_array([first, second, first_bottom, second_bottom])
normals.append_array([side_normal, side_normal, side_normal, side_normal])
var side_color := Color("152a32")
colors.append_array([side_color, side_color, side_color, side_color])
indices.append_array([
base_index,
base_index + 1,
base_index + 2,
base_index + 1,
base_index + 3,
base_index + 2,
])
func _sample_bounds(samples: Dictionary) -> Dictionary:
var minimum_row := 1 << 30
var maximum_row := -(1 << 30)
var minimum_column := 1 << 30
var maximum_column := -(1 << 30)
for key: Vector2i in samples:
minimum_row = mini(minimum_row, key.y)
maximum_row = maxi(maximum_row, key.y)
minimum_column = mini(minimum_column, key.x)
maximum_column = maxi(maximum_column, key.x)
return {
"min_row": minimum_row,
"max_row": maximum_row,
"min_column": minimum_column,
"max_column": maximum_column,
}
func _bathymetry_color(cell: Dictionary) -> Color:
if int(cell["material"]) == SonarModelScript.MaterialCategory.METAL:
return Color("ff6b22")
var elevation := clampf(float(cell.get("elevation", 0.0)), 0.0, 1.0)
var color: Color
if elevation < 0.28:
color = Color("173f91").lerp(Color("168fc1"), elevation / 0.28)
elif elevation < 0.52:
color = Color("168fc1").lerp(Color("36b979"), (elevation - 0.28) / 0.24)
elif elevation < 0.76:
color = Color("36b979").lerp(Color("d7d93d"), (elevation - 0.52) / 0.24)
else:
color = Color("d7d93d").lerp(Color("ee5734"), (elevation - 0.76) / 0.24)
var return_strength := clampf(float(cell.get("intensity", 0.5)), 0.0, 1.0)
return color.lerp(Color.WHITE, return_strength * 0.08)
func _update_anomaly(instance: MeshInstance3D, cells: Array[Dictionary]) -> void:
var position_sum := Vector3.ZERO
var anomaly_count := 0
for cell: Dictionary in cells:
if int(cell.get("material", SonarModelScript.MaterialCategory.ROCK)) != SonarModelScript.MaterialCategory.METAL:
continue
position_sum += cell["position"]
anomaly_count += 1
instance.visible = anomaly_count > 0
if anomaly_count > 0:
instance.global_position = position_sum / float(anomaly_count) + Vector3.UP * 1.4