diff --git a/godot/src/test/testing_api.gd b/godot/src/test/testing_api.gd index b5711b54..4f4c1437 100644 --- a/godot/src/test/testing_api.gd +++ b/godot/src/test/testing_api.gd @@ -167,6 +167,10 @@ func async_take_and_compare_snapshot( dcl_rpc_sender ) + var pending_promises := Global.content_provider.get_pending_promises() + if not pending_promises.is_empty(): + await PromiseUtils.async_all(Global.content_provider.get_pending_promises()) + # TODO: make this configurable var hide_player := true diff --git a/godot/src/ui/components/pointer_tooltip/tooltip_label.gd b/godot/src/ui/components/pointer_tooltip/tooltip_label.gd index b481c6c0..eaeb2722 100644 --- a/godot/src/ui/components/pointer_tooltip/tooltip_label.gd +++ b/godot/src/ui/components/pointer_tooltip/tooltip_label.gd @@ -14,11 +14,12 @@ func _ready(): func set_tooltip_data(text: String, action: String): var key: String - var index: int = InputMap.get_actions().find(action.to_lower(), 0) + var action_lower: String = action.to_lower() + var index: int = InputMap.get_actions().find(action_lower, 0) if label_text: - if index != -1: - action_to_trigger = action.to_lower() - show() + if index == -1 and action_lower == "ia_any": + key = "Any" + elif index != -1: var event = InputMap.action_get_events(InputMap.get_actions()[index])[0] if event is InputEventKey: key = char(event.unicode).to_upper() @@ -29,15 +30,22 @@ func set_tooltip_data(text: String, action: String): key = "Mouse Right Button" if event.button_index == 0: key = "Mouse Wheel Button" + + if not key.is_empty(): + show() + action_to_trigger = action_lower label_action.text = key label_text.text = text else: - action_to_trigger = "" hide() + action_to_trigger = "" printerr("Action doesn't exist ", action) func mobile_on_panel_container_gui_input(event): + if action_to_trigger.is_empty(): + return + if event is InputEventMouseButton: if event.pressed: Input.action_press(action_to_trigger) diff --git a/rust/decentraland-godot-lib/src/auth/dcl_player_identity.rs b/rust/decentraland-godot-lib/src/auth/dcl_player_identity.rs index 60b41ba6..87e18aea 100644 --- a/rust/decentraland-godot-lib/src/auth/dcl_player_identity.rs +++ b/rust/decentraland-godot-lib/src/auth/dcl_player_identity.rs @@ -5,6 +5,7 @@ use rand::thread_rng; use tokio::task::JoinHandle; use crate::comms::profile::{LambdaProfiles, UserProfile}; +use crate::content::bytes::fast_create_packed_byte_array_from_vec; use crate::dcl::scene_apis::RpcResultSender; use crate::godot_classes::promise::Promise; use crate::http_request::request_response::{RequestResponse, ResponseEnum}; @@ -388,21 +389,7 @@ impl DclPlayerIdentity { return; }; - // TODO: gdext should implement a packedByteArray constructor from &[u8] and not iteration - let body_payload = { - let byte_length = body_payload.len(); - let mut param = PackedByteArray::new(); - param.resize(byte_length); - let data_arr_ptr = param.as_mut_slice(); - - unsafe { - let dst_ptr = &mut data_arr_ptr[0] as *mut u8; - let src_ptr = &body_payload[0] as *const u8; - std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, byte_length); - } - param - }; - + let body_payload = fast_create_packed_byte_array_from_vec(&body_payload); let mut dict = Dictionary::default(); dict.set("content_type", content_type.to_variant()); dict.set("body_payload", body_payload.to_variant()); diff --git a/rust/decentraland-godot-lib/src/av/video_context.rs b/rust/decentraland-godot-lib/src/av/video_context.rs index bb5ce337..cecacb34 100644 --- a/rust/decentraland-godot-lib/src/av/video_context.rs +++ b/rust/decentraland-godot-lib/src/av/video_context.rs @@ -7,10 +7,12 @@ use ffmpeg_next::software::scaling::{context::Context, flag::Flags}; use ffmpeg_next::{decoder, format::context::Input, media::Type, util::frame, Packet}; use godot::engine::image::Format; use godot::engine::{Image, ImageTexture}; -use godot::prelude::{Gd, PackedByteArray, Vector2}; +use godot::prelude::{Gd, Vector2}; use thiserror::Error; use tracing::debug; +use crate::content::bytes::fast_create_packed_byte_array_from_slice; + use super::stream_processor::FfmpegContext; pub struct VideoInfo { @@ -150,17 +152,7 @@ impl FfmpegContext for VideoContext { // let data_arr = PackedByteArray::from(current_frame.data(0)); let raw_data = current_frame.data(0); - let byte_length = raw_data.len(); - let mut data_arr = PackedByteArray::new(); - data_arr.resize(raw_data.len()); - - let data_arr_ptr = data_arr.as_mut_slice(); - - unsafe { - let dst_ptr = &mut data_arr_ptr[0] as *mut u8; - let src_ptr = &raw_data[0] as *const u8; - std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, byte_length); - } + let data_arr = fast_create_packed_byte_array_from_slice(raw_data); let diff = self.last_frame_time.elapsed().as_secs_f32(); debug!( diff --git a/rust/decentraland-godot-lib/src/content/audio.rs b/rust/decentraland-godot-lib/src/content/audio.rs index 289361c5..2f7c9382 100644 --- a/rust/decentraland-godot-lib/src/content/audio.rs +++ b/rust/decentraland-godot-lib/src/content/audio.rs @@ -1,91 +1,57 @@ use godot::{ - builtin::{meta::ToGodot, GString}, - engine::{file_access::ModeFlags, AudioStream, AudioStreamMp3, AudioStreamWav, FileAccess}, + builtin::{meta::ToGodot, Variant}, + engine::{AudioStream, AudioStreamMp3, AudioStreamOggVorbis, AudioStreamWav}, obj::Gd, }; - -use crate::{ - godot_classes::promise::Promise, - http_request::request_response::{RequestOption, ResponseType}, -}; +use tokio::io::AsyncReadExt; use super::{ - content_mapping::ContentMappingAndUrlRef, - content_provider::ContentProviderContext, - file_string::get_extension, - thread_safety::{reject_promise, resolve_promise}, + bytes::fast_create_packed_byte_array_from_vec, content_mapping::ContentMappingAndUrlRef, + content_provider::ContentProviderContext, download::fetch_resource_or_wait, + file_string::get_extension, thread_safety::GodotSingleThreadSafety, }; pub async fn load_audio( file_path: String, content_mapping: ContentMappingAndUrlRef, - get_promise: impl Fn() -> Option>, ctx: ContentProviderContext, -) { +) -> Result, anyhow::Error> { let extension = get_extension(&file_path); if ["wav", "ogg", "mp3"].contains(&extension.as_str()) { - reject_promise( - get_promise, - format!("Audio {} unrecognized format", file_path), - ); - return; + return Err(anyhow::Error::msg(format!( + "Audio {} unrecognized format", + file_path + ))); } - let Some(file_hash) = content_mapping.content.get(&file_path) else { - reject_promise( - get_promise, - "File not found in the content mappings".to_string(), - ); - return; - }; + let file_hash = content_mapping + .content + .get(&file_path) + .ok_or(anyhow::Error::msg("File not found in the content mappings"))?; + let url = format!("{}{}", content_mapping.base_url, file_hash); let absolute_file_path = format!("{}{}", ctx.content_folder, file_hash); - if !FileAccess::file_exists(GString::from(&absolute_file_path)) { - let request = RequestOption::new( - 0, - format!("{}{}", content_mapping.base_url, file_hash), - http::Method::GET, - ResponseType::ToFile(absolute_file_path.clone()), - None, - None, - None, - ); - match ctx.http_queue_requester.request(request, 0).await { - Ok(_response) => {} - Err(err) => { - reject_promise( - get_promise, - format!( - "Error downloading audio {file_hash} ({file_path}): {:?}", - err - ), - ); - return; - } - } - } + fetch_resource_or_wait(&url, file_hash, &absolute_file_path, ctx.clone()) + .await + .map_err(anyhow::Error::msg)?; - let Some(file) = FileAccess::open(GString::from(&absolute_file_path), ModeFlags::READ) else { - reject_promise( - get_promise, - format!("Error opening audio file {}", absolute_file_path), - ); - return; - }; + let mut file = tokio::fs::File::open(&absolute_file_path).await?; + let mut bytes_vec = Vec::new(); + file.read_to_end(&mut bytes_vec).await?; + + let _thread_safe_check = GodotSingleThreadSafety::acquire_owned(&ctx) + .await + .ok_or(anyhow::Error::msg("Failed while trying to "))?; - let bytes = file.get_buffer(file.get_length() as i64); + let bytes = fast_create_packed_byte_array_from_vec(&bytes_vec); let audio_stream: Option> = match extension.as_str() { ".wav" => { let mut audio_stream = AudioStreamWav::new(); audio_stream.set_data(bytes); Some(audio_stream.upcast()) } - // ".ogg" => { - // let audio_stream = AudioStreamOggVorbis::new(); - // // audio_stream.set_(bytes); - // audio_stream.upcast() - // } + ".ogg" => AudioStreamOggVorbis::load_from_buffer(bytes).map(|value| value.upcast()), ".mp3" => { let mut audio_stream = AudioStreamMp3::new(); audio_stream.set_data(bytes); @@ -94,13 +60,6 @@ pub async fn load_audio( _ => None, }; - let Some(audio_stream) = audio_stream else { - reject_promise( - get_promise, - format!("Error creating audio stream for {}", absolute_file_path), - ); - return; - }; - - resolve_promise(get_promise, Some(audio_stream.to_variant())); + let audio_stream = audio_stream.ok_or(anyhow::Error::msg("Error creating audio stream"))?; + Ok(Some(audio_stream.to_variant())) } diff --git a/rust/decentraland-godot-lib/src/content/bytes.rs b/rust/decentraland-godot-lib/src/content/bytes.rs new file mode 100644 index 00000000..9bb256c3 --- /dev/null +++ b/rust/decentraland-godot-lib/src/content/bytes.rs @@ -0,0 +1,21 @@ +use godot::builtin::PackedByteArray; + +// TODO: gdext should implement a packedByteArray constructor from &[u8] and not iteration +pub fn fast_create_packed_byte_array_from_slice(bytes_slice: &[u8]) -> PackedByteArray { + let byte_length = bytes_slice.len(); + let mut bytes = PackedByteArray::new(); + bytes.resize(byte_length); + + let data_arr_ptr = bytes.as_mut_slice(); + unsafe { + let dst_ptr = &mut data_arr_ptr[0] as *mut u8; + let src_ptr = &bytes_slice[0] as *const u8; + std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, byte_length); + } + + bytes +} + +pub fn fast_create_packed_byte_array_from_vec(bytes_vec: &Vec) -> PackedByteArray { + fast_create_packed_byte_array_from_slice(bytes_vec.as_slice()) +} diff --git a/rust/decentraland-godot-lib/src/content/content_notificator.rs b/rust/decentraland-godot-lib/src/content/content_notificator.rs index 848f536a..ba48c9cf 100644 --- a/rust/decentraland-godot-lib/src/content/content_notificator.rs +++ b/rust/decentraland-godot-lib/src/content/content_notificator.rs @@ -2,8 +2,15 @@ use std::{collections::HashMap, sync::Arc}; use tokio::sync::{Notify, RwLock}; +#[derive(Clone, Debug)] +pub enum ContentState { + RequestOwner, + Busy(Arc), + Released(Result<(), String>), +} + pub struct ContentNotificator { - files: RwLock>>, + files: RwLock>, } impl Default for ContentNotificator { @@ -19,24 +26,31 @@ impl ContentNotificator { } } - pub async fn get_or_create_notify(&self, key: String) -> (bool, Arc) { + pub async fn get(&self, key: &String) -> Option { + let files = self.files.read().await; + files.get(key).cloned() + } + + pub async fn get_or_create_notify(&self, key: &String) -> ContentState { { let files = self.files.read().await; - if let Some(notify) = files.get(&key) { - return (false, notify.clone()); + if let Some(content_state) = files.get(key) { + return content_state.clone(); } } let mut files = self.files.write().await; - let notify = Arc::new(Notify::new()); - files.insert(key, notify.clone()); - drop(files); - - (true, notify) + let content_state = ContentState::Busy(Arc::new(Notify::new())); + files.insert(key.clone(), content_state.clone()); + ContentState::RequestOwner } - pub async fn remove_notify(&mut self, key: String) { + pub async fn resolve(&self, key: &str, result: Result<(), String>) { let mut files = self.files.write().await; - files.remove(&key); + if let Some(ContentState::Busy(notify)) = + files.insert(key.to_owned(), ContentState::Released(result)) + { + notify.notify_waiters(); + } } } diff --git a/rust/decentraland-godot-lib/src/content/content_provider.rs b/rust/decentraland-godot-lib/src/content/content_provider.rs index d17cccd1..de51c1ea 100644 --- a/rust/decentraland-godot-lib/src/content/content_provider.rs +++ b/rust/decentraland-godot-lib/src/content/content_provider.rs @@ -20,7 +20,7 @@ use super::{ content_notificator::ContentNotificator, gltf::{apply_update_set_mask_colliders, load_gltf}, texture::{load_png_texture, TextureEntry}, - thread_safety::{resolve_promise, set_thread_safety_checks_enabled}, + thread_safety::{set_thread_safety_checks_enabled, then_promise}, video::download_video, wearable_entities::request_wearables, }; @@ -64,6 +64,10 @@ impl INode for ContentProvider { } } fn ready(&mut self) {} + fn exit_tree(&mut self) { + self.cached.clear(); + tracing::info!("ContentProvider::exit_tree"); + } } #[godot_api] @@ -91,13 +95,8 @@ impl ContentProvider { let gltf_file_path = file_path.to_string(); let content_provider_context = self.get_context(); TokioRuntime::spawn(async move { - load_gltf( - gltf_file_path, - content_mapping, - get_promise, - content_provider_context, - ) - .await; + let result = load_gltf(gltf_file_path, content_mapping, content_provider_context).await; + then_promise(get_promise, result); }); self.cached.insert( @@ -123,16 +122,16 @@ impl ContentProvider { let gltf_node_instance_id = gltf_node.instance_id(); let content_provider_context = self.get_context(); TokioRuntime::spawn(async move { - apply_update_set_mask_colliders( + let result = apply_update_set_mask_colliders( gltf_node_instance_id, dcl_visible_cmask, dcl_invisible_cmask, dcl_scene_id, dcl_entity_id, - get_promise, content_provider_context, ) .await; + then_promise(get_promise, result); }); promise @@ -161,13 +160,9 @@ impl ContentProvider { let audio_file_path = file_path.to_string(); let content_provider_context = self.get_context(); TokioRuntime::spawn(async move { - load_audio( - audio_file_path, - content_mapping, - get_promise, - content_provider_context, - ) - .await; + let result = + load_audio(audio_file_path, content_mapping, content_provider_context).await; + then_promise(get_promise, result); }); self.cached.insert( @@ -205,18 +200,17 @@ impl ContentProvider { return entry.promise.clone(); } - let absolute_file_path = format!("{}{}", self.content_folder, file_hash); - let url = format!("{}{}", content_mapping.bind().get_base_url(), file_hash); + let url = format!( + "{}{}", + content_mapping.bind().get_base_url(), + file_hash.clone() + ); let (promise, get_promise) = Promise::make_to_async(); let content_provider_context = self.get_context(); + let sent_file_hash = file_hash.clone(); TokioRuntime::spawn(async move { - load_png_texture( - url, - absolute_file_path, - get_promise, - content_provider_context, - ) - .await; + let result = load_png_texture(url, sent_file_hash, content_provider_context).await; + then_promise(get_promise, result); }); self.cached.insert( @@ -236,17 +230,12 @@ impl ContentProvider { return entry.promise.clone(); } let url = url.to_string(); - let absolute_file_path = format!("{}{}", self.content_folder, file_hash); let (promise, get_promise) = Promise::make_to_async(); let content_provider_context = self.get_context(); + let sent_file_hash = file_hash.clone(); TokioRuntime::spawn(async move { - load_png_texture( - url, - absolute_file_path, - get_promise, - content_provider_context, - ) - .await; + let result = load_png_texture(url, sent_file_hash, content_provider_context).await; + then_promise(get_promise, result); }); self.cached.insert( @@ -303,13 +292,9 @@ impl ContentProvider { let video_file_hash = file_hash.clone(); let content_provider_context = self.get_context(); TokioRuntime::spawn(async move { - download_video( - video_file_hash, - content_mapping, - get_promise, - content_provider_context, - ) - .await; + let result = + download_video(video_file_hash, content_mapping, content_provider_context).await; + then_promise(get_promise, result); }); self.cached.insert( @@ -356,7 +341,7 @@ impl ContentProvider { set_thread_safety_checks_enabled(true); - resolve_promise(get_promise, None); + then_promise(get_promise, Ok(None)); }); promise @@ -405,14 +390,14 @@ impl ContentProvider { let content_base_url = format!("{}{extra_slash}", content_base_url); let ipfs_content_base_url = format!("{content_base_url}contents/"); TokioRuntime::spawn(async move { - request_wearables( + let result = request_wearables( content_base_url, ipfs_content_base_url, wearable_to_fetch.into_iter().collect(), - get_promise, content_provider_context, ) .await; + then_promise(get_promise, result); }); self.cached.insert( "wearables".to_string(), @@ -437,6 +422,16 @@ impl ContentProvider { } Variant::nil() } + + #[func] + pub fn get_pending_promises(&self) -> Array> { + Array::from_iter( + self.cached + .iter() + .filter(|(_, entry)| !entry.promise.bind().is_resolved()) + .map(|(_, entry)| entry.promise.clone()), + ) + } } impl ContentProvider { diff --git a/rust/decentraland-godot-lib/src/content/download.rs b/rust/decentraland-godot-lib/src/content/download.rs new file mode 100644 index 00000000..85421f6e --- /dev/null +++ b/rust/decentraland-godot-lib/src/content/download.rs @@ -0,0 +1,55 @@ +use crate::http_request::request_response::{RequestOption, ResponseType}; + +use super::{content_notificator::ContentState, content_provider::ContentProviderContext}; + +pub async fn fetch_resource_or_wait( + url: &String, + file_hash: &String, + absolute_file_path: &String, + ctx: ContentProviderContext, +) -> Result<(), String> { + let content_state = ctx + .content_notificator + .get_or_create_notify(file_hash) + .await; + + match content_state { + ContentState::Busy(notify) => { + notify.notified().await; + match ctx.content_notificator.get(file_hash).await { + Some(ContentState::Released(result)) => result, + _ => Err("Double busy state ".to_string()), + } + } + ContentState::Released(result) => result, + ContentState::RequestOwner => { + if tokio::fs::metadata(&absolute_file_path).await.is_err() { + let request = RequestOption::new( + 0, + url.clone(), + http::Method::GET, + ResponseType::ToFile(absolute_file_path.clone()), + None, + None, + None, + ); + + let result = match ctx.http_queue_requester.request(request, 0).await { + Ok(_response) => Ok(()), + Err(err) => Err(format!( + "Error downloading content {url} ({absolute_file_path}): {:?}", + err + )), + }; + + ctx.content_notificator + .resolve(file_hash, result.clone()) + .await; + result + } else { + ctx.content_notificator.resolve(file_hash, Ok(())).await; + Ok(()) + } + } + } +} diff --git a/rust/decentraland-godot-lib/src/content/gltf.rs b/rust/decentraland-godot-lib/src/content/gltf.rs index 0b82baf3..7da58688 100644 --- a/rust/decentraland-godot-lib/src/content/gltf.rs +++ b/rust/decentraland-godot-lib/src/content/gltf.rs @@ -1,92 +1,42 @@ use std::{collections::HashMap, sync::Arc}; use godot::{ - builtin::{meta::ToGodot, Dictionary, GString}, + builtin::{meta::ToGodot, Dictionary, GString, Variant}, engine::{ - file_access::ModeFlags, global::Error, node::ProcessMode, AnimatableBody3D, - AnimationLibrary, AnimationPlayer, CollisionShape3D, ConcavePolygonShape3D, FileAccess, - GltfDocument, GltfState, MeshInstance3D, Node, Node3D, NodeExt, StaticBody3D, + global::Error, node::ProcessMode, AnimatableBody3D, AnimationLibrary, AnimationPlayer, + CollisionShape3D, ConcavePolygonShape3D, GltfDocument, GltfState, MeshInstance3D, Node, + Node3D, NodeExt, StaticBody3D, }, obj::{Gd, InstanceId}, }; - -use crate::{ - godot_classes::promise::Promise, - http_request::request_response::{RequestOption, ResponseType}, -}; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; use super::{ - content_mapping::ContentMappingAndUrlRef, - content_provider::ContentProviderContext, - file_string::get_base_dir, - thread_safety::{reject_promise, resolve_promise, set_thread_safety_checks_enabled}, + content_mapping::ContentMappingAndUrlRef, content_provider::ContentProviderContext, + download::fetch_resource_or_wait, file_string::get_base_dir, + thread_safety::GodotSingleThreadSafety, }; -struct GodotSingleThreadSafety { - _guard: tokio::sync::OwnedSemaphorePermit, -} - -impl GodotSingleThreadSafety { - pub async fn acquire_owned(ctx: &ContentProviderContext) -> Option { - let guard = ctx.godot_single_thread.clone().acquire_owned().await.ok()?; - set_thread_safety_checks_enabled(false); - Some(Self { _guard: guard }) - } - - fn nop(&self) { /* nop */ - } -} - -impl Drop for GodotSingleThreadSafety { - fn drop(&mut self) { - set_thread_safety_checks_enabled(true); - } -} - pub async fn load_gltf( file_path: String, content_mapping: ContentMappingAndUrlRef, - get_promise: impl Fn() -> Option>, ctx: ContentProviderContext, -) { +) -> Result, anyhow::Error> { let base_path = Arc::new(get_base_dir(&file_path)); - let Some(file_hash) = content_mapping.content.get(&file_path) else { - reject_promise( - get_promise, - "File not found in the content mappings".to_string(), - ); - return; - }; + let file_hash = content_mapping + .content + .get(&file_path) + .ok_or(anyhow::Error::msg("File not found in the content mappings"))?; + let url = format!("{}{}", content_mapping.base_url, file_hash); let absolute_file_path = format!("{}{}", ctx.content_folder, file_hash); - if !FileAccess::file_exists(GString::from(&absolute_file_path)) { - let request = RequestOption::new( - 0, - format!("{}{}", content_mapping.base_url, file_hash), - http::Method::GET, - ResponseType::ToFile(absolute_file_path.clone()), - None, - None, - None, - ); - - match ctx.http_queue_requester.request(request, 0).await { - Ok(_response) => {} - Err(err) => { - reject_promise( - get_promise, - format!( - "Error downloading gltf {file_hash} ({file_path}): {:?}", - err - ), - ); - return; - } - } - } + fetch_resource_or_wait(&url, file_hash, &absolute_file_path, ctx.clone()) + .await + .map_err(anyhow::Error::msg)?; let dependencies = get_dependencies(&absolute_file_path) + .await? .into_iter() .map(|dep| { let full_path = if base_path.is_empty() { @@ -102,11 +52,9 @@ pub async fn load_gltf( .collect::)>>(); if dependencies.iter().any(|(_, hash)| hash.is_none()) { - reject_promise( - get_promise, + return Err(anyhow::Error::msg( "There are some missing dependencies in the gltf".to_string(), - ); - return; + )); } let dependencies_hash = dependencies @@ -114,48 +62,41 @@ pub async fn load_gltf( .map(|(file_path, hash)| (file_path, hash.unwrap())) .collect::>(); - let futures = dependencies_hash.iter().map(|(_, file_hash)| { + let futures = dependencies_hash.iter().map(|(_, dependency_file_hash)| { let ctx = ctx.clone(); - let absolute_file_path = format!("{}{}", ctx.content_folder, file_hash); let content_mapping = content_mapping.clone(); async move { - if !FileAccess::file_exists(GString::from(&absolute_file_path)) { - let request = RequestOption::new( - 0, - format!("{}{}", content_mapping.base_url, file_hash), - http::Method::GET, - ResponseType::ToFile(absolute_file_path.clone()), - None, - None, - None, - ); - - match ctx.http_queue_requester.request(request, 0).await { - Ok(_response) => Ok(()), - Err(_err) => Err(()), - } - } else { - Ok(()) - } + let url = format!("{}{}", content_mapping.base_url, dependency_file_hash); + let absolute_file_path = format!("{}{}", ctx.content_folder, dependency_file_hash); + fetch_resource_or_wait(&url, dependency_file_hash, &absolute_file_path, ctx.clone()) + .await + .map_err(|e| { + format!( + "Dependency {} failed to fetch: {:?}", + dependency_file_hash, e + ) + }) } }); let result = futures_util::future::join_all(futures).await; if result.iter().any(|res| res.is_err()) { - reject_promise( - get_promise, - "Error downloading gltf dependencies".to_string(), - ); - return; + // collect errors + let errors = result + .into_iter() + .filter_map(|res| res.err()) + .map(|err| err.to_string()) + .collect::>() + .join("\n"); + + return Err(anyhow::Error::msg(format!( + "Error downloading gltf dependencies: {errors}" + ))); } - let Some(thread_safe_check) = GodotSingleThreadSafety::acquire_owned(&ctx).await else { - reject_promise( - get_promise, - "Error loading gltf when acquiring thread safety".to_string(), - ); - return; - }; + let _thread_safe_check = GodotSingleThreadSafety::acquire_owned(&ctx) + .await + .ok_or(anyhow::Error::msg("Failed trying to get thread-safe check"))?; let mut new_gltf = GltfDocument::new(); let mut new_gltf_state = GltfState::new(); @@ -180,34 +121,26 @@ pub async fn load_gltf( if err != Error::OK { let err = err.to_variant().to::(); - reject_promise( - get_promise, - format!("Error loading gltf after appending from file {}", err), - ); - return; + return Err(anyhow::Error::msg(format!( + "Error loading gltf after appending from file {}", + err + ))); } - let Some(node) = new_gltf.generate_scene(new_gltf_state) else { - reject_promise( - get_promise, + let node = new_gltf + .generate_scene(new_gltf_state) + .ok_or(anyhow::Error::msg( "Error loading gltf when generating scene".to_string(), - ); - return; - }; + ))?; - let Ok(mut node) = node.try_cast::() else { - reject_promise( - get_promise, - "Error loading gltf when casting to Node3D".to_string(), - ); - return; - }; + let mut node = node.try_cast::().map_err(|err| { + anyhow::Error::msg(format!("Error loading gltf when casting to Node3D: {err}")) + })?; node.rotate_y(std::f32::consts::PI); create_colliders(node.clone().upcast()); - resolve_promise(get_promise, Some(node.to_variant())); - thread_safe_check.nop(); + Ok(Some(node.to_variant())) } pub async fn apply_update_set_mask_colliders( @@ -216,23 +149,19 @@ pub async fn apply_update_set_mask_colliders( dcl_invisible_cmask: i32, dcl_scene_id: i32, dcl_entity_id: i32, - get_promise: impl Fn() -> Option>, ctx: ContentProviderContext, -) { - let Some(thread_safe_check) = GodotSingleThreadSafety::acquire_owned(&ctx).await else { - reject_promise( - get_promise, - "Error loading gltf when acquiring thread safety".to_string(), - ); - return; - }; +) -> Result, anyhow::Error> { + let _thread_safe_check = GodotSingleThreadSafety::acquire_owned(&ctx) + .await + .ok_or(anyhow::Error::msg("Failed trying to get thread-safe check"))?; let mut to_remove_nodes = Vec::new(); let gltf_node: Gd = Gd::from_instance_id(gltf_node_instance_id); - let Some(gltf_node) = gltf_node.duplicate_ex().flags(8).done() else { - reject_promise(get_promise, "unable to duplicate gltf node".into()); - return; - }; + let gltf_node = gltf_node + .duplicate_ex() + .flags(8) + .done() + .ok_or(anyhow::Error::msg("unable to duplicate gltf node"))?; update_set_mask_colliders( gltf_node.clone(), @@ -249,39 +178,29 @@ pub async fn apply_update_set_mask_colliders( node.queue_free(); } - resolve_promise(get_promise, Some(gltf_node.to_variant())); - thread_safe_check.nop(); + Ok(Some(gltf_node.to_variant())) } -fn get_dependencies(file_path: &String) -> Vec { +async fn get_dependencies(file_path: &String) -> Result, anyhow::Error> { let mut dependencies = Vec::new(); - let Some(mut p_file) = FileAccess::open(GString::from(&file_path), ModeFlags::READ) else { - return dependencies; - }; - - p_file.seek(0); + let mut file = tokio::fs::File::open(file_path).await?; - let magic = p_file.get_32(); - let maybe_json: Result = if magic == 0x46546C67 { - p_file.get_32(); // version - p_file.get_32(); // length + let magic = file.read_i32_le().await?; + let json: serde_json::Value = if magic == 0x46546C67 { + let _version = file.read_i32_le().await?; + let _length = file.read_i32_le().await?; + let chunk_length = file.read_i32_le().await?; + let _chunk_type = file.read_i32_le().await?; - let chunk_length = p_file.get_32(); - p_file.get_32(); // chunk_type - - let json_data = p_file.get_buffer(chunk_length as i64); + let mut json_data = vec![0u8; chunk_length as usize]; + let _ = file.read_exact(&mut json_data).await?; serde_json::de::from_slice(json_data.as_slice()) } else { - p_file.seek(0); - let json_data = p_file.get_buffer(p_file.get_length() as i64); + let mut json_data = Vec::new(); + let _ = file.seek(std::io::SeekFrom::Start(0)).await?; + let _ = file.read_to_end(&mut json_data).await?; serde_json::de::from_slice(json_data.as_slice()) - }; - - if maybe_json.is_err() { - return dependencies; - } - - let json = maybe_json.unwrap(); + }?; if let Some(images) = json.get("images") { if let Some(images) = images.as_array() { @@ -311,7 +230,7 @@ fn get_dependencies(file_path: &String) -> Vec { } } - dependencies + Ok(dependencies) } fn get_collider(mesh_instance: &Gd) -> Option> { diff --git a/rust/decentraland-godot-lib/src/content/mod.rs b/rust/decentraland-godot-lib/src/content/mod.rs index c0594615..27234b5b 100644 --- a/rust/decentraland-godot-lib/src/content/mod.rs +++ b/rust/decentraland-godot-lib/src/content/mod.rs @@ -1,7 +1,9 @@ mod audio; +pub mod bytes; pub mod content_mapping; pub mod content_notificator; pub mod content_provider; +mod download; mod file_string; mod gltf; mod texture; diff --git a/rust/decentraland-godot-lib/src/content/texture.rs b/rust/decentraland-godot-lib/src/content/texture.rs index f4bcd608..442acfdc 100644 --- a/rust/decentraland-godot-lib/src/content/texture.rs +++ b/rust/decentraland-godot-lib/src/content/texture.rs @@ -1,19 +1,14 @@ +use super::{ + bytes::fast_create_packed_byte_array_from_vec, content_provider::ContentProviderContext, + download::fetch_resource_or_wait, thread_safety::GodotSingleThreadSafety, +}; use godot::{ bind::GodotClass, - builtin::{meta::ToGodot, GString}, - engine::{file_access::ModeFlags, global::Error, DirAccess, FileAccess, Image, ImageTexture}, + builtin::{meta::ToGodot, GString, Variant}, + engine::{global::Error, DirAccess, Image, ImageTexture}, obj::Gd, }; - -use crate::{ - godot_classes::promise::Promise, - http_request::request_response::{RequestOption, ResponseType}, -}; - -use super::{ - content_provider::ContentProviderContext, - thread_safety::{reject_promise, resolve_promise}, -}; +use tokio::io::AsyncReadExt; #[derive(GodotClass)] #[class(init, base=RefCounted)] @@ -26,69 +21,41 @@ pub struct TextureEntry { pub async fn load_png_texture( url: String, - absolute_file_path: String, - get_promise: impl Fn() -> Option>, + file_hash: String, ctx: ContentProviderContext, -) { - if !FileAccess::file_exists(GString::from(&absolute_file_path)) { - let request = RequestOption::new( - 0, - url.clone(), - http::Method::GET, - ResponseType::ToFile(absolute_file_path.clone()), - None, - None, - None, - ); +) -> Result, anyhow::Error> { + let absolute_file_path = format!("{}{}", ctx.content_folder, file_hash); + fetch_resource_or_wait(&url, &file_hash, &absolute_file_path, ctx.clone()) + .await + .map_err(anyhow::Error::msg)?; - match ctx.http_queue_requester.request(request, 0).await { - Ok(_response) => {} - Err(err) => { - reject_promise( - get_promise, - format!( - "Error downloading png texture {url} ({absolute_file_path}): {:?}", - err - ), - ); - return; - } - } - } + let mut file = tokio::fs::File::open(&absolute_file_path).await?; + let mut bytes_vec = Vec::new(); + file.read_to_end(&mut bytes_vec).await?; - let Some(file) = FileAccess::open(GString::from(&absolute_file_path), ModeFlags::READ) else { - reject_promise( - get_promise, - format!("Error opening png file {}", absolute_file_path), - ); - return; - }; + let _thread_safe_check = GodotSingleThreadSafety::acquire_owned(&ctx) + .await + .ok_or(anyhow::Error::msg("Failed trying to get thread-safe check"))?; - let bytes = file.get_buffer(file.get_length() as i64); - drop(file); + let bytes = fast_create_packed_byte_array_from_vec(&bytes_vec); let mut image = Image::new(); let err = image.load_png_from_buffer(bytes); if err != Error::OK { DirAccess::remove_absolute(GString::from(&absolute_file_path)); let err = err.to_variant().to::(); - reject_promise( - get_promise, - format!("Error loading texture {absolute_file_path}: {}", err), - ); - return; + return Err(anyhow::Error::msg(format!( + "Error loading texture {absolute_file_path}: {}", + err + ))); } - let Some(mut texture) = ImageTexture::create_from_image(image.clone()) else { - reject_promise( - get_promise, - format!("Error creating texture from image {}", absolute_file_path), - ); - return; - }; - + let mut texture = ImageTexture::create_from_image(image.clone()).ok_or(anyhow::Error::msg( + format!("Error creating texture from image {}", absolute_file_path), + ))?; texture.set_name(GString::from(&url)); let texture_entry = Gd::from_init_fn(|_base| TextureEntry { texture, image }); - resolve_promise(get_promise, Some(texture_entry.to_variant())); + + Ok(Some(texture_entry.to_variant())) } diff --git a/rust/decentraland-godot-lib/src/content/thread_safety.rs b/rust/decentraland-godot-lib/src/content/thread_safety.rs index a0ececaa..d557f974 100644 --- a/rust/decentraland-godot-lib/src/content/thread_safety.rs +++ b/rust/decentraland-godot-lib/src/content/thread_safety.rs @@ -6,6 +6,26 @@ use godot::{ use crate::godot_classes::promise::Promise; +use super::content_provider::ContentProviderContext; + +pub struct GodotSingleThreadSafety { + _guard: tokio::sync::OwnedSemaphorePermit, +} + +impl GodotSingleThreadSafety { + pub async fn acquire_owned(ctx: &ContentProviderContext) -> Option { + let guard = ctx.godot_single_thread.clone().acquire_owned().await.ok()?; + set_thread_safety_checks_enabled(false); + Some(Self { _guard: guard }) + } +} + +impl Drop for GodotSingleThreadSafety { + fn drop(&mut self) { + set_thread_safety_checks_enabled(true); + } +} + // Interacting with Godot API is not thread safe, so we need to disable thread safety checks // When this option is triggered (as false), be sure to not use async/await until you set it back to true // Following the same logic, do not exit of sync closure until you set it back to true @@ -17,7 +37,7 @@ pub fn set_thread_safety_checks_enabled(enabled: bool) { ); } -pub fn reject_promise(get_promise: impl Fn() -> Option>, reason: String) -> bool { +fn reject_promise(get_promise: impl Fn() -> Option>, reason: String) -> bool { if let Some(mut promise) = get_promise() { promise.call_deferred("reject".into(), &[reason.to_variant()]); true @@ -26,10 +46,7 @@ pub fn reject_promise(get_promise: impl Fn() -> Option>, reason: Str } } -pub fn resolve_promise( - get_promise: impl Fn() -> Option>, - value: Option, -) -> bool { +fn resolve_promise(get_promise: impl Fn() -> Option>, value: Option) -> bool { if let Some(mut promise) = get_promise() { if let Some(value) = value { promise.call_deferred("resolve_with_data".into(), &[value]); @@ -41,3 +58,13 @@ pub fn resolve_promise( false } } + +pub fn then_promise( + get_promise: impl Fn() -> Option>, + result: Result, anyhow::Error>, +) { + match result { + Ok(value) => resolve_promise(get_promise, value), + Err(reason) => reject_promise(get_promise, reason.to_string()), + }; +} diff --git a/rust/decentraland-godot-lib/src/content/video.rs b/rust/decentraland-godot-lib/src/content/video.rs index 3ee42d5f..8dc2481b 100644 --- a/rust/decentraland-godot-lib/src/content/video.rs +++ b/rust/decentraland-godot-lib/src/content/video.rs @@ -1,57 +1,18 @@ -use godot::{ - builtin::GString, - engine::{file_access::ModeFlags, FileAccess}, - obj::Gd, -}; - -use crate::{ - godot_classes::promise::Promise, - http_request::request_response::{RequestOption, ResponseType}, -}; - use super::{ - content_mapping::ContentMappingAndUrlRef, - content_provider::ContentProviderContext, - thread_safety::{reject_promise, resolve_promise}, + content_mapping::ContentMappingAndUrlRef, content_provider::ContentProviderContext, + download::fetch_resource_or_wait, }; +use godot::builtin::Variant; pub async fn download_video( file_hash: String, content_mapping: ContentMappingAndUrlRef, - get_promise: impl Fn() -> Option>, ctx: ContentProviderContext, -) { +) -> Result, anyhow::Error> { + let url = format!("{}{}", content_mapping.base_url, file_hash); let absolute_file_path = format!("{}{}", ctx.content_folder, file_hash); - if !FileAccess::file_exists(GString::from(&absolute_file_path)) { - let request = RequestOption::new( - 0, - format!("{}{}", content_mapping.base_url, file_hash), - http::Method::GET, - ResponseType::ToFile(absolute_file_path.clone()), - None, - None, - None, - ); - - match ctx.http_queue_requester.request(request, 0).await { - Ok(_response) => {} - Err(err) => { - reject_promise( - get_promise, - format!("Error downloading video {file_hash}: {:?}", err), - ); - return; - } - } - } - - let Some(_file) = FileAccess::open(GString::from(&absolute_file_path), ModeFlags::READ) else { - reject_promise( - get_promise, - format!("Error opening video file {}", absolute_file_path), - ); - return; - }; - - resolve_promise(get_promise, None); + fetch_resource_or_wait(&url, &file_hash, &absolute_file_path, ctx.clone()) + .await + .map_err(anyhow::Error::msg)?; + Ok(None) } diff --git a/rust/decentraland-godot-lib/src/content/wearable_entities.rs b/rust/decentraland-godot-lib/src/content/wearable_entities.rs index 562d0e4f..a13c64f2 100644 --- a/rust/decentraland-godot-lib/src/content/wearable_entities.rs +++ b/rust/decentraland-godot-lib/src/content/wearable_entities.rs @@ -1,22 +1,11 @@ -use std::collections::HashMap; - +use super::{content_mapping::DclContentMappingAndUrl, content_provider::ContentProviderContext}; +use crate::http_request::request_response::{RequestOption, ResponseEnum, ResponseType}; use godot::{ builtin::{meta::ToGodot, Dictionary, GString, Variant, VariantArray}, engine::{global::Error, Json}, - obj::Gd, }; use serde::Serialize; - -use crate::{ - godot_classes::promise::Promise, - http_request::request_response::{RequestOption, ResponseEnum, ResponseType}, -}; - -use super::{ - content_mapping::DclContentMappingAndUrl, - content_provider::ContentProviderContext, - thread_safety::{reject_promise, resolve_promise}, -}; +use std::collections::HashMap; #[derive(Serialize)] struct EntitiesRequest { @@ -27,9 +16,8 @@ pub async fn request_wearables( content_server_base_url: String, ipfs_content_base_url: String, pointers: Vec, - get_promise: impl Fn() -> Option>, ctx: ContentProviderContext, -) { +) -> Result, anyhow::Error> { let url = format!("{content_server_base_url}entities/active"); let headers = vec![("Content-Type: application/json".to_string())]; let payload = serde_json::to_string(&EntitiesRequest { @@ -47,33 +35,31 @@ pub async fn request_wearables( None, ); - let result = match ctx.http_queue_requester.request(request_option, 0).await { - Ok(response) => match response.response_data { - Ok(ResponseEnum::String(result)) => { - let mut json = Json::new(); - let err = json.parse(GString::from(result)); - - if err != Error::OK { - Err("Couldn't parse wearable entities response".to_string()) - } else { - match json.get_data().try_to::() { - Ok(array) => Ok(array), - Err(_err) => Err("Pointers response is not an array".to_string()), - } + let response = ctx + .http_queue_requester + .request(request_option, 0) + .await + .map_err(|e| anyhow::Error::msg(e.error_message))?; + + let pointers_result = match response.response_data { + Ok(ResponseEnum::String(result)) => { + let mut json = Json::new(); + let err = json.parse(GString::from(result)); + + if err != Error::OK { + Err("Couldn't parse wearable entities response".to_string()) + } else { + match json.get_data().try_to::() { + Ok(array) => Ok(array), + Err(_err) => Err("Pointers response is not an array".to_string()), } } - _ => Err("Invalid response".to_string()), - }, - Err(err) => Err(err.error_message), - }; - - if let Err(err) = result { - reject_promise(get_promise, err); - return; + } + _ => Err("Invalid response".to_string()), } + .map_err(anyhow::Error::msg)?; let mut dictionary_result = Dictionary::new(); - let pointers_result = result.unwrap(); for item in pointers_result.iter_shared() { let Ok(mut dict) = item.try_to::() else { continue; @@ -133,5 +119,5 @@ pub async fn request_wearables( } } - resolve_promise(get_promise, Some(dictionary_result.to_variant())); + Ok(Some(dictionary_result.to_variant())) } diff --git a/rust/decentraland-godot-lib/src/dcl/js/engine.rs b/rust/decentraland-godot-lib/src/dcl/js/engine.rs index 8b07f73d..15719be8 100644 --- a/rust/decentraland-godot-lib/src/dcl/js/engine.rs +++ b/rust/decentraland-godot-lib/src/dcl/js/engine.rs @@ -30,6 +30,7 @@ pub fn ops() -> Vec { vec![ op_crdt_send_to_renderer::DECL, op_crdt_recv_from_renderer::DECL, + op_run_async::DECL, ] } @@ -79,6 +80,11 @@ fn op_crdt_send_to_renderer(op_state: Rc>, messages: &[u8]) { .expect("error sending scene response!!") } +#[op(v8)] +async fn op_run_async(op_state: Rc>) { + let _ = op_state.borrow_mut(); +} + #[op(v8)] async fn op_crdt_recv_from_renderer(op_state: Rc>) -> Vec> { let dying = op_state.borrow().borrow::().0; diff --git a/rust/decentraland-godot-lib/src/dcl/js/js_modules/CommunicationsController.js b/rust/decentraland-godot-lib/src/dcl/js/js_modules/CommunicationsController.js index 774e1598..c9a808a6 100644 --- a/rust/decentraland-godot-lib/src/dcl/js/js_modules/CommunicationsController.js +++ b/rust/decentraland-godot-lib/src/dcl/js/js_modules/CommunicationsController.js @@ -1 +1,2 @@ -module.exports.send = async function (body) { return {} } \ No newline at end of file +module.exports.send = async function (body) { return {} } +module.exports.sendBinary = async function (body) { return { data: [] } } \ No newline at end of file diff --git a/rust/decentraland-godot-lib/src/dcl/js/js_modules/utils.js b/rust/decentraland-godot-lib/src/dcl/js/js_modules/utils.js new file mode 100644 index 00000000..7d1ee07c --- /dev/null +++ b/rust/decentraland-godot-lib/src/dcl/js/js_modules/utils.js @@ -0,0 +1,3 @@ +module.exports.run_async = async function () { + await Deno.core.ops.op_run_async(); +} \ No newline at end of file diff --git a/rust/decentraland-godot-lib/src/dcl/js/mod.rs b/rust/decentraland-godot-lib/src/dcl/js/mod.rs index 2de6356e..f514cc1a 100644 --- a/rust/decentraland-godot-lib/src/dcl/js/mod.rs +++ b/rust/decentraland-godot-lib/src/dcl/js/mod.rs @@ -254,6 +254,17 @@ pub(crate) fn scene_thread( Ok(script) => script, }; + let utils_script = rt.block_on(async { + runtime.execute_script("", ascii_str!("require (\"~utils.js\")")) + }); + let utils_script = match utils_script { + Err(e) => { + tracing::error!("[scene thread {scene_id:?}] utils script load error: {}", e); + return; + } + Ok(script) => script, + }; + let result = rt.block_on(async { run_script(&mut runtime, &script, "onStart", |_| Vec::new()).await }); if let Err(e) = result { @@ -261,6 +272,15 @@ pub(crate) fn scene_thread( return; } + // instead of using run_event_loop for polling, this is a workaround to resolve pending promises + let result = rt.block_on(async { + run_script(&mut runtime, &utils_script, "run_async", |_| Vec::new()).await + }); + if let Err(e) = result { + tracing::error!("[scene thread {scene_id:?}] script load running: {}", e); + return; + } + let start_time = std::time::SystemTime::now(); let mut elapsed = Duration::default(); @@ -378,6 +398,7 @@ fn op_require( match module_spec.as_str() { // user module load "~scene.js" => Ok(state.take::().0), + "~utils.js" => Ok(include_str!("js_modules/utils.js").to_owned()), // core module load "~system/CommunicationsController" => { Ok(include_str!("js_modules/CommunicationsController.js").to_owned()) diff --git a/rust/decentraland-godot-lib/src/godot_classes/dcl_ui_background.rs b/rust/decentraland-godot-lib/src/godot_classes/dcl_ui_background.rs index 93b0134f..584ffe57 100644 --- a/rust/decentraland-godot-lib/src/godot_classes/dcl_ui_background.rs +++ b/rust/decentraland-godot-lib/src/godot_classes/dcl_ui_background.rs @@ -23,6 +23,7 @@ pub struct DclUiBackground { waiting_hash: GString, texture_loaded: bool, + first_texture_load_shot: bool, } #[godot_api] @@ -33,6 +34,7 @@ impl INode for DclUiBackground { current_value: PbUiBackground::default(), waiting_hash: GString::default(), texture_loaded: false, + first_texture_load_shot: false, } } @@ -100,7 +102,11 @@ impl DclUiBackground { .bind_mut() .get_texture_from_hash(self.waiting_hash.clone()) else { - tracing::error!("trying to set texture not found: {}", self.waiting_hash); + if self.first_texture_load_shot { + self.first_texture_load_shot = false; + } else { + tracing::error!("trying to set texture not found: {}", self.waiting_hash); + } return; }; self.texture_loaded = true; @@ -232,6 +238,7 @@ impl DclUiBackground { ); } + self.first_texture_load_shot = true; self.base.call_deferred("_on_texture_loaded".into(), &[]); } DclSourceTex::VideoTexture(_) => { diff --git a/rust/decentraland-godot-lib/src/http_request/http_queue_requester.rs b/rust/decentraland-godot-lib/src/http_request/http_queue_requester.rs index 10cd2a5a..477e0ea2 100644 --- a/rust/decentraland-godot-lib/src/http_request/http_queue_requester.rs +++ b/rust/decentraland-godot-lib/src/http_request/http_queue_requester.rs @@ -2,6 +2,7 @@ use reqwest::Client; use std::cmp::Ordering; use std::collections::BinaryHeap; use std::sync::{Arc, Mutex}; +use tokio::io::AsyncWriteExt; use tokio::sync::{oneshot, Semaphore}; use super::request_response::{ @@ -131,12 +132,13 @@ impl HttpQueueRequester { } ResponseType::ToFile(file_path) => { let content = response.bytes().await.map_err(map_err_func)?.to_vec(); - let mut file = - std::fs::File::create(file_path.clone()).map_err(|e| RequestResponseError { + let mut file = tokio::fs::File::create(file_path.clone()) + .await + .map_err(|e| RequestResponseError { id: request_option.id, error_message: e.to_string(), })?; - let result = std::io::Write::write_all(&mut file, &content); + let result = file.write_all(&content).await; let result = result.map(|_| file_path); ResponseEnum::ToFile(result) } diff --git a/rust/decentraland-godot-lib/src/scene_runner/components/ui/scene_ui.rs b/rust/decentraland-godot-lib/src/scene_runner/components/ui/scene_ui.rs index 796925e6..8d214e75 100644 --- a/rust/decentraland-godot-lib/src/scene_runner/components/ui/scene_ui.rs +++ b/rust/decentraland-godot-lib/src/scene_runner/components/ui/scene_ui.rs @@ -255,6 +255,12 @@ pub fn update_scene_ui( && dirty_lww_components .get(&SceneComponentId::UI_TEXT) .is_none() + && dirty_lww_components + .get(&SceneComponentId::UI_DROPDOWN) + .is_none() + && dirty_lww_components + .get(&SceneComponentId::UI_INPUT) + .is_none() && !need_update_ui_canvas; if need_update_ui_canvas {