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)