diff --git a/.github/actions/create-native-build/action.yaml b/.github/actions/create-native-build/action.yaml index 09f49152..840d1149 100644 --- a/.github/actions/create-native-build/action.yaml +++ b/.github/actions/create-native-build/action.yaml @@ -52,16 +52,32 @@ runs: mv api/ windows cd ../../ - - name: Installing FMOD on Linux & Android + - name: Installing FMOD on Linux, Android & Web if: runner.os == 'Linux' shell: ${{ inputs.shell }} run: | cd .. mkdir libs && cd libs mkdir fmod && cd fmod - python ../../fmod-gdextension/get_fmod.py ${{inputs.fmod-user}} ${{inputs.fmod-password}} ${{inputs.platform}} ${{env.FMOD_VERSION}} - tar -xvf fmodstudioapi${{env.FMOD_VERSION}}${{inputs.fmod-executable-suffix}} - mv fmodstudioapi${{env.FMOD_VERSION}}${{inputs.platform}}/api ${{inputs.platform}} + PLATFORM_FOR_FMOD=${{ inputs.platform }} + if [ "$PLATFORM_FOR_FMOD" = "web" ]; then + PLATFORM_FOR_FMOD="html5" + fi + python ../../fmod-gdextension/get_fmod.py ${{inputs.fmod-user}} ${{inputs.fmod-password}} $PLATFORM_FOR_FMOD ${{env.FMOD_VERSION}} + if [ "$PLATFORM_FOR_FMOD" = "html5" ]; then + unzip fmodstudioapi${{env.FMOD_VERSION}}${{inputs.fmod-executable-suffix}} + else + tar -xvf fmodstudioapi${{env.FMOD_VERSION}}${{inputs.fmod-executable-suffix}} + fi + FMOD_EXTRACT_DIR=$(find . -maxdepth 3 -type d -name "fmodstudioapi${{env.FMOD_VERSION}}${PLATFORM_FOR_FMOD}" | head -n 1) + if [ -z "$FMOD_EXTRACT_DIR" ]; then + echo "FMOD archive directory not found" && exit 1 + fi + if [ "${{ inputs.platform }}" = "web" ]; then + mv "$FMOD_EXTRACT_DIR/api" . + else + mv "$FMOD_EXTRACT_DIR/api" ${{inputs.platform}} + fi cd ../../ - name: Installing FMOD on MacOS & iOS diff --git a/.github/workflows/check_pr.yml b/.github/workflows/check_pr.yml index 338efbb5..0e530b5e 100644 --- a/.github/workflows/check_pr.yml +++ b/.github/workflows/check_pr.yml @@ -11,7 +11,7 @@ env: NDK_VERSION: 27.3.13750724 TARGET_PATH: demo/addons/fmod/libs/ TARGET_NAME: libGodotFmod - FMOD_VERSION: 20306 + FMOD_VERSION: 20311 jobs: gate: runs-on: ubuntu-latest @@ -152,6 +152,20 @@ jobs: fmod-executable-suffix: ios.dmg shell: bash + - name: Web Debug Compilation + os: "ubuntu-22.04" + platform: web + target: template_debug + fmod-executable-suffix: html5.zip + shell: bash + + - name: Web Release Compilation + os: "ubuntu-22.04" + platform: web + target: template_release + fmod-executable-suffix: html5.zip + shell: bash + steps: - name: Checkout (in-repo PR) if: github.event_name == 'pull_request' @@ -176,6 +190,13 @@ jobs: ndk-version: r23c link-to-sdk: true + - name: Install Emscripten SDK + if: matrix.platform == 'web' + uses: mymindstorm/setup-emsdk@v14 + with: + version: 3.1.56 + actions-cache-key: emsdk-cache + - name: Compile native plugin uses: ./.github/actions/create-native-build with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9a970a7..b5db2434 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ env: NDK_VERSION: 27.3.13750724 TARGET_PATH: demo/addons/fmod/libs/ TARGET_NAME: libGodotFmod - FMOD_VERSION: 20306 + FMOD_VERSION: 20311 jobs: build: name: ${{ matrix.name }} diff --git a/.gitignore b/.gitignore index 478ede43..8a56a263 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,6 @@ config.log demo/addons/fmod/libs/android/aar/ *.pdb -.vs \ No newline at end of file +.vs + +*.wasm \ No newline at end of file diff --git a/SConstruct b/SConstruct index 16d843e9..c9944783 100644 --- a/SConstruct +++ b/SConstruct @@ -9,6 +9,12 @@ target_path = ARGUMENTS.pop("target_path", "demo/addons/fmod/libs/") target_name = ARGUMENTS.pop("target_name", "libGodotFmod") fmod_lib_dir = ARGUMENTS.pop("fmod_lib_dir", "../libs/fmod/") +# Although FMOD's WebAssembly libraries are built with pthreads / shared memory support, Godot doesn't yet like it. +# For web builds, default to single-threaded godot-cpp (unless the user explicitly overrides it). +platform_arg = ARGUMENTS.get("platform") +if platform_arg == "web" and "threads" not in ARGUMENTS: + ARGUMENTS["threads"] = "no" + env = SConscript("godot-cpp/SConstruct") # Add those directory manually, so we can skip the godot_cpp directory when including headers in C++ files @@ -122,6 +128,21 @@ elif env["platform"] == "android": env.Append(LIBPATH=[env['fmod_lib_dir'] + 'android/core/lib/' + arch_dir, env['fmod_lib_dir'] + 'android/studio/lib/' + arch_dir]) env.Append(LIBS=[libfmod, libfmodstudio]) +elif env["platform"] == "web": + html_lib = os.path.join(fmod_lib_dir, 'api/studio/lib/w32/') + html_inc = os.path.join(fmod_lib_dir, 'api/studio/inc/') + + html_core_lib = os.path.join(fmod_lib_dir, 'api/core/lib/w32/') + html_core_inc = os.path.join(fmod_lib_dir, 'api/core/inc/') + + libfmodstudio_path = os.path.join(html_lib, 'fmodstudio%s_wasm.a' % lfix) + + env.Append(CPPPATH=[html_inc, html_core_inc]) + env.Append(LIBPATH=[html_lib]) + # env.Append(LIBS=[libfmodstudio]) # ← REMOVE or comment this out + env.Append(LINKFLAGS=[libfmodstudio_path]) + env.Append(LINKFLAGS=["-sEXPORTED_RUNTIME_METHODS=ccall,cwrap,setValue,getValue"]) + #Output is placed in the addons directory of the demo project directly target = "{}{}/{}.{}.{}".format( target_path, env["platform"], target_name, env["platform"], env["target"] @@ -200,8 +221,9 @@ def copy_fmod_libraries(self, arg, env, executor = None): source_files = [env.Glob(os.path.join(source_dir, '*.*')) for source_dir in [fmod_core_lib_dir, fmod_studio_lib_dir]] [[shutil.copy(str(file), addon_fmod_libs_output) for file in files] for files in source_files] - -copy_fmod_libraries_action = Action('', copy_fmod_libraries) -AddPostAction(library, copy_fmod_libraries_action) +# web bundles everything inside the final .wasm - no need to export libs +if env["platform"] != "web": + copy_fmod_libraries_action = Action('', copy_fmod_libraries) + AddPostAction(library, copy_fmod_libraries_action) Default(library) \ No newline at end of file diff --git a/demo/addons/fmod/fmod.gdextension b/demo/addons/fmod/fmod.gdextension index eacba962..6540cd47 100644 --- a/demo/addons/fmod/fmod.gdextension +++ b/demo/addons/fmod/fmod.gdextension @@ -18,6 +18,9 @@ android.debug.arm64 = "res://addons/fmod/libs/android/arm64/libGodotFmod.android android.release.arm64 = "res://addons/fmod/libs/android/arm64/libGodotFmod.android.template_release.arm64.so" ios.debug = "res://addons/fmod/libs/ios/libGodotFmod.ios.template_debug.xcframework" ios.release = "res://addons/fmod/libs/ios/libGodotFmod.ios.template_release.xcframework" +web.editor = "res://addons/fmod/libs/web/libGodotFmod.web.editor.wasm32.wasm" +web.debug = "res://addons/fmod/libs/web/libGodotFmod.web.template_debug.wasm32.wasm" +web.release = "res://addons/fmod/libs/web/libGodotFmod.web.template_release.wasm32.wasm" [icons] FmodEventEmitter2D = "res://addons/fmod/icons/fmod_icon.svg" diff --git a/demo/export_presets.cfg b/demo/export_presets.cfg index f4347b9b..c7a6052d 100644 --- a/demo/export_presets.cfg +++ b/demo/export_presets.cfg @@ -35,14 +35,14 @@ package/signed=true package/app_category=2 package/retain_data_on_uninstall=false package/exclude_from_recents=false +package/show_in_android_tv=false +package/show_in_app_library=true +package/show_as_launcher_app=false launcher_icons/main_192x192="" launcher_icons/adaptive_foreground_432x432="" launcher_icons/adaptive_background_432x432="" graphics/opengl_debug=false xr_features/xr_mode=0 -xr_features/hand_tracking=0 -xr_features/hand_tracking_frequency=0 -xr_features/passthrough=0 screen/immersive_mode=true screen/support_small=true screen/support_normal=true @@ -200,6 +200,9 @@ permissions/write_sms=false permissions/write_social_stream=false permissions/write_sync_settings=false permissions/write_user_dictionary=false +xr_features/hand_tracking=0 +xr_features/hand_tracking_frequency=0 +xr_features/passthrough=0 graphics/32_bits_framebuffer=true xr_features/degrees_of_freedom=0 one_click_deploy/clear_previous_install=false @@ -251,6 +254,7 @@ application/product_name="" application/file_description="" application/copyright="" application/trademarks="" +application/export_angle=0 ssh_remote_deploy/enabled=false ssh_remote_deploy/host="user@host_ip" ssh_remote_deploy/port="22" @@ -304,6 +308,7 @@ application/short_version="1.0" application/version="1.0" application/icon_interpolation=4 application/launch_screens_interpolation=4 +application/export_project_only=false capabilities/access_wifi=false capabilities/push_notifications=false user_data/accessible_from_files_app=false @@ -398,6 +403,7 @@ application/version="1.0" application/copyright="" application/copyright_localized={} application/min_macos_version="10.12" +application/export_angle=0 display/high_res=true xcode/platform_build="14C18" xcode/sdk_version="13.1" @@ -431,6 +437,7 @@ codesign/entitlements/app_sandbox/files_downloads=0 codesign/entitlements/app_sandbox/files_pictures=0 codesign/entitlements/app_sandbox/files_music=0 codesign/entitlements/app_sandbox/files_movies=0 +codesign/entitlements/app_sandbox/files_user_selected=0 codesign/entitlements/app_sandbox/helper_executables=[] codesign/custom_options=PackedStringArray() notarization/notarization=0 @@ -467,3 +474,41 @@ open \"{temp_dir}/{exe_name}.app\" --args {cmd_args}" ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash kill $(pgrep -x -f \"{temp_dir}/{exe_name}.app/Contents/MacOS/{exe_name} {cmd_args}\") rm -rf \"{temp_dir}\"" + +[preset.4] + +name="Web" +platform="Web" +runnable=true +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="../../../../Desktop/exp/test/index.html" +encryption_include_filters="" +encryption_exclude_filters="" +encrypt_pck=false +encrypt_directory=false + +[preset.4.options] + +custom_template/debug="" +custom_template/release="" +variant/extensions_support=true +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=false +html/export_icon=true +html/custom_html_shell="" +html/head_include="" +html/canvas_resize_policy=2 +html/focus_canvas_on_start=true +html/experimental_virtual_keyboard=false +progressive_web_app/enabled=false +progressive_web_app/offline_page="" +progressive_web_app/display=1 +progressive_web_app/orientation=0 +progressive_web_app/icon_144x144="" +progressive_web_app/icon_180x180="" +progressive_web_app/icon_512x512="" +progressive_web_app/background_color=Color(0, 0, 0, 1) diff --git a/demo/test/unit/test_listener.gd b/demo/test/unit/test_listener.gd index 570f428d..e186bbc8 100644 --- a/demo/test/unit/test_listener.gd +++ b/demo/test/unit/test_listener.gd @@ -45,13 +45,13 @@ class TestListener: func test_assert_should_have_proper_weight(): var desiredValue: float = 1.0 assert_listener_weight(0, desiredValue) - desiredValue = 2.0 + desiredValue = 0.5 FmodServer.set_listener_weight(0, desiredValue) assert_listener_weight(0, desiredValue) desiredValue = 1.0 FmodServer.set_listener_weight(0, desiredValue) FmodServer.set_listener_number(2) - desiredValue = 2.0 + desiredValue = 0.5 FmodServer.add_listener(1, sprite) FmodServer.set_listener_weight(1, desiredValue) assert_listener_weight(1, desiredValue) diff --git a/get_fmod.py b/get_fmod.py index ce3e4485..884d08fb 100644 --- a/get_fmod.py +++ b/get_fmod.py @@ -30,6 +30,10 @@ # iOS... filename = f'fmodstudioapi{fmod_version}ios.dmg' downloadlink = f'https://www.fmod.com/api-get-download-link?path=files/fmodstudio/api/iOS/&filename=fmodstudioapi{fmod_version}ios-installer.dmg&user=$1' +elif platform == 'html5': + # HTML5 + filename = f'fmodstudioapi{fmod_version}html5.zip' + downloadlink = f'https://www.fmod.com/api-get-download-link?path=files/fmodstudio/api/HTML5/&filename=fmodstudioapi{fmod_version}html5.zip&user=' downloadlink += user diff --git a/src/callback/file_callbacks.cpp b/src/callback/file_callbacks.cpp index cd0c9cd8..abd709a7 100644 --- a/src/callback/file_callbacks.cpp +++ b/src/callback/file_callbacks.cpp @@ -8,6 +8,7 @@ namespace Callbacks { } void GodotFileRunner::queueReadRequest(FMOD_ASYNCREADINFO* request, ReadPriority priority) { +#if !defined(WEB_ENABLED) // High-priority requests have to be processed first. if (priority == ReadPriority::HIGH) { // lock so we can't add and remove elements from the queue at the same time. @@ -19,9 +20,16 @@ namespace Callbacks { requests.push_back(request); } read_cv.notify_one(); +#else + // On Web, threaded async IO is disabled. These callbacks should not be + // used because we don't register a custom file system in that build. + (void)request; + (void)priority; +#endif } FMOD_RESULT GodotFileRunner::cancelReadRequest(FMOD_ASYNCREADINFO* request) { +#if !defined(WEB_ENABLED) // lock so we can't add and remove elements from the queue at the same time. { std::lock_guard lk(read_mut); @@ -40,8 +48,14 @@ namespace Callbacks { } return FMOD_RESULT::FMOD_OK; +#else + (void)request; + return FMOD_RESULT::FMOD_OK; +#endif } +// The worker thread entry point exists only on non-web platforms. +#if !defined(WEB_ENABLED) void GodotFileRunner::run() { while (!stop) { // waiting for the container to have one request @@ -88,17 +102,23 @@ namespace Callbacks { } } } - +#endif void GodotFileRunner::start() { +#if !defined(WEB_ENABLED) stop = false; fileThread = std::thread(&GodotFileRunner::run, this); +#endif } void GodotFileRunner::finish() { +#if !defined(WEB_ENABLED) stop = true; // we need to notify the loop one last time, otherwise it will stay stuck in the wait method. read_cv.notify_one(); - fileThread.join(); + if (fileThread.joinable()) { + fileThread.join(); + } +#endif } FMOD_RESULT F_CALL godotFileOpen(const char* name, unsigned int* filesize, void** handle, void* userdata) { @@ -119,6 +139,41 @@ namespace Callbacks { return FMOD_RESULT::FMOD_OK; } + FMOD_RESULT F_CALL godotFileRead(void* handle, void* buffer, unsigned int sizebytes, unsigned int* bytesread, void* userdata) { + GodotFileHandle* fileHandle {reinterpret_cast(handle)}; + godot::Ref file {fileHandle->file}; + + godot::PackedByteArray data = file->get_buffer(sizebytes); + const int read_size = static_cast(data.size()); + + if (read_size > 0) { + memcpy(buffer, data.ptr(), read_size); + } + + if (bytesread) { + *bytesread = static_cast(read_size); + } + + if (read_size < static_cast(sizebytes)) { + return FMOD_RESULT::FMOD_ERR_FILE_EOF; + } + + return FMOD_RESULT::FMOD_OK; + } + + FMOD_RESULT F_CALL godotFileSeek(void* handle, unsigned int pos, void* userdata) { + GodotFileHandle* fileHandle {reinterpret_cast(handle)}; + godot::Ref file {fileHandle->file}; + + file->seek(pos); + + if (file->get_position() != pos) { + return FMOD_RESULT::FMOD_ERR_FILE_COULDNOTSEEK; + } + + return FMOD_RESULT::FMOD_OK; + } + FMOD_RESULT F_CALL godotSyncRead(FMOD_ASYNCREADINFO* info, void* userdata) { GodotFileRunner* runner {GodotFileRunner::get_singleton()}; int priority {info->priority}; diff --git a/src/callback/file_callbacks.h b/src/callback/file_callbacks.h index d44fed03..0975a915 100644 --- a/src/callback/file_callbacks.h +++ b/src/callback/file_callbacks.h @@ -6,9 +6,13 @@ #include #include -#include #include // This include is required for both Linux and MacOS targets as they don't include the necessary headers for 'memcpy' by default + +#if !defined(WEB_ENABLED) +#include #include +#include +#endif namespace Callbacks { struct GodotFileHandle { @@ -27,6 +31,7 @@ namespace Callbacks { ~GodotFileRunner() = default; private: +#if !defined(WEB_ENABLED) std::thread fileThread; std::condition_variable read_cv; @@ -42,9 +47,8 @@ namespace Callbacks { GodotFileRunner() = default; GodotFileRunner(const GodotFileRunner&) = delete; GodotFileRunner& operator=(const GodotFileRunner&) = delete; - void run(); - +#endif public: void queueReadRequest(FMOD_ASYNCREADINFO* request, ReadPriority priority); FMOD_RESULT cancelReadRequest(FMOD_ASYNCREADINFO* request); @@ -55,6 +59,10 @@ namespace Callbacks { FMOD_RESULT F_CALL godotFileClose(void* handle, void* userdata); + FMOD_RESULT F_CALL godotFileRead(void* handle, void* buffer, unsigned int sizebytes, unsigned int* bytesread, void* userdata); + + FMOD_RESULT F_CALL godotFileSeek(void* handle, unsigned int pos, void* userdata); + FMOD_RESULT F_CALL godotSyncRead(FMOD_ASYNCREADINFO* info, void* userdata); FMOD_RESULT F_CALL godotSyncCancel(FMOD_ASYNCREADINFO* info, void* userdata); diff --git a/src/fmod_cache.cpp b/src/fmod_cache.cpp index bc874b5c..2bd804f6 100644 --- a/src/fmod_cache.cpp +++ b/src/fmod_cache.cpp @@ -47,7 +47,11 @@ bool FmodCache::is_loading() { Ref FmodCache::add_bank(const String& bank_path, unsigned int flag) { FMOD::Studio::Bank* bank = nullptr; - ERROR_CHECK_WITH_REASON(system->loadBankFile(bank_path.utf8().get_data(), flag, &bank), vformat("Cannot load bank %s", bank_path)); + ERROR_CHECK_WITH_REASON( + system->loadBankFile(bank_path.utf8().get_data(), flag, &bank), + vformat("Cannot load bank %s", bank_path) + ); + if (!bank) { return {}; } Ref ref = FmodBank::create_ref(bank, bank_path); GODOT_LOG_VERBOSE("FMOD Sound System: LOADING BANK " + String(bank_path)) diff --git a/src/fmod_server.cpp b/src/fmod_server.cpp index 11452a78..8059f0ec 100644 --- a/src/fmod_server.cpp +++ b/src/fmod_server.cpp @@ -148,7 +148,9 @@ FmodServer::FmodServer() : singleton = this; callback_mutex.instantiate(); performanceData = create_ref(); +#if !defined(WEB_ENABLED) Callbacks::GodotFileRunner::get_singleton()->start(); +#endif } FmodServer::~FmodServer() { @@ -212,11 +214,33 @@ void FmodServer::init(const Ref& p_settings) { } } +#if defined(WEB_ENABLED) + if (ERROR_CHECK( + coreSystem->setFileSystem( + &Callbacks::godotFileOpen, + &Callbacks::godotFileClose, + &Callbacks::godotFileRead, + &Callbacks::godotFileSeek, + nullptr, + nullptr, + -1) + )) { + GODOT_LOG_VERBOSE("Custom File System enabled (sync, Web).") + } +#else if (ERROR_CHECK( - coreSystem->setFileSystem(&Callbacks::godotFileOpen, &Callbacks::godotFileClose, nullptr, nullptr, &Callbacks::godotSyncRead, &Callbacks::godotSyncCancel, -1) + coreSystem->setFileSystem( + &Callbacks::godotFileOpen, + &Callbacks::godotFileClose, + nullptr, + nullptr, + &Callbacks::godotSyncRead, + &Callbacks::godotSyncCancel, + -1) )) { GODOT_LOG_VERBOSE("Custom File System enabled.") } +#endif cache = new FmodCache(system, coreSystem); } @@ -329,6 +353,8 @@ void FmodServer::set_system_listener_number(int p_listenerNumber) { void FmodServer::add_listener(int index, Node* game_obj) { if (index >= 0 && index < systemListenerNumber) { Listener* listener = &listeners[index]; + if (listener->weight < 0.0f) { listener->weight = 0.0f; } + if (listener->weight > 1.0f) { listener->weight = 1.0f; } listener->wrapper.set_node(game_obj); ERROR_CHECK_WITH_REASON( system->setListenerWeight(index, listener->weight), @@ -370,6 +396,8 @@ float FmodServer::get_system_listener_weight(const int index) { void FmodServer::set_system_listener_weight(const int index, float weight) { if (index >= 0 && index < systemListenerNumber) { + if (weight < 0.0f) { weight = 0.0f; } + if (weight > 1.0f) { weight = 1.0f; } listeners[index].weight = weight; ERROR_CHECK_WITH_REASON(system->setListenerWeight(index, weight), vformat("Cannot set listener %d weight to %f", index, weight)); } else {