Files
portal/emission_point.gd
T
2026-06-17 15:28:56 -07:00

879 lines
28 KiB
GDScript

extends Node3D
const TILE_SCENE := preload("res://tile.tscn")
const NINE_PATCH_GRID_SPACING := 1.1
enum PresentationFormat {
HEMISPHERE = 0,
WRAPAROUND,
NINE_PATCH,
FULL_SPHERE,
RING_OF_RINGS,
SPIRAL_STAIRCASE
}
var shared_viewport: SubViewport
var shared_player: VideoStreamPlayer
var viewer_camera: Node3D
var is_initialized := false
var rng := RandomNumberGenerator.new()
var shell_time := 0.0
var slot_directions: Array[Vector3] = []
var slot_phase_offsets: Array[float] = []
var slot_tiles: Array[Tile] = []
# --- format selection ---
@export var presentation_format: PresentationFormat = PresentationFormat.HEMISPHERE:
get:
return _current_format
set(val):
# Guard: only start a transition if the value is different from
# what is currently displayed (_current_format).
if val == _current_format and _format_transition_progress >= 1.0:
return
_set_presentation_format(val)
# --- common params ---
@export var slot_limit := 32
@export var viewport_size: Vector2i = Vector2i(840, 420)
@export var video_stream: VideoStream
@export_range(1.0, 24.0, 0.1) var base_radius := 4.8
@export_range(0.0, 8.0, 0.05) var pulse_amplitude := 0.7
@export_range(0.01, 1.0, 0.01) var pulse_speed_hz := 0.09
@export_range(60.0, 180.0, 1.0) var hemisphere_coverage_degrees := 160.0
@export_range(3, 48, 1) var sphere_rings := 14
@export_range(6, 96, 1) var sphere_segments := 24
@export_range(0.5, 2.0, 0.05) var tile_scale_min := 0.9
@export_range(0.5, 2.0, 0.05) var tile_scale_max := 1.2
@export var deterministic_seed := -1
@export_range(0.1, 2.0, 0.05) var transition_duration_sec := 0.6
# --- wraparound params ---
@export_group("Wraparound")
@export_range(1.0, 20.0, 0.1) var wraparound_radius := 6.0
@export_range(0.0, 1.0, 0.001) var wraparound_rotation_speed_rad_s := 0.08
# --- 9-patch params ---
@export_group("Nine-Patch")
@export_range(1.0, 20.0, 0.1) var nine_patch_radius := 8.0
@export_range(0.0, 2.0, 0.01) var nine_patch_wobble_speed_hz := 0.15
@export_range(0.0, 1.0, 0.01) var nine_patch_wobble_amplitude := 0.12
@export_range(0.0, 3.0, 0.05) var nine_patch_phase_spread := 1.5
@export_range(0.0, 10.0, 0.1) var nine_patch_orientation_lag_sec := 3.0
# --- full sphere params ---
@export_group("Full Sphere")
@export_range(1.0, 20.0, 0.1) var full_sphere_radius := 8.0
@export var full_sphere_use_fibonacci := true
# --- ring of rings params ---
@export_group("Ring of Rings")
@export_range(1.0, 15.0, 0.1) var ring_of_rings_inner_radius := 3.5
@export_range(1.0, 15.0, 0.1) var ring_of_rings_middle_radius := 6.5
@export_range(1.0, 15.0, 0.1) var ring_of_rings_outer_radius := 10.0
@export_range(0.0, 1.0, 0.001) var ring_of_rings_inner_speed_rad_s := 0.15
@export_range(0.0, 1.0, 0.001) var ring_of_rings_middle_speed_rad_s := 0.08
@export_range(0.0, 1.0, 0.001) var ring_of_rings_outer_speed_rad_s := 0.03
@export_range(0.0, 2.0, 0.01) var ring_of_rings_vertical_wobble := 0.0
# --- spiral staircase params ---
@export_group("Spiral Staircase")
@export_range(1.0, 20.0, 0.1) var spiral_staircase_radius := 5.0
@export_range(0.5, 10.0, 0.1) var spiral_staircase_height := 6.0
@export_range(1.0, 20.0, 0.5) var spiral_staircase_turns := 5.0
@export_range(0.0, 0.5, 0.001) var spiral_staircase_rotation_speed_rad_s := 0.04
@export_range(0.0, 2.0, 0.01) var spiral_staircase_wobble_amplitude := 0.08
# --- format transition state ---
var _current_format: PresentationFormat = PresentationFormat.HEMISPHERE
var _target_format: PresentationFormat = PresentationFormat.HEMISPHERE
var _format_transition_progress: float = 1.0
var _transition_start_positions := PackedVector3Array()
var _transition_target_positions := PackedVector3Array()
# cached hemisphere data so we can restore it
var _cached_hemisphere_directions: Array[Vector3] = []
var _cached_hemisphere_phase_offsets: Array[float] = []
# ring assignment for ring_of_rings
var _slot_ring_assignment: Array[int] = []
# per-format rotation accumulators
var _wraparound_accumulator: float = 0.0
var _ring_accumulators := [0.0, 0.0, 0.0]
var _spiral_accumulator: float = 0.0
# --- nine-patch orientation lag ---
var _nine_patch_lagged_basis := Basis.IDENTITY
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
if deterministic_seed >= 0:
rng.seed = deterministic_seed
set_process(true)
_current_format = presentation_format
func _exit_tree() -> void:
if shared_player != null:
shared_player.paused = true
shared_player.stop()
func set_viewer_camera(camera: Node3D) -> void:
viewer_camera = camera
func initialize_video_pool() -> void:
if is_initialized:
return
if slot_limit < 1:
slot_limit = 1
if tile_scale_max < tile_scale_min:
var scale_swap: float = tile_scale_min
tile_scale_min = tile_scale_max
tile_scale_max = scale_swap
shared_viewport = SubViewport.new()
shared_viewport.name = "SubViewport_shared"
shared_viewport.size = viewport_size
shared_viewport.disable_3d = true
shared_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
add_child(shared_viewport)
shared_player = VideoStreamPlayer.new()
shared_player.name = "VideoStreamPlayer_shared"
shared_player.stream = _create_unique_stream_instance()
shared_player.expand = true
shared_player.loop = true
shared_player.size = Vector2(viewport_size)
shared_viewport.add_child(shared_player)
shared_player.play()
var shared_texture := shared_viewport.get_texture()
_compute_slots_for_format(_current_format)
slot_tiles.resize(slot_directions.size())
for i in range(slot_directions.size()):
var tile: Tile = TILE_SCENE.instantiate()
tile.set_display_texture(shared_texture)
tile.set_phase_offset(slot_phase_offsets[i])
tile.set_scale_multiplier(rng.randf_range(tile_scale_min, tile_scale_max))
slot_tiles[i] = tile
add_child(tile)
is_initialized = true
_update_shell(0.0)
func _process(delta: float) -> void:
if not is_initialized:
return
if viewer_camera == null:
viewer_camera = get_viewport().get_camera_3d()
if viewer_camera == null:
return
_update_orientation_lag(delta)
shell_time += delta
_update_shell(shell_time)
# ----------------------------------------------------------------
# Presentation format switching
# ----------------------------------------------------------------
func _set_presentation_format(new_format: PresentationFormat) -> void:
if new_format == _target_format:
return
if _current_format == PresentationFormat.HEMISPHERE:
_cached_hemisphere_directions = slot_directions.duplicate()
_cached_hemisphere_phase_offsets = slot_phase_offsets.duplicate()
_transition_start_positions.resize(slot_tiles.size())
for i in range(slot_tiles.size()):
if slot_tiles[i] != null:
_transition_start_positions[i] = slot_tiles[i].global_position
else:
_transition_start_positions[i] = Vector3.ZERO
_target_format = new_format
_compute_slots_for_format(new_format)
_transition_target_positions.resize(slot_tiles.size())
for i in range(slot_tiles.size()):
if i < slot_directions.size():
_transition_target_positions[i] = _compute_slot_world_position(i)
else:
_transition_target_positions[i] = Vector3.INF
_wraparound_accumulator = 0.0
_ring_accumulators = [0.0, 0.0, 0.0]
_spiral_accumulator = 0.0
_format_transition_progress = 0.0
# Cycle to the next presentation format.
func cycle_format() -> void:
if _format_transition_progress < 1.0:
return
var next: int = int(presentation_format) + 1
if next > int(PresentationFormat.SPIRAL_STAIRCASE):
next = 0
presentation_format = next as PresentationFormat
# ----------------------------------------------------------------
# Slot computation
# ----------------------------------------------------------------
func _compute_slots_for_format(format: PresentationFormat) -> void:
slot_directions.clear()
slot_phase_offsets.clear()
_slot_ring_assignment.clear()
var slot_count: int = \
slot_tiles.size() if slot_tiles.size() > 0 else slot_limit
match format:
PresentationFormat.HEMISPHERE:
_compute_slots_hemisphere()
PresentationFormat.WRAPAROUND:
_compute_slots_wraparound(slot_count)
PresentationFormat.NINE_PATCH:
_compute_slots_nine_patch(slot_count)
PresentationFormat.FULL_SPHERE:
_compute_slots_full_sphere(slot_count)
PresentationFormat.RING_OF_RINGS:
_compute_slots_ring_of_rings(slot_count)
PresentationFormat.SPIRAL_STAIRCASE:
_compute_slots_spiral_staircase(slot_count)
func _compute_slots_hemisphere() -> void:
if not _cached_hemisphere_directions.is_empty():
slot_directions = _cached_hemisphere_directions.duplicate()
slot_phase_offsets = _cached_hemisphere_phase_offsets.duplicate()
return
var face_directions := _sample_sphere_face_directions()
if face_directions.is_empty():
face_directions = _fallback_uv_directions()
var forward := _get_view_forward_axis()
var half_angle := deg_to_rad(hemisphere_coverage_degrees * 0.5)
var min_dot := cos(half_angle)
var filtered: Array[Vector3] = []
for dir in face_directions:
if dir.dot(forward) >= min_dot:
filtered.push_back(dir)
if filtered.is_empty():
filtered = face_directions
if filtered.size() > slot_limit:
var downsampled: Array[Vector3] = []
var step := float(filtered.size()) / float(slot_limit)
for i in range(slot_limit):
var idx := int(floor(float(i) * step))
downsampled.push_back(
filtered[clampi(idx, 0, filtered.size() - 1)]
)
filtered = downsampled
for dir in filtered:
slot_directions.push_back(dir)
slot_phase_offsets.push_back(rng.randf_range(0.0, TAU))
func _compute_slots_wraparound(slot_count: int) -> void:
for i in range(slot_count):
var angle: float = TAU * float(i) / float(slot_count)
slot_directions.push_back(Vector3(cos(angle), 0.0, sin(angle)))
slot_phase_offsets.push_back(rng.randf_range(0.0, TAU))
func _compute_slots_nine_patch(slot_count: int) -> void:
for i in range(slot_count):
var cell_index: int = i % 9
var row: int = int(float(cell_index) / 3.0)
var col: int = cell_index % 3
var x: float = float(col - 1)
var z: float = float(row - 1)
slot_directions.push_back(Vector3(x, 0.0, z).normalized())
slot_phase_offsets.push_back(
float(cell_index) / 9.0 * nine_patch_phase_spread
)
func _compute_slots_full_sphere(slot_count: int) -> void:
if full_sphere_use_fibonacci:
var golden_angle: float = PI * (3.0 - sqrt(5.0))
for i in range(slot_count):
var y: float = 1.0 - (2.0 * float(i) + 1.0) \
/ float(slot_count)
var radius_at_y: float = sqrt(maxf(0.0, 1.0 - y * y))
var theta: float = golden_angle * float(i)
slot_directions.push_back(
Vector3(cos(theta) * radius_at_y,
y, sin(theta) * radius_at_y)
)
slot_phase_offsets.push_back(rng.randf_range(0.0, TAU))
else:
var rings: float = max(1, int(sqrt(float(slot_count))))
var per_ring: float = slot_count / rings
var idx: int = 0
for r in range(rings):
var theta: float = PI * float(r + 0.5) / float(rings)
var slots_in_ring: int
if r < rings - 1:
slots_in_ring = int(per_ring)
else:
slots_in_ring = slot_count - idx
var ring_radius: float = sin(theta)
if ring_radius < 0.001:
if r < rings / 2:
slot_directions.push_back(Vector3.UP)
else:
slot_directions.push_back(-Vector3.UP)
slot_phase_offsets.push_back(rng.randf_range(0.0, TAU))
idx += 1
continue
for s in range(slots_in_ring):
var phi: float = TAU * float(s) / float(slots_in_ring)
slot_directions.push_back(
Vector3(cos(phi) * ring_radius,
cos(theta), sin(phi) * ring_radius)
)
slot_phase_offsets.push_back(rng.randf_range(0.0, TAU))
idx += 1
func _compute_slots_ring_of_rings(slot_count: int) -> void:
var inner_count: int = max(1, int(float(slot_count) / 4.0))
var middle_count: int = max(1, int(float(slot_count) / 2.0))
var outer_count: int = slot_count - inner_count - middle_count
if inner_count < 1:
inner_count = 1
if middle_count < 1:
middle_count = 1
outer_count = slot_count - inner_count - middle_count
_slot_ring_assignment.resize(slot_count)
var idx: int = 0
for ring in range(3):
var count: int
match ring:
0: count = inner_count
1: count = middle_count
2: count = max(1, outer_count)
for s in range(count):
if idx >= slot_count:
break
var angle: float = TAU * float(s) / float(count)
slot_directions.push_back(
Vector3(cos(angle), 0.0, sin(angle))
)
slot_phase_offsets.push_back(rng.randf_range(0.0, TAU))
_slot_ring_assignment[idx] = ring
idx += 1
while _slot_ring_assignment.size() < idx:
_slot_ring_assignment.push_back(ring)
func _compute_slots_spiral_staircase(slot_count: int) -> void:
for i in range(slot_count):
var t: float = float(i) / max(1, float(slot_count - 1))
var angle: float = t * TAU * spiral_staircase_turns
slot_directions.push_back(
Vector3(cos(angle), 0.0, sin(angle))
)
slot_phase_offsets.push_back(t * TAU * 2.0)
# ----------------------------------------------------------------
# World position computation
# ----------------------------------------------------------------
func _compute_slot_world_position(index: int) -> Vector3:
var center := viewer_camera.global_position
match _target_format:
PresentationFormat.HEMISPHERE:
var dir: Vector3 = Vector3.FORWARD
if index < slot_directions.size():
dir = slot_directions[index]
var radius: float = base_radius + pulse_amplitude * \
sin(shell_time * TAU * pulse_speed_hz)
return center + dir * max(radius, 0.2)
PresentationFormat.WRAPAROUND:
var angle: float = TAU * float(index) / \
float(slot_directions.size()) + _wraparound_accumulator
var dir: Vector3 = Vector3(cos(angle), 0.0, sin(angle))
var radius: float = base_radius + pulse_amplitude * \
sin(shell_time * TAU * pulse_speed_hz)
return center + dir * max(radius, 0.2)
PresentationFormat.NINE_PATCH:
var cell_index: int = index % 9
var layer: int = int(float(index) / 9.0)
var row: int = int(float(cell_index) / 3.0)
var col: int = cell_index % 3
var cam_basis: Basis = _nine_patch_lagged_basis
var spacing: float = NINE_PATCH_GRID_SPACING \
* nine_patch_radius
var layer_depth_step: float = nine_patch_radius * 0.35
var offset: Vector3 = cam_basis.x * (
float(col - 1) * spacing
) + cam_basis.y * (
float(row - 1) * spacing * 0.5625
) - cam_basis.z * (
nine_patch_radius + float(layer) * layer_depth_step
)
return center + offset
PresentationFormat.FULL_SPHERE:
var dir: Vector3 = Vector3.FORWARD
if index < slot_directions.size():
dir = slot_directions[index]
var radius: float = full_sphere_radius + pulse_amplitude * \
sin(shell_time * TAU * pulse_speed_hz)
return center + dir * max(radius, 0.2)
PresentationFormat.RING_OF_RINGS:
var ring: int = 2
if index < _slot_ring_assignment.size():
ring = _slot_ring_assignment[index]
var ring_count: int
match ring:
0: ring_count = max(1, int(float(slot_limit) / 4.0))
1: ring_count = max(1, int(float(slot_limit) / 2.0))
2: ring_count = slot_limit - ring_count
var ring_radius: float
match ring:
0: ring_radius = ring_of_rings_inner_radius
1: ring_radius = ring_of_rings_middle_radius
2: ring_radius = ring_of_rings_outer_radius
var s: int = index - ring_count if ring == 0 else \
index - (int(float(slot_limit) / 4.0)
+ int(float(slot_limit) / 2.0))
var angle: float = TAU * float(s) / float(ring_count) + \
_ring_accumulators[ring]
var y_drift: float = sin(
shell_time * TAU * 0.2
) * ring_of_rings_vertical_wobble
var dir: Vector3 = Vector3(
cos(angle), y_drift, sin(angle)
).normalized()
return center + dir * ring_radius
PresentationFormat.SPIRAL_STAIRCASE:
var i: int = index
var t: float = float(i) / max(1, float(slot_limit - 1))
var angle: float = t * TAU * spiral_staircase_turns + \
_spiral_accumulator
var height: float = (t - 0.5) * spiral_staircase_height
var drift: float = sin(
shell_time * TAU * 0.3 + t * TAU * 2
) * spiral_staircase_wobble_amplitude
return center + Vector3(
cos(angle) * spiral_staircase_radius,
height + drift,
sin(angle) * spiral_staircase_radius
)
return center
# ----------------------------------------------------------------
# Per-frame animation dispatch
# ----------------------------------------------------------------
func _update_shell(time_sec: float) -> void:
if _format_transition_progress < 1.0:
_handle_transition(time_sec)
return
var center := viewer_camera.global_position
match _current_format:
PresentationFormat.HEMISPHERE:
_update_hemisphere(time_sec, center)
PresentationFormat.WRAPAROUND:
_update_wraparound(time_sec, center)
PresentationFormat.NINE_PATCH:
_update_nine_patch(time_sec, center)
PresentationFormat.FULL_SPHERE:
_update_full_sphere(time_sec, center)
PresentationFormat.RING_OF_RINGS:
_update_ring_of_rings(time_sec, center)
PresentationFormat.SPIRAL_STAIRCASE:
_update_spiral_staircase(time_sec, center)
# ----------------------------------------------------------------
# Transition system (two-phase lerp)
# ----------------------------------------------------------------
func _handle_transition(_time_sec: float) -> void:
_format_transition_progress += \
get_process_delta_time() / transition_duration_sec
if _format_transition_progress >= 1.0:
_format_transition_progress = 1.0
_current_format = _target_format
for i in range(slot_tiles.size()):
if slot_tiles[i] != null \
and i < _transition_target_positions.size():
slot_tiles[i].global_position = \
_transition_target_positions[i]
for i in range(slot_tiles.size()):
if slot_tiles[i] != null:
slot_tiles[i].visible = true
return
var t: float = _format_transition_progress
var lerp_t: float = smoothstep(0.0, 1.0, t)
for i in range(slot_tiles.size()):
if slot_tiles[i] == null:
continue
if i < _transition_target_positions.size() \
and _transition_target_positions[i] != Vector3.INF:
var target: Vector3 = _transition_target_positions[i]
slot_tiles[i].global_position = \
_transition_start_positions[i].lerp(target, lerp_t)
var fade: float = smoothstep(0.5, 1.0, t)
var tile: Tile = slot_tiles[i]
if fade < 1.0:
var current_scale := tile.mesh_instance.scale
var denom: float = tile.scale_multiplier * (
1.0 + sin(shell_time * TAU
* tile.pulse_frequency_hz)
* tile.pulse_strength
)
if denom > 0.0001:
var base_scale: Vector3 = current_scale / denom
tile.mesh_instance.scale = base_scale * (
1.0 + fade * 0.05
)
# ----------------------------------------------------------------
# Hemisphere (existing)
# ----------------------------------------------------------------
func _update_hemisphere(time_sec: float, center: Vector3) -> void:
var base_phase := time_sec * TAU * pulse_speed_hz
for i in range(slot_tiles.size()):
var tile: Tile = slot_tiles[i]
if tile == null:
continue
var slot_phase: float = slot_phase_offsets[i]
var slot_radius: float = base_radius + pulse_amplitude * \
sin(base_phase + slot_phase * 0.35)
tile.place_on_shell(
center, slot_directions[i],
max(slot_radius, 0.2), viewer_camera
)
tile.visible = true
# ---------------------------------------------------------------
# Wraparound
# ----------------------------------------------------------------
func _update_wraparound(time_sec: float, center: Vector3) -> void:
_wraparound_accumulator += \
wraparound_rotation_speed_rad_s * get_process_delta_time()
var base_phase := time_sec * TAU * pulse_speed_hz
for i in range(slot_tiles.size()):
var tile: Tile = slot_tiles[i]
if tile == null:
continue
var angle: float = TAU * float(i) / \
float(slot_directions.size()) + _wraparound_accumulator
var dir: Vector3 = Vector3(cos(angle), 0.0, sin(angle))
var radius: float = base_radius + pulse_amplitude * \
sin(base_phase + slot_phase_offsets[i] * 0.35)
tile.global_position = center + dir * max(radius, 0.2)
tile.viewer_camera = viewer_camera
tile.visible = true
# ----------------------------------------------------------------
# Nine-patch orientation lag
# ----------------------------------------------------------------
func _update_orientation_lag(delta: float) -> void:
var target_basis: Basis = viewer_camera. \
global_transform.basis.orthonormalized()
if nine_patch_orientation_lag_sec <= 0.0:
_nine_patch_lagged_basis = target_basis
return
var factor: float = 1.0 - exp(
-delta / nine_patch_orientation_lag_sec
)
_nine_patch_lagged_basis.x = _nine_patch_lagged_basis.x.lerp(
target_basis.x, factor)
_nine_patch_lagged_basis.y = _nine_patch_lagged_basis.y.lerp(
target_basis.y, factor)
_nine_patch_lagged_basis.z = _nine_patch_lagged_basis.z.lerp(
target_basis.z, factor)
# ----------------------------------------------------------------
# Nine-patch
# ----------------------------------------------------------------
func _update_nine_patch(time_sec: float, center: Vector3) -> void:
var cam_basis: Basis = _nine_patch_lagged_basis
var spacing: float = NINE_PATCH_GRID_SPACING * nine_patch_radius
var layer_depth_step: float = nine_patch_radius * 0.35
var wobble_phase: float = time_sec * TAU * nine_patch_wobble_speed_hz
for i in range(slot_tiles.size()):
var tile: Tile = slot_tiles[i]
if tile == null:
continue
var cell_index: int = i % 9
var layer: int = int(float(i) / 9.0)
var row: int = int(float(cell_index) / 3.0)
var col: int = cell_index % 3
var offset: Vector3 = cam_basis.x * (
float(col - 1) * spacing
) + cam_basis.y * (
float(row - 1) * spacing * 0.5625
) - cam_basis.z * (
nine_patch_radius + float(layer) * layer_depth_step
)
tile.visible = true
tile.global_position = center + offset
var tile_wobble: float = sin(wobble_phase + \
float(cell_index) * nine_patch_phase_spread / 9.0) * \
nine_patch_wobble_amplitude
var base: Vector3 = tile.base_scale * tile.scale_multiplier
tile.mesh_instance.scale = base * (1.0 + tile_wobble)
tile.viewer_camera = viewer_camera
# ----------------------------------------------------------------
# Full sphere
# ----------------------------------------------------------------
func _update_full_sphere(time_sec: float, center: Vector3) -> void:
var base_phase := time_sec * TAU * pulse_speed_hz
for i in range(slot_tiles.size()):
var tile: Tile = slot_tiles[i]
if tile == null:
continue
var radius: float = full_sphere_radius + pulse_amplitude * \
sin(base_phase + slot_phase_offsets[i] * 0.35)
tile.place_on_shell(
center, slot_directions[i],
max(radius, 0.2), viewer_camera
)
tile.visible = true
# ----------------------------------------------------------------
# Ring of rings
# ----------------------------------------------------------------
func _update_ring_of_rings(time_sec: float, center: Vector3) -> void:
var delta: float = get_process_delta_time()
_ring_accumulators[0] += ring_of_rings_inner_speed_rad_s * delta
_ring_accumulators[1] += ring_of_rings_middle_speed_rad_s * delta
_ring_accumulators[2] += ring_of_rings_outer_speed_rad_s * delta
var base_phase := time_sec * TAU * pulse_speed_hz
var ring_radii := [
ring_of_rings_inner_radius,
ring_of_rings_middle_radius,
ring_of_rings_outer_radius
]
var n: int = slot_tiles.size()
var ring_counts := [
max(1, int(float(n) / 4.0)),
max(1, int(float(n) / 2.0)),
n - max(1, int(float(n) / 4.0))
- max(1, int(float(n) / 2.0))
]
for i in range(slot_tiles.size()):
var tile: Tile = slot_tiles[i]
if tile == null:
continue
var ring: int = 2
if i < _slot_ring_assignment.size():
ring = _slot_ring_assignment[i]
var ring_r: float = ring_radii[ring]
var ring_c: int = ring_counts[ring]
var offset: int = ring_counts[0] if ring == 0 else \
ring_counts[0] + ring_counts[1]
var local_index: int = i - offset
var angle: float = TAU * float(local_index) / float(ring_c) + \
_ring_accumulators[ring]
if i >= slot_directions.size():
break
var y_drift: float = sin(time_sec * TAU * 0.2) * \
ring_of_rings_vertical_wobble
var dir: Vector3 = Vector3(
cos(angle), y_drift, sin(angle)
).normalized()
var radius: float = ring_r + pulse_amplitude * \
sin(base_phase + slot_phase_offsets[i] * 0.35)
tile.global_position = center + dir * radius
tile.viewer_camera = viewer_camera
tile.visible = true
# ----------------------------------------------------------------
# Spiral staircase
# ----------------------------------------------------------------
func _update_spiral_staircase(time_sec: float, center: Vector3) -> void:
_spiral_accumulator += spiral_staircase_rotation_speed_rad_s * \
get_process_delta_time()
for i in range(slot_tiles.size()):
var tile: Tile = slot_tiles[i]
if tile == null:
continue
var t: float = float(i) / max(1, float(slot_tiles.size() - 1))
var angle: float = t * TAU * spiral_staircase_turns + \
_spiral_accumulator
var height: float = (t - 0.5) * spiral_staircase_height
var drift: float = sin(time_sec * TAU * 0.3 + t * TAU * 2) * \
spiral_staircase_wobble_amplitude
if i >= slot_directions.size():
break
var world_pos: Vector3 = center + Vector3(
cos(angle) * spiral_staircase_radius,
height + drift,
sin(angle) * spiral_staircase_radius
)
tile.global_position = world_pos
# Partial billboard
var to_camera: Vector3 = (center - world_pos).normalized()
var current_dir: Vector3 = tile.global_transform.basis.z
var dot: float = to_camera.dot(current_dir)
if dot < 0.98:
var axis: Vector3 = Vector3.UP.cross(to_camera)
if axis.length_squared() > 0.001:
var angle_between: float = acos(clampf(dot, -1.0, 1.0))
tile.rotate_object_local(
axis.normalized(),
angle_between * 0.6
)
tile.visible = true
var osc: float = Time.get_ticks_msec() * 0.001
var pulse: float = 1.0 + sin(
osc * TAU * tile.pulse_frequency_hz
+ tile.phase_offset
) * tile.pulse_strength
tile.mesh_instance.scale = tile.base_scale * \
tile.scale_multiplier * pulse
if tile.tile_material != null:
tile.tile_material.emission_energy_multiplier = \
tile.base_emission_energy * (
0.9 + tile.pulse_strength
)
# ----------------------------------------------------------------
# Legacy helpers
# ----------------------------------------------------------------
func _build_hemisphere_slots() -> void:
_compute_slots_for_format(PresentationFormat.HEMISPHERE)
func _sample_sphere_face_directions() -> Array[Vector3]:
var sphere := SphereMesh.new()
sphere.radius = 1.0
sphere.height = 2.0
sphere.rings = sphere_rings
sphere.radial_segments = sphere_segments
var arrays := sphere.get_mesh_arrays()
if arrays.is_empty():
return []
var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
if vertices.is_empty():
return []
var out: Array[Vector3] = []
if indices.is_empty():
var tri_count: int = int(vertices.size() / 3.0)
for tri in range(tri_count):
var i0: int = tri * 3
var v0: Vector3 = vertices[i0]
var v1: Vector3 = vertices[i0 + 1]
var v2: Vector3 = vertices[i0 + 2]
var center: Vector3 = (v0 + v1 + v2) / 3.0
if center.length_squared() > 0.0001:
out.push_back(center.normalized())
else:
var tri_count_indexed: int = int(indices.size() / 3.0)
for tri in range(tri_count_indexed):
var idx: int = tri * 3
var v0: Vector3 = vertices[indices[idx]]
var v1: Vector3 = vertices[indices[idx + 1]]
var v2: Vector3 = vertices[indices[idx + 2]]
var center: Vector3 = (v0 + v1 + v2) / 3.0
if center.length_squared() > 0.0001:
out.push_back(center.normalized())
return out
func _fallback_uv_directions() -> Array[Vector3]:
var out: Array[Vector3] = []
for ring in range(1, sphere_rings):
var v: float = float(ring) / float(max(2, sphere_rings))
var theta: float = v * PI
var sin_theta: float = sin(theta)
var cos_theta: float = cos(theta)
for segment in range(sphere_segments):
var u: float = float(segment) / float(max(1, sphere_segments))
var phi: float = u * TAU
var dir: Vector3 = Vector3(
cos(phi) * sin_theta, cos_theta,
sin(phi) * sin_theta
)
if dir.length_squared() > 0.0001:
out.push_back(dir.normalized())
return out
func _get_view_forward_axis() -> Vector3:
if viewer_camera == null:
viewer_camera = get_viewport().get_camera_3d()
if viewer_camera == null:
return -global_transform.basis.z.normalized()
var camera_basis := viewer_camera.global_transform.basis.orthonormalized()
return (-camera_basis.z).normalized()
func _create_unique_stream_instance() -> VideoStream:
if video_stream == null:
return null
if video_stream.resource_path.is_empty():
return video_stream.duplicate(true)
var loaded := ResourceLoader.load(
video_stream.resource_path,
"VideoStream",
ResourceLoader.CACHE_MODE_REUSE
)
if loaded is VideoStream:
return loaded as VideoStream
return video_stream.duplicate(true)