diff --git a/.gitignore b/.gitignore index b3f34dd6..1cd3248b 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ platform/android/apk/app/src/main/jniLibs/ # Filled by `make sync/android-assets` (source: platform/android/game-shell or example/). platform/android/apk/app/src/main/assets/game/* !platform/android/apk/app/src/main/assets/game/.gitkeep +# Filled by `make sync/android-test-assets` (zeroerr suite + examples on-device tree). +platform/android/apk/app/src/main/assets/test/ +platform/android/apk/app/src/main/assets/examples/ diff --git a/CMakeLists.txt b/CMakeLists.txt index e234105b..af5f8da3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,10 +7,27 @@ set(EVENGINE_PATCH_VERSION "0") option(BUILD_TESTING "enable testing" ON) option(EVENGINE_BUILD_DEMO "Build optional Demo module (procedural demo assets + default demo.nut)" ON) +set(EVENGINE_THIRD_PARTY_BINARY_DIR "" CACHE PATH + "Use a prebuilt third-party install tree (include/lib/bin) without rebuilding or modifying it") +set(EVENGINE_PREBUILT_THIRD_PARTY "" CACHE PATH + "Compatibility alias for EVENGINE_THIRD_PARTY_BINARY_DIR") set(EVENGINE_THIRD_PARTY_ROOT "" CACHE PATH - "Read-only installed third-party prefix (include/, lib/); skips third-party source checkout") + "Deprecated compatibility alias for EVENGINE_THIRD_PARTY_BINARY_DIR") +if(NOT EVENGINE_THIRD_PARTY_BINARY_DIR) + if(EVENGINE_PREBUILT_THIRD_PARTY) + set(EVENGINE_THIRD_PARTY_BINARY_DIR "${EVENGINE_PREBUILT_THIRD_PARTY}" CACHE PATH + "Use a prebuilt third-party install tree" FORCE) + elseif(EVENGINE_THIRD_PARTY_ROOT) + set(EVENGINE_THIRD_PARTY_BINARY_DIR "${EVENGINE_THIRD_PARTY_ROOT}" CACHE PATH + "Use a prebuilt third-party install tree" FORCE) + endif() +endif() +option(EVENGINE_ANDROID_TEST_APP + "Build the unit_test suite as an Android test app (libmain.so entry replaced by the zeroerr runner)" + OFF) message("Build UnitTest: " ${BUILD_TESTING}) message("Build Demo module: " ${EVENGINE_BUILD_DEMO}) +message("Build Android test app: " ${EVENGINE_ANDROID_TEST_APP}) if (CMAKE_BUILD_TYPE STREQUAL "") set(CMAKE_BUILD_TYPE "Debug" CACHE STRING "The build type (Debug, Release)") endif() @@ -22,6 +39,7 @@ include(CTest) function(main) get_available_build() load_third_party() + load_webgpu_backend() set_include() check_ipo() set(CMAKE_CXX_STANDARD 20) @@ -39,8 +57,7 @@ function(main) # /EHsc: CMAKE_CXX_FLAGS is empty in this tree, so MSVC/clang-cl otherwise compile # with exceptions off. clangd then errors on try/throw (zeroerr ASSERT, Exception). if(MSVC) - # /FS serializes writes to a target's compiler PDB. Visual Studio may launch - # multiple cl.exe instances for one target, otherwise causing C1041. + # /FS serializes writes to a target PDB when MSBuild schedules several cl.exe jobs. add_compile_options(/utf-8 /EHsc /FS) endif() @@ -50,13 +67,64 @@ function(main) set(USE_MOLD OFF CACHE BOOL "" FORCE) set(COLORFUL_OUTPUT OFF CACHE STRING "" FORCE) if(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) - set(BUILD_TESTING OFF CACHE BOOL "enable testing" FORCE) - message(STATUS "Android build: unit tests disabled") + if(EVENGINE_ANDROID_TEST_APP) + message(STATUS "Android build: unit tests enabled (Android test app)") + else() + set(BUILD_TESTING OFF CACHE BOOL "enable testing" FORCE) + message(STATUS "Android build: unit tests disabled") + endif() endif() if(CMAKE_SYSTEM_NAME STREQUAL "iOS") set(BUILD_TESTING OFF CACHE BOOL "enable testing" FORCE) message(STATUS "iOS build: unit tests disabled") endif() + # WebGPU (browser WASM + native Dawn): unit tests are disabled by default — + # most tests link Vulkan-only helpers. Native Dawn builds can opt back in + # with -DBUILD_TESTING=ON once the test tree is backend-neutral. + if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + set(BUILD_TESTING OFF CACHE BOOL "enable testing" FORCE) + message(STATUS "Emscripten/webgpu build: unit tests disabled") + # Emdawnwebgpu port: provides + + # and the browser WebGPU JS glue. Directory-scope so every module target + # (EVGraphics, EVUI, EVGpgpu, eve_imgui, ...) gets it at compile + link. + add_compile_options(--use-port=emdawnwebgpu) + add_link_options(--use-port=emdawnwebgpu) + # SDL2 and zlib come from Emscripten ports (vendored trees are skipped + # by third-party/CMakeLists.txt on Emscripten). -sUSE_SDL=2 must also be + # a compile flag so TUs that include (imgui_impl_sdl, window, + # event, mouse, keyboard, ...) resolve the port headers. + add_compile_options(-sUSE_SDL=2 -sUSE_ZLIB) + add_link_options(-sUSE_SDL=2 -sUSE_ZLIB) + # PROXY_TO_PTHREAD turns on shared memory; every TU must be compiled + # with atomics/bulk-memory or wasm-ld rejects the final link. + add_compile_options(-matomics -mbulk-memory) + # Trimmed third-party / module set for the WASM runtime. + set(EVENGINE_WEBGPU_TRIM TRUE CACHE BOOL "Trimmed dependency/module set for Emscripten" FORCE) + # Emscripten 6.x vendors a fork of CheckTypeSize.cmake that is + # incompatible with CMake >= 4.2 (mpg123/physfs/... try_compile fail + # with "/CheckTypeSize.c.in does not exist"). Force a CMake 3.29.x for + # Emscripten builds: it must be on PATH (or pip-installed) so that both + # this configure and the ExternalProject sub-builds use it. + find_program(EVENGINE_WEB_CMAKE NAMES cmake + PATHS "$ENV{APPDATA}/Python/Python314/Scripts" + "$ENV{APPDATA}/Python/Python311/Scripts" + "$ENV{USERPROFILE}/.local/bin") + if(EVENGINE_WEB_CMAKE) + execute_process(COMMAND "${EVENGINE_WEB_CMAKE}" --version + OUTPUT_VARIABLE _eve_cmake_ver OUTPUT_STRIP_TRAILING_WHITESPACE) + if(_eve_cmake_ver MATCHES "cmake version 3\.2[0-9]\.") + message(STATUS "Emscripten build: using CMake ${_eve_cmake_ver} from ${EVENGINE_WEB_CMAKE}") + set(CMAKE_COMMAND "${EVENGINE_WEB_CMAKE}" CACHE INTERNAL "CMake executable (forced 3.29 for Emscripten)") + else() + message(WARNING "Emscripten build needs CMake 3.29.x (found: ${_eve_cmake_ver}). " + "Install with: python -m pip install cmake==3.29.6") + endif() + else() + message(WARNING "Emscripten build needs CMake 3.29.x on PATH (pip: python -m pip install cmake==3.29.6)") + endif() + elseif(BUILD_PLATFORM STREQUAL "webgpu" AND BUILD_TESTING) + message(STATUS "webgpu (native Dawn) build: unit tests enabled") + endif() add_subdirectory(external/zeroerr) target_compile_definitions(zeroerr PUBLIC ZEROERR_NO_MAIN) # Strip ASSERT macros in non-Debug (upstream ZEROERR_NO_ASSERT under NDEBUG is commented out). @@ -89,6 +157,8 @@ macro(create_module libname folder) execute_process(COMMAND ${CMAKE_COMMAND} -Doutput_dir=${CMAKE_CURRENT_BINARY_DIR} -Dmodule_name=${folder} + -Dexclude_dirs=${EVENGINE_EXCLUDED_BACKEND_SUBDIRS} + "-Dexclude_files=${EVENGINE_EXCLUDED_MODULE_FILES}" -P ${CMAKE_SOURCE_DIR}/cmake/scan_source.cmake WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}) @@ -109,7 +179,10 @@ macro(create_module libname folder) COMMAND ${CMAKE_COMMAND} -Doutput_dir=${CMAKE_CURRENT_BINARY_DIR} -Dbuild_dir=${CMAKE_BINARY_DIR} - -Dmodule_name=${folder} -P ${CMAKE_SOURCE_DIR}/cmake/rescan_source.cmake + -Dmodule_name=${folder} + -Dexclude_dirs=${EVENGINE_EXCLUDED_BACKEND_SUBDIRS} + "-Dexclude_files=${EVENGINE_EXCLUDED_MODULE_FILES}" + -P ${CMAKE_SOURCE_DIR}/cmake/rescan_source.cmake WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} ) @@ -131,6 +204,11 @@ function(set_include) ${CMAKE_CURRENT_SOURCE_DIR}/external/ECS.hpp/src ${CMAKE_CURRENT_SOURCE_DIR}/external/ik.hpp ${CMAKE_CURRENT_SOURCE_DIR}/external/zeroerr/include + # medialoader public headers (image/pixelformat.h etc.) — needed by + # ImageData.h; the vendored tree is present even when the library is + # not linked (Emscripten trim). + ${CMAKE_CURRENT_SOURCE_DIR}/third-party/medialoader/include + ${CMAKE_CURRENT_SOURCE_DIR}/third-party/medialoader/model-libs/assimp/include # Dungeon Template Library (header-only) ${CMAKE_CURRENT_SOURCE_DIR}/external/DungeonTemplateLibrary/include ) @@ -140,6 +218,15 @@ function(set_include) # On iOS, link/embed the SDK's MoltenVK.xcframework (iphoneos arm64 slice). # On Android, link the NDK loader (libvulkan.so). # + # WebGPU builds (browser WASM or native Dawn) use webgpu.h and do not touch + # the Vulkan SDK at all; the Vulkan backend directories are excluded by the + # source scanner, so this probe would only ever FATAL_ERROR a valid build. + if(EVENGINE_WEBGPU) + message(STATUS "WebGPU build: skipping Vulkan SDK probe") + set(EVENGINE_VULKAN_LIB "" CACHE INTERNAL "Vulkan link target (unused on webgpu)") + return() + endif() + # # Windows: installer sets Machine-level VULKAN_SDK, but long-lived IDEs (Cursor) # often still lack it in the process env. Probe C:/VulkanSDK/* when unset. if(WIN32 AND (NOT DEFINED ENV{VULKAN_SDK} OR "$ENV{VULKAN_SDK}" STREQUAL "")) @@ -266,8 +353,13 @@ endfunction() macro(get_available_build) set(AVAILABLE_PLATFORM "") + # Cross-compiling for Emscripten (web/WASM, browser WebGPU) must win over host detection. + if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + list(APPEND AVAILABLE_PLATFORM "webgpu") + set(BUILD_PLATFORM "webgpu" CACHE STRING "The build platfrom (${AVAILABLE_PLATFORM})") + SET_PROPERTY(CACHE BUILD_PLATFORM PROPERTY STRINGS ${AVAILABLE_PLATFORM}) # Cross-compiling for Android (NDK toolchain) must win over host Apple/Linux detection. - if(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) + elseif(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) list(APPEND AVAILABLE_PLATFORM "android") set(BUILD_PLATFORM "android" CACHE STRING "The build platfrom (${AVAILABLE_PLATFORM})") SET_PROPERTY(CACHE BUILD_PLATFORM PROPERTY STRINGS ${AVAILABLE_PLATFORM}) @@ -280,11 +372,13 @@ macro(get_available_build) list(APPEND AVAILABLE_PLATFORM "macosx") list(APPEND AVAILABLE_PLATFORM "ios") list(APPEND AVAILABLE_PLATFORM "android") + list(APPEND AVAILABLE_PLATFORM "webgpu") set(BUILD_PLATFORM "macosx" CACHE STRING "The build platfrom (${AVAILABLE_PLATFORM})") SET_PROPERTY(CACHE BUILD_PLATFORM PROPERTY STRINGS ${AVAILABLE_PLATFORM}) else() # Linux list(APPEND AVAILABLE_PLATFORM "linux") list(APPEND AVAILABLE_PLATFORM "android") + list(APPEND AVAILABLE_PLATFORM "webgpu") set(BUILD_PLATFORM "linux" CACHE STRING "The build platfrom (${AVAILABLE_PLATFORM})") SET_PROPERTY(CACHE BUILD_PLATFORM PROPERTY STRINGS ${AVAILABLE_PLATFORM}) endif() @@ -292,6 +386,7 @@ macro(get_available_build) if(WIN32) list(APPEND AVAILABLE_PLATFORM "win32") list(APPEND AVAILABLE_PLATFORM "android") + list(APPEND AVAILABLE_PLATFORM "webgpu") set(BUILD_PLATFORM "win32" CACHE STRING "The build platfrom (${AVAILABLE_PLATFORM})") MESSAGE("Build Platform: ${BUILD_PLATFORM}") SET_PROPERTY(CACHE BUILD_PLATFORM PROPERTY STRINGS ${AVAILABLE_PLATFORM}) @@ -302,7 +397,7 @@ macro(get_available_build) # when switching BUILD_PLATFORM in the same build tree. foreach(_eve_plat_var IN ITEMS EVENGINE_WINDOWS EVENGINE_MACOSX EVENGINE_LINUX - EVENGINE_ANDROID EVENGINE_IOS) + EVENGINE_ANDROID EVENGINE_IOS EVENGINE_WEBGPU) unset(${_eve_plat_var} CACHE) unset(${_eve_plat_var}) endforeach() @@ -321,6 +416,30 @@ macro(get_available_build) set_platform_var(linux EVENGINE_LINUX) set_platform_var(android EVENGINE_ANDROID) set_platform_var(ios EVENGINE_IOS) + set_platform_var(webgpu EVENGINE_WEBGPU) + + # Backend directories compiled out of the active build. Native backends + # (graphics/vulkan, gpgpu/vulkan) are excluded from a webgpu build; the + # webgpu backend is excluded from every other platform. ui/imgui stays in + # the scan — ImGuiBackend.cpp is the EVUI implementation and gains a + # webgpu branch instead. + if(BUILD_PLATFORM STREQUAL "webgpu") + set(EVENGINE_EXCLUDED_BACKEND_SUBDIRS "vulkan" CACHE INTERNAL "Backend subdirs excluded from webgpu builds") + else() + set(EVENGINE_EXCLUDED_BACKEND_SUBDIRS "webgpu" CACHE INTERNAL "Backend subdirs excluded from native builds") + endif() + + # Poco-heavy sources trimmed from the WASM (Emscripten) build. The data + # module keeps ByteData (needed by gpgpu/webgpu) but drops the JSON/XML/ + # DataModule files that require Poco. graphics/Font.cpp needs the EVFont + # module (FreeType/Poco, skipped on WASM), so it is trimmed as well. + if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + set(EVENGINE_EXCLUDED_MODULE_FILES + "DataModule.cpp;JsonDocument.cpp;XmlDocument.cpp;Font.cpp" + CACHE INTERNAL "Exact basenames excluded from module source scan on Emscripten") + else() + set(EVENGINE_EXCLUDED_MODULE_FILES "" CACHE INTERNAL "Exact basenames excluded from module source scan") + endif() message("Availabe: ${AVAILABLE_PLATFORM}") @@ -415,6 +534,29 @@ endfunction() function(check_third_party_project name repo) include(ExternalProject) + if(EVENGINE_THIRD_PARTY_BINARY_DIR) + get_filename_component(_eve_tp_binary "${EVENGINE_THIRD_PARTY_BINARY_DIR}" ABSOLUTE) + if(NOT IS_DIRECTORY "${_eve_tp_binary}/include" OR + NOT IS_DIRECTORY "${_eve_tp_binary}/lib") + message(FATAL_ERROR + "EVENGINE_THIRD_PARTY_BINARY_DIR must contain include/ and lib/: " + "${_eve_tp_binary}") + endif() + add_custom_target(${name}) + include_directories("${_eve_tp_binary}/include") + # SDL2 and FreeType install their public headers one level below include/. + # Their sources use and , so expose those roots too. + if(IS_DIRECTORY "${_eve_tp_binary}/include/SDL2") + include_directories("${_eve_tp_binary}/include/SDL2") + endif() + if(IS_DIRECTORY "${_eve_tp_binary}/include/freetype2") + include_directories("${_eve_tp_binary}/include/freetype2") + endif() + link_directories("${_eve_tp_binary}/lib") + message(STATUS "third-party: using read-only prebuilt tree ${_eve_tp_binary}") + return() + endif() + if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/${name}/CMakeLists.txt) # This is a git clone version message("load ${name} folder from git") @@ -476,6 +618,17 @@ function(check_third_party_project name repo) # Force Ninja for deps: parent Xcode generator makes SDL try_compile unbearably slow. set(_eve_tp_use_ninja TRUE) message(STATUS "third-party iOS toolchain: ARCH=${CMAKE_OSX_ARCHITECTURES} DEPLOY=${CMAKE_OSX_DEPLOYMENT_TARGET} GEN=Ninja FILE=${_eve_ios_tp_toolchain}") + elseif(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + # Third-party aggregates for the browser build use emcmake so they pick + # up the emcc toolchain; Ninja keeps the many try_compile probes fast. + find_program(_eve_ninja ninja REQUIRED) + list(APPEND _eve_tp_cmake_args + -DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE} + -DCMAKE_SYSTEM_NAME=Emscripten + -DCMAKE_MAKE_PROGRAM=${_eve_ninja} + ) + set(_eve_tp_use_ninja TRUE) + message(STATUS "third-party Emscripten toolchain: ${CMAKE_TOOLCHAIN_FILE}") endif() # --config is required for multi-config generators (VS); ignored by Ninja. @@ -523,19 +676,21 @@ function(load_third_party) set(TP_BUILD_PATH ${BUILD_PLATFORM}${DEBUG_APPENDIX}) message("Third Party Build Path: ${TP_BUILD_PATH}") - # CI/worktrees can share a prebuilt dependency prefix. Do not require the aggregate - # third-party repository in this mode: its headers and libraries are already installed. - if(EVENGINE_THIRD_PARTY_ROOT) - if(NOT EXISTS "${EVENGINE_THIRD_PARTY_ROOT}/include" OR - NOT EXISTS "${EVENGINE_THIRD_PARTY_ROOT}/lib") - message(FATAL_ERROR "EVENGINE_THIRD_PARTY_ROOT must contain include/ and lib/: ${EVENGINE_THIRD_PARTY_ROOT}") + if(EVENGINE_THIRD_PARTY_BINARY_DIR) + cmake_path(ABSOLUTE_PATH EVENGINE_THIRD_PARTY_BINARY_DIR NORMALIZE + OUTPUT_VARIABLE _eve_prebuilt_tp) + if(NOT IS_DIRECTORY "${_eve_prebuilt_tp}/include" OR + NOT IS_DIRECTORY "${_eve_prebuilt_tp}/lib") + message(FATAL_ERROR + "EVENGINE_THIRD_PARTY_BINARY_DIR must contain include/ and lib/: ${_eve_prebuilt_tp}") endif() - message(STATUS "third-party: using read-only installed prefix ${EVENGINE_THIRD_PARTY_ROOT}") - include_directories("${EVENGINE_THIRD_PARTY_ROOT}/include") - link_directories("${EVENGINE_THIRD_PARTY_ROOT}/lib") - set(EVENGINE_THIRD_PARTY_BINARY_ONLY ON CACHE INTERNAL "Use installed third-party prefix") - # create_module() uniformly depends on this target; make it a no-op when - # consuming a prebuilt prefix instead of building the aggregate project. + message(STATUS "third-party: using read-only prebuilt install ${_eve_prebuilt_tp}") + include_directories("${_eve_prebuilt_tp}/include") + link_directories("${_eve_prebuilt_tp}/lib") + set(EVENGINE_THIRD_PARTY_BINARY_ONLY ON CACHE INTERNAL + "Use installed third-party prefix" FORCE) + # Preserve the dependency target expected by create_module(), but give it no + # configure/build/install commands so the prebuilt tree can remain read-only. add_custom_target(third-party) add_custom_target(deps DEPENDS third-party) return() @@ -545,16 +700,66 @@ function(load_third_party) # EVEngine's database module uses POCO Data and its bundled SQLite connector. # The third-party aggregate disables these by default to reduce its footprint; # enable the two targets in the checked-out aggregate before configuring it. - set(_eve_tp_cmake "${CMAKE_CURRENT_SOURCE_DIR}/third-party/CMakeLists.txt") - file(READ "${_eve_tp_cmake}" _eve_tp_contents) - string(REPLACE "set(ENABLE_DATA OFF CACHE BOOL \"\" FORCE)" - "set(ENABLE_DATA ON CACHE BOOL \"\" FORCE)" _eve_tp_contents "${_eve_tp_contents}") - string(REPLACE "set(ENABLE_DATA_SQLITE OFF CACHE BOOL \"\" FORCE)" - "set(ENABLE_DATA_SQLITE ON CACHE BOOL \"\" FORCE)" _eve_tp_contents "${_eve_tp_contents}") - file(WRITE "${_eve_tp_cmake}" "${_eve_tp_contents}") + if(NOT EVENGINE_THIRD_PARTY_BINARY_DIR) + set(_eve_tp_cmake "${CMAKE_CURRENT_SOURCE_DIR}/third-party/CMakeLists.txt") + file(READ "${_eve_tp_cmake}" _eve_tp_contents) + string(REPLACE "set(ENABLE_DATA OFF CACHE BOOL \"\" FORCE)" + "set(ENABLE_DATA ON CACHE BOOL \"\" FORCE)" _eve_tp_contents "${_eve_tp_contents}") + string(REPLACE "set(ENABLE_DATA_SQLITE OFF CACHE BOOL \"\" FORCE)" + "set(ENABLE_DATA_SQLITE ON CACHE BOOL \"\" FORCE)" _eve_tp_contents "${_eve_tp_contents}") + file(WRITE "${_eve_tp_cmake}" "${_eve_tp_contents}") + endif() check_third_party_project(third-party "https://github.com/EVEngine/third-party") ADD_CUSTOM_TARGET(deps DEPENDS third-party) endfunction() + +# Resolve the WebGPU headers / library for BUILD_PLATFORM=webgpu. +# * Emscripten (browser): webgpu.h / webgpu_cpp.h ship with the emsdk and are +# already on emcc's include path; no link target is needed. +# * Native desktop: Google Dawn is fetched via FetchContent and linked through +# the imported target dawn::dawn (set into EVENGINE_WEBGPU_LIB). +function(load_webgpu_backend) + if(NOT BUILD_PLATFORM STREQUAL "webgpu") + set(EVENGINE_WEBGPU_LIB "" CACHE INTERNAL "WebGPU link target (unused)") + return() + endif() + + if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + message(STATUS "WebGPU: using emsdk headers (no Dawn build needed)") + set(EVENGINE_WEBGPU_LIB "" CACHE INTERNAL "WebGPU link target (unused on Emscripten)") + return() + endif() + + message(STATUS "WebGPU (native): fetching Google Dawn") + include(FetchContent) + set(DAWN_BUILD_SAMPLES OFF CACHE BOOL "" FORCE) + set(DAWN_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(DAWN_BUILD_WGPU_TESTS OFF CACHE BOOL "" FORCE) + set(DAWN_ENABLE_NULL OFF CACHE BOOL "" FORCE) + set(DAWN_ENABLE_D3D11 OFF CACHE BOOL "" FORCE) + set(DAWN_ENABLE_OPENGLES OFF CACHE BOOL "" FORCE) + set(DAWN_USE_XCODE OFF CACHE BOOL "" FORCE) + + set(EVENGINE_DAWN_TAG "main" CACHE STRING + "Dawn commit/tag to fetch for the native WebGPU backend (pin for reproducible builds)") + FetchContent_Declare(dawn + GIT_REPOSITORY https://github.com/google/dawn.git + GIT_TAG ${EVENGINE_DAWN_TAG} + GIT_SHALLOW TRUE + GIT_PROGRESS TRUE + ) + FetchContent_MakeAvailable(dawn) + + if(NOT TARGET dawn::dawn) + message(FATAL_ERROR "Dawn fetch did not produce the imported target dawn::dawn") + endif() + # Dawn's webgpu.h / webgpu_cpp.h live under src/ in the checkout; expose + # them to every module target (engine, eve_imgui, gpgpu/webgpu, ...). + include_directories("${dawn_SOURCE_DIR}/src") + set(EVENGINE_WEBGPU_LIB dawn::dawn CACHE INTERNAL "WebGPU link target") + message(STATUS "WebGPU (native): Dawn ready, link target = ${EVENGINE_WEBGPU_LIB}") +endfunction() + main() diff --git a/Makefile b/Makefile index 1d19d659..30be8ff2 100644 --- a/Makefile +++ b/Makefile @@ -100,11 +100,13 @@ GAME ?= .PHONY: all build/win32 build/linux build/macosx build/android build/ios \ build/win32-debug build/linux-debug build/macosx-debug build/android-debug build/ios-debug \ + build/android-debug-test \ wsl/linux wsl/linux-debug show-targets \ debug release example \ run run/win32 run/linux run/macosx \ run/win32-debug run/linux-debug run/macosx-debug \ - sync/android-libs sync/android-assets install/android-debug run/android-debug log/android \ + sync/android-libs sync/android-assets sync/android-test-assets install/android-debug run/android-debug log/android \ + run/android-test-debug log/android-test \ install/ios-debug run/ios-debug log/ios \ sdk/win32 sdk/linux sdk/macosx sdk/android sdk/ios \ sdk/win32-debug sdk/linux-debug sdk/macosx-debug sdk/android-debug sdk/ios-debug \ @@ -340,9 +342,16 @@ reinstall/third-party/ios-debug: cmake --build build/third-party/ios-debug --target install -j 8 # Copy native shared libraries into the Gradle jniLibs tree. +# In Android test-app mode the runner lives in test/libmain.so (zeroerr suite); +# otherwise it is the engine's src/engine/libmain.so. sync/android-libs: mkdir -p $(JNI_LIBS) - cp -f $(BUILD_DIR)/src/engine/libmain.so $(JNI_LIBS)/ + @if [ -f $(BUILD_DIR)/test/libmain.so ]; then \ + echo "syncing test runner libmain.so"; \ + cp -f $(BUILD_DIR)/test/libmain.so $(JNI_LIBS)/; \ + else \ + cp -f $(BUILD_DIR)/src/engine/libmain.so $(JNI_LIBS)/; \ + fi @TP_DIR=build/third-party-binary/$$(basename $(BUILD_DIR)); \ if [ ! -d "$$TP_DIR/lib" ]; then TP_DIR=build/third-party-binary/android-debug; fi; \ if [ ! -d "$$TP_DIR/lib" ]; then TP_DIR=build/third-party-binary/android; fi; \ @@ -373,6 +382,16 @@ sync/android-assets: echo "Synced Android game assets ($$src) -> $(ANDROID_ASSETS_GAME)"; \ ls -la "$(ANDROID_ASSETS_GAME)" +# Populate APK assets/test + assets/examples for the Android test app +# (EVTestActivity unpacks them to /evengine_test at first launch). +sync/android-test-assets: + @rm -rf "$(APK_DIR)/app/src/main/assets/test" "$(APK_DIR)/app/src/main/assets/examples" "$(APK_DIR)/app/src/main/assets/game" + @mkdir -p "$(APK_DIR)/app/src/main/assets/test" "$(APK_DIR)/app/src/main/assets/examples" + cp -R test/. "$(APK_DIR)/app/src/main/assets/test/" + cp -R examples/. "$(APK_DIR)/app/src/main/assets/examples/" + @echo "Synced Android test assets -> $(APK_DIR)/app/src/main/assets/{test,examples}" + ls -la "$(APK_DIR)/app/src/main/assets/test" | head -20 + install/android-debug: $(ANDROID_SDK)/platform-tools/adb install -r \ $(APK_DIR)/app/build/outputs/apk/debug/app-debug.apk @@ -383,6 +402,46 @@ run/android-debug: install/android-debug log/android: $(ANDROID_SDK)/platform-tools/adb logcat -s EVEngineActivity:I SDL:V SDL/APP:V vulkan:V libc:F DEBUG:F +# ---- Android test app (zeroerr suite as libmain.so) ---- +# make build/android-debug-test # configure + deps + engine/tests + APK +# make run/android-test-debug [FILTER=math.*] # install + launch on device +# make log/android-test # stream test results from logcat +build/android-debug-test: build/android-debug-test/build.ninja + cmake --build $@ --target deps -j $(ANDROID_JOBS) + cmake --build $@ -j $(ANDROID_JOBS) + $(MAKE) sync/android-libs BUILD_DIR=build/android-debug-test + $(MAKE) sync/android-test-assets + cd $(APK_DIR) && JAVA_HOME="$(JAVA_HOME)" ANDROID_HOME="$(ANDROID_SDK)" ./gradlew assembleDebug + +build/android-debug-test/build.ninja: + cmake -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=$(ANDROID_NDK)/build/cmake/android.toolchain.cmake \ + -DANDROID_ABI=$(ANDROID_ABI) \ + -DANDROID_PLATFORM=$(ANDROID_PLATFORM) \ + -DANDROID_STL=$(ANDROID_STL) \ + -DCMAKE_BUILD_TYPE=Debug \ + -DBUILD_PLATFORM=android \ + -DBUILD_TESTING=ON \ + -DEVENGINE_ANDROID_TEST_APP=ON \ + -DEVENGINE_DOWNLOAD_CLASSIC_SCENES=OFF \ + -DEVENGINE_DOWNLOAD_SPINE_MODELS=OFF \ + -DEVENGINE_DOWNLOAD_SKINNED_CHARACTER=OFF \ + $(CMAKE_EXTRA_ARGS) \ + -B build/android-debug-test -S . + +# Convenience: reuse the game APK's install/run with the test filter extra. +FILTER ?= +run/android-test-debug: install/android-debug + @if [ -n "$(FILTER)" ]; then \ + $(ANDROID_SDK)/platform-tools/adb shell am start -n com.evengine.example/.EVTestActivity --es evengine.test.filter "$(FILTER)"; \ + else \ + $(ANDROID_SDK)/platform-tools/adb shell am start -n com.evengine.example/.EVTestActivity; \ + fi + +log/android-test: + $(ANDROID_SDK)/platform-tools/adb logcat -c + $(ANDROID_SDK)/platform-tools/adb logcat -s EVEngineTest:V SDL:V vulkan:V libc:F DEBUG:F + # Install Debug .app to the first connected physical iOS/iPadOS device. install/ios-debug: @test -n "$$(security find-identity -v -p codesigning 2>/dev/null | sed -n '/Apple Development/p')" \ diff --git a/cmake/install_sdk.cmake b/cmake/install_sdk.cmake index b7c3a133..40cf4a74 100644 --- a/cmake/install_sdk.cmake +++ b/cmake/install_sdk.cmake @@ -20,6 +20,23 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS) BUNDLE DESTINATION bin RUNTIME DESTINATION bin ) +elseif(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + install(TARGETS ${EVENGINE_NATIVE_TARGET} + RUNTIME DESTINATION bin + ) + # The browser build produces eve.js / eve.wasm / eve.html next to the + # runtime output; copy them plus the preloaded game into bin/. + install(FILES + "$/eve.js" + "$/eve.wasm" + "$/eve.html" + DESTINATION bin + OPTIONAL + ) + install(DIRECTORY "${CMAKE_SOURCE_DIR}/platform/webgpu/game-shell" + DESTINATION bin + OPTIONAL + ) else() install(TARGETS ${EVENGINE_NATIVE_TARGET} RUNTIME DESTINATION bin @@ -53,6 +70,7 @@ foreach(_eve_mod IN LISTS _eve_module_dirs) PATTERN "*.hpp" PATTERN "sdl" EXCLUDE PATTERN "vulkan" EXCLUDE + PATTERN "webgpu" EXCLUDE PATTERN "physfs" EXCLUDE PATTERN "openal" EXCLUDE PATTERN "imgui" EXCLUDE @@ -95,8 +113,12 @@ endif() # ---- Android runtime shared libs needed to assemble an APK ---- if(BUILD_PLATFORM STREQUAL "android") - set(_eve_tp_lib "${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}") - if(CMAKE_BUILD_TYPE STREQUAL "Debug") + if(EVENGINE_THIRD_PARTY_BINARY_DIR) + set(_eve_tp_lib "${EVENGINE_THIRD_PARTY_BINARY_DIR}") + else() + set(_eve_tp_lib "${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}") + endif() + if(NOT EVENGINE_THIRD_PARTY_BINARY_DIR AND CMAKE_BUILD_TYPE STREQUAL "Debug") set(_eve_tp_lib "${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}-debug") endif() if(EXISTS "${_eve_tp_lib}/lib/libSDL2.so") @@ -117,8 +139,12 @@ if(BUILD_PLATFORM STREQUAL "android") endif() # ---- Selected third-party headers needed to compile plugins (SSQ bindings) ---- -set(_eve_tp_inc "${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}") -if(CMAKE_BUILD_TYPE STREQUAL "Debug") +if(EVENGINE_THIRD_PARTY_BINARY_DIR) + set(_eve_tp_inc "${EVENGINE_THIRD_PARTY_BINARY_DIR}") +else() + set(_eve_tp_inc "${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}") +endif() +if(NOT EVENGINE_THIRD_PARTY_BINARY_DIR AND CMAKE_BUILD_TYPE STREQUAL "Debug") set(_eve_tp_inc "${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}-debug") endif() if(EXISTS "${_eve_tp_inc}/include") diff --git a/cmake/rescan_source.cmake b/cmake/rescan_source.cmake index 2b55b779..ecf44584 100644 --- a/cmake/rescan_source.cmake +++ b/cmake/rescan_source.cmake @@ -3,10 +3,42 @@ file(GLOB_RECURSE sub_source_files ${CMAKE_CURRENT_SOURCE_DIR}/${module_name}/** set(file_list "") foreach(file ${source_files}) - set(file_list "${file_list}${file}\n") + set(_eve_skip FALSE) + foreach(exc ${exclude_dirs}) + if(file MATCHES "/${exc}/" OR file MATCHES "${exc}[\\/]+") + set(_eve_skip TRUE) + endif() + endforeach() + if(NOT _eve_skip) + foreach(exc ${exclude_files}) + get_filename_component(_eve_fname "${file}" NAME) + if(_eve_fname STREQUAL "${exc}") + set(_eve_skip TRUE) + endif() + endforeach() + endif() + if(NOT _eve_skip) + set(file_list "${file_list}${file}\n") + endif() endforeach() foreach(file ${sub_source_files}) - set(file_list "${file_list}${file}\n") + set(_eve_skip FALSE) + foreach(exc ${exclude_dirs}) + if(file MATCHES "/${exc}/" OR file MATCHES "${exc}[\\/]+") + set(_eve_skip TRUE) + endif() + endforeach() + if(NOT _eve_skip) + foreach(exc ${exclude_files}) + get_filename_component(_eve_fname "${file}" NAME) + if(_eve_fname STREQUAL "${exc}") + set(_eve_skip TRUE) + endif() + endforeach() + endif() + if(NOT _eve_skip) + set(file_list "${file_list}${file}\n") + endif() endforeach() file(READ ${output_dir}/${module_name}_src.txt old_file_list) diff --git a/cmake/scan_source.cmake b/cmake/scan_source.cmake index ddd331af..72cb6079 100644 --- a/cmake/scan_source.cmake +++ b/cmake/scan_source.cmake @@ -1,13 +1,50 @@ file(GLOB_RECURSE source_files ${CMAKE_CURRENT_SOURCE_DIR}/${module_name}/*.cpp) file(GLOB_RECURSE sub_source_files ${CMAKE_CURRENT_SOURCE_DIR}/${module_name}/**/*.cpp) +# Semicolon-separated list of backend subdirectories to exclude from the module +# (e.g. "vulkan" for webgpu builds, "webgpu" for native builds). Entries match +# either an exact file name or a path fragment. +# Semicolon-separated list of exact file base names to exclude (e.g. Poco-heavy +# files trimmed from the WASM build). set(file_list "") foreach(file ${source_files}) - set(file_list "${file_list}${file}\n") + set(_eve_skip FALSE) + foreach(exc ${exclude_dirs}) + if(file MATCHES "/${exc}/" OR file MATCHES "${exc}[\\/]+") + set(_eve_skip TRUE) + endif() + endforeach() + if(NOT _eve_skip) + foreach(exc ${exclude_files}) + get_filename_component(_eve_fname "${file}" NAME) + if(_eve_fname STREQUAL "${exc}") + set(_eve_skip TRUE) + endif() + endforeach() + endif() + if(NOT _eve_skip) + set(file_list "${file_list}${file}\n") + endif() endforeach() foreach(file ${sub_source_files}) - set(file_list "${file_list}${file}\n") + set(_eve_skip FALSE) + foreach(exc ${exclude_dirs}) + if(file MATCHES "/${exc}/" OR file MATCHES "${exc}[\\/]+") + set(_eve_skip TRUE) + endif() + endforeach() + if(NOT _eve_skip) + foreach(exc ${exclude_files}) + get_filename_component(_eve_fname "${file}" NAME) + if(_eve_fname STREQUAL "${exc}") + set(_eve_skip TRUE) + endif() + endforeach() + endif() + if(NOT _eve_skip) + set(file_list "${file_list}${file}\n") + endif() endforeach() file(WRITE ${output_dir}/${module_name}_src.txt "${file_list}") -message(STATUS "detected source files: ${output_dir}/${module_name}_src.txt") \ No newline at end of file +message(STATUS "detected source files: ${output_dir}/${module_name}_src.txt") diff --git a/docs/dev/依赖项.md b/docs/dev/依赖项.md index 79539cf9..57757d63 100644 --- a/docs/dev/依赖项.md +++ b/docs/dev/依赖项.md @@ -21,6 +21,16 @@ smmalloc - 用来优化内存分配和实现snapshot的内存分配器 CLI11 - 命令行解析 rang - 命令行彩色输出 +### 只读预编译第三方目录 + +已有完整第三方安装树时,可在配置阶段传入: + +```sh +cmake -S . -B build -DEVENGINE_PREBUILT_THIRD_PARTY=/path/to/third-party-binary/win32-debug +``` + +该目录必须包含 `include/` 与 `lib/`。启用后 CMake 只从该目录读取头文件和库,并创建兼容现有模块依赖关系的空 `third-party` 目标;不会配置、构建或安装到这个目录。 + ### 程序化生成(`procgen`) | 库 | 许可 | 用途 | 状态 | diff --git a/docs/dev/程序化生成模块设计.md b/docs/dev/程序化生成模块设计.md index a674f80e..ffa07144 100644 --- a/docs/dev/程序化生成模块设计.md +++ b/docs/dev/程序化生成模块设计.md @@ -132,6 +132,7 @@ OutputSpec { | `rooms.scatter` | 房间 | 随机房间放置 + 最近邻/MST 连廊(轻量) | | `wfc.simple` | 约束 | 简易 Wave Function Collapse(preset: dungeon/cave/terrain) | | `mesh.marchingcubes` | 网格 | Marching Cubes 等值面(sphere/torus/noise/terrain 标量场 → MeshBuild) | +| `mesh.hexplanet` | 网格 | 细分二十面体的对偶网格:12 个五边形 + 其余六边形,输出球面单元 MeshBuild | ### 注册 API @@ -258,10 +259,24 @@ procgen.generate* → Grid2D ### Phase C — 装饰物 / 网格(进行中) - [x] Marching Cubes:`mesh.marchingcubes` → `MeshBuild` / `Graphics::newMeshFromArrays` +- [x] 六边形星球:`mesh.hexplanet` → `MeshBuild` / `Graphics::newMeshFromArrays` - [x] Wave Function Collapse:`wfc.simple`(tiled 邻接约束,内置 preset) - [ ] 低模树/石/草 billboard 或简易网格 - [ ] 按 height/biome/mask 散布实例数据(非 GPU scatter) +#### 六边形星球网格约定 + +- 实现采用细分二十面体的对偶网格,不依赖第三方几何库。 +- 细分等级为 `n` 时,单元数为 `10 * 4^n + 2`;固定有 12 个五边形, + 其余单元为六边形。这是欧拉示性数决定的球面拓扑约束。 +- `radius` 控制球半径;`tileInset` 只收缩每个单元的边界,收缩后重新归一化, + 因而所有顶点仍落在同一球面上。 +- 每个单元使用独立顶点并扇形三角化,便于后续按单元设置材质、地形高度或 + 选择状态;当前 recipe 输出统一法线与球面 UV。 +- 拓扑测试验证索引范围、有限且单位长度的法线、半径、单元数量及 12 个五边形; + 渲染测试通过 EVEngine 的 3D 管线读取帧缓冲并编码 PNG,输出到测试构建目录的 + `out/hex_planet.png`。 + ## 目录建议 ```text diff --git a/docs/usr/modules/procgen.md b/docs/usr/modules/procgen.md index 2d83eda8..da39a0c6 100644 --- a/docs/usr/modules/procgen.md +++ b/docs/usr/modules/procgen.md @@ -34,6 +34,67 @@ local cpu = gen.buildMesh("mesh.marchingcubes", p); local gpu = gen.generateMesh("mesh.marchingcubes", p, gfx); ``` +### 生成六边形网格星球 + +`mesh.hexplanet` 生成细分二十面体的对偶网格。星球表面以六边形单元为主, +并包含球面拓扑必需的 12 个五边形单元。所有顶点都位于指定半径的球面上, +可直接交给 3D 渲染系统使用。 + +```squirrel +local gen = eve.Procgen(); +local p = gen.newParams(); +p.setFloat("radius", 1.0); +p.setInt("subdivisions", 3); +p.setFloat("tileInset", 0.12); + +local cpu = gen.buildMesh("mesh.hexplanet", p); +local gpu = gen.generateMesh("mesh.hexplanet", p, gfx); +``` + +参数: + +- `radius`:星球半径,必须大于 `0`,默认 `1.0`。 +- `subdivisions`:二十面体细分次数,范围 `[0, 7]`,默认 `2`。单元总数为 + `10 * 4^subdivisions + 2`;每增加一级,网格规模约增至四倍。 +- `tileInset`:每个单元向自身中心收缩的比例,范围 `[0, 0.5)`,默认 + `0.06`。设为 `0` 时单元无缝相接;增大该值可形成清晰的网格间隙。 + +生成结果的 metadata 包含 `algorithm`、`cells`、`pentagons`、`hexagons` +和 `subdivisions`。网格始终含 12 个五边形,其余单元均为六边形;这些五边形 +是用多边形铺满球面时无法消除的拓扑要求。 + +建议在创建关卡或切换星球时生成一次并缓存 GPU Mesh,不要逐帧重新生成。 +### 生成随机树木网格 + +`mesh.tree` 生成可复现的树干、分枝和叶片网格。`branchAlgorithm` 的两个值互斥: + +- `weberPenn`(默认):按层级、枝序和分枝角快速生成稳定骨架。 +- `spaceColonization`:让枝梢向树冠吸引点迭代生长,适合更不规则的冠形。 + +```squirrel +local p = gen.newParams(); +p.setSeed(31415); +p.setString("style", "lowpoly"); // lowpoly | realistic +p.setString("branchAlgorithm", "weberPenn"); +p.setString("leafMode", "canopy"); // cards | canopy | none +p.setFloat("leafDensity", 0.75); +p.setFloat("height", 6.0); +p.setFloat("crownRadius", 2.0); + +local tree = gen.generateMesh("mesh.tree", p, gfx); +``` + +两种算法共享主干曲率、向性、下垂、随高度变化的枝长/枝径,以及上下层叶片覆盖参数。常用调整: + +- 外形与精度:`height`、`trunkRadius`、`crownRadius`、`radialSegments`、`curveSegments`。 +- 通用枝形:`trunkCurve`、`branchCurve`、`curveBack`、`tropism`、`droop`。 +- 层级生长:`branchLengthFalloff`、`branchRadiusFalloff`;默认表现为下层枝更粗更长。 +- 叶片:`leafSize`、`leafDensity`、`foliageStart`、`lowerLeafCoverage`、`upperLeafCoverage`。 +- Weber–Penn:`branchLevels`、`branchCount`、`branchAngle`、`branchAngleVariation`、`phyllotaxis`、`apicalDominance`。 +- 空间殖民:`attractorCount`、`colonizationIterations`、`influenceRadius`、`killRadius`、`growthStep`、`branchInertia`、`maxTurnAngle`、`maxCumulativeAngle`、`maxChildren`。 + +完整参数范围、交互快捷键和可运行脚本见 [`examples/tree-generator`](../../../examples/tree-generator/README.md)。生成网格有一定成本,应在加载、换 seed 或修改参数时重建,不要每帧调用。 + ### 生成无缝材质 设置 texture recipe、尺寸、octaves、pixelSize 和 seamless,调用纹理或法线图生成接口;开发期切换 seed 预览,发布时缓存 Texture,不能每帧重新生成。 @@ -43,6 +104,7 @@ local gpu = gen.generateMesh("mesh.marchingcubes", p, gfx); - 未保存 seed,无法复现玩家问题。 - Output palette 缺少算法输出的 tile key。 - 在每帧 update 生成大地图或纹理。 +- 在每帧重新生成树木网格;应缓存 `Mesh`,仅在 seed 或参数变化时重建。 ## API 快查 diff --git a/docs/usr/modules/thread.md b/docs/usr/modules/thread.md index df99e18c..157ced6e 100644 --- a/docs/usr/modules/thread.md +++ b/docs/usr/modules/thread.md @@ -25,13 +25,17 @@ pool.submitPush(channel, "done", 100); ### 等待关卡加载任务 -为每项任务保留 Task,轮询 `isDone()` / `hasFailed()`,显示进度;仅在退出或确定不会卡住界面时调用 `wait()` / `waitAll()`。 +需要观察状态时为每项任务保留 Task,轮询 `isDone()` / `hasFailed()`,显示进度。脚本提前释放 Task 或匿名 Channel 不会中止已提交工作;线程池会持有任务完成所需的原生状态。仅在退出或确定不会卡住界面时调用 `wait()` / `waitAll()`。 + +`newThreadPool(0)` 使用硬件并发度;显式线程数上限为 256。`waitAll()` 与 `stop()` 不能从同一个线程池的 worker 中调用,这类调用会立即抛出异常,避免自等待死锁。 ## 常见问题 - worker 调用 gfx/ui/Squirrel:这些对象不是线程安全的。 +- worker 调用所属 Pool 的 `waitAll()` / `stop()`:会抛出异常;把关闭与汇合操作放在主线程。 - 主线程立即 `waitAll()`:异步退化成卡顿的同步。 - 忽略 `hasFailed()` / `getError()`:失败任务会悄悄丢结果。 +- 并发任务使用相同 Event 名称:业务监听仍按队列消费;`asyncPost()` 的 Promise 按具体 Task 关联,不会因同名事件乱序而串线。 ## API 快查 diff --git a/examples/metroidvania/README.md b/examples/metroidvania/README.md new file mode 100644 index 00000000..66a3d088 --- /dev/null +++ b/examples/metroidvania/README.md @@ -0,0 +1,35 @@ +# Momentum Ruins + +Physics-driven side-scrolling action demo for EVEngine. The three connected regions teach +combo launching, wall jumping, air dashing, kicking props, momentum damage, and finally a +Dragon boss encounter. + +Run from the repository root with the platform-specific engine target, for example: + +```sh +make run/win32-debug GAME=examples/metroidvania +``` + +Controls: `A/D` or arrows move, `Space` jumps/double-jumps/wall-jumps, `J` attacks, `K` kicks props, and +either `Shift` key air-dashes after the upgrade. Enable `debug` in `config.nut` to see Box2D +fixtures. + +Run the startup/frame-error smoke test on Windows with: + +```powershell +python examples/metroidvania/smoke_test.py build/src/engine/Debug/eve.exe +``` + +Add `--input-replay` to exercise movement, both jump directions, the three-hit combo, and +the kick action while checking the engine log for frame/runtime errors. + +## Asset licenses + +- Tile, prop, and background art is selected from Kenney's **Platformer Pack Redux** and is + CC0. The original license is included at `assets/KENNEY-CC0.txt`; source: + https://opengameart.org/node/39408 +- The animated Hero, Spineboy, and Dragon are loaded directly from `test/assets/spine`. + Their individual license files remain beside those assets. This demo is non-commercial: + Hero may not be redistributed and Spineboy may not be used commercially. + +The generated map can be reproduced with `python generate_map.py`. diff --git a/examples/metroidvania/assets/KENNEY-CC0.txt b/examples/metroidvania/assets/KENNEY-CC0.txt new file mode 100644 index 00000000..80343674 --- /dev/null +++ b/examples/metroidvania/assets/KENNEY-CC0.txt @@ -0,0 +1,22 @@ + +############################################################################### + + + Platformer Pack Redux + by Kenney Vleugels for Kenney (www.kenney.nl) + + ------------------------------ + + License (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + You may use these graphics in personal and commercial projects. + Credit (Kenney or www.kenney.nl) would be nice but is not mandatory. + + ------------------------------ + + Donate: http://donate.kenney.nl/ + Request: http://request.kenney.nl/ + + +############################################################################### \ No newline at end of file diff --git a/examples/metroidvania/assets/background_forest.png b/examples/metroidvania/assets/background_forest.png new file mode 100644 index 00000000..8469d4ae Binary files /dev/null and b/examples/metroidvania/assets/background_forest.png differ diff --git a/examples/metroidvania/assets/background_mine.png b/examples/metroidvania/assets/background_mine.png new file mode 100644 index 00000000..af050cdd Binary files /dev/null and b/examples/metroidvania/assets/background_mine.png differ diff --git a/examples/metroidvania/assets/background_ruins.png b/examples/metroidvania/assets/background_ruins.png new file mode 100644 index 00000000..a6ffcd38 Binary files /dev/null and b/examples/metroidvania/assets/background_ruins.png differ diff --git a/examples/metroidvania/assets/crate.png b/examples/metroidvania/assets/crate.png new file mode 100644 index 00000000..8f5737f0 Binary files /dev/null and b/examples/metroidvania/assets/crate.png differ diff --git a/examples/metroidvania/assets/rock.png b/examples/metroidvania/assets/rock.png new file mode 100644 index 00000000..d8624b11 Binary files /dev/null and b/examples/metroidvania/assets/rock.png differ diff --git a/examples/metroidvania/assets/tiles.png b/examples/metroidvania/assets/tiles.png new file mode 100644 index 00000000..c6a8d4ca Binary files /dev/null and b/examples/metroidvania/assets/tiles.png differ diff --git a/examples/metroidvania/config.nut b/examples/metroidvania/config.nut new file mode 100644 index 00000000..e2bc1c16 --- /dev/null +++ b/examples/metroidvania/config.nut @@ -0,0 +1,7 @@ +config = { + width = 1280 + height = 720 + title = "EVEngine - Momentum Ruins" + debug = false + hotReload = true +}; diff --git a/examples/metroidvania/generate_map.py b/examples/metroidvania/generate_map.py new file mode 100644 index 00000000..56fbf41e --- /dev/null +++ b/examples/metroidvania/generate_map.py @@ -0,0 +1,171 @@ +"""Regenerate maps/world.json for the EVEngine physics metroidvania demo.""" + +import json +from pathlib import Path + +W, H, TILE = 120, 22, 32 + + +def layer(fill=0): + return [fill] * (W * H) + + +def put(data, x, y, gid): + if 0 <= x < W and 0 <= y < H: + data[y * W + x] = gid + + +def rect(data, x, y, w, h, gid): + for yy in range(y, y + h): + for xx in range(x, x + w): + put(data, xx, yy, gid) + + +def obj(name, kind, tx, ty, w=1, h=1): + return { + "name": name, + "type": kind, + "x": tx * TILE, + "y": ty * TILE, + "width": w * TILE, + "height": h * TILE, + } + + +background = layer() +terrain = layer() +foreground = layer() +hazards = layer() + +# Region color language: grass forest, dirt ruins, stone mine/boss. +rect(background, 0, 18, 40, 1, 2) +rect(background, 40, 18, 40, 1, 4) +rect(background, 80, 18, 40, 1, 6) +rect(terrain, 0, 19, W, 3, 2) +for x in range(0, 40): + put(terrain, x, 19, 1) +for x in range(40, 80): + put(terrain, x, 19, 3) +for x in range(80, W): + put(terrain, x, 19, 5) + +# Forest: attack/kick playground and a wall-jump gate. +rect(terrain, 5, 16, 7, 1, 1) +rect(terrain, 15, 13, 7, 1, 1) +rect(terrain, 25, 16, 6, 1, 1) +# The left shaft wall floats above a three-tile entrance. Players collect wall +# jump before it, walk underneath, then alternate between the two faces. +rect(terrain, 34, 12, 1, 4, 2) +rect(terrain, 38, 10, 2, 9, 2) + +# Ruins: vertical rooms for wall jumping, then a dash altar. +rect(terrain, 42, 16, 7, 1, 3) +rect(terrain, 51, 13, 5, 1, 3) +rect(terrain, 59, 12, 1, 7, 4) +rect(terrain, 64, 9, 1, 10, 4) +rect(terrain, 60, 15, 4, 1, 3) +rect(terrain, 65, 11, 7, 1, 3) +rect(terrain, 74, 15, 5, 1, 3) +for y in range(12, 19, 2): + put(foreground, 57, y, 9) + put(foreground, 66, y, 9) + +# Mine: lava gaps, movable rocks and a long dash bridge. +for x in range(84, 88): + put(terrain, x, 19, 0) + put(hazards, x, 19, 11) +rect(terrain, 81, 15, 3, 1, 5) +rect(terrain, 88, 15, 4, 1, 5) +rect(terrain, 94, 12, 5, 1, 5) +rect(terrain, 101, 16, 3, 1, 5) +for x in range(84, 88): + put(foreground, x, 17, 12) + +# Boss arena boundary and decoration. +rect(terrain, 105, 10, 1, 9, 6) +rect(terrain, 119, 10, 1, 9, 6) +for x in (108, 112, 116): + put(foreground, x, 18, 14) +put(foreground, 118, 18, 16) + +objects = [ + obj("player_start", "player", 3, 18), + obj("cp_forest", "checkpoint", 3, 18), + obj("crate_1", "prop_crate", 9, 15), + obj("crate_2", "prop_crate", 20, 18), + obj("rock_1", "prop_rock", 28, 15), + obj("scout_1", "enemy_melee", 18, 18), + obj("scout_2", "enemy_leaper", 29, 15), + # Full-height trigger before the shaft: running or double-jumping cannot + # accidentally skip the ability required by the following room. + obj("wall_jump", "ability_walljump", 32, 19, 2, 7), + obj("cp_ruins", "checkpoint", 42, 18), + obj("guard_1", "enemy_melee", 48, 18), + obj("guard_2", "enemy_leaper", 68, 10), + obj("dash", "ability_dash", 76, 14), + obj("cp_mine", "checkpoint", 81, 18), + obj("mine_platform", "moving_platform", 85, 17, 3, 1), + obj("rock_2", "prop_rock", 89, 14), + obj("crate_3", "prop_crate", 96, 11), + obj("miner_1", "enemy_melee", 91, 18), + obj("miner_2", "enemy_leaper", 100, 18), + obj("cp_boss", "checkpoint", 108, 18), + obj("dragon", "boss", 113, 12, 3, 3), + obj("boss_exit", "exit", 117, 18, 2, 2), +] + + +def validate_level(): + """Catch ability-order and jump-route regressions before writing the map.""" + wall_jump = next(item for item in objects if item["type"] == "ability_walljump") + trigger_left = wall_jump["x"] // TILE + trigger_right = (wall_jump["x"] + wall_jump["width"]) // TILE + assert trigger_left < 34 and trigger_right <= 34, "wall jump must be acquired before the shaft" + assert wall_jump["y"] - wall_jump["height"] <= 12 * TILE, "wall-jump trigger is too short" + assert all(terrain[y * W + 34] == 0 for y in range(16, 19)), "shaft entrance is blocked" + assert all(terrain[y * W + x] == 0 for y in range(10, 19) for x in range(35, 38)), ( + "wall-jump shaft contains an impassable crossbar" + ) + + gap = longest_gap = 0 + for x in range(W): + if terrain[19 * W + x] == 0: + gap += 1 + longest_gap = max(longest_gap, gap) + else: + gap = 0 + assert longest_gap <= 4, "ground gap exceeds the configured running-jump route" + + +validate_level() + +tileset = { + "firstgid": 1, + "image": "assets/tiles.png", + "columns": 4, + "tilewidth": TILE, + "tileheight": TILE, + "imagewidth": 128, + "imageheight": 128, +} +doc = { + "width": W, + "height": H, + "tilewidth": TILE, + "tileheight": TILE, + "orientation": "orthogonal", + "autoReload": True, + "tilesets": [tileset], + "layers": [ + {"type": "tilelayer", "name": "background", "layer": -2, "width": W, "height": H, "data": background}, + {"type": "tilelayer", "name": "collision", "layer": 0, "width": W, "height": H, "data": terrain}, + {"type": "tilelayer", "name": "hazards", "layer": 1, "width": W, "height": H, "data": hazards}, + {"type": "tilelayer", "name": "foreground", "layer": 5, "width": W, "height": H, "data": foreground}, + {"type": "objectgroup", "name": "gameplay", "objects": objects}, + ], +} + +out = Path(__file__).with_name("maps") / "world.json" +out.parent.mkdir(parents=True, exist_ok=True) +out.write_text(json.dumps(doc, indent=2), encoding="utf-8") +print(out) diff --git a/examples/metroidvania/main.nut b/examples/metroidvania/main.nut new file mode 100644 index 00000000..d1cdb4ed --- /dev/null +++ b/examples/metroidvania/main.nut @@ -0,0 +1,965 @@ +dofile("tuning.nut"); + +const CAT_TERRAIN = 1; +const CAT_PLAYER = 2; +const CAT_ENEMY = 4; +const CAT_PROP = 8; +const CAT_PLAYER_ATTACK = 16; +const CAT_PICKUP = 32; +const CAT_HAZARD = 64; + +if (!("game" in getroottable())) game <- null; +if (!("prevKeys" in getroottable())) prevKeys <- {}; + +function clampf(v, lo, hi) { + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +function absf(v) { return v < 0.0 ? -v : v; } + +function startsWith(text, prefix) { + return text.len() >= prefix.len() && text.slice(0, prefix.len()) == prefix; +} + +function keyDown(primary, alternate = "") { + return keyboard.isDown(primary) || (alternate != "" && keyboard.isDown(alternate)); +} + +function keyPressed(primary, alternate = "") { + local now = keyDown(primary, alternate); + local key = primary + ":" + alternate; + local was = (key in prevKeys) ? prevKeys[key] : false; + prevKeys[key] <- now; + return now && !was; +} + +function bodyPair(a, b) { + return a < b ? (a + ":" + b) : (b + ":" + a); +} + +function setFixtureFilter(fx, category, mask, tag, sensor = false) { + fx.setCategoryBits(category); + fx.setMaskBits(mask); + fx.setTag(tag); + fx.setSensor(sensor); +} + +function makeSpine(model, animationName, scale) { + local modelPath = "spine/" + model + "/export/"; + local data = anim.newSpineSkeletonDataFromFile(modelPath + model + ".json"); + local atlas = anim.newSpineAtlasFromFile(modelPath + model + ".atlas"); + if (data == null || atlas == null) return null; + local skeleton = anim.newSpineSkeleton(data); + local player = anim.newSpineAnim(skeleton); + player.setAtlas(atlas); + local textures = []; + for (local i = 0; i < atlas.getPageCount(); i += 1) { + local tex = gfx.newTextureFromFile(modelPath + atlas.getPageName(i)); + player.setPageTexture(i, tex); + textures.push(tex); + } + player.setScale(scale, scale); + player.setLoop(true); + player.play(animationName); + return { data = data, atlas = atlas, skeleton = skeleton, player = player, + textures = textures, current = animationName }; +} + +function playSpine(holder, name, loop = true, restart = false, speed = 1.0) { + if (holder == null || (holder.current == name && !restart)) return; + holder.current = name; + holder.player.setSpeed(speed); + holder.player.setLoop(loop); + holder.player.play(name); +} + +function addBodyEntity(e) { + game.byBody[e.body.getId()] <- e; + game.entities.push(e); + return e; +} + +function spawnPlayer(x, y) { + local body = game.world.newBody("dynamic", x, y - 28.0); + body.setFixedRotation(true); + body.setBullet(true); + local main = body.newRectangleFixture(30.0, 54.0, 1.0, 0.0, 0.0); + setFixtureFilter(main, CAT_PLAYER, + CAT_TERRAIN | CAT_ENEMY | CAT_PROP | CAT_PICKUP | CAT_HAZARD, + "player"); + local foot = body.newRectangleFixtureAt(22.0, 7.0, 0.0, 29.0, 0.0, 0.0, 0.0); + setFixtureFilter(foot, CAT_PLAYER, CAT_TERRAIN | CAT_PROP, "player_foot", true); + local wallL = body.newRectangleFixtureAt(6.0, 34.0, -18.0, 0.0, 0.0, 0.0, 0.0); + setFixtureFilter(wallL, CAT_PLAYER, CAT_TERRAIN, "player_wall_l", true); + local wallR = body.newRectangleFixtureAt(6.0, 34.0, 18.0, 0.0, 0.0, 0.0, 0.0); + setFixtureFilter(wallR, CAT_PLAYER, CAT_TERRAIN, "player_wall_r", true); + local attackL = body.newRectangleFixtureAt(48.0, 42.0, -37.0, -2.0, 0.0, 0.0, 0.0); + local attackR = body.newRectangleFixtureAt(48.0, 42.0, 37.0, -2.0, 0.0, 0.0, 0.0); + setFixtureFilter(attackL, CAT_PLAYER_ATTACK, 0, "player_attack", true); + setFixtureFilter(attackR, CAT_PLAYER_ATTACK, 0, "player_attack", true); + local e = { + kind = "player", body = body, fixtures = [main, foot, wallL, wallR, attackL, attackR], + attackL = attackL, attackR = attackR, hp = TUNE.playerHp, maxHp = TUNE.playerHp, + facing = 1, grounded = 0, wallLeft = 0, wallRight = 0, + groundContacts = {}, wallLeftContacts = {}, wallRightContacts = {}, coyote = 0.0, + jumpBuffer = 0.0, airJumpsRemaining = TUNE.airJumpCount, + wallCoyote = 0.0, wallJumpDir = 0.0, wallJumpFeedback = 0.0, + dashTimer = 0.0, dashReady = true, attackTimer = 0.0, + wallJumpLock = 0.0, attackStartup = 0.0, attackActive = 0.0, + attackEnabled = false, attackWindowDone = false, attackKind = "none", attackFixture = null, + queuedAttack = false, queuedKick = false, combo = 0, comboGrace = 0.0, attackHits = {}, + invulnerable = 0.0, spine = makeSpine("hero", "Idle", TUNE.heroVisualScale) + }; + return addBodyEntity(e); +} + +function spawnEnemy(kind, x, y) { + local body = game.world.newBody("dynamic", x + 16.0, y - 27.0); + body.setFixedRotation(true); + body.setBullet(true); + local main = body.newRectangleFixture(31.0, 52.0, TUNE.enemyDensity, 0.0, 0.02); + setFixtureFilter(main, CAT_ENEMY, CAT_TERRAIN | CAT_PLAYER | CAT_PROP | CAT_ENEMY | + CAT_PLAYER_ATTACK, "enemy"); + local foot = body.newRectangleFixtureAt(21.0, 7.0, 0.0, 28.0, 0.0, 0.0, 0.0); + setFixtureFilter(foot, CAT_ENEMY, CAT_TERRAIN | CAT_PROP, "enemy_foot", true); + local wall = body.newRectangleFixtureAt(37.0, 30.0, 0.0, 0.0, 0.0, 0.0, 0.0); + setFixtureFilter(wall, CAT_ENEMY, CAT_TERRAIN, "enemy_wall", true); + local e = { + kind = kind, body = body, fixtures = [main, foot, wall], hp = TUNE.enemyHp, maxHp = TUNE.enemyHp, + stagger = 0.0, staggerDelay = 0.0, state = "normal", stateTimer = 0.0, + attackCooldown = 0.3, attackDealt = false, impactCooldown = 0.0, facing = -1, + grounded = 0, groundContacts = {}, wallContacts = {}, + spine = makeSpine("spineboy", "idle", TUNE.enemyVisualScale) + }; + return addBodyEntity(e); +} + +function spawnProp(kind, x, y) { + local isRock = kind == "prop_rock"; + local body = game.world.newBody("dynamic", x + 16.0, y - 17.0); + body.setBullet(true); + local fx = isRock + ? body.newCircleFixture(15.0, TUNE.rockDensity, TUNE.frictionRock, TUNE.restitutionProp) + : body.newRectangleFixture(31.0, 31.0, TUNE.crateDensity, TUNE.frictionCrate, + TUNE.restitutionProp); + setFixtureFilter(fx, CAT_PROP, CAT_TERRAIN | CAT_PLAYER | CAT_ENEMY | CAT_PROP | + CAT_PLAYER_ATTACK, isRock ? "prop_rock" : "prop_crate"); + local e = { + kind = kind, body = body, fixtures = [fx], hp = isRock ? 999999.0 : TUNE.crateHp, + maxHp = isRock ? 999999.0 : TUNE.crateHp, impactCooldown = 0.0, + texture = isRock ? game.rockTexture : game.crateTexture, dead = false + }; + return addBodyEntity(e); +} + +function spawnSensor(kind, x, y, w, h) { + local body = game.world.newBody("static", x + w * 0.5, y - h * 0.5); + local fx = body.newRectangleFixture(w, h, 0.0, 0.0, 0.0); + setFixtureFilter(fx, kind == "hazard" ? CAT_HAZARD : CAT_PICKUP, CAT_PLAYER, kind, true); + return addBodyEntity({ kind = kind, body = body, fixtures = [fx], dead = false }); +} + +function spawnMovingPlatform(x, y, w, h) { + local body = game.world.newBody("kinematic", x + w * 0.5, y - h * 0.5); + local fx = body.newRectangleFixture(w, h, 0.0, TUNE.frictionTerrain, 0.0); + setFixtureFilter(fx, CAT_TERRAIN, CAT_PLAYER | CAT_ENEMY | CAT_PROP, "terrain"); + return addBodyEntity({ kind = "moving_platform", body = body, fixtures = [fx], time = 0.0, + speed = 130.0 }); +} + +function spawnBoss(x, y) { + local body = game.world.newBody("kinematic", x + 48.0, y - 48.0); + local fx = body.newRectangleFixture(94.0, 70.0, 1.0, 0.3, 0.03); + setFixtureFilter(fx, CAT_ENEMY, CAT_PLAYER | CAT_PROP | CAT_PLAYER_ATTACK, "boss"); + local e = { + kind = "boss", body = body, fixtures = [fx], hp = TUNE.bossHp, maxHp = TUNE.bossHp, + stagger = 0.0, staggerDelay = 0.0, state = "normal", stateTimer = 0.0, + impactCooldown = 0.0, phase = 1, time = 0.0, + homeX = x + 48.0, homeY = y - 48.0, + spine = makeSpine("dragon", "flying", TUNE.bossVisualScale) + }; + game.boss = e; + return addBodyEntity(e); +} + +function buildTerrain(layer) { + local w = layer.getMapWidth(); + local h = layer.getMapHeight(); + local used = []; + for (local i = 0; i < w * h; i += 1) used.push(false); + for (local y = 0; y < h; y += 1) { + for (local x = 0; x < w; x += 1) { + local idx = y * w + x; + if (used[idx] || layer.getTile(x, y) == 0) continue; + local rw = 1; + while (x + rw < w && !used[y * w + x + rw] && layer.getTile(x + rw, y) != 0) + rw += 1; + local rh = 1; + local extend = true; + while (y + rh < h && extend) { + for (local xx = 0; xx < rw; xx += 1) { + if (used[(y + rh) * w + x + xx] || layer.getTile(x + xx, y + rh) == 0) { + extend = false; + break; + } + } + if (extend) rh += 1; + } + for (local yy = 0; yy < rh; yy += 1) + for (local xx = 0; xx < rw; xx += 1) + used[(y + yy) * w + x + xx] = true; + local tw = layer.getTileWidth(); + local th = layer.getTileHeight(); + local body = game.world.newBody("static", (x + rw * 0.5) * tw, (y + rh * 0.5) * th); + local fx = body.newRectangleFixture(rw * tw, rh * th, 0.0, TUNE.frictionTerrain, 0.0); + setFixtureFilter(fx, CAT_TERRAIN, CAT_PLAYER | CAT_ENEMY | CAT_PROP, "terrain"); + game.staticBodies.push({ body = body, fixture = fx }); + } + } +} + +function spawnMapObjects() { + for (local i = 0; i < map.getObjectCount(); i += 1) { + local kind = map.getObjectType(i); + local x = map.getObjectX(i); + local y = map.getObjectY(i); + local w = map.getObjectWidth(i); + local h = map.getObjectHeight(i); + if (kind == "player") game.player = spawnPlayer(x + 16.0, y); + else if (kind == "enemy_melee" || kind == "enemy_leaper") spawnEnemy(kind, x, y); + else if (kind == "prop_crate" || kind == "prop_rock") spawnProp(kind, x, y); + else if (kind == "moving_platform") spawnMovingPlatform(x, y, w, h); + else if (kind == "boss") spawnBoss(x, y); + else if (kind == "checkpoint" || kind == "ability_walljump" || kind == "ability_dash" || + kind == "exit") + spawnSensor(kind, x, y, w, h); + } + spawnSensor("hazard", 84.0 * 32.0, 20.0 * 32.0, 4.0 * 32.0, 32.0); +} + +function resetRun(preserveAbilities) { + local wall = preserveAbilities && game.hasWallJump; + local dash = preserveAbilities && game.hasDash; + if (game.world != null) game.world.destroy(); + game.world = physics.newWorld(0.0, TUNE.gravity, true); + game.entities = []; + game.byBody = {}; + game.staticBodies = []; + game.activeImpacts = {}; + game.contactCounts = {}; + game.effects = []; + game.boss = null; + game.hasWallJump = wall; + game.hasDash = dash; + buildTerrain(game.collisionLayer); + spawnMapObjects(); + game.player.body.setPosition(game.checkpointX, game.checkpointY); +} + +function beginAttack(stage, kick) { + local p = game.player; + p.attackL.setMaskBits(0); + p.attackR.setMaskBits(0); + p.combo = stage; + p.attackKind = kick ? "kick" : "attack"; + p.attackTimer = kick ? TUNE.kickDuration : TUNE.attackDuration[stage - 1]; + p.attackStartup = kick ? TUNE.kickStartup : TUNE.attackStartup[stage - 1]; + p.attackActive = kick ? TUNE.kickActive : TUNE.attackActive[stage - 1]; + p.attackEnabled = false; + p.attackWindowDone = false; + p.attackFixture = p.facing > 0 ? p.attackR : p.attackL; + p.comboGrace = 0.0; + p.attackHits = {}; + p.attackFixture.setTag(kick ? "player_kick" : ("player_attack_" + stage)); + local animSpeed = kick ? 0.88 : (stage == 1 ? 1.15 : (stage == 2 ? 1.0 : 0.82)); + playSpine(p.spine, "Attack", false, true, animSpeed); +} + +function damageActor(e, damage, stagger, impulseX, impulseY) { + if (e == null || e.hp <= 0.0 || e.state == "dying" || e.state == "dead" || + e.impactCooldown > 0.0) return; + e.hp -= damage; + e.stagger += stagger; + e.staggerDelay = TUNE.staggerDecayDelay; + e.impactCooldown = TUNE.impactInvulnerability; + game.hitStop = damage >= 12.0 ? 0.04 : 0.018; + if (e.hp <= 0.0) { + e.hp = 0.0; + if (e.kind == "boss") { + e.state = "dead"; + game.message = "Dragon defeated - reach the opened gate"; + game.messageTimer = 6.0; + } else { + e.state = "dying"; + e.stateTimer = TUNE.enemyDeathTime; + e.body.setLinearVelocity(0.0, 0.0); + playSpine(e.spine, "death", false, true); + } + return; + } + if (e.kind == "boss") { + e.body.applyLinearImpulse(impulseX, impulseY); + if (e.stagger >= TUNE.staggerThreshold * 2.0) { + e.state = "knocked_down"; + e.stateTimer = 2.4; + e.stagger = 0.0; + } + return; + } + if (e.stagger >= TUNE.staggerThreshold) { + e.body.applyLinearImpulse(impulseX, impulseY); + e.state = "launched"; + e.stateTimer = 0.0; + e.stagger = 0.0; + playSpine(e.spine, "jump", true, true); + } else { + e.body.applyLinearImpulse(impulseX * 0.55, impulseY * 0.22); + e.state = "hit_stun"; + e.stateTimer = 0.18; + playSpine(e.spine, "hit", false, true); + } +} + +function handleAttackContact(aId, aTag, bId, bTag) { + local attackId = 0; + local targetId = 0; + local tag = ""; + if (startsWith(aTag, "player_attack") || aTag == "player_kick") { + attackId = aId; targetId = bId; tag = aTag; + } else if (startsWith(bTag, "player_attack") || bTag == "player_kick") { + attackId = bId; targetId = aId; tag = bTag; + } + if (attackId == 0 || attackId != game.player.body.getId()) return; + if (!(targetId in game.byBody) || (targetId in game.player.attackHits)) return; + local target = game.byBody[targetId]; + if (target.kind == "checkpoint" || target.kind == "ability_walljump" || + target.kind == "ability_dash" || target.kind == "exit") return; + game.player.attackHits[targetId] <- true; + local dir = game.player.facing.tofloat(); + if (tag == "player_kick") { + if (target.kind == "prop_crate" || target.kind == "prop_rock") { + target.body.applyLinearImpulse(TUNE.kickImpulseX * dir, TUNE.kickImpulseY); + } else if (target.kind == "enemy_melee" || target.kind == "enemy_leaper") { + damageActor(target, 5.0, 36.0, TUNE.kickImpulseX * dir, TUNE.kickImpulseY); + } + return; + } + local stage = game.player.combo; + if (target.kind == "enemy_melee" || target.kind == "enemy_leaper" || target.kind == "boss") + damageActor(target, TUNE.attackDamage[stage - 1], TUNE.attackStagger[stage - 1], + TUNE.attackImpulseX[stage - 1] * dir, TUNE.attackImpulseY[stage - 1]); +} + +function tagsAre(a, b, ta, tb) { + return (a == ta && b == tb) || (a == tb && b == ta); +} + +function contactOtherId(aId, aTag, bId, tag) { return aTag == tag ? bId : aId; } + +function addContact(table, id) { if (!(id in table)) table[id] <- true; } +function removeContact(table, id) { if (id in table) delete table[id]; } + +function processContactEvents() { + local p = game.player; + for (local i = 0; i < game.world.getBeginContactCount(); i += 1) { + local aId = game.world.getBeginContactBodyAId(i); + local bId = game.world.getBeginContactBodyBId(i); + local a = game.world.getBeginContactFixtureATag(i); + local b = game.world.getBeginContactFixtureBTag(i); + local pairKey = bodyPair(aId, bId); + game.contactCounts[pairKey] <- (pairKey in game.contactCounts) ? + game.contactCounts[pairKey] + 1 : 1; + if (tagsAre(a, b, "player_foot", "terrain") || + tagsAre(a, b, "player_foot", "prop_crate") || tagsAre(a, b, "player_foot", "prop_rock")) + addContact(p.groundContacts, contactOtherId(aId, a, bId, "player_foot")); + if (tagsAre(a, b, "player_wall_l", "terrain")) + addContact(p.wallLeftContacts, contactOtherId(aId, a, bId, "player_wall_l")); + if (tagsAre(a, b, "player_wall_r", "terrain")) + addContact(p.wallRightContacts, contactOtherId(aId, a, bId, "player_wall_r")); + if (a == "enemy_foot" || b == "enemy_foot") { + local enemyId = a == "enemy_foot" ? aId : bId; + if (enemyId in game.byBody) + addContact(game.byBody[enemyId].groundContacts, + contactOtherId(aId, a, bId, "enemy_foot")); + } + if (a == "enemy_wall" || b == "enemy_wall") { + local enemyId = a == "enemy_wall" ? aId : bId; + if (enemyId in game.byBody) + addContact(game.byBody[enemyId].wallContacts, + contactOtherId(aId, a, bId, "enemy_wall")); + } + handleAttackContact(aId, a, bId, b); + local other = aId == p.body.getId() ? bId : (bId == p.body.getId() ? aId : 0); + if (other != 0 && other in game.byBody) { + local e = game.byBody[other]; + if (e.kind == "checkpoint") { + game.checkpointX = e.body.getX(); game.checkpointY = e.body.getY() - 22.0; + game.message = "Checkpoint awakened"; game.messageTimer = 2.0; + } else if (e.kind == "ability_walljump" && !game.hasWallJump) { + game.hasWallJump = true; e.dead = true; e.body.setActive(false); + game.message = "Wall Jump acquired"; game.messageTimer = 3.0; + } else if (e.kind == "ability_dash" && !game.hasDash) { + game.hasDash = true; e.dead = true; e.body.setActive(false); + game.message = "Air Dash acquired"; game.messageTimer = 3.0; + } else if (e.kind == "exit") { + if (game.boss != null && game.boss.state == "dead") game.won = true; + else { game.message = "The dragon seals this gate"; game.messageTimer = 1.5; } + } else if (e.kind == "hazard") { + p.hp -= 20.0; p.body.setPosition(game.checkpointX, game.checkpointY); + p.body.setLinearVelocity(0.0, 0.0); + p.groundContacts.clear(); p.wallLeftContacts.clear(); p.wallRightContacts.clear(); + p.grounded = 0; p.wallLeft = 0; p.wallRight = 0; p.coyote = 0.0; + } + } + } + for (local i = 0; i < game.world.getEndContactCount(); i += 1) { + local aId = game.world.getEndContactBodyAId(i); + local bId = game.world.getEndContactBodyBId(i); + local a = game.world.getEndContactFixtureATag(i); + local b = game.world.getEndContactFixtureBTag(i); + if (tagsAre(a, b, "player_foot", "terrain") || + tagsAre(a, b, "player_foot", "prop_crate") || tagsAre(a, b, "player_foot", "prop_rock")) + removeContact(p.groundContacts, contactOtherId(aId, a, bId, "player_foot")); + if (tagsAre(a, b, "player_wall_l", "terrain")) + removeContact(p.wallLeftContacts, contactOtherId(aId, a, bId, "player_wall_l")); + if (tagsAre(a, b, "player_wall_r", "terrain")) + removeContact(p.wallRightContacts, contactOtherId(aId, a, bId, "player_wall_r")); + if (a == "enemy_foot" || b == "enemy_foot") { + local enemyId = a == "enemy_foot" ? aId : bId; + if (enemyId in game.byBody) + removeContact(game.byBody[enemyId].groundContacts, + contactOtherId(aId, a, bId, "enemy_foot")); + } + if (a == "enemy_wall" || b == "enemy_wall") { + local enemyId = a == "enemy_wall" ? aId : bId; + if (enemyId in game.byBody) + removeContact(game.byBody[enemyId].wallContacts, + contactOtherId(aId, a, bId, "enemy_wall")); + } + local key = bodyPair(aId, bId); + if (key in game.contactCounts) { + game.contactCounts[key] -= 1; + if (game.contactCounts[key] <= 0) { + delete game.contactCounts[key]; + if (key in game.activeImpacts) delete game.activeImpacts[key]; + } + } + } + p.grounded = p.groundContacts.len(); + p.wallLeft = p.wallLeftContacts.len(); + p.wallRight = p.wallRightContacts.len(); + foreach (e in game.entities) + if ("groundContacts" in e && e.kind != "player") e.grounded = e.groundContacts.len(); +} + +function impactCarrier(e, preSolveSpeed) { + if (e == null) return false; + if (e.kind == "prop_crate" || e.kind == "prop_rock") + return preSolveSpeed > TUNE.impactSpeedThreshold; + return ("state" in e) && e.state == "launched"; +} + +function massDamageFactor(target, other) { + if (target == null || other == null) return 1.0; + local targetMass = target.body.getMass(); + local otherMass = other.body.getMass(); + if (targetMass <= 0.0 || otherMass <= 0.0) return 1.0; + return clampf(otherMass / targetMass, 0.45, 1.8); +} + +function processImpacts() { + for (local i = 0; i < game.world.getImpactCount(); i += 1) { + local aId = game.world.getImpactBodyAId(i); + local bId = game.world.getImpactBodyBId(i); + local key = bodyPair(aId, bId); + if (key in game.activeImpacts) continue; + local speed = game.world.getImpactRelativeNormalSpeed(i); + local impulse = game.world.getImpactNormalImpulse(i); + if (speed < TUNE.impactSpeedThreshold || impulse < TUNE.impactImpulseThreshold) continue; + local a = (aId in game.byBody) ? game.byBody[aId] : null; + local b = (bId in game.byBody) ? game.byBody[bId] : null; + if (!impactCarrier(a, speed) && !impactCarrier(b, speed)) continue; + local aWasLaunched = a != null && "state" in a && a.state == "launched"; + local bWasLaunched = b != null && "state" in b && b.state == "launched"; + game.activeImpacts[key] <- true; + local damage = clampf(impulse * (speed - TUNE.impactSpeedThreshold) * TUNE.impactScale, + 1.0, TUNE.impactDamageCap); + local nx = game.world.getImpactNormalX(i); + local ny = game.world.getImpactNormalY(i); + if (a != null && (a.kind == "enemy_melee" || a.kind == "enemy_leaper" || a.kind == "boss")) + damageActor(a, damage * massDamageFactor(a, b), damage * 2.2, + -nx * impulse * 0.25, -ny * impulse * 0.25); + if (b != null && (b.kind == "enemy_melee" || b.kind == "enemy_leaper" || b.kind == "boss")) + damageActor(b, damage * massDamageFactor(b, a), damage * 2.2, + nx * impulse * 0.25, ny * impulse * 0.25); + if (a != null && a.kind == "prop_crate") { + a.hp -= damage; + if (impulse >= TUNE.crateBreakImpulse) a.hp = 0.0; + } + if (b != null && b.kind == "prop_crate") { + b.hp -= damage; + if (impulse >= TUNE.crateBreakImpulse) b.hp = 0.0; + } + if (aWasLaunched && a.state != "dying" && a.state != "dead") { + a.state = "knocked_down"; a.stateTimer = TUNE.knockdownTime; + playSpine(a.spine, "hit", false, true); + } + if (bWasLaunched && b.state != "dying" && b.state != "dead") { + b.state = "knocked_down"; b.stateTimer = TUNE.knockdownTime; + playSpine(b.spine, "hit", false, true); + } + game.shake = clampf(damage * 0.35, 2.0, 10.0); + game.hitStop = damage > 18.0 ? 0.05 : (damage > 7.0 ? 0.022 : game.hitStop); + local px = game.world.getImpactPointX(i); + local py = game.world.getImpactPointY(i); + for (local spark = 0; spark < 6; spark += 1) { + local spread = spark.tofloat() - 2.5; + game.effects.push({ x = px, y = py, vx = -nx * 120.0 + spread * 38.0, + vy = -ny * 120.0 - 75.0, life = 0.18 + spark * 0.015, + heavy = damage > 12.0 }); + } + } +} + +function updatePlayer(dt) { + local p = game.player; + if (p.hp <= 0.0 || p.body.getY() > 780.0) { + resetRun(true); + game.message = "Returned to checkpoint"; game.messageTimer = 2.0; + return; + } + if (p.grounded > 0) { + p.coyote = TUNE.coyoteTime; + p.dashReady = true; + p.airJumpsRemaining = TUNE.airJumpCount; + } + else p.coyote -= dt; + if (p.wallJumpLock > 0.0) p.wallJumpLock -= dt; + if (p.wallJumpFeedback > 0.0) p.wallJumpFeedback -= dt; + if (p.grounded > 0) { + p.wallCoyote = 0.0; + } else if (p.wallLeft > 0) { + p.wallCoyote = TUNE.wallContactGrace; p.wallJumpDir = 1.0; + } else if (p.wallRight > 0) { + p.wallCoyote = TUNE.wallContactGrace; p.wallJumpDir = -1.0; + } else { + p.wallCoyote -= dt; + } + if (keyPressed("Space")) p.jumpBuffer = TUNE.jumpBuffer; + else p.jumpBuffer -= dt; + local move = 0.0; + if (keyDown("A", "Left")) move -= 1.0; + if (keyDown("D", "Right")) move += 1.0; + // Keep the visual and the already-selected attack/dash fixture pointing in + // the same direction until that action completes. + if (move != 0.0 && p.attackTimer <= 0.0 && p.dashTimer <= 0.0) + p.facing = move > 0.0 ? 1 : -1; + + if (p.jumpBuffer > 0.0) { + // Wall jump takes priority over the air jump so touching a wall always + // produces the expected horizontal kick instead of consuming double jump. + if (p.grounded == 0 && game.hasWallJump && p.wallCoyote > 0.0) { + local dir = p.wallJumpDir; + p.body.setLinearVelocity(TUNE.wallJumpX * dir, -TUNE.wallJumpY); + p.facing = dir > 0.0 ? 1 : -1; + p.wallJumpLock = TUNE.wallJumpLockTime; + p.wallJumpFeedback = TUNE.wallJumpFeedbackTime; + p.wallCoyote = 0.0; + p.airJumpsRemaining = TUNE.airJumpCount; + p.wallLeftContacts.clear(); p.wallRightContacts.clear(); + p.wallLeft = 0; p.wallRight = 0; + p.jumpBuffer = 0.0; p.coyote = 0.0; + playSpine(p.spine, "Jump", false, true, 1.15); + } else if (p.coyote > 0.0) { + p.body.setLinearVelocity(p.body.getLinearVelocityX(), -TUNE.jumpSpeed); + p.groundContacts.clear(); p.grounded = 0; + p.jumpBuffer = 0.0; p.coyote = 0.0; + } else if (p.airJumpsRemaining > 0) { + p.body.setLinearVelocity(p.body.getLinearVelocityX(), -TUNE.airJumpSpeed); + p.airJumpsRemaining -= 1; + p.jumpBuffer = 0.0; p.coyote = 0.0; + playSpine(p.spine, "Jump", false, true, 1.2); + for (local puff = 0; puff < 5; puff += 1) + game.effects.push({ x = p.body.getX() + (puff - 2) * 5.0, + y = p.body.getY() + 24.0, vx = (puff - 2) * 24.0, + vy = 45.0, life = 0.16, heavy = false }); + } + } + if (game.hasDash && p.dashReady && keyPressed("Left Shift", "Right Shift")) { + p.dashTimer = TUNE.dashTime; p.dashReady = false; + p.body.setLinearVelocity(TUNE.dashSpeed * p.facing, 0.0); + } + if (p.dashTimer > 0.0) { + p.dashTimer -= dt; + p.body.setLinearVelocity(TUNE.dashSpeed * p.facing, 0.0); + } else { + local target = move * TUNE.moveSpeed; + local vx = p.body.getLinearVelocityX(); + local acceleration = p.grounded > 0 ? TUNE.groundAcceleration : TUNE.airAcceleration; + local nextVx = p.wallJumpLock > 0.0 ? vx : + vx + (target - vx) * clampf(dt * acceleration, 0.0, 1.0); + local nextVy = p.body.getLinearVelocityY(); + if (!keyDown("Space") && nextVy < -80.0) + nextVy += TUNE.jumpCutAcceleration * dt; + p.body.setLinearVelocity(nextVx, nextVy); + local pressingIntoWall = (p.wallLeft > 0 && move < 0.0) || (p.wallRight > 0 && move > 0.0); + if (game.hasWallJump && p.grounded == 0 && pressingIntoWall && + p.body.getLinearVelocityY() > TUNE.wallSlideSpeed) + p.body.setLinearVelocity(p.body.getLinearVelocityX(), TUNE.wallSlideSpeed); + } + + if (p.attackTimer <= 0.0) p.comboGrace -= dt; + if (keyPressed("J")) { + if (p.attackTimer > 0.0) p.queuedAttack = true; + else beginAttack(p.comboGrace > 0.0 ? (p.combo % 3) + 1 : 1, false); + } + if (keyPressed("K")) { + if (p.attackTimer > 0.0) p.queuedKick = true; + else beginAttack(1, true); + } + if (p.attackTimer > 0.0) { + p.attackTimer -= dt; + if (!p.attackEnabled && !p.attackWindowDone) { + p.attackStartup -= dt; + if (p.attackStartup <= 0.0) { + p.attackEnabled = true; + p.attackFixture.setMaskBits(CAT_ENEMY | CAT_PROP); + } + } else { + p.attackActive -= dt; + if (p.attackActive <= 0.0) { + p.attackEnabled = false; + p.attackWindowDone = true; + p.attackL.setMaskBits(0); p.attackR.setMaskBits(0); + } + } + if (p.attackTimer <= 0.0) { + p.attackL.setMaskBits(0); p.attackR.setMaskBits(0); + p.attackEnabled = false; + p.comboGrace = TUNE.comboGrace; + if (p.queuedKick) { + p.queuedKick = false; p.queuedAttack = false; + beginAttack(1, true); + } else if (p.queuedAttack) { + p.queuedAttack = false; + beginAttack((p.combo % 3) + 1, false); + } + } + } + if (p.invulnerable > 0.0) p.invulnerable -= dt; + + if (p.attackTimer <= 0.0) { + p.spine.player.setSpeed(1.0); + local vy = p.body.getLinearVelocityY(); + if (p.grounded > 0) playSpine(p.spine, absf(p.body.getLinearVelocityX()) > 25.0 ? "Run" : "Idle"); + else playSpine(p.spine, vy < 0.0 ? "Jump" : "Fall"); + } +} + +function updateEnemies(dt) { + local px = game.player.body.getX(); + local py = game.player.body.getY(); + foreach (e in game.entities) { + if (!("state" in e) || e.kind == "player" || e.kind == "boss" || e.state == "dead") continue; + if (e.impactCooldown > 0.0) e.impactCooldown -= dt; + if (e.attackCooldown > 0.0) e.attackCooldown -= dt; + if (e.staggerDelay > 0.0) e.staggerDelay -= dt; + else e.stagger = clampf(e.stagger - TUNE.staggerDecay * dt, 0.0, TUNE.staggerThreshold); + if (e.state == "dying") { + e.stateTimer -= dt; + if (e.stateTimer <= 0.0) { e.state = "dead"; e.body.setActive(false); } + continue; + } else if (e.state == "attacking") { + e.stateTimer -= dt; + e.body.setLinearVelocity(0.0, e.body.getLinearVelocityY()); + if (!e.attackDealt && e.stateTimer <= TUNE.enemyAttackDuration - TUNE.enemyAttackHitTime) { + e.attackDealt = true; + local dxNow = game.player.body.getX() - e.body.getX(); + local dyNow = game.player.body.getY() - e.body.getY(); + if (absf(dxNow) < 54.0 && absf(dyNow) < 52.0 && game.player.invulnerable <= 0.0) { + game.player.hp -= 9.0; + game.player.invulnerable = 0.55; + game.player.body.applyLinearImpulse(e.facing * 240.0, -80.0); + } + } + if (e.stateTimer <= 0.0) { e.state = "normal"; e.attackCooldown = 0.72; } + continue; + } else if (e.state == "hit_stun") { + e.stateTimer -= dt; + if (e.stateTimer <= 0.0) e.state = "normal"; + } else if (e.state == "launched") { + if (e.grounded > 0 && absf(e.body.getLinearVelocityY()) < TUNE.launchedLandSpeed) { + e.state = "knocked_down"; + e.stateTimer = TUNE.knockdownTime; + e.body.setLinearVelocity(e.body.getLinearVelocityX() * 0.55, 0.0); + playSpine(e.spine, "hit", false, true); + } + } else if (e.state == "knocked_down") { + e.stateTimer -= dt; + if (e.stateTimer <= 0.0) { + e.state = "getting_up"; e.stateTimer = TUNE.getupTime; + playSpine(e.spine, "idle", true, true); + } + } else if (e.state == "getting_up") { + e.stateTimer -= dt; + if (e.stateTimer <= 0.0) e.state = "normal"; + } + if (e.state != "normal") continue; + local dx = px - e.body.getX(); + local dy = py - e.body.getY(); + e.facing = dx >= 0.0 ? 1 : -1; + if (absf(dx) < 48.0 && absf(dy) < 52.0 && e.attackCooldown <= 0.0) { + e.state = "attacking"; + e.stateTimer = TUNE.enemyAttackDuration; + e.attackDealt = false; + e.body.setLinearVelocity(0.0, e.body.getLinearVelocityY()); + playSpine(e.spine, "shoot", false, true); + } else if (absf(dx) < 430.0) { + local speed = e.kind == "enemy_leaper" ? 105.0 : 78.0; + local blocked = e.wallContacts.len() > 0; + e.body.setLinearVelocity(blocked ? 0.0 : e.facing * speed, + e.body.getLinearVelocityY()); + if (e.kind == "enemy_leaper" && e.grounded > 0 && absf(dx) < 180.0 && + e.attackCooldown <= 0.0 && (!blocked || absf(dx) < 90.0)) { + e.body.applyLinearImpulse(e.facing * 190.0, -420.0); + e.attackCooldown = 1.8; + playSpine(e.spine, "jump", true); + } else playSpine(e.spine, "run"); + } else playSpine(e.spine, "idle"); + } +} + +function updateBoss(dt) { + local b = game.boss; + if (b == null || b.state == "dead") return; + if (b.impactCooldown > 0.0) b.impactCooldown -= dt; + b.time += dt; + b.phase = b.hp < b.maxHp * 0.33 ? 3 : (b.hp < b.maxHp * 0.66 ? 2 : 1); + if (b.state == "knocked_down") { + b.stateTimer -= dt; + if (b.stateTimer <= 0.0) b.state = "normal"; + return; + } + local radiusX = b.phase == 1 ? 115.0 : 175.0; + local radiusY = b.phase == 3 ? 105.0 : 65.0; + b.body.setPosition(b.homeX + sin(b.time * (0.7 + b.phase * 0.16)) * radiusX, + b.homeY + sin(b.time * 1.35) * radiusY); + if (absf(game.player.body.getX() - b.body.getX()) < 70.0 && + absf(game.player.body.getY() - b.body.getY()) < 65.0 && game.player.invulnerable <= 0.0) { + game.player.hp -= 14.0 + b.phase * 2.0; + game.player.invulnerable = 0.7; + game.player.body.applyLinearImpulse((game.player.body.getX() < b.body.getX() ? -420.0 : 420.0), -180.0); + } +} + +function updatePlatforms(dt) { + foreach (e in game.entities) { + if (e.kind != "moving_platform") continue; + e.time += dt; + e.body.setLinearVelocity(cos(e.time * 1.35) * e.speed, 0.0); + } +} + +function updateEffects(dt) { + for (local i = game.effects.len() - 1; i >= 0; i -= 1) { + local effect = game.effects[i]; + effect.life -= dt; + if (effect.life <= 0.0) { game.effects.remove(i); continue; } + effect.x += effect.vx * dt; + effect.y += effect.vy * dt; + effect.vy += 620.0 * dt; + } +} + +function removeBrokenProps() { + foreach (e in game.entities) { + if (e.kind == "prop_crate" && !e.dead && e.hp <= 0.0) { + e.dead = true; e.body.setActive(false); + game.message = "Crate shattered by momentum"; game.messageTimer = 1.4; + } + } +} + +function updateCamera(dt) { + local worldWidth = 120.0 * 32.0; + local worldHeight = 22.0 * 32.0; + local targetX = worldWidth <= config.width ? worldWidth * 0.5 : + clampf(game.player.body.getX(), config.width * 0.5, worldWidth - config.width * 0.5); + local targetY = worldHeight <= config.height ? worldHeight * 0.5 : + clampf(game.player.body.getY(), config.height * 0.5, worldHeight - config.height * 0.5); + game.cameraX += (targetX - game.cameraX) * clampf(dt * 5.5, 0.0, 1.0); + game.cameraY += (targetY - game.cameraY) * clampf(dt * 5.5, 0.0, 1.0); + local shakeX = game.shake > 0.0 ? sin(game.time * 97.0) * game.shake : 0.0; + local shakeY = game.shake > 0.0 ? cos(game.time * 83.0) * game.shake * 0.55 : 0.0; + game.camera.setPosition(game.cameraX + shakeX, game.cameraY + shakeY); + if (game.shake > 0.0) game.shake = clampf(game.shake - dt * 24.0, 0.0, 10.0); +} + +function buildHud() { + ui.beginBuild(); + ui.beginWindow("Momentum Ruins", "root"); + ui.progress(1.0, "hp", ""); + ui.text("HP", "hptext"); + ui.text("", "ability"); + ui.text("A/D move Space jump J combo K kick Shift dash", "help"); + ui.text("", "message"); + ui.progress(1.0, "boss", ""); + ui.text("", "bosstext"); + ui.end(); + ui.mountBuildAs("hud"); + ui.select("hud"); + ui.setHostOverlay(true); + ui.setHostPos(16.0, 16.0, 0.0, 0.0); +} + +function refreshHud() { + ui.select("hud"); + ui.setValue("hp", clampf(game.player.hp / game.player.maxHp, 0.0, 1.0)); + ui.setText("hptext", "HP " + game.player.hp.tointeger() + "/" + game.player.maxHp.tointeger()); + ui.setText("ability", "Abilities: Double Jump ON Wall Jump " + + (game.hasWallJump ? "ON" : "--") + " Air Dash " + (game.hasDash ? "ON" : "--")); + ui.setText("message", game.won ? "RUINS CLEARED - press R to restart" : game.message); + if (game.boss != null && game.boss.hp > 0.0 && game.player.body.getX() > 104.0 * 32.0) { + ui.setValue("boss", game.boss.hp / game.boss.maxHp); + ui.setText("bosstext", "Dragon " + game.boss.hp.tointeger() + "/" + game.boss.maxHp.tointeger()); + } else { + ui.setValue("boss", 0.0); ui.setText("bosstext", ""); + } +} + +eve_init = function() { + gfx.setBackgroundColor(0.055, 0.075, 0.11, 1.0); + physics.setMeter(30.0); + local spineRoot = fs.getSourceBaseDirectory() + "/../test/assets/spine"; + fs.allowMountingForPath(spineRoot); + if (!fs.mountPath(spineRoot, "spine", false)) + print("warning: could not mount test Spine assets from " + spineRoot + "\n"); + map.loadFromFile("maps/world.json"); + local camera = eve.Camera2D(); + for (local i = 0; i < 4; i += 1) { + local layer = map.getLayer(i); + if (layer != null) layer.setCamera(camera); + } + game = { + world = null, entities = [], byBody = {}, staticBodies = [], player = null, boss = null, + collisionLayer = map.getLayer(1), camera = camera, cameraX = 640.0, cameraY = 360.0, + checkpointX = 112.0, checkpointY = 548.0, hasWallJump = false, hasDash = false, + activeImpacts = {}, contactCounts = {}, crateTexture = gfx.newTextureFromFile("assets/crate.png"), + rockTexture = gfx.newTextureFromFile("assets/rock.png"), + backgrounds = [gfx.newTextureFromFile("assets/background_forest.png"), + gfx.newTextureFromFile("assets/background_ruins.png"), + gfx.newTextureFromFile("assets/background_mine.png")], + message = "Reach the ruins", + messageTimer = 3.0, shake = 0.0, time = 0.0, hitStop = 0.0, accumulator = 0.0, + effects = [], won = false + }; + buildHud(); + resetRun(false); +}; + +function simulateStep(dt) { + game.time += dt; + if (game.won && keyPressed("R")) { game.won = false; resetRun(false); } + game.world.clearContactEvents(); + updatePlayer(dt); + updateEnemies(dt); + updateBoss(dt); + updatePlatforms(dt); + game.world.updateFull(dt, 8, 3); + processContactEvents(); + processImpacts(); + removeBrokenProps(); + updateEffects(dt); + anim.update(dt); + map.update(dt); + updateCamera(dt); + if (game.messageTimer > 0.0) game.messageTimer -= dt; + else game.message = ""; + refreshHud(); +} + +eve_update = function(dt) { + if (game.hitStop > 0.0) { game.hitStop -= clampf(dt, 0.0, 0.05); return; } + game.accumulator += clampf(dt, 0.0, 0.05); + local steps = 0; + while (game.accumulator >= 1.0 / 60.0 && steps < 4) { + simulateStep(1.0 / 60.0); + game.accumulator -= 1.0 / 60.0; + steps += 1; + if (game.hitStop > 0.0) break; + } +}; + +function screenX(wx) { + return game.camera.worldToScreenX(wx, 0.0, config.width.tofloat(), config.height.tofloat()); +} +function screenY(wy) { + return game.camera.worldToScreenY(0.0, wy, config.width.tofloat(), config.height.tofloat()); +} + +eve_render = function() { + gfx.clear(); + local region = game.player.body.getX() < 40.0 * 32.0 ? 0 : (game.player.body.getX() < 80.0 * 32.0 ? 1 : 2); + local tintR = region == 0 ? 0.08 : (region == 1 ? 0.10 : 0.13); + local tintG = region == 0 ? 0.16 : (region == 1 ? 0.10 : 0.09); + gfx.drawTexturedRect(game.backgrounds[region], 0.0, 0.0, + config.width.tofloat(), config.height.tofloat(), + 0.72, 0.72, 0.78, 1.0); + gfx.drawSolidRect(0.0, 0.0, config.width.tofloat(), config.height.tofloat(), + tintR, tintG, 0.18, 0.45); + map.render(gfx); + + foreach (e in game.entities) { + if ("dead" in e && e.dead) continue; + if (e.kind == "prop_crate" || e.kind == "prop_rock") { + local size = e.kind == "prop_rock" ? 42.0 : 40.0; + if (e.body.getLinearSpeed() > TUNE.impactSpeedThreshold) { + local trailX = e.body.getX() - e.body.getLinearVelocityX() * 0.045; + local trailY = e.body.getY() - e.body.getLinearVelocityY() * 0.045; + gfx.drawSolidRect(screenX(trailX) - size * 0.35, screenY(trailY) - size * 0.35, + size * 0.7, size * 0.7, 0.9, 0.7, 0.35, 0.30); + } + gfx.drawTexturedRect(e.texture, screenX(e.body.getX()) - size * 0.5, + screenY(e.body.getY()) - size * 0.5, size, size, 1.0, 1.0, 1.0, 1.0); + } else if (e.kind == "moving_platform") { + gfx.drawSolidRect(screenX(e.body.getX()) - 48.0, screenY(e.body.getY()) - 12.0, + 96.0, 24.0, 0.36, 0.42, 0.48, 1.0); + } else if (e.kind == "ability_walljump" || e.kind == "ability_dash") { + local pulse = 13.0 + sin(game.time * 5.0) * 3.0; + local r = e.kind == "ability_walljump" ? 0.25 : 0.85; + local g = e.kind == "ability_walljump" ? 0.78 : 0.45; + gfx.drawSolidRect(screenX(e.body.getX()) - pulse, screenY(e.body.getY()) - pulse, + pulse * 2.0, pulse * 2.0, r, g, 1.0, 0.85); + } else if ("spine" in e && e.spine != null && (!("state" in e) || e.state != "dead")) { + local sx = screenX(e.body.getX()); + local sy = screenY(e.body.getY()); + local facing = ("facing" in e) ? e.facing : 1; + local baseScale = e.kind == "player" ? TUNE.heroVisualScale : + (e.kind == "boss" ? TUNE.bossVisualScale : TUNE.enemyVisualScale); + e.spine.player.setScale(baseScale * facing, baseScale); + e.spine.player.setPosition(sx, sy + (e.kind == "boss" ? 20.0 : TUNE.actorVisualYOffset)); + if (e.kind == "player" && e.wallJumpFeedback > 0.0) + e.spine.player.setColor(0.45, 0.82, 1.0, 1.0); + else if ((e.kind == "player" && e.invulnerable > 0.0) || + ("impactCooldown" in e && e.impactCooldown > 0.0)) + e.spine.player.setColor(1.0, 0.55, 0.48, 1.0); + else e.spine.player.setColor(1.0, 1.0, 1.0, 1.0); + e.spine.player.draw(gfx); + } + } + foreach (effect in game.effects) { + local radius = effect.heavy ? 7.0 : 4.0; + gfx.drawSolidRect(screenX(effect.x) - radius, screenY(effect.y) - radius, + radius * 2.0, radius * 2.0, 1.0, + effect.heavy ? 0.42 : 0.76, 0.22, + clampf(effect.life * 4.0, 0.0, 1.0)); + } + if (config.debug) game.world.drawDebug(gfx); + ui.beginFrameAndRender(); +}; + +eve_quit = function() { + if (game != null && game.world != null) game.world.destroy(); +}; diff --git a/examples/metroidvania/maps/world.json b/examples/metroidvania/maps/world.json new file mode 100644 index 00000000..2fc424d4 --- /dev/null +++ b/examples/metroidvania/maps/world.json @@ -0,0 +1,10791 @@ +{ + "width": 120, + "height": 22, + "tilewidth": 32, + "tileheight": 32, + "orientation": "orthogonal", + "autoReload": true, + "tilesets": [ + { + "firstgid": 1, + "image": "assets/tiles.png", + "columns": 4, + "tilewidth": 32, + "tileheight": 32, + "imagewidth": 128, + "imageheight": 128 + } + ], + "layers": [ + { + "type": "tilelayer", + "name": "background", + "layer": -2, + "width": 120, + "height": 22, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "type": "tilelayer", + "name": "collision", + "layer": 0, + "width": 120, + "height": 22, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 5, + 5, + 5, + 5, + 5, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 3, + 3, + 3, + 3, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 3, + 3, + 3, + 3, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 3, + 3, + 3, + 3, + 0, + 0, + 5, + 5, + 5, + 0, + 0, + 0, + 0, + 5, + 5, + 5, + 5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 2, + 0, + 0, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 5, + 5, + 5, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 5, + 5, + 5, + 5, + 0, + 0, + 0, + 0, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 5, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2 + ] + }, + { + "type": "tilelayer", + "name": "hazards", + "layer": 1, + "width": 120, + "height": 22, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 11, + 11, + 11, + 11, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "type": "tilelayer", + "name": "foreground", + "layer": 5, + "width": 120, + "height": 22, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 12, + 12, + 12, + 12, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 14, + 0, + 0, + 0, + 14, + 0, + 0, + 0, + 14, + 0, + 16, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "type": "objectgroup", + "name": "gameplay", + "objects": [ + { + "name": "player_start", + "type": "player", + "x": 96, + "y": 576, + "width": 32, + "height": 32 + }, + { + "name": "cp_forest", + "type": "checkpoint", + "x": 96, + "y": 576, + "width": 32, + "height": 32 + }, + { + "name": "crate_1", + "type": "prop_crate", + "x": 288, + "y": 480, + "width": 32, + "height": 32 + }, + { + "name": "crate_2", + "type": "prop_crate", + "x": 640, + "y": 576, + "width": 32, + "height": 32 + }, + { + "name": "rock_1", + "type": "prop_rock", + "x": 896, + "y": 480, + "width": 32, + "height": 32 + }, + { + "name": "scout_1", + "type": "enemy_melee", + "x": 576, + "y": 576, + "width": 32, + "height": 32 + }, + { + "name": "scout_2", + "type": "enemy_leaper", + "x": 928, + "y": 480, + "width": 32, + "height": 32 + }, + { + "name": "wall_jump", + "type": "ability_walljump", + "x": 1024, + "y": 608, + "width": 64, + "height": 224 + }, + { + "name": "cp_ruins", + "type": "checkpoint", + "x": 1344, + "y": 576, + "width": 32, + "height": 32 + }, + { + "name": "guard_1", + "type": "enemy_melee", + "x": 1536, + "y": 576, + "width": 32, + "height": 32 + }, + { + "name": "guard_2", + "type": "enemy_leaper", + "x": 2176, + "y": 320, + "width": 32, + "height": 32 + }, + { + "name": "dash", + "type": "ability_dash", + "x": 2432, + "y": 448, + "width": 32, + "height": 32 + }, + { + "name": "cp_mine", + "type": "checkpoint", + "x": 2592, + "y": 576, + "width": 32, + "height": 32 + }, + { + "name": "mine_platform", + "type": "moving_platform", + "x": 2720, + "y": 544, + "width": 96, + "height": 32 + }, + { + "name": "rock_2", + "type": "prop_rock", + "x": 2848, + "y": 448, + "width": 32, + "height": 32 + }, + { + "name": "crate_3", + "type": "prop_crate", + "x": 3072, + "y": 352, + "width": 32, + "height": 32 + }, + { + "name": "miner_1", + "type": "enemy_melee", + "x": 2912, + "y": 576, + "width": 32, + "height": 32 + }, + { + "name": "miner_2", + "type": "enemy_leaper", + "x": 3200, + "y": 576, + "width": 32, + "height": 32 + }, + { + "name": "cp_boss", + "type": "checkpoint", + "x": 3456, + "y": 576, + "width": 32, + "height": 32 + }, + { + "name": "dragon", + "type": "boss", + "x": 3616, + "y": 384, + "width": 96, + "height": 96 + }, + { + "name": "boss_exit", + "type": "exit", + "x": 3744, + "y": 576, + "width": 64, + "height": 64 + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/metroidvania/smoke_test.py b/examples/metroidvania/smoke_test.py new file mode 100644 index 00000000..f6da8e05 --- /dev/null +++ b/examples/metroidvania/smoke_test.py @@ -0,0 +1,118 @@ +"""Launch Momentum Ruins and fail if the engine reports a frame/runtime error.""" + +from __future__ import annotations + +import argparse +import ctypes +import subprocess +import sys +import time +from pathlib import Path + + +def replay_input(process_id: int) -> None: + """Exercise movement, jumping, facing changes, combo attacks, and kicking.""" + if sys.platform != "win32": + raise RuntimeError("--input-replay currently requires Windows") + + user32 = ctypes.windll.user32 + target = ctypes.c_void_p() + enum_proc_type = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) + + @enum_proc_type + def find_window(hwnd: int, _param: int) -> bool: + owner_pid = ctypes.c_ulong() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner_pid)) + if owner_pid.value == process_id and user32.IsWindowVisible(hwnd): + target.value = hwnd + return False + return True + + deadline = time.monotonic() + 3.0 + while not target.value and time.monotonic() < deadline: + user32.EnumWindows(find_window, 0) + time.sleep(0.05) + if not target.value: + raise RuntimeError("game window did not appear") + + def set_key(vk: int, down: bool) -> None: + scan = user32.MapVirtualKeyW(vk, 0) + flags = 1 | (scan << 16) + message = 0x0100 # WM_KEYDOWN + if not down: + message = 0x0101 # WM_KEYUP + flags |= (1 << 30) | (1 << 31) + if not user32.PostMessageW(target.value, message, vk, flags): + raise RuntimeError(f"failed to post key 0x{vk:02x}") + + def key(vk: int, held: float = 0.08) -> None: + set_key(vk, True) + time.sleep(held) + set_key(vk, False) + + set_key(ord("D"), True) + time.sleep(0.8) + key(0x20, 0.12) # jump while moving right + time.sleep(0.16) + key(0x20, 0.09) # double jump + time.sleep(0.40) + set_key(ord("D"), False) + for _ in range(3): + key(ord("J")) + time.sleep(0.2) + key(ord("K")) + set_key(ord("A"), True) + time.sleep(0.35) + key(0x20, 0.09) # reverse-facing jump + set_key(ord("A"), False) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("engine", type=Path, help="Path to the eve executable") + parser.add_argument("--seconds", type=float, default=6.0) + parser.add_argument("--input-replay", action="store_true") + args = parser.parse_args() + + game_dir = Path(__file__).resolve().parent + process = subprocess.Popen( + [str(args.engine.resolve()), "run", str(game_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + started = time.monotonic() + replay_error = None + if args.input_replay: + try: + replay_input(process.pid) + except RuntimeError as exc: + replay_error = str(exc) + time.sleep(max(0.0, args.seconds - (time.monotonic() - started))) + exited_early = process.poll() is not None + if not exited_early: + process.terminate() + try: + process.wait(timeout=3.0) + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + combined = stdout + "\n" + stderr + bad_markers = ("frame error", "Run failed:", "Runtime error") + failures = [marker for marker in bad_markers if marker.lower() in combined.lower()] + + if failures or replay_error or (exited_early and process.returncode != 0): + print(combined, file=sys.stderr) + if replay_error: + print("Input replay failed: " + replay_error, file=sys.stderr) + print("Smoke test failed: " + ", ".join(failures), file=sys.stderr) + return 1 + mode = " with input replay" if args.input_replay else "" + print(f"Smoke test passed ({args.seconds:.1f}s{mode}, no frame/runtime errors).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/metroidvania/tuning.nut b/examples/metroidvania/tuning.nut new file mode 100644 index 00000000..7d1b4085 --- /dev/null +++ b/examples/metroidvania/tuning.nut @@ -0,0 +1,65 @@ +// All combat and physics tuning lives here so momentum behavior is easy to audit. +TUNE <- { + gravity = 1200.0, + moveSpeed = 240.0, + groundAcceleration = 18.0, + airAcceleration = 8.5, + airControl = 0.72, + jumpSpeed = 470.0, + airJumpSpeed = 445.0, + airJumpCount = 1, + wallJumpX = 330.0, + wallJumpY = 470.0, + wallSlideSpeed = 105.0, + coyoteTime = 0.10, + jumpBuffer = 0.12, + jumpCutAcceleration = 1550.0, + wallJumpLockTime = 0.14, + wallContactGrace = 0.12, + wallJumpFeedbackTime = 0.16, + dashSpeed = 650.0, + dashTime = 0.16, + playerHp = 100.0, + heroVisualScale = 0.32, + enemyVisualScale = 0.15, + bossVisualScale = 0.20, + actorVisualYOffset = 27.0, + enemyHp = 55.0, + bossHp = 360.0, + staggerThreshold = 80.0, + staggerDecayDelay = 0.8, + staggerDecay = 24.0, + attackDamage = [7.0, 9.0, 14.0], + attackStagger = [20.0, 29.0, 48.0], + attackImpulseX = [250.0, 360.0, 780.0], + attackImpulseY = [-25.0, -55.0, -330.0], + attackDuration = [0.27, 0.30, 0.40], + attackStartup = [0.055, 0.070, 0.105], + attackActive = [0.10, 0.11, 0.14], + comboGrace = 0.32, + kickDuration = 0.34, + kickStartup = 0.09, + kickActive = 0.12, + kickImpulseX = 720.0, + kickImpulseY = -105.0, + impactSpeedThreshold = 180.0, + impactImpulseThreshold = 35.0, + impactScale = 0.00022, + impactDamageCap = 35.0, + impactInvulnerability = 0.18, + crateHp = 30.0, + crateBreakImpulse = 520.0, + crateDensity = 1.0, + rockDensity = 8.0, + enemyDensity = 1.15, + frictionTerrain = 0.75, + frictionCrate = 0.45, + frictionRock = 0.85, + restitutionProp = 0.05, + enemyAttackDuration = 0.52, + enemyAttackHitTime = 0.28, + enemyDeathTime = 0.75, + launchedLandSpeed = 95.0, + knockdownTime = 1.15, + getupTime = 0.34 +}; diff --git a/examples/rock-generator/README.md b/examples/rock-generator/README.md new file mode 100644 index 00000000..60d8a4fe --- /dev/null +++ b/examples/rock-generator/README.md @@ -0,0 +1,23 @@ +# LITHIC — Random 3D Rock Generator + +A deterministic real-time rock authoring example for EVEngine. Its default path deforms a +shared-vertex icosphere, adds seeded procedural stone albedo and normals, and exposes the defining +parameters through an in-engine UI. Three geometric LODs are generated automatically. + +Run it with the same command used for other examples, selecting `examples/rock-generator` as the game. + +Controls: + +- **New specimen** or **R** advances to a new deterministic seed. +- **Next base shape** cycles through mixed, boulder, slab, block, and shard silhouettes. +- Shape sliders control flattening, fracture planes, erosion, and geologic noise scale. +- Surface roughness updates without rebuilding the mesh. +- **Pause rotation** freezes the turntable for inspection. + +The reusable default recipe is `mesh.rock`. `baseShape` accepts `mixed`, `boulder`, `slab`, +`block`, or `shard`; `mixed` chooses a profile deterministically from the seed. Shape composition +can be tuned further with `variation`, `axisX/Y/Z`, `shapePower`, `skewX/Z`, `cutCount`, and +`cutDepth`, alongside `radius`, `flattening`, `angularity`, `erosion`, `scale`, `octaves`, +`subdivisions`, and `seed`. Subdivision levels 3, 2, and 1 contain 1,280, 320, and 80 triangles +respectively. The volumetric +`mesh.marchingcubes`/`field = "rock"` path remains available for complex topology. diff --git a/examples/rock-generator/config.nut b/examples/rock-generator/config.nut new file mode 100644 index 00000000..8930dabe --- /dev/null +++ b/examples/rock-generator/config.nut @@ -0,0 +1,7 @@ +config <- { + width = 1180 + height = 720 + title = "LITHIC / Random 3D Rock Generator" + debug = false + hotReload = true +}; diff --git a/examples/rock-generator/main.nut b/examples/rock-generator/main.nut new file mode 100644 index 00000000..ce7e8bf3 --- /dev/null +++ b/examples/rock-generator/main.nut @@ -0,0 +1,183 @@ +// LITHIC — deterministic, real-time 3D rock authoring example. +// Every seed recreates the same mesh and procedural stone textures. + +if (!("rock" in getroottable())) rock <- null; +if (!("camera" in getroottable())) camera <- null; +if (!("seed" in getroottable())) seed <- 1847; +if (!("shapeIndex" in getroottable())) shapeIndex <- 0; +if (!("shapeNames" in getroottable())) shapeNames <- ["mixed", "boulder", "slab", "block", "shard"]; +if (!("flattening" in getroottable())) flattening <- 0.22; +if (!("angularity" in getroottable())) angularity <- 0.38; +if (!("erosion" in getroottable())) erosion <- 0.16; +if (!("noiseScale" in getroottable())) noiseScale <- 2.4; +if (!("roughness" in getroottable())) roughness <- 0.86; +if (!("autoSpin" in getroottable())) autoSpin <- true; +if (!("yaw" in getroottable())) yaw <- 0.0; +if (!("uiReady" in getroottable())) uiReady <- false; +if (!("rWasDown" in getroottable())) rWasDown <- false; + +function coreId() { + local s = seed.tostring(); + while (s.len() < 6) s = "0" + s; + return "BAS-" + s; +} + +function buildRock() { + local p = procgen.newParams(); + p.setSeed(seed); + p.setInt("subdivisions", 3); + p.setString("baseShape", shapeNames[shapeIndex]); + p.setFloat("variation", 0.48); + p.setFloat("radius", 0.72); + p.setFloat("flattening", flattening); + p.setFloat("angularity", angularity); + p.setFloat("erosion", erosion); + p.setFloat("scale", noiseScale); + p.setInt("octaves", 4); + + local mesh = procgen.generateMesh("mesh.rock", p, gfx); + if (mesh == null) { + ui.select("lab"); + ui.setText("status", "Generation failed: " + procgen.lastError()); + return; + } + p.setInt("subdivisions", 2); + local lod1 = procgen.generateMesh("mesh.rock", p, gfx); + p.setInt("subdivisions", 1); + local lod2 = procgen.generateMesh("mesh.rock", p, gfx); + + local tp = procgen.newParams(); + tp.setSeed(seed); + tp.setSize(256, 256); + tp.setFloat("scale", 4.2); + tp.setInt("octaves", 5); + tp.setInt("seamless", 1); + local albedo = procgen.generateTexture("tex.stone", tp, gfx); + local normalImage = procgen.generateNormalImage("tex.stone", tp); + local normal = normalImage == null ? null : gfx.newTexture(normalImage, true, true); + + if (rock == null) rock = eve.Renderable3D(); + rock.clearMeshLod(); + rock.setMeshLod(0, mesh, 0.0); + if (lod1 != null) rock.setMeshLod(1, lod1, 8.0); + if (lod2 != null) rock.setMeshLod(2, lod2, 16.0); + rock.setTexture(albedo); + if (normal != null) rock.setNormalTexture(normal); + rock.setScale(2.25, 2.25, 2.25); + rock.setPosition(-0.42, -0.08, 0.0); + rock.setTint(0.69, 0.64, 0.54, 1.0); + rock.setMetallic(0.02); + rock.setRoughness(roughness); + rock.setCastShadow(true); + rock.setReceiveShadow(true); + + if (uiReady) { + ui.select("lab"); + ui.setText("sample", coreId()); + ui.setText("status", shapeNames[shapeIndex] + " base / 1,280 triangles / 3 LODs"); + } +} + +function buildPanel() { + ui.setTheme("dark"); + ui.setNavKeyboard(true); + ui.beginBuild(); + ui.beginWindow("LITHIC / FIELD LAB", "root"); + ui.text("CURRENT CORE", "eyebrow"); + ui.text(coreId(), "sample"); + ui.text("Shape", "shapeLabel"); + ui.slider("Flattening", flattening, 0.0, 0.65, "flattening"); + ui.slider("Fracture planes", angularity, 0.0, 1.0, "angularity"); + ui.slider("Surface erosion", erosion, 0.0, 0.42, "erosion"); + ui.slider("Geologic scale", noiseScale, 0.6, 5.5, "noiseScale"); + ui.text("Surface", "surfaceLabel"); + ui.slider("Roughness", roughness, 0.35, 1.0, "roughness"); + ui.beginRow("actions", 8.0); + ui.button("New specimen", "randomize"); + ui.button("Rebuild", "rebuild"); + ui.end(); + ui.button("Next base shape", "nextShape"); + ui.button("Pause rotation", "spin"); + ui.text("Drag-free inspection • R creates a new specimen", "hint"); + ui.text("Generating…", "status"); + ui.end(); + ui.mountBuildAs("lab"); + ui.select("lab"); + ui.setHostOverlay(true); + ui.setHostPos(850.0, 30.0, 300.0, 650.0); + uiReady = true; +} + +function randomize() { + seed = (seed * 1664525 + 1013904223) & 0x7fffffff; + buildRock(); +} + +function randomizeKeyPressed() { + local down = keyboard.isDown("r") || keyboard.isDown("R"); + local pressed = down && !rWasDown; + rWasDown = down; + return pressed; +} + +eve_init = function() { + gfx.setBackgroundColor(0.045, 0.052, 0.050, 1.0); + camera = eve.Camera3D(); + camera.setEye(3.25, 2.15, 5.1); + camera.setTarget(-0.4, -0.1, 0.0); + camera.setFov(37.0); + camera.setAmbient(0.24, 0.25, 0.23); + gfx.setDirectionalLight(-0.55, 0.85, 0.35, 2.0, 1.72, 1.35); + if (!uiReady) buildPanel(); + buildRock(); +}; + +eve_update = function(dt) { + if (autoSpin && rock != null) { + yaw += dt * 16.0; + rock.setYaw(yaw); + } + if (randomizeKeyPressed()) randomize(); + + local clicked = ui.consumeClick(); + while (clicked != "") { + if (clicked == "lab/randomize") randomize(); + else if (clicked == "lab/rebuild") buildRock(); + else if (clicked == "lab/nextShape") { + shapeIndex = (shapeIndex + 1) % shapeNames.len(); + buildRock(); + } + else if (clicked == "lab/spin") { + autoSpin = !autoSpin; + ui.select("lab"); + ui.setText("spin", autoSpin ? "Pause rotation" : "Resume rotation"); + } + clicked = ui.consumeClick(); + } + + local changed = ui.consumeChange(); + local rebuild = false; + while (changed != "") { + ui.select("lab"); + if (changed == "lab/flattening") { flattening = ui.getValue("flattening"); rebuild = true; } + else if (changed == "lab/angularity") { angularity = ui.getValue("angularity"); rebuild = true; } + else if (changed == "lab/erosion") { erosion = ui.getValue("erosion"); rebuild = true; } + else if (changed == "lab/noiseScale") { noiseScale = ui.getValue("noiseScale"); rebuild = true; } + else if (changed == "lab/roughness") { + roughness = ui.getValue("roughness"); + if (rock != null) rock.setRoughness(roughness); + } + changed = ui.consumeChange(); + } + if (rebuild) buildRock(); +}; + +eve_render = function() { + gfx.clear(); + gfx.render3D(); + // Copper datum lines make the viewport read like a geological instrument. + gfx.drawSolidRect(54, 650, 720, 1, 0.55, 0.34, 0.18, 0.55); + gfx.drawSolidRect(54, 646, 1, 9, 0.55, 0.34, 0.18, 0.75); + gfx.drawSolidRect(774, 646, 1, 9, 0.55, 0.34, 0.18, 0.75); + ui.beginFrameAndRender(); +}; diff --git a/examples/tree-generator/README.md b/examples/tree-generator/README.md new file mode 100644 index 00000000..fbdca0e6 --- /dev/null +++ b/examples/tree-generator/README.md @@ -0,0 +1,70 @@ +# Arbor Lab — 随机树木生成器 + +`mesh.tree` 是 EVEngine 的原生程序化网格配方。相同 seed 与参数会生成一致的网格,可通过 +`procgen.buildMesh` 获取 CPU 数据,或通过 `procgen.generateMesh` 直接上传 GPU。 + +## 快速使用 + +```squirrel +local p = procgen.newParams(); +p.setSeed(31415); +p.setString("style", "lowpoly"); +p.setString("branchAlgorithm", "weberPenn"); +p.setString("leafMode", "canopy"); +p.setFloat("leafDensity", 0.75); + +local mesh = procgen.generateMesh("mesh.tree", p, gfx); +``` + +`branchAlgorithm` 只能选择一种骨架算法:`weberPenn` 是默认值,规则稳定、生成较快; +`spaceColonization` 通过树冠吸引点迭代生长,轮廓更不规则。主干、枝条层级和叶片系统由两种算法共享。 + +## 通用参数 + +- `style`: `lowpoly` / `realistic` +- `leafMode`: `cards`(独立双面叶片)/ `canopy`(整片冠层网格)/ `none` +- `leafDensity`: `0..1` +- `height`, `trunkRadius`, `crownRadius`, `leafSize` +- `foliageStart`: 树冠起始高度比例,`0.1..0.9` +- `radialSegments`: 枝干横截面精度,`3..24` +- `curveSegments`: 主干/枝条弯曲分段数,`2..20` +- `trunkCurve`: 主干整体弯曲强度,`0..0.45` +- `branchCurve`: 枝条弯曲强度,`0..0.5` +- `curveBack`: S 形回弯,`-0.5..0.5` +- `tropism`: 向上生长趋势,`-0.5..0.8` +- `droop`: 枝梢下垂,`0..0.8` +- `branchLengthFalloff`: 随树冠高度增加的枝长衰减,`0..0.9` +- `branchRadiusFalloff`: 随树冠高度增加的枝径衰减,`0..0.9` +- `lowerLeafCoverage`: 下层老枝沿枝条分布叶片的概率,`0..1` +- `upperLeafCoverage`: 上层新梢沿枝条分布叶片的概率,`0..1` + +## Weber–Penn 参数 + +- `branchLevels`: `1..5` +- `branchCount`: 每层主要分枝数量,`2..20` +- `branchAngle`, `branchAngleVariation`: 分枝角度与随机变化(度) +- `phyllotaxis`: 枝序旋转角(度),默认 `137.5` +- `apicalDominance`: 顶端优势,`0..1` + +## 空间殖民参数 + +- `attractorCount`: 树冠吸引点数量,`12..1200` +- `colonizationIterations`: 最大迭代数,`4..160` +- `influenceRadius`, `killRadius`: 吸引范围与到达判定距离 +- `growthStep`: 每轮枝梢生长步长 +- `branchInertia`: 延续上一段方向的惯性,`0..4` +- `maxTurnAngle`: 相邻生长段允许的最大转角(度) +- `maxCumulativeAngle`: 枝条偏离初始生长轴的最大角度,用于阻止 U 形回卷 +- `maxChildren`: 单个生长节点允许的最大子枝数,`1..4` + +## 运行示例 + +```sh +make run/linux-debug GAME=examples/tree-generator +make run/win32-debug GAME=examples/tree-generator +``` + +快捷键:`R` 换 seed,`1/2` 切换 Low Poly/写实,`A` 切换分枝算法,`L` 开关叶片, +`C` 切换整片冠层,`[` / `]` 调节叶片密度。 + +树木网格应在加载、换 seed 或修改参数时生成并缓存,不要每帧重新生成。 diff --git a/examples/tree-generator/config.nut b/examples/tree-generator/config.nut new file mode 100644 index 00000000..f331131b --- /dev/null +++ b/examples/tree-generator/config.nut @@ -0,0 +1,7 @@ +config <- { + width = 1100 + height = 720 + title = "Arbor Lab — Procedural Tree Generator" + debug = false + hotReload = true +}; diff --git a/examples/tree-generator/main.nut b/examples/tree-generator/main.nut new file mode 100644 index 00000000..daf71945 --- /dev/null +++ b/examples/tree-generator/main.nut @@ -0,0 +1,82 @@ +// Arbor Lab — interactive procedural tree recipe showcase. +// R regenerate, 1/2 Low Poly/Realistic, A algorithm, L leaves, [/] density, C canopy. + +if (!("treeSeed" in getroottable())) treeSeed <- 31415; +if (!("treeStyle" in getroottable())) treeStyle <- "lowpoly"; +if (!("treeAlgorithm" in getroottable())) treeAlgorithm <- "weberPenn"; +if (!("leafMode" in getroottable())) leafMode <- "cards"; +if (!("leafDensity" in getroottable())) leafDensity <- 0.65; +if (!("treeMesh" in getroottable())) treeMesh <- null; +if (!("treeObject" in getroottable())) treeObject <- null; +if (!("treeCamera" in getroottable())) treeCamera <- null; +if (!("treeYaw" in getroottable())) treeYaw <- 0.0; +if (!("prevTreeKeys" in getroottable())) prevTreeKeys <- {}; + +function pressed(k) { + local down = keyboard.isDown(k); + local old = k in prevTreeKeys ? prevTreeKeys[k] : false; + prevTreeKeys[k] <- down; + return down && !old; +} + +function rebuildTree() { + local p = procgen.newParams(); + p.setSeed(treeSeed); + p.setString("style", treeStyle); + p.setString("branchAlgorithm", treeAlgorithm); + p.setString("leafMode", leafMode); + p.setFloat("leafDensity", leafDensity); + p.setFloat("height", 6.2); + p.setFloat("crownRadius", 2.15); + p.setInt("branchLevels", treeStyle == "realistic" ? 3 : 2); + p.setInt("branchCount", treeStyle == "realistic" ? 10 : 6); + treeMesh = procgen.generateMesh("mesh.tree", p, gfx); + if (treeObject == null) { + treeObject = eve.Renderable3D(); + treeObject.setPosition(1.35, -3.0, 0.0); + treeObject.setTint(0.34, 0.48, 0.22, 1.0); + treeObject.setRoughness(0.86); + treeObject.setCastShadow(true); + } + treeObject.setMesh(treeMesh); +} + +if (treeCamera == null) { + treeCamera = eve.Camera3D(); + treeCamera.setEye(10.0, 4.6, 12.0); + treeCamera.setTarget(1.1, 0.2, 0.0); + treeCamera.setUp(0.0, 1.0, 0.0); + treeCamera.setFov(38.0); + treeCamera.setAmbient(0.32, 0.38, 0.30); + treeCamera.setActive(true); + gfx.setDirectionalLight(-0.45, -1.0, -0.35, 1.35, 1.24, 1.02); +} +if (treeMesh == null) rebuildTree(); + +function update(dt) { + treeYaw += dt * 7.0; + treeObject.setYaw(treeYaw); + if (pressed("r") || pressed("R")) { treeSeed += 1; rebuildTree(); } + if (pressed("1")) { treeStyle = "lowpoly"; rebuildTree(); } + if (pressed("2")) { treeStyle = "realistic"; rebuildTree(); } + if (pressed("a") || pressed("A")) { + treeAlgorithm = treeAlgorithm == "weberPenn" ? "spaceColonization" : "weberPenn"; + rebuildTree(); + } + if (pressed("l") || pressed("L")) { + leafMode = leafMode == "none" ? "cards" : "none"; + rebuildTree(); + } + if (pressed("c") || pressed("C")) { + leafMode = leafMode == "canopy" ? "cards" : "canopy"; + rebuildTree(); + } + if (pressed("leftbracket")) { leafDensity = max(0.0, leafDensity - 0.1); rebuildTree(); } + if (pressed("rightbracket")) { leafDensity = min(1.0, leafDensity + 0.1); rebuildTree(); } +} + +function draw() { + gfx.clear(0.055, 0.075, 0.07, 1.0); + gfx.render3D(); + gfx.drawSolidRect(28, 28, 310, 212, 0.92, 0.94, 0.86, 0.96); +} diff --git a/platform/android/apk/app/src/main/AndroidManifest.xml b/platform/android/apk/app/src/main/AndroidManifest.xml index bca3cb03..fcb71b9b 100644 --- a/platform/android/apk/app/src/main/AndroidManifest.xml +++ b/platform/android/apk/app/src/main/AndroidManifest.xml @@ -16,7 +16,7 @@ android:hardwareAccelerated="true"> /evengine_test}) and launches the zeroerr runner built + * into {@code libmain.so}. SDL_main chdirs to that tree, so the compile-time + * {@code EVENGINE_SOURCE_DIR="."} / {@code EVENGINE_TEST_BINARY_DIR="."} paths + * resolve on-device. A test filter can be passed as the + * {@code evengine.test.filter} intent extra, e.g. + * {@code adb shell am start -n com.evengine.example/.EVTestActivity --es evengine.test.filter "math.*"}. + */ +public class EVTestActivity extends SDLActivity { + private static final String TAG = "EVTestActivity"; + private String testDir; + + @Override + protected void onCreate(Bundle savedInstanceState) { + testDir = new File(getFilesDir(), "evengine_test").getAbsolutePath(); + try { + // Wipe first so assets removed from the package do not linger across upgrades. + deleteRecursive(new File(testDir)); + copyAssetDir("test", testDir); + copyAssetDir("examples", testDir + "/examples"); + Log.i(TAG, "Test assets ready at " + testDir); + } catch (IOException e) { + Log.e(TAG, "Failed to unpack test assets", e); + } + super.onCreate(savedInstanceState); + } + + @Override + protected String[] getLibraries() { + return new String[] { + "SDL2", + "c++_shared", + "main" + }; + } + + @Override + protected String[] getArguments() { + if (getIntent() != null) { + String filter = getIntent().getStringExtra("evengine.test.filter"); + if (filter != null && !filter.isEmpty()) { + return new String[] { "--testcase=" + filter }; + } + } + return new String[0]; + } + + private void copyAssetDir(String assetPath, String destPath) throws IOException { + AssetManager am = getAssets(); + String[] children = am.list(assetPath); + File dest = new File(destPath); + if (!dest.exists() && !dest.mkdirs()) { + throw new IOException("Cannot create " + destPath); + } + if (children == null || children.length == 0) { + // Leaf file + copyAssetFile(assetPath, destPath); + return; + } + for (String child : children) { + String childAsset = assetPath.isEmpty() ? child : assetPath + "/" + child; + String childDest = destPath + "/" + child; + String[] grand = am.list(childAsset); + if (grand != null && grand.length > 0) { + copyAssetDir(childAsset, childDest); + } else { + copyAssetFile(childAsset, childDest); + } + } + } + + private void copyAssetFile(String assetPath, String destPath) throws IOException { + File outFile = new File(destPath); + File parent = outFile.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException("Cannot create parent for " + destPath); + } + try (InputStream in = getAssets().open(assetPath); + OutputStream out = new FileOutputStream(outFile)) { + byte[] buf = new byte[16 * 1024]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + } + } + + private static void deleteRecursive(File f) { + if (f == null || !f.exists()) return; + File[] children = f.listFiles(); + if (children != null) { + for (File c : children) deleteRecursive(c); + } + //noinspection ResultOfMethodCallIgnored + f.delete(); + } +} diff --git a/platform/android/apk/gradle/wrapper/gradle-wrapper.properties b/platform/android/apk/gradle/wrapper/gradle-wrapper.properties index 62f495df..1af9e093 100644 --- a/platform/android/apk/gradle/wrapper/gradle-wrapper.properties +++ b/platform/android/apk/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/platform/webgpu/CMakeLists.txt b/platform/webgpu/CMakeLists.txt new file mode 100644 index 00000000..4645a09c --- /dev/null +++ b/platform/webgpu/CMakeLists.txt @@ -0,0 +1,14 @@ +# WebGPU platform: shared by the browser (Emscripten/WASM) and native Dawn +# desktop builds. The platform library is backend-neutral; toolchain-specific +# link flags (Emscripten) and game-asset packaging are applied in +# src/engine/CMakeLists.txt (which owns the `eve` host target). + +add_library(EVPlatformWebGPU STATIC webplatform.cpp) +target_include_directories(EVPlatformWebGPU PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) +target_compile_features(EVPlatformWebGPU PUBLIC cxx_std_20) + +set(EVENGINE_PLATFORM_LIB EVPlatformWebGPU CACHE INTERNAL "Platform library for the active BUILD_PLATFORM") + +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + message(STATUS "WebGPU (Emscripten): game dir = ${EVENGINE_WEBGPU_GAME_DIR}") +endif() diff --git a/platform/webgpu/README.md b/platform/webgpu/README.md new file mode 100644 index 00000000..6d58620c --- /dev/null +++ b/platform/webgpu/README.md @@ -0,0 +1,109 @@ +# WebGPU target (browser WASM + native Dawn) + +The `webgpu` platform runs the engine on WebGPU instead of Vulkan. It shares a +single backend under `src/modules/graphics/webgpu/` (and `src/modules/gpgpu/webgpu/`) +that targets the standard `webgpu.h` API, and can be produced two ways: + +- **Browser**: compiled with Emscripten to WASM, rendering with the browser's + native WebGPU (Chromium/Edge/Firefox with `--enable-unsafe-webgpu`). Shaders + must be WGSL. +- **Native desktop**: links Google Dawn (`dawn::dawn`), which accepts WGSL (and + SPIR-V) and runs on top of Vulkan/Metal/D3D12. + +## Prerequisites + +- **Emscripten** (`emsdk`, `emcmake`) with WebGPU support. Emscripten ships the + `webgpu.h`/`webgpu_cpp.h` headers. +- **CMake 3.29.x** (pip-installed). CMake 4.x is **incompatible** with the + Emscripten SDK fork of `CheckTypeSize.cmake` (`/CheckTypeSize.c.in does not + exist`), which breaks third-party configure (e.g. mpg123). Install and force + CMake 3.29: + ```sh + python -m pip install cmake==3.29.6 + # EMSCRIPTEN builds must use this cmake for BOTH the main and the + # third-party aggregate — the root CMakeLists detects and forces it. + ``` +- **Native Dawn**: fetched automatically via CMake `FetchContent` (fixed commit). +- A WebGPU-capable browser (Chrome 113+ / Edge 113+ / Firefox 118+). + +## Browser build (Emscripten / WASM) + +```sh +# activate emsdk once +emsdk activate latest && emsdk_env.bat # Windows +emcmake cmake -B build/webgpu-web -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build/webgpu-web # produces eve.js / eve.wasm / eve.data +``` + +The Emscripten build **trims** third-party dependencies and engine modules: + +- **Skipped deps**: Poco, OpenAL-soft, medialoader (assimp/mpg123/vorbis), FreeType, + googletest. SDL2 + zlib come from Emscripten ports (`-sUSE_SDL=2`, `-sUSE_ZLIB`). + Kept: squirrel/simplesquirrel, physfs, lz4, Box2D, xxHash, imgui, and a minimal + `medialoader_image` (stb/lodepng/dds/tinyexr image decoders, needed by EVImage). +- **Compiled-out modules** (their deps were dropped): + `EVAnimation EVAudio EVAvatar EVBuilding EVDatabase EVDialogue EVFont EVInventory + EVMap EVModel3D EVNetwork EVParticles EVPlugins EVProcgen EVRPG EVSound EVStylize + EVVoxel`, plus `graphics/Font.cpp` and the Poco-heavy `DataModule/JsonDocument/ + XmlDocument`. +- **Threads**: the engine main loop runs on a pthread + (`-sPROXY_TO_PTHREAD=1`), so **all TUs are compiled with `-matomics + -mbulk-memory`** (engine and third-party) and the page must be served with + COOP/COEP headers so `SharedArrayBuffer` is available. + +### Serving + browser verification + +The output directory is `build/webgpu-web/src/engine/`. Serve it with a real +HTTP server that sends the cross-origin-isolation headers: + +```sh +python build/serve_webgpu.py # serves http://127.0.0.1:8090/eve.html + # with COOP/COEP + no-store headers +``` + +Open `http://127.0.0.1:8090/eve.html` in Chrome/Edge (WebGPU enabled). +`shell.html` prints a status line with `crossOriginIsolated` and the resolved +WebGPU adapter, so init failures are visible without DevTools. + +**Headless verification** (optional): headless Chromium needs explicit GPU flags +for WebGPU to be exposed — plain `--headless` reports "No available adapters": + +```sh +msedge --headless=new --no-sandbox --disable-gpu-sandbox \ + --enable-unsafe-webgpu --enable-features=Vulkan --use-vulkan=swiftshader \ + --timeout=15000 --screenshot=frame.png http://127.0.0.1:8090/eve.html +``` + +Note: with `PROXY_TO_PTHREAD`, the main loop runs on a worker; use the real-time +`--timeout` wait (not `--virtual-time-budget`, which never advances the worker +clock) so the first frames are actually presented before the screenshot. + +The shell (`platform/webgpu/shell.html`) provides the `` +that the WebGPU surface attaches to. Game assets under +`platform/webgpu/game-shell/` are preloaded at `/game`. + +## Native Dawn build + +```sh +cmake -B build/webgpu-native -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_PLATFORM=webgpu +cmake --build build/webgpu-native +./build/webgpu-native/src/engine/eve run +``` + +Set `EVENGINE_WEBGPU_GAME_DIR` to point the platform helpers at a game folder +when not running from the game directory itself. + +## Notes / limitations + +- Runtime GLSL compilation (`gfx.newShader(glsl)`) is **not** available in the + browser — shaders must ship as pre-compiled WGSL (see the `*_wgsl.inc` + generation step). Native Dawn builds can compile GLSL via glslang+Tint. +- The Vulkan backend and `ui/imgui` Vulkan renderer are excluded from webgpu + builds by the source scanner (`EVENGINE_EXCLUDED_BACKEND_SUBDIRS`). +- ImGui overlays use `imgui_impl_wgpu.cpp` (compiled by the EVUI module scan; + `imgui_impl_wgpu.cpp` includes `common/config.h` **before** its + `#if defined(EVENGINE_WEBGPU)` guard so the platform define is visible). +- Poco/OpenAL/medialoader/FreeType are not built on WASM; the corresponding + modules are compiled out (see the trim list above). The native Dawn build + still compiles them. diff --git a/platform/webgpu/game-shell/config.nut b/platform/webgpu/game-shell/config.nut new file mode 100644 index 00000000..bb7bd814 --- /dev/null +++ b/platform/webgpu/game-shell/config.nut @@ -0,0 +1,7 @@ +config <- { + width = 800 + height = 600 + title = "EVEngine WebGPU" + debug = false + hotReload = false +}; diff --git a/platform/webgpu/game-shell/main.nut b/platform/webgpu/game-shell/main.nut new file mode 100644 index 00000000..aeeca8ec --- /dev/null +++ b/platform/webgpu/game-shell/main.nut @@ -0,0 +1,34 @@ +// EVEngine WebGPU minimal demo: bouncing 2D squares + HUD. +// Uses only the trimmed module set (Window/Graphics/Timer). + +eve_init <- function() { + gfx.setBackgroundColor(0.04, 0.05, 0.12, 1.0); + x <- 0.0; + y <- 0.0; + vx <- 180.0; + vy <- 120.0; + frameCount <- 0; + print("eve_init ok\n"); +}; + +eve_update <- function(dt) { + x = x + vx * dt; + y = y + vy * dt; + if (x < 0 || x > config.width - 40.0) vx = -vx; + if (y < 0 || y > config.height - 40.0) vy = -vy; +}; + +eve_render <- function() { + gfx.clear(); + + // Bouncing square. + gfx.drawSolidRect(x, y, 40.0, 40.0, 0.35, 0.85, 1.0, 1.0); + + // Static HUD bar. + gfx.drawSolidRect(10.0, 10.0, 180.0, 28.0, 0.15, 0.5, 0.95, 0.9); + + frameCount += 1; + if (frameCount % 30 == 0) { + print("frame " + frameCount + "\n"); + } +}; diff --git a/platform/webgpu/game-shell/root.nut b/platform/webgpu/game-shell/root.nut new file mode 100644 index 00000000..d23490a7 --- /dev/null +++ b/platform/webgpu/game-shell/root.nut @@ -0,0 +1,13 @@ +// WebGPU shell root script. When no main.nut ships with the game this runs the +// embedded demo (eve.demoScript) exactly like the desktop/iOS shells, but also +// exercises the WebGPU surface + ImGui overlay so a browser smoke test has +// something visible to validate. + +// demoScript is injected by the engine at runtime (src/scripts/demo.nut). +if ("demoScript" in eve && eve.demoScript != null && eve.demoScript != "") { + try { + compilestring(eve.demoScript)(); + } catch (e) { + print("embedded demo failed to load: " + e + "\n"); + } +} diff --git a/platform/webgpu/shell.html b/platform/webgpu/shell.html new file mode 100644 index 00000000..6e4524b7 --- /dev/null +++ b/platform/webgpu/shell.html @@ -0,0 +1,94 @@ + + + + + + EVEngine — WebGPU + + + +
+
+ Loading EVEngine… +
+ + + + {{{ SCRIPT }}} + + diff --git a/platform/webgpu/webplatform.cpp b/platform/webgpu/webplatform.cpp new file mode 100644 index 00000000..07023435 --- /dev/null +++ b/platform/webgpu/webplatform.cpp @@ -0,0 +1,86 @@ +#include "webgpu/webplatform.h" + +#include +#include +#include + +#if defined(__EMSCRIPTEN__) +#include +#endif + +namespace eve { +namespace webgpu_platform { + +namespace { +const char *envOr(const char *name, const char *fallback) { + const char *v = std::getenv(name); + return (v && *v) ? v : fallback; +} +} // namespace + +std::string getGameDirectory() { +#if defined(__EMSCRIPTEN__) + return "/game"; +#else + return envOr("EVENGINE_WEBGPU_GAME_DIR", "."); +#endif +} + +std::string getAppdataDirectory() { +#if defined(__EMSCRIPTEN__) + return "/persist"; +#else + const char *base = envOr("EVENGINE_WEBGPU_APPDATA", "appdata"); + return std::string(base); +#endif +} + +std::string getHomeDirectory() { +#if defined(__EMSCRIPTEN__) + return "/"; +#else + return "."; +#endif +} + +std::string getExecutablePath() { +#if defined(__EMSCRIPTEN__) + return std::string("eve.html"); +#else + return std::string("eve"); +#endif +} + +bool openURL(const std::string &url) { +#if defined(__EMSCRIPTEN__) + // Let the browser open the URL in a new tab. + char *js = new char[url.size() + 64]; + std::snprintf(js, url.size() + 64, "window.open('%s','_blank')", url.c_str()); + emscripten_run_script(js); + delete[] js; + return true; +#else + (void)url; + return false; +#endif +} + +void vibrate() { +#if defined(__EMSCRIPTEN__) + emscripten_run_script("if (navigator.vibrate) navigator.vibrate(500);"); +#endif +} + +bool hasBackgroundMusic() { return false; } + +void pumpEvents() { +#if defined(__EMSCRIPTEN__) + // Non-blocking browser event / callback drain. Works both on the main + // thread and inside pthread workers (emscripten_sleep below is a busy + // return to the browser, not a real sleep). + emscripten_sleep(0); +#endif +} + +} // namespace webgpu_platform +} // namespace eve diff --git a/platform/webgpu/webplatform.h b/platform/webgpu/webplatform.h new file mode 100644 index 00000000..dbbae7ce --- /dev/null +++ b/platform/webgpu/webplatform.h @@ -0,0 +1,68 @@ +#pragma once + +#include + +namespace eve { +namespace webgpu_platform { + +/** + * WebGPU platform services shared by the browser (Emscripten/WASM) and native + * Dawn builds. Mirrors the interface of platform/ios and platform/android so + * the engine code can call into it uniformly. + * + * On the browser the game directory is the preloaded VFS mount ("/game"), + * appdata is an IndexedDB-backed persistent dir ("/persist"), and the + * "executable" is the page itself. On native builds the helpers resolve to + * filesystem paths relative to the CWD. + */ + +/** + * Root directory containing the game assets (main.nut / config.nut / data). + * Browser: "/game" (--preload-file). Native: "." unless EVENGINE_WEBGPU_GAME_DIR + * is set. + **/ +std::string getGameDirectory(); + +/** + * Directory where files should be stored (saves / user data). + * Browser: "/persist" (IndexedDB). Native: "appdata" under the CWD. + **/ +std::string getAppdataDirectory(); + +/** + * Home directory. Browser: the VFS root "/". Native: CWD. + **/ +std::string getHomeDirectory(); + +/** + * Returns the full path to the "executable". + * Browser: the page URL. Native: argv[0]-style best effort. + **/ +std::string getExecutablePath(); + +/** + * Opens the specified URL in a new browser tab / system default browser. + * Browser: window.open. Native: no-op returning false. + **/ +bool openURL(const std::string &url); + +/** + * Causes devices with vibration support to vibrate for about 0.5 seconds. + * Browser: navigator.vibrate. Native: no-op. + **/ +void vibrate(); + +/** + * Returns whether another application is playing audio (browser: no-op). + **/ +bool hasBackgroundMusic(); + +/** + * Flush pending browser-side work (device requests, message queue). + * On Emscripten this processes the browser message queue so requestDevice + * callbacks fire on the main thread when PROXY_TO_PTHREAD is disabled. + **/ +void pumpEvents(); + +} // namespace webgpu_platform +} // namespace eve diff --git a/scripts/compile_mesh3d_shaders.py b/scripts/compile_mesh3d_shaders.py index 7405dae6..5b1a7955 100644 --- a/scripts/compile_mesh3d_shaders.py +++ b/scripts/compile_mesh3d_shaders.py @@ -27,6 +27,8 @@ SHADERS = [ ("mesh3d_clustered.frag", "frag"), ("mesh3d_hair.vert", "vert"), ("mesh3d_hair.frag", "frag"), + ("mesh3d_grass.vert", "vert"), + ("mesh3d_grass.frag", "frag"), ("mesh3d_gbuffer.vert", "vert"), ("mesh3d_gbuffer.frag", "frag"), ("mesh3d_toon.vert", "vert"), diff --git a/scripts/compile_webgpu_shaders.py b/scripts/compile_webgpu_shaders.py new file mode 100644 index 00000000..c6f34065 --- /dev/null +++ b/scripts/compile_webgpu_shaders.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Compile the EVEngine GLSL shader sources to WGSL C-arrays for the WebGPU +backend. + +For every `*.vert` / `*.frag` / `*.glsl` under src/modules/graphics/shaders, +this script runs the classic GLSL -> SPIR-V toolchain (glslc, or +glslangValidator as fallback) and then converts the SPIR-V to WGSL with Tint, +emitting a `_wgsl.inc` file holding a `static const uint32_t` array (UTF-8 +WGSL is embedded word-aligned via uint32_t storage, mirroring the existing +`*_spv.inc` convention). + +Existing files are only rewritten when the WGSL content actually changes, so the +committed `.inc` files stay stable across builds. + +Usage: + python3 scripts/compile_webgpu_shaders.py --shaders-dir [--tint ] [--glslc ] + +Environment overrides: EVENGINE_GLSLC, EVENGINE_TINT. +""" + +import argparse +import os +import re +import shutil +import struct +import subprocess +import sys +import tempfile +from pathlib import Path + +STAGE_EXT = {".vert": "vertex", ".frag": "fragment", ".glsl": "fragment"} + + +def find_tool(name, env_name, extra_hints): + if env_name and os.environ.get(env_name): + return os.environ[env_name] + p = shutil.which(name) + if p: + return p + for hint in extra_hints: + if hint and os.path.exists(hint): + return hint + return None + + +def emit_wgsl_inc(src_path: Path, wgsl: bytes) -> Path: + out = src_path.with_suffix(src_path.suffix + "_wgsl.inc") + stem = src_path.stem.replace("-", "_").replace(".", "_") + array_name = f"{stem}_wgsl" + + # Pack the WGSL source so the C++ side can memcpy it as an opaque blob. + padded = wgsl + b"\x00" * (-len(wgsl) % 4) + words = struct.unpack(f"<{len(padded) // 4}I", padded) + lines = [f"static const uint32_t {array_name}[] = {{"] + for i in range(0, len(words), 8): + lines.append(" " + ", ".join(f"0x{w:08x}" for w in words[i : i + 8]) + ",") + lines.append("};") + lines.append(f"static const size_t {array_name}_count = {len(words)};") + content = "\n".join(lines) + "\n" + + if out.exists() and out.read_bytes() == content.encode("utf-8"): + return out + out.write_text(content, encoding="utf-8") + print(f"[webgpu-shaders] wrote {out}") + return out + + +def compile_one(glsl_path: Path, glslc, glslang, tint, scratch: Path): + ext = glsl_path.suffix.lower() + stage = STAGE_EXT.get(ext) + if stage is None: + return + + # Fragment includes (parallax_map.glsl / tex_cell_bomb.glsl / tonemap.glsl) + # are pulled into a main shader via #include; they have no entry point and + # cannot be compiled standalone. Skip them. + try: + src = glsl_path.read_text(encoding="utf-8", errors="replace") + except OSError: + return + if "void main" not in src: + print(f"[webgpu-shaders] SKIP {glsl_path.name}: include fragment (no main)") + return + + spv_path = scratch / (glsl_path.stem + ".spv") + + # 1) GLSL -> SPIR-V + if glslc: + cmd = [glslc, f"-fshader-stage={stage}", "-o", str(spv_path), str(glsl_path)] + elif glslang: + cmd = [glslang, f"-S{stage}", "-V", "-o", str(spv_path), str(glsl_path)] + else: + print(f"[webgpu-shaders] SKIP {glsl_path.name}: no glslc/glslangValidator on PATH") + return + subprocess.run(cmd, check=True, capture_output=True) + + # 2) SPIR-V -> WGSL + if not tint: + print(f"[webgpu-shaders] SKIP {glsl_path.name}: no tint on PATH") + return + wgsl_path = scratch / (glsl_path.stem + ".wgsl") + subprocess.run([tint, str(spv_path), "--format", "wgsl", "-o", str(wgsl_path)], + check=True, capture_output=True) + wgsl = wgsl_path.read_bytes() + emit_wgsl_inc(glsl_path, wgsl) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--shaders-dir", required=True) + ap.add_argument("--tint", default="") + ap.add_argument("--glslc", default="") + args = ap.parse_args() + + shaders_dir = Path(args.shaders_dir) + if not shaders_dir.is_dir(): + print(f"[webgpu-shaders] missing shaders dir {shaders_dir}") + return 1 + + glslc = args.glslc or find_tool("glslc", "EVENGINE_GLSLC", []) + glslang = args.glslc or find_tool("glslangValidator", "EVENGINE_GLSLANG", []) + tint = args.tint or find_tool("tint", "EVENGINE_TINT", []) + + if not (glslc or glslang): + print("[webgpu-shaders] WARNING: no GLSL compiler (glslc/glslangValidator); " + "WebGPU shader .inc files will not be (re)generated.") + if not tint: + print("[webgpu-shaders] WARNING: no tint found; WGSL .inc files will not be regenerated.") + + with tempfile.TemporaryDirectory(prefix="eve_wgsl_") as td: + scratch = Path(td) + for glsl_path in sorted(shaders_dir.glob("*")): + if glsl_path.suffix.lower() in STAGE_EXT: + try: + compile_one(glsl_path, glslc, glslang, tint, scratch) + except (subprocess.CalledProcessError, OSError) as e: + print(f"[webgpu-shaders] ERROR {glsl_path.name}: {e}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/open_webgpu.bat b/scripts/open_webgpu.bat new file mode 100644 index 00000000..26fc0cba --- /dev/null +++ b/scripts/open_webgpu.bat @@ -0,0 +1,30 @@ +@echo off +rem Launch EVEngine WebGPU demo in the default browser. +rem Serves build/webgpu-web/src/engine with COOP/COEP headers (required for +rem Emscripten pthreads / SharedArrayBuffer) and opens eve.html. + +setlocal +set "ROOT=%~dp0.." +set "ENGINE_DIR=%ROOT%\build\webgpu-web\src\engine" + +if not exist "%ENGINE_DIR%\eve.js" ( + echo eve.js not found. Build first: + echo emcmake cmake -B build/webgpu-web -G Ninja -DCMAKE_BUILD_TYPE=Release + echo cmake --build build/webgpu-web + exit /b 1 +) + +echo Checking for an existing server on 8090... +powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://127.0.0.1:8090/eve.js' -Method Head -UseBasicParsing -TimeoutSec 2).StatusCode } catch { 'down' }" > "%TEMP%\eve_srv.txt" +set /p SRV=<"%TEMP%\eve_srv.txt" +if "%SRV%"=="200" ( + echo Server already running on 8090. +) else ( + echo Starting COOP/COEP server on 8090... + start "EVEngine WebGPU server" python "%ROOT%\build\serve_webgpu.py" + timeout /t 2 /nobreak >nul +) + +echo Opening http://127.0.0.1:8090/eve.html ... +start "" "http://127.0.0.1:8090/eve.html" +endlocal diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt index adc9028b..cbf64b10 100644 --- a/src/engine/CMakeLists.txt +++ b/src/engine/CMakeLists.txt @@ -10,14 +10,19 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/common/config.h.in common/config.h) set(EVENGINE_IOS_DEPLOYMENT_TARGET "13.0" CACHE STRING "Minimum iOS/iPadOS deployment target") if(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) - # SDLActivity loads libmain.so and calls SDL_main (via SDL_main.h macro on main). - add_library(main SHARED main.cpp) + if(NOT EVENGINE_ANDROID_TEST_APP) + # SDLActivity loads libmain.so and calls SDL_main (via SDL_main.h macro on main). + # In Android test-app mode the shared `main` library is built by test/CMakeLists.txt + # from the zeroerr unit-test runner instead of the engine main.cpp. + add_library(main SHARED main.cpp) + endif() set(EVENGINE_NATIVE_TARGET main CACHE INTERNAL "Host target for SDK install") elseif(CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS) # UIKit SDL entry: MACOSX_BUNDLE .app with SDL_main. add_executable(eve MACOSX_BUNDLE main.cpp) set(EVENGINE_NATIVE_TARGET eve CACHE INTERNAL "Host target for SDK install") else() + # Desktop + webgpu (native Dawn and Emscripten/WASM) produce `eve`. add_executable(eve main.cpp) set(EVENGINE_NATIVE_TARGET eve CACHE INTERNAL "Host target for SDK install") endif() @@ -36,6 +41,10 @@ elseif(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) m c++_shared ) +elseif(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + # Emscripten exposes pthread/dl/GL as virtual libraries; nothing to link. + # Game-side threading is enabled through -sPROXY_TO_PTHREAD (platform/webgpu). + set(SystemLibraries ${SystemLibraries}) elseif(CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS) set(SystemLibraries ${SystemLibraries} "-framework UIKit" @@ -129,6 +138,14 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS OR APPLE) freetype PocoDataSQLite PocoData PocoNet PocoXML PocoJSON PocoFoundation ${SystemLibraries}) +elseif(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + # WASM-trimmed third-party: only the simple vendored libs built by the + # aggregate (squirrel/simplesquirrel/physfs/lz4/Box2D/xxHash). SDL2 and + # zlib come from Emscripten ports (-sUSE_SDL=2 / -sUSE_ZLIB); Poco/OpenAL/ + # medialoader/assimp/freetype are skipped (their modules are compiled out). + set(ThirdParty ${ThirdParty} simplesquirrel_static sqstdlib_static squirrel_static + medialoader_image physfs lz4 Box2D xxHash + ${SystemLibraries}) else() set(ThirdParty ${ThirdParty} simplesquirrel_static sqstdlib_static squirrel_static SDL2 SDL2main medialoader_image medialoader_model medialoader_sound assimp z physfs lz4 Box2D @@ -154,8 +171,9 @@ set(EVELIBS EVTouch EVCmdLine) -# Desktop only: DAP / MCP / DevTools (trimmed out of Android / iOS runtimes). -if(NOT (ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android") OR CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS)) +# Desktop only: DAP / MCP / DevTools (trimmed out of Android / iOS / browser runtimes). +if(NOT (ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android") OR CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS + OR CMAKE_SYSTEM_NAME STREQUAL "Emscripten")) list(APPEND EVELIBS EVDevTools) endif() @@ -192,7 +210,17 @@ list(APPEND EVELIBS EVStylize EVEditor) -if(EVENGINE_BUILD_DEMO) +# WASM (Emscripten) trims the modules whose third-party deps were dropped. +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + foreach(_eve_trimmed IN ITEMS + EVAnimation EVAudio EVAvatar EVBuilding EVDatabase EVFont EVInventory + EVMap EVModel3D EVNetwork EVParticles EVPlugins EVRPG EVSound EVStylize EVVoxel + EVDialogue EVProcgen) + list(REMOVE_ITEM EVELIBS ${_eve_trimmed}) + endforeach() +endif() + +if(EVENGINE_BUILD_DEMO AND NOT (CMAKE_SYSTEM_NAME STREQUAL "Emscripten")) list(APPEND EVELIBS EVDemo) endif() @@ -200,7 +228,8 @@ if(EVENGINE_PLATFORM_LIB) list(APPEND EVELIBS ${EVENGINE_PLATFORM_LIB}) endif() -if(CMAKE_GENERATOR MATCHES "Xcode") +if(NOT EVENGINE_ANDROID_TEST_APP) + if(CMAKE_GENERATOR MATCHES "Xcode") # create_module() emits STATIC libraries here, so the linker would drop the # translation units whose only job is to self-register a module or a # subcommand at static-init time. Pull the archives in whole. @@ -208,9 +237,10 @@ if(CMAKE_GENERATOR MATCHES "Xcode") foreach(_eve_lib IN LISTS EVELIBS) list(APPEND _eve_whole_archive_libs "$") endforeach() - target_link_libraries(${EVENGINE_NATIVE_TARGET} ${_eve_whole_archive_libs} zeroerr ${ThirdParty} ${EVENGINE_VULKAN_LIB}) -else() - target_link_libraries(${EVENGINE_NATIVE_TARGET} ${EVELIBS} zeroerr ${ThirdParty} ${EVENGINE_VULKAN_LIB}) + target_link_libraries(${EVENGINE_NATIVE_TARGET} ${_eve_whole_archive_libs} zeroerr ${ThirdParty} ${EVENGINE_VULKAN_LIB} ${EVENGINE_WEBGPU_LIB}) + else() + target_link_libraries(${EVENGINE_NATIVE_TARGET} ${EVELIBS} zeroerr ${ThirdParty} ${EVENGINE_VULKAN_LIB} ${EVENGINE_WEBGPU_LIB}) + endif() endif() if(CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS) @@ -301,6 +331,54 @@ if(CMAKE_SYSTEM_NAME STREQUAL "iOS" OR EVENGINE_IOS) endif() endif() +# ---- Emscripten (browser WebGPU / WASM) ---- +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + # The engine's blocking script loop (load.nut) runs on a pthread; browser + # WebGPU calls are proxied to the main thread by Emscripten. + target_link_options(${EVENGINE_NATIVE_TARGET} PRIVATE + # main() runs on the browser main thread so emdawnwebgpu can reach the + # DOM canvas directly (PROXY_TO_PTHREAD would run main() on a worker + # with no DOM, and the OffscreenCanvas transfer path hits + # emscripten_proxy_async failures). pthreads stay available for the + # Thread module; ASYNCIFY lets the blocking main loop (emscripten_sleep) + # yield to the browser event loop. + -sUSE_PTHREADS=1 + -sALLOW_MEMORY_GROWTH=1 + # Pre-allocate the worker pool so Emscripten does not print "thread pool + # is exhausted" (it warns whenever a new worker is spawned while the pool + # is empty). 4 covers the engine's ThreadPool default (capped at 4 on + # Emscripten) and any library init. + -sPTHREAD_POOL_SIZE=4 + -sEXIT_RUNTIME=0 + -sFORCE_FILESYSTEM=1 + -sASSERTIONS=1 + -sFETCH=1 + # The WebGPU backend spin-waits for requestAdapter/requestDevice with + # emscripten_sleep(0); on the main thread that requires ASYNCIFY. + -sASYNCIFY=1 + ) + + set(EVENGINE_WEBGPU_GAME_DIR "${CMAKE_SOURCE_DIR}/platform/webgpu/game-shell" CACHE PATH + "Directory preloaded as /game on the browser (default: platform/webgpu/game-shell)") + + set(_eve_web_shell "${CMAKE_SOURCE_DIR}/platform/webgpu/shell.html") + if(EXISTS "${_eve_web_shell}") + target_link_options(${EVENGINE_NATIVE_TARGET} PRIVATE --shell-file "${_eve_web_shell}") + endif() + + # --preload-file mounts the game assets at /game so getGameDirectory() + # ("/game") resolves directly against the preloaded VFS. + if(IS_DIRECTORY "${EVENGINE_WEBGPU_GAME_DIR}") + # --preload-file takes the source path relative to the CMake working + # directory; use the absolute game-shell dir so the wasm data embeds + # the assets directly at /game without a staging copy. + target_link_options(${EVENGINE_NATIVE_TARGET} PRIVATE + "--preload-file" "${EVENGINE_WEBGPU_GAME_DIR}@/game" + ) + endif() +endif() + +if(NOT EVENGINE_ANDROID_TEST_APP) # Plugins resolve ModuleManager / ModuleRegister symbols from the host process. # Export surface is EVENGINE_API (__declspec(dllexport)) only — do not enable # WINDOWS_EXPORT_ALL_SYMBOLS: it pulls Poco/ImGui template instantiations into @@ -318,8 +396,15 @@ if(WIN32) elseif(APPLE) # macOS + iOS: Apple ld rejects GNU --export-dynamic. target_link_options(${EVENGINE_NATIVE_TARGET} PRIVATE "LINKER:-export_dynamic") -elseif(UNIX AND NOT ANDROID AND NOT (CMAKE_SYSTEM_NAME STREQUAL "Android")) +elseif(UNIX AND NOT ANDROID AND NOT (CMAKE_SYSTEM_NAME STREQUAL "Android") + AND NOT (CMAKE_SYSTEM_NAME STREQUAL "Emscripten")) target_link_options(${EVENGINE_NATIVE_TARGET} PRIVATE "LINKER:--export-dynamic") +elseif(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + # WASM: dlopen/plugins are not supported; no symbol export flags needed. + set_target_properties(${EVENGINE_NATIVE_TARGET} PROPERTIES + CXX_VISIBILITY_PRESET default + VISIBILITY_INLINES_HIDDEN OFF + ) elseif(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) # libmain.so: keep default-visible EVENGINE_API symbols for dlopened plugins. set_target_properties(${EVENGINE_NATIVE_TARGET} PROPERTIES @@ -327,3 +412,4 @@ elseif(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) VISIBILITY_INLINES_HIDDEN OFF ) endif() +endif() diff --git a/src/engine/cmdline/Run.cpp b/src/engine/cmdline/Run.cpp index 93228620..ae6b556f 100644 --- a/src/engine/cmdline/Run.cpp +++ b/src/engine/cmdline/Run.cpp @@ -1,9 +1,10 @@ #include "cmdline.h" #include "scripts.h" #include "common/Module.h" +#include "common/Runtime.h" #include "common/config.h" #include "filesystem/Filesystem.h" -#if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) +#if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) && !defined(EVENGINE_WEBGPU) #include "devtools/DevTool.hpp" #include "devtools/McpServer.hpp" #endif @@ -14,10 +15,34 @@ #include #include +#if defined(EVENGINE_WEBGPU) +#include + +namespace { +// Global frame-loop state: the root script (load_web.nut) defines a global +// eve_frame() function; emscripten_set_main_loop drives it per animation frame. +ssq::VM* gFrameVm = nullptr; +ssq::Function* gFrameFunc = nullptr; + +void webgpuFrameTick() { + if (!gFrameVm || !gFrameFunc || gFrameFunc->isEmpty()) return; + bool keep = true; + try { + ssq::Object r = gFrameVm->callFunc(*gFrameFunc, *gFrameVm); + if (r.getType() == ssq::Type::BOOL) keep = r.toBool(); + } catch (const std::exception& e) { + fprintf(stderr, "EVEngine: eve_frame error: %s\n", e.what()); + keep = false; + } + if (!keep) emscripten_cancel_main_loop(); +} +} // namespace +#endif + #if defined(EVENGINE_ANDROID) #include #define EVE_ANDROID_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "EVEngine", __VA_ARGS__) -#elif defined(EVENGINE_IOS) +#elif defined(EVENGINE_IOS) || defined(EVENGINE_WEBGPU) #include #define EVE_ANDROID_LOGE(...) do { fprintf(stderr, "EVEngine: "); fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n"); } while (0) #else @@ -61,8 +86,14 @@ struct RunArgs : Handler { } std::string load_root((istreambuf_iterator(ifs)), (istreambuf_iterator())); return cmd.Run(current_path, load_root, debug, dap_port, mcp_port); - } else + } else { +#ifdef EVENGINE_WEBGPU + // The Emscripten runtime only wires the trimmed module set. + return cmd.Run(current_path, load_web_content, debug, dap_port, mcp_port); +#else return cmd.Run(current_path, load_content, debug, dap_port, mcp_port); +#endif + } } return -1; // not handle } @@ -98,13 +129,13 @@ int Cmdline::Run(std::string path, std::string root, bool debug, int dapPort, in } } - ssq::VM vm(2048, ssq::Libs::ALL); - ModuleManager::expose(vm); -#if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) + Runtime runtime(2048, ssq::Libs::ALL); + runtime.initialize(); +#if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) && !defined(EVENGINE_WEBGPU) if (debug) { auto& dt = eve::dev::DevTool::instance(); - dt.attach(vm, /*sampleLocals=*/true); - dt.exposeScriptApi(vm); + dt.attach(runtime.vm(), /*sampleLocals=*/true); + dt.exposeScriptApi(runtime.vm()); if (dapPort > 0) { const int bound = dt.startDap(static_cast(dapPort)); if (bound > 0) { @@ -139,19 +170,27 @@ int Cmdline::Run(std::string path, std::string root, bool debug, int dapPort, in #endif // Embedded default demo (src/scripts/demo.nut); load.nut runs it when no main.nut. { - ssq::Table eve(vm.find("eve")); + ssq::Table eve = runtime.table("eve"); eve.set("demoScript", std::string(demo_content ? demo_content : "")); eve.set("asyncScript", std::string(async_content ? async_content : "")); } // Name the embedded root so DAP stack frames map to load.nut (not "buffer"). - ssq::Script script = vm.compileSource(root.c_str(), "load.nut"); - vm.run(script); -#if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) + runtime.runSource(root, "load.nut"); +#if defined(EVENGINE_WEBGPU) + // Instead of a blocking while(running) Squirrel loop (which the browser + // never composites), drive the global eve_frame() function from an + // Emscripten requestAnimationFrame main loop. simulateInfiniteLoop=1 + // keeps main() alive; `runtime` stays in scope because Run() never returns. + gFrameVm = &runtime.vm(); + gFrameFunc = new ssq::Function(runtime.vm().find("eve_frame").toFunction()); + emscripten_set_main_loop(&webgpuFrameTick, 0, /*simulateInfiniteLoop=*/1); +#endif +#if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) && !defined(EVENGINE_WEBGPU) if (debug) eve::dev::DevTool::instance().detach(); #endif return 0; } catch (const std::exception& e) { -#if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) +#if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) && !defined(EVENGINE_WEBGPU) if (debug) { const std::string report = eve::dev::DevTool::instance().notifyError(e.what()); @@ -166,7 +205,7 @@ int Cmdline::Run(std::string path, std::string root, bool debug, int dapPort, in EVE_ANDROID_LOGE("Run failed: %s", e.what()); return 3; } catch (...) { -#if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) +#if !defined(EVENGINE_ANDROID) && !defined(EVENGINE_IOS) && !defined(EVENGINE_WEBGPU) if (debug) { const std::string report = eve::dev::DevTool::instance().notifyError("unknown exception"); diff --git a/src/engine/common/Module.cpp b/src/engine/common/Module.cpp index 884e137c..5163a137 100644 --- a/src/engine/common/Module.cpp +++ b/src/engine/common/Module.cpp @@ -1,5 +1,6 @@ #include "common/Module.h" #include "common/ECS.h" +#include "common/Runtime.h" #include @@ -33,28 +34,51 @@ void ModuleManager::register_module(const char* name, creator_t c, exposer_t e) } } -void ModuleManager::expose(ssq::VM& vm) { - inst().active_vm_ = &vm; - auto table = vm.addTable("eve"); +void ModuleManager::exposeVM(ssq::VM& vm) { + ssq::Table table; + try { + table = ssq::Table(vm.find("eve")); + } catch (const ssq::NotFoundException&) { + table = vm.addTable("eve"); + } for (auto& D : inst().registered_modules) { - D.second.exposer(table); + if (D.second.exposer) D.second.exposer(table); D.second.exposed = true; } // After modules: script ECS owns eve.Component / Entity / System / view. exposeECS(table); } -void ModuleManager::set_vm(ssq::VM* vm) { inst().active_vm_ = vm; } +void ModuleManager::expose(Runtime& runtime) { + inst().active_runtime_ = &runtime; + exposeVM(runtime.vm()); +} -ssq::VM* ModuleManager::vm() { return inst().active_vm_; } +void ModuleManager::expose(ssq::VM& vm) { + inst().active_runtime_ = nullptr; + exposeVM(vm); +} + +Runtime* ModuleManager::runtime() { return inst().active_runtime_; } + +ssq::VM* ModuleManager::vm() { + Runtime* active = runtime(); + return active ? &active->vm() : nullptr; +} + +void ModuleManager::detach(Runtime* runtime) { + if (inst().active_runtime_ == runtime) inst().active_runtime_ = nullptr; +} int ModuleManager::expose_pending() { - ssq::VM* v = inst().active_vm_; - if (!v) + Runtime* active = inst().active_runtime_; + if (!active) return -1; int count = 0; try { - ssq::Table table(v->find("eve")); + auto scope = active->enter(); + auto stack = active->guard(); + ssq::Table table = active->table("eve"); for (auto& D : inst().registered_modules) { if (D.second.exposed || !D.second.exposer) continue; diff --git a/src/engine/common/Module.h b/src/engine/common/Module.h index 7f41c27c..a84d3bf4 100644 --- a/src/engine/common/Module.h +++ b/src/engine/common/Module.h @@ -38,6 +38,8 @@ class Class; namespace eve { +class Runtime; + class EVENGINE_API Module { public: virtual ~Module() {} @@ -55,11 +57,14 @@ public: static void insert(const char* name, Module* inst); static void register_module(const char* name, creator_t c, exposer_t e); + static void expose(Runtime& runtime); + // Compatibility for embedders that still own their ssq::VM directly. static void expose(ssq::VM& vm); // Expose modules registered after the initial expose() (e.g. plugins). static int expose_pending(); - static void set_vm(ssq::VM* vm); + static Runtime* runtime(); static ssq::VM* vm(); + static void detach(Runtime* runtime); template static T* getInstance(const char* name) { @@ -74,6 +79,8 @@ public: } protected: + static void exposeVM(ssq::VM& vm); + struct ModuleInfo { creator_t creator = nullptr; exposer_t exposer = nullptr; @@ -82,7 +89,7 @@ protected: }; std::unordered_map registered_modules; - ssq::VM* active_vm_ = nullptr; + Runtime* active_runtime_ = nullptr; }; struct EVENGINE_API ModuleRegister { diff --git a/src/engine/common/Runtime.cpp b/src/engine/common/Runtime.cpp index 7f5071d0..dc9ba34a 100644 --- a/src/engine/common/Runtime.cpp +++ b/src/engine/common/Runtime.cpp @@ -1,7 +1,490 @@ -#include "Runtime.h" -#include +#include "common/Runtime.h" -int createRuntime() { - std::cout << "Runtime Printing" << std::endl; - return 0; -} \ No newline at end of file +#include "common/Module.h" + +#include +#include +#include + +namespace eve { +namespace { + +thread_local std::vector runtime_stack; + +const char* stageName(ScriptStage stage) { + switch (stage) { + case ScriptStage::Compile: return "compile"; + case ScriptStage::Execute: return "execute"; + case ScriptStage::Reflect: return "reflect"; + case ScriptStage::Unload: return "unload"; + case ScriptStage::Shutdown: return "shutdown"; + } + return "unknown"; +} + +std::string valueString(HSQUIRRELVM vm, SQInteger index) { + switch (sq_gettype(vm, index)) { + case OT_NULL: return "null"; + case OT_BOOL: { + SQBool value = SQFalse; + return SQ_SUCCEEDED(sq_getbool(vm, index, &value)) && value ? "true" : "false"; + } + case OT_INTEGER: { + SQInteger value = 0; + if (SQ_SUCCEEDED(sq_getinteger(vm, index, &value))) return std::to_string(value); + break; + } + case OT_FLOAT: { + SQFloat value = 0; + if (SQ_SUCCEEDED(sq_getfloat(vm, index, &value))) { + std::ostringstream out; + out << value; + return out.str(); + } + break; + } + case OT_STRING: { + const SQChar* value = nullptr; + if (SQ_SUCCEEDED(sq_getstring(vm, index, &value)) && value) return value; + break; + } + default: break; + } + return {}; +} + +SQUserPointer objectIdentity(const HSQOBJECT& object) { + return reinterpret_cast(object._unVal.pRefCounted); +} + +} // namespace + +struct Runtime::ScriptRecord { + ScriptInfo info; + std::string source_text; + bool from_file = false; + std::unique_ptr compiled; + std::unordered_map class_objects; +}; + +ScriptException::ScriptException(ScriptStage stage, std::string source, uint64_t scriptId, + const std::string& message) + : std::runtime_error([&] { + std::ostringstream out; + out << "Script " << stageName(stage); + if (!source.empty()) out << " failed in '" << source << "'"; + if (scriptId != 0) out << " [id=" << scriptId << "]"; + out << ": " << message; + return out.str(); + }()), + stage_(stage), source_(std::move(source)), script_id_(scriptId) {} + +Runtime::StackGuard::StackGuard(Runtime& runtime) noexcept + : vm_(runtime.handle()), top_(vm_ ? sq_gettop(vm_) : 0) {} + +Runtime::StackGuard::~StackGuard() { + if (vm_) sq_settop(vm_, top_); +} + +Runtime::StackGuard::StackGuard(StackGuard&& other) noexcept + : vm_(std::exchange(other.vm_, nullptr)), top_(other.top_) {} + +Runtime::StackGuard& Runtime::StackGuard::operator=(StackGuard&& other) noexcept { + if (this == &other) return *this; + if (vm_) sq_settop(vm_, top_); + vm_ = std::exchange(other.vm_, nullptr); + top_ = other.top_; + return *this; +} + +Runtime::Scope::Scope(Runtime& runtime) : runtime_(&runtime) { runtime_stack.push_back(&runtime); } + +Runtime::Scope::~Scope() { + if (!runtime_) return; + auto it = std::find(runtime_stack.rbegin(), runtime_stack.rend(), runtime_); + if (it != runtime_stack.rend()) runtime_stack.erase(std::next(it).base()); +} + +Runtime::Scope::Scope(Scope&& other) noexcept + : runtime_(std::exchange(other.runtime_, nullptr)) {} + +Runtime::Runtime(size_t stackSize, ssq::Libs::Flag libraries) + : vm_(std::make_unique(stackSize, libraries)) {} + +Runtime::~Runtime() { shutdown(); } + +void Runtime::initialize() { + if (initialized_) return; + auto scope = enter(); + auto stack = guard(); + try { + ModuleManager::expose(*this); + initialized_ = true; + } catch (const std::exception& error) { + ModuleManager::detach(this); + fail(ScriptStage::Execute, "", 0, error); + } +} + +void Runtime::shutdown() noexcept { + if (shutting_down_ || stopped_) return; + shutting_down_ = true; + unloadAll(); + ModuleManager::detach(this); + while (true) { + auto it = std::find(runtime_stack.begin(), runtime_stack.end(), this); + if (it == runtime_stack.end()) break; + runtime_stack.erase(it); + } + classes_.clear(); + class_owners_.clear(); + initialized_ = false; + vm_.reset(); + stopped_ = true; + shutting_down_ = false; +} + +bool Runtime::initialized() const noexcept { return initialized_; } +ssq::VM& Runtime::vm() noexcept { return *vm_; } +const ssq::VM& Runtime::vm() const noexcept { return *vm_; } +HSQUIRRELVM Runtime::handle() const noexcept { return vm_ ? vm_->getHandle() : nullptr; } +ssq::Table Runtime::root() const { return ssq::Table(static_cast(*vm_)); } +ssq::Table Runtime::table(const char* name) const { return ssq::Table(vm_->find(name)); } + +Runtime* Runtime::current() noexcept { + return runtime_stack.empty() ? nullptr : runtime_stack.back(); +} + +size_t Runtime::stackDepth() noexcept { return runtime_stack.size(); } + +Runtime::ScriptId Runtime::compileSource(std::string source, std::string sourceName) { + if (stopped_) throw ScriptException(ScriptStage::Compile, sourceName, 0, "runtime is shut down"); + auto scope = enter(); + auto stack = guard(); + const ScriptId id = next_script_id_++; + auto record = std::make_unique(); + record->info.id = id; + record->info.source = sourceName; + record->source_text = std::move(source); + try { + record->compiled = + std::make_unique(vm_->compileSource(record->source_text.c_str(), sourceName.c_str())); + auto [it, inserted] = scripts_.emplace(id, std::move(record)); + (void)inserted; + notifyLifecycle(it->second->info); + return id; + } catch (const std::exception& error) { + fail(ScriptStage::Compile, sourceName, id, error); + } +} + +Runtime::ScriptId Runtime::compileFile(const std::string& path) { + if (stopped_) throw ScriptException(ScriptStage::Compile, path, 0, "runtime is shut down"); + auto scope = enter(); + auto stack = guard(); + const ScriptId id = next_script_id_++; + auto record = std::make_unique(); + record->info.id = id; + record->info.source = path; + record->source_text = path; + record->from_file = true; + try { + record->compiled = std::make_unique(vm_->compileFile(path.c_str())); + auto [it, inserted] = scripts_.emplace(id, std::move(record)); + (void)inserted; + notifyLifecycle(it->second->info); + return id; + } catch (const std::exception& error) { + fail(ScriptStage::Compile, path, id, error); + } +} + +const ScriptInfo& Runtime::execute(ScriptId id) { + auto found = scripts_.find(id); + if (found == scripts_.end()) + throw ScriptException(ScriptStage::Execute, {}, id, "unknown script"); + ScriptRecord& record = *found->second; + if (!record.compiled || record.info.state == ScriptState::Unloaded) + throw ScriptException(ScriptStage::Execute, record.info.source, id, "script is unloaded"); + + auto scope = enter(); + auto stack = guard(); + const auto before = rootClasses(); + record.info.state = ScriptState::Running; + record.info.error.clear(); + notifyLifecycle(record.info); + try { + vm_->run(*record.compiled); + discoverClasses(record, before); + record.info.state = ScriptState::Loaded; + notifyLifecycle(record.info); + return record.info; + } catch (const ScriptException&) { + throw; + } catch (const std::exception& error) { + try { + discoverClasses(record, before); + } catch (...) { + } + record.info.state = ScriptState::Failed; + record.info.error = error.what(); + notifyLifecycle(record.info); + fail(ScriptStage::Execute, record.info.source, id, error); + } +} + +Runtime::ScriptId Runtime::runSource(std::string source, std::string sourceName) { + const ScriptId id = compileSource(std::move(source), std::move(sourceName)); + execute(id); + return id; +} + +Runtime::ScriptId Runtime::runFile(const std::string& path) { + const ScriptId id = compileFile(path); + execute(id); + return id; +} + +const ReflectedClass& Runtime::reflectClass(const std::string& name, const std::string& source) { + auto scope = enter(); + auto stack = guard(); + try { + ssq::Class cls = vm_->findClass(name.c_str()); + classes_[name] = inspectClass(name, cls, source); + return classes_.at(name); + } catch (const std::exception& error) { + fail(ScriptStage::Reflect, source.empty() ? name : source, 0, error); + } +} + +Runtime::ScriptId Runtime::reload(ScriptId id) { + auto found = scripts_.find(id); + if (found == scripts_.end()) + throw ScriptException(ScriptStage::Compile, {}, id, "unknown script"); + const bool fromFile = found->second->from_file; + const std::string input = found->second->source_text; + const std::string source = found->second->info.source; + unload(id); + return fromFile ? runFile(input) : runSource(input, source); +} + +bool Runtime::unload(ScriptId id) { + auto found = scripts_.find(id); + if (found == scripts_.end()) return false; + ScriptRecord& record = *found->second; + if (record.info.state == ScriptState::Unloaded) return false; + auto scope = enter(); + auto stack = guard(); + record.info.state = ScriptState::Unloading; + notifyLifecycle(record.info); + try { + for (const auto& pair : record.class_objects) { + auto owner = class_owners_.find(pair.first); + if (owner == class_owners_.end() || owner->second != id) continue; + ssq::Object currentObject = vm_->find(pair.first.c_str()); + if (currentObject.getType() == ssq::Type::CLASS && + objectIdentity(currentObject.getRaw()) == objectIdentity(pair.second.getRaw())) { + sq_pushroottable(handle()); + sq_pushstring(handle(), pair.first.c_str(), static_cast(pair.first.size())); + sq_rawdeleteslot(handle(), -2, SQFalse); + sq_pop(handle(), 1); + } + classes_.erase(pair.first); + class_owners_.erase(owner); + } + record.class_objects.clear(); + record.compiled.reset(); + record.info.state = ScriptState::Unloaded; + notifyLifecycle(record.info); + return true; + } catch (const std::exception& error) { + record.info.state = ScriptState::Failed; + record.info.error = error.what(); + notifyLifecycle(record.info); + fail(ScriptStage::Unload, record.info.source, id, error); + } +} + +void Runtime::unloadAll() noexcept { + std::vector ids; + ids.reserve(scripts_.size()); + for (const auto& pair : scripts_) ids.push_back(pair.first); + std::reverse(ids.begin(), ids.end()); + for (ScriptId id : ids) { + try { + unload(id); + } catch (...) { + } + } + scripts_.clear(); +} + +bool Runtime::contains(ScriptId id) const noexcept { return scripts_.count(id) != 0; } + +const ScriptInfo* Runtime::script(ScriptId id) const noexcept { + auto found = scripts_.find(id); + return found == scripts_.end() ? nullptr : &found->second->info; +} + +std::vector Runtime::scripts() const { + std::vector result; + result.reserve(scripts_.size()); + for (const auto& pair : scripts_) result.push_back(pair.second->info); + std::sort(result.begin(), result.end(), [](const ScriptInfo& a, const ScriptInfo& b) { + return a.id < b.id; + }); + return result; +} + +const ReflectedClass* Runtime::reflectedClass(const std::string& name) const noexcept { + auto found = classes_.find(name); + return found == classes_.end() ? nullptr : &found->second; +} + +std::vector Runtime::reflectedClasses() const { + std::vector result; + result.reserve(classes_.size()); + for (const auto& pair : classes_) result.push_back(pair.second); + std::sort(result.begin(), result.end(), [](const ReflectedClass& a, const ReflectedClass& b) { + return a.name < b.name; + }); + return result; +} + +ssq::Class Runtime::findClass(const std::string& name) const { return vm_->findClass(name.c_str()); } + +void Runtime::notifyLifecycle(const ScriptInfo& info) noexcept { + if (!lifecycle_handler_) return; + try { + lifecycle_handler_(info); + } catch (...) { + } +} + +[[noreturn]] void Runtime::fail(ScriptStage stage, const std::string& source, ScriptId id, + const std::exception& error) { + ScriptException wrapped(stage, source, id, error.what()); + if (error_handler_) { + try { + error_handler_(wrapped); + } catch (...) { + } + } + throw wrapped; +} + +std::unordered_map Runtime::rootClasses() const { + std::unordered_map result; + StackGuard stack(*const_cast(this)); + HSQUIRRELVM squirrel = handle(); + sq_pushroottable(squirrel); + sq_pushnull(squirrel); + while (SQ_SUCCEEDED(sq_next(squirrel, -2))) { + if (sq_gettype(squirrel, -2) == OT_STRING && sq_gettype(squirrel, -1) == OT_CLASS) { + const SQChar* name = nullptr; + HSQOBJECT object; + if (SQ_SUCCEEDED(sq_getstring(squirrel, -2, &name)) && name && + SQ_SUCCEEDED(sq_getstackobj(squirrel, -1, &object))) + result[name] = objectIdentity(object); + } + sq_pop(squirrel, 2); + } + return result; +} + +void Runtime::discoverClasses( + ScriptRecord& record, const std::unordered_map& before) { + const auto after = rootClasses(); + for (const auto& pair : after) { + auto old = before.find(pair.first); + if (old != before.end() && old->second == pair.second) continue; + ssq::Class cls = vm_->findClass(pair.first.c_str()); + record.class_objects.insert_or_assign(pair.first, cls); + if (std::find(record.info.classes.begin(), record.info.classes.end(), pair.first) == + record.info.classes.end()) + record.info.classes.push_back(pair.first); + classes_[pair.first] = inspectClass(pair.first, cls, record.info.source); + class_owners_[pair.first] = record.info.id; + } + std::sort(record.info.classes.begin(), record.info.classes.end()); + + // Resolve base class names after every newly defined class is registered. + for (const std::string& name : record.info.classes) { + auto info = classes_.find(name); + auto cls = record.class_objects.find(name); + if (info == classes_.end() || cls == record.class_objects.end()) continue; + StackGuard stack(*this); + sq_pushobject(handle(), cls->second.getRaw()); + if (SQ_SUCCEEDED(sq_getbase(handle(), -1)) && sq_gettype(handle(), -1) == OT_CLASS) { + HSQOBJECT baseObject; + if (SQ_SUCCEEDED(sq_getstackobj(handle(), -1, &baseObject))) { + const SQUserPointer identity = objectIdentity(baseObject); + for (const auto& candidate : after) { + if (candidate.second == identity) { + info->second.base = candidate.first; + break; + } + } + } + } + } +} + +ReflectedClass Runtime::inspectClass(const std::string& name, const ssq::Class& cls, + const std::string& source) const { + ReflectedClass info; + info.name = name; + info.source = source; + StackGuard stack(*const_cast(this)); + HSQUIRRELVM squirrel = handle(); + sq_pushobject(squirrel, cls.getRaw()); + sq_pushnull(squirrel); + while (SQ_SUCCEEDED(sq_next(squirrel, -2))) { + if (sq_gettype(squirrel, -2) == OT_STRING) { + const SQChar* memberName = nullptr; + if (SQ_SUCCEEDED(sq_getstring(squirrel, -2, &memberName)) && memberName) { + ReflectedMember member; + member.name = memberName; + member.type = static_cast(sq_gettype(squirrel, -1)); + member.method = member.type == ssq::Type::CLOSURE || + member.type == ssq::Type::NATIVECLOSURE; + + // sq_getattributes consumes the key at the stack top and replaces it + // with the attribute table (or null). + const SQInteger memberTop = sq_gettop(squirrel); + sq_push(squirrel, -2); + if (SQ_SUCCEEDED(sq_getattributes(squirrel, -5)) && + sq_gettype(squirrel, -1) == OT_TABLE) { + sq_pushnull(squirrel); + while (SQ_SUCCEEDED(sq_next(squirrel, -2))) { + if (sq_gettype(squirrel, -2) == OT_STRING) { + const SQChar* attributeName = nullptr; + if (SQ_SUCCEEDED(sq_getstring(squirrel, -2, &attributeName)) && + attributeName) { + ReflectedAttribute attribute; + attribute.name = attributeName; + attribute.type = + static_cast(sq_gettype(squirrel, -1)); + attribute.value = valueString(squirrel, -1); + member.attributes.push_back(std::move(attribute)); + } + } + sq_pop(squirrel, 2); + } + } + sq_settop(squirrel, memberTop); + std::sort(member.attributes.begin(), member.attributes.end(), + [](const ReflectedAttribute& a, const ReflectedAttribute& b) { + return a.name < b.name; + }); + info.members.push_back(std::move(member)); + } + } + sq_pop(squirrel, 2); + } + std::sort(info.members.begin(), info.members.end(), + [](const ReflectedMember& a, const ReflectedMember& b) { return a.name < b.name; }); + return info; +} + +} // namespace eve diff --git a/src/engine/common/Runtime.h b/src/engine/common/Runtime.h index e69de29b..2a742f00 100644 --- a/src/engine/common/Runtime.h +++ b/src/engine/common/Runtime.h @@ -0,0 +1,185 @@ +#pragma once + +#include "common/Export.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace eve { + +enum class ScriptState { + Compiled, + Running, + Loaded, + Unloading, + Unloaded, + Failed, +}; + +enum class ScriptStage { + Compile, + Execute, + Reflect, + Unload, + Shutdown, +}; + +/** Exception raised at the public Runtime boundary. */ +class EVENGINE_API ScriptException : public std::runtime_error { +public: + ScriptException(ScriptStage stage, std::string source, uint64_t scriptId, + const std::string& message); + + ScriptStage stage() const noexcept { return stage_; } + const std::string& source() const noexcept { return source_; } + uint64_t scriptId() const noexcept { return script_id_; } + +private: + ScriptStage stage_; + std::string source_; + uint64_t script_id_; +}; + +struct EVENGINE_API ReflectedAttribute { + std::string name; + ssq::Type type = ssq::Type::NULLPTR; + std::string value; +}; + +struct EVENGINE_API ReflectedMember { + std::string name; + ssq::Type type = ssq::Type::NULLPTR; + bool method = false; + std::vector attributes; +}; + +struct EVENGINE_API ReflectedClass { + std::string name; + std::string source; + std::string base; + std::vector members; +}; + +struct EVENGINE_API ScriptInfo { + uint64_t id = 0; + std::string source; + ScriptState state = ScriptState::Compiled; + std::vector classes; + std::string error; +}; + +class EVENGINE_API Runtime { +public: + using ScriptId = uint64_t; + using ErrorHandler = std::function; + using LifecycleHandler = std::function; + + /** Restores the native Squirrel stack when a binding operation leaves scope. */ + class EVENGINE_API StackGuard { + public: + explicit StackGuard(Runtime& runtime) noexcept; + ~StackGuard(); + StackGuard(StackGuard&& other) noexcept; + StackGuard& operator=(StackGuard&& other) noexcept; + StackGuard(const StackGuard&) = delete; + StackGuard& operator=(const StackGuard&) = delete; + + int top() const noexcept { return top_; } + void dismiss() noexcept { vm_ = nullptr; } + + private: + HSQUIRRELVM vm_ = nullptr; + SQInteger top_ = 0; + }; + + /** Pushes this Runtime on the current thread's runtime stack. */ + class EVENGINE_API Scope { + public: + explicit Scope(Runtime& runtime); + ~Scope(); + Scope(Scope&& other) noexcept; + Scope& operator=(Scope&& other) = delete; + Scope(const Scope&) = delete; + Scope& operator=(const Scope&) = delete; + + private: + Runtime* runtime_ = nullptr; + }; + + explicit Runtime(size_t stackSize = 2048, ssq::Libs::Flag libraries = ssq::Libs::ALL); + ~Runtime(); + Runtime(Runtime&&) = delete; + Runtime& operator=(Runtime&&) = delete; + Runtime(const Runtime&) = delete; + Runtime& operator=(const Runtime&) = delete; + + /** Exposes registered engine modules. Safe to call more than once. */ + void initialize(); + void shutdown() noexcept; + bool initialized() const noexcept; + + ssq::VM& vm() noexcept; + const ssq::VM& vm() const noexcept; + HSQUIRRELVM handle() const noexcept; + ssq::Table root() const; + ssq::Table table(const char* name) const; + + StackGuard guard() noexcept { return StackGuard(*this); } + Scope enter() { return Scope(*this); } + static Runtime* current() noexcept; + static size_t stackDepth() noexcept; + + ScriptId compileSource(std::string source, std::string sourceName = "buffer"); + ScriptId compileFile(const std::string& path); + const ScriptInfo& execute(ScriptId id); + ScriptId runSource(std::string source, std::string sourceName = "buffer"); + ScriptId runFile(const std::string& path); + ScriptId reflectFile(const std::string& path) { return runFile(path); } + const ReflectedClass& reflectClass(const std::string& name, + const std::string& source = {}); + ScriptId reload(ScriptId id); + bool unload(ScriptId id); + void unloadAll() noexcept; + + bool contains(ScriptId id) const noexcept; + const ScriptInfo* script(ScriptId id) const noexcept; + std::vector scripts() const; + const ReflectedClass* reflectedClass(const std::string& name) const noexcept; + std::vector reflectedClasses() const; + ssq::Class findClass(const std::string& name) const; + + void setErrorHandler(ErrorHandler handler) { error_handler_ = std::move(handler); } + void setLifecycleHandler(LifecycleHandler handler) { lifecycle_handler_ = std::move(handler); } + +private: + struct ScriptRecord; + + std::unique_ptr vm_; + std::unordered_map> scripts_; + std::unordered_map classes_; + std::unordered_map class_owners_; + ScriptId next_script_id_ = 1; + bool initialized_ = false; + bool shutting_down_ = false; + bool stopped_ = false; + ErrorHandler error_handler_; + LifecycleHandler lifecycle_handler_; + + void notifyLifecycle(const ScriptInfo& info) noexcept; + [[noreturn]] void fail(ScriptStage stage, const std::string& source, ScriptId id, + const std::exception& error); + void discoverClasses(ScriptRecord& record, + const std::unordered_map& before); + std::unordered_map rootClasses() const; + ReflectedClass inspectClass(const std::string& name, const ssq::Class& cls, + const std::string& source) const; +}; + +} // namespace eve diff --git a/src/engine/common/config.h.in b/src/engine/common/config.h.in index ec61498e..5803e3be 100644 --- a/src/engine/common/config.h.in +++ b/src/engine/common/config.h.in @@ -11,6 +11,7 @@ #cmakedefine EVENGINE_LINUX #cmakedefine EVENGINE_ANDROID #cmakedefine EVENGINE_IOS +#cmakedefine EVENGINE_WEBGPU #cmakedefine EVENGINE_WINDOWS_UWP diff --git a/src/engine/devtools/DebugAdapter.cpp b/src/engine/devtools/DebugAdapter.cpp index 2c6b5fa1..decad5a8 100644 --- a/src/engine/devtools/DebugAdapter.cpp +++ b/src/engine/devtools/DebugAdapter.cpp @@ -525,7 +525,7 @@ void DebugAdapter::handleRequest(const std::string& json) { const int ref = args ? args->optValue("variablesReference", 0) : 0; Poco::JSON::Array::Ptr arr = new Poco::JSON::Array(); if (ref == varRefLocals_) { - for (const auto& v : dbg.locals(1)) { + for (const auto& v : dbg.locals(0)) { Poco::JSON::Object::Ptr o = new Poco::JSON::Object(); o->set("name", v.name); o->set("value", v.value); diff --git a/src/engine/devtools/Debugger.cpp b/src/engine/devtools/Debugger.cpp index 9d0186f0..46b011f5 100644 --- a/src/engine/devtools/Debugger.cpp +++ b/src/engine/devtools/Debugger.cpp @@ -182,7 +182,7 @@ int Debugger::scriptStackDepth() const { HSQUIRRELVM vm = vm_; if (!vm) return 0; int depth = 0; - for (int level = 1;; ++level) { + for (int level = 0;; ++level) { SQStackInfos si; if (SQ_FAILED(sq_stackinfos(vm, level, &si))) break; ++depth; @@ -481,7 +481,7 @@ VariableInfo Debugger::evaluate(const std::string& expression) const { } // Prefer local, then roottable slot. Full expression eval is intentionally // limited (safe for watches of variable names / dotted root paths). - auto local = readLocal(vm, 1, expression); + auto local = readLocal(vm, 0, expression); if (local.type != "error") return local; // Support a.b root path (tables only). @@ -522,7 +522,7 @@ std::vector Debugger::locals(int stackLevel) const { std::vector out; HSQUIRRELVM vm = vm_; if (!vm) return out; - const SQUnsignedInteger level = static_cast(stackLevel < 0 ? 1 : stackLevel); + const SQUnsignedInteger level = static_cast(stackLevel < 0 ? 0 : stackLevel); for (SQUnsignedInteger n = 0;; ++n) { const SQInteger top = sq_gettop(vm); const SQChar* name = sq_getlocal(vm, level, n); @@ -545,7 +545,7 @@ std::vector Debugger::stackTrace(int maxFrames) const { HSQUIRRELVM vm = vm_; if (!vm) return out; if (maxFrames <= 0) maxFrames = 32; - for (int level = 1; level <= maxFrames; ++level) { + for (int level = 0; level <= maxFrames; ++level) { SQStackInfos si; if (SQ_FAILED(sq_stackinfos(vm, level, &si))) break; StackFrameInfo f; diff --git a/src/engine/devtools/Debugger.hpp b/src/engine/devtools/Debugger.hpp index a65816a8..4fdf115e 100644 --- a/src/engine/devtools/Debugger.hpp +++ b/src/engine/devtools/Debugger.hpp @@ -135,7 +135,8 @@ public: void refreshWatches(); /** Evaluate a single expression (local name, or roottable slot). */ VariableInfo evaluate(const std::string& expression) const; - std::vector locals(int stackLevel = 1) const; + /** Locals of the given call-stack level (0 = current script frame). */ + std::vector locals(int stackLevel = 0) const; std::vector stackTrace(int maxFrames = 32) const; using PumpFn = std::function; diff --git a/src/engine/devtools/DevTool.cpp b/src/engine/devtools/DevTool.cpp index 43b407be..cf504444 100644 --- a/src/engine/devtools/DevTool.cpp +++ b/src/engine/devtools/DevTool.cpp @@ -393,10 +393,10 @@ void DevTool::sampleFrameLocals(HSQUIRRELVM vm, const SourceLoc& loc) { auto& prev = localSnap_[depth]; std::unordered_map cur; - // Level 0 = current function (hook runs inside the debug callback frame?); - // Squirrel docs: level is the call stack level — 0 is top (current). - // When the native hook fires, level 1 is typically the script frame of interest. - const SQUnsignedInteger level = 1; + // Level 0 = current script frame. The native debug hook is invoked as a + // direct C call (no CallInfo pushed), so the top of the Squirrel call + // stack is the script frame that triggered the line event. + const SQUnsignedInteger level = 0; for (SQUnsignedInteger n = 0;; ++n) { const SQInteger top = sq_gettop(vm); const SQChar* name = sq_getlocal(vm, level, n); @@ -489,7 +489,7 @@ void DevTool::markErrorUses(const SourceLoc& loc, } graph_.onLine(site); - const SQUnsignedInteger level = 1; + const SQUnsignedInteger level = 0; for (SQUnsignedInteger n = 0;; ++n) { const SQInteger top = sq_gettop(vm_); const SQChar* name = sq_getlocal(vm_, level, n); diff --git a/src/engine/main.cpp b/src/engine/main.cpp index 6a0dbea0..4023ca14 100644 --- a/src/engine/main.cpp +++ b/src/engine/main.cpp @@ -6,7 +6,7 @@ #include #include -#if defined(EVENGINE_ANDROID) || defined(EVENGINE_IOS) +#if defined(EVENGINE_ANDROID) || defined(EVENGINE_IOS) || defined(EVENGINE_WEBGPU) #include #include #endif @@ -15,6 +15,10 @@ #include "ios/ios.h" #endif +#if defined(EVENGINE_WEBGPU) +#include "webgpu/webplatform.h" +#endif + using namespace eve; using namespace std; @@ -57,6 +61,20 @@ int main(int argc, char **argv) argv = injected.data(); } eve::ios::initAudioSessionInterruptionHandler(); +#elif defined(EVENGINE_WEBGPU) + // The browser launches with no CLI args; inject `run ` where + // the game is the preloaded VFS mount (/game) or the CWD. + static std::string gamePath; + static std::vector injected; + if (argc <= 1) { + gamePath = eve::webgpu_platform::getGameDirectory(); + if (gamePath.empty()) + gamePath = "."; + static char runFlag[] = "run"; + injected = {argv[0], runFlag, gamePath.data()}; + argc = static_cast(injected.size()); + argv = injected.data(); + } #endif return requireModInst(eve::cmd,Cmdline)->runArgs(argc, argv); } diff --git a/src/modules/CMakeLists.txt b/src/modules/CMakeLists.txt index be0fb9b8..cbe7a688 100644 --- a/src/modules/CMakeLists.txt +++ b/src/modules/CMakeLists.txt @@ -1,10 +1,34 @@ create_module(EVFileSystem filesystem) -target_compile_definitions(EVFileSystem PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) +if(NOT EVENGINE_WEBGPU_TRIM) + target_compile_definitions(EVFileSystem PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) +endif() create_module(EVGraphics graphics) # Header-only VKBuilder: prefer the source tree over a stale third-party install. target_include_directories(EVGraphics BEFORE PUBLIC ${CMAKE_SOURCE_DIR}/third-party/VKBuilder/include) +if(EVENGINE_WEBGPU_TRIM) + # Graphics.h includes (header-only usage). The vendored + # assimp headers live in medialoader (skipped on WASM) but the interface is + # still needed; expose the header tree without linking the library. + target_include_directories(EVGraphics BEFORE PUBLIC + ${CMAKE_SOURCE_DIR}/third-party/medialoader/model-libs/assimp/include) +endif() +if(BUILD_PLATFORM STREQUAL "webgpu") + # Regenerate WGSL .inc arrays from the GLSL shader sources when tooling + # (glslc + tint) is available; committed .inc files keep the build going + # otherwise. + find_package(Python3 COMPONENTS Interpreter QUIET) + if(Python3_Interpreter_FOUND) + add_custom_target(webgpu_shaders + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/scripts/compile_webgpu_shaders.py + --shaders-dir ${CMAKE_SOURCE_DIR}/src/modules/graphics/shaders + COMMENT "Generating WGSL shader .inc files for the WebGPU backend" + VERBATIM) + add_dependencies(EVGraphics webgpu_shaders) + endif() +endif() create_module(EVWindow window) create_module(EVEvent event) create_module(EVTimer timer) @@ -13,39 +37,52 @@ create_module(EVMath math) create_module(EVSpatial spatial) create_module(EVIK ik) target_include_directories(EVIK PUBLIC ${CMAKE_SOURCE_DIR}/external/ik.hpp) -create_module(EVAnimation animation) -target_compile_definitions(EVAnimation PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) -# AnimImporter uses Assimp headers (same install tree as model3d / medialoader). -# Spine JSON uses Poco via data/JsonDocument; atlas/skeleton loaders use filesystem. -target_include_directories(EVAnimation PRIVATE - ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}-debug/include - ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}/include - ${CMAKE_SOURCE_DIR}/third-party/medialoader/include - ${CMAKE_SOURCE_DIR}/third-party/medialoader/assimp/include -) +if(NOT EVENGINE_WEBGPU_TRIM) + create_module(EVAnimation animation) + target_compile_definitions(EVAnimation PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) + # AnimImporter uses Assimp headers (same install tree as model3d / medialoader). + # Spine JSON uses Poco via data/JsonDocument; atlas/skeleton loaders use filesystem. + target_include_directories(EVAnimation PRIVATE + ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}-debug/include + ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}/include + ${CMAKE_SOURCE_DIR}/third-party/medialoader/include + ${CMAKE_SOURCE_DIR}/third-party/medialoader/assimp/include + ) +endif() create_module(EVTensor tensor) create_module(EVMouse mouse) create_module(EVKeyboard keyboard) create_module(EVJoystick joystick) create_module(EVTouch touch) create_module(EVData data) -target_compile_definitions(EVData PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) +if(NOT EVENGINE_WEBGPU_TRIM) + target_compile_definitions(EVData PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) +endif() create_module(EVImage image) -create_module(EVNetwork network) -target_compile_definitions(EVNetwork PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) -create_module(EVDatabase database) -target_compile_definitions(EVDatabase PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) -create_module(EVSound sound) -target_include_directories(EVSound PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/sound/include_shim - ${CMAKE_SOURCE_DIR}/third-party/medialoader/sound-libs - ${CMAKE_SOURCE_DIR}/third-party/medialoader/sound-libs/libmodplug/src - ${CMAKE_SOURCE_DIR}/third-party/medialoader/sound-libs/libogg/include -) -create_module(EVAudio audio) -target_compile_definitions(EVAudio PRIVATE AL_LIBTYPE_STATIC) +if(EVENGINE_WEBGPU_TRIM) + # ImageData.h includes medialoader/image/pixelformat.h; the vendored + # medialoader header tree is available in the source checkout even though the + # library itself is skipped on Emscripten. + target_include_directories(EVImage BEFORE PUBLIC + ${CMAKE_SOURCE_DIR}/third-party/medialoader/include) +endif() +if(NOT EVENGINE_WEBGPU_TRIM) + create_module(EVNetwork network) + target_compile_definitions(EVNetwork PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) + create_module(EVDatabase database) + target_compile_definitions(EVDatabase PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) + create_module(EVSound sound) + target_include_directories(EVSound PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/sound/include_shim + ${CMAKE_SOURCE_DIR}/third-party/medialoader/sound-libs + ${CMAKE_SOURCE_DIR}/third-party/medialoader/sound-libs/libmodplug/src + ${CMAKE_SOURCE_DIR}/third-party/medialoader/sound-libs/libogg/include + ) + create_module(EVAudio audio) + target_compile_definitions(EVAudio PRIVATE AL_LIBTYPE_STATIC) +endif() -if(EVENGINE_THIRD_PARTY_BINARY_ONLY) +if(EVENGINE_THIRD_PARTY_BINARY_ONLY AND NOT BUILD_PLATFORM STREQUAL "webgpu") # The shared prefix already contains ImGui and its SDL/Vulkan backends. add_library(eve_imgui INTERFACE) if(WIN32 AND CMAKE_BUILD_TYPE STREQUAL "Debug") @@ -65,14 +102,22 @@ else() ${CMAKE_SOURCE_DIR}/third-party/imgui/imgui_impl_sdl.cpp ${CMAKE_SOURCE_DIR}/third-party/imgui/imgui_impl_vulkan.cpp ) + if(BUILD_PLATFORM STREQUAL "webgpu") + # Vulkan renderer is compiled out; the WebGPU backend is compiled by EVUI. + list(REMOVE_ITEM _eve_imgui_sources + ${CMAKE_SOURCE_DIR}/third-party/imgui/imgui_impl_vulkan.cpp) + endif() add_library(eve_imgui STATIC ${_eve_imgui_sources}) target_include_directories(eve_imgui PUBLIC ${CMAKE_SOURCE_DIR}/third-party/imgui) - # imgui_impl_sdl includes ; vendored install puts headers under include/SDL2/. - target_include_directories(eve_imgui PRIVATE - ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}-debug/include/SDL2 - ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}/include/SDL2 - ) - # Avoid pulling imgui_demo.cpp (ShowFontAtlas etc.) into every link. + # On Emscripten SDL2 headers come from the port sysroot. + if(EVENGINE_WEBGPU_TRIM) + target_include_directories(eve_imgui PRIVATE ${EMSCRIPTEN_SYSROOT}/include) + else() + target_include_directories(eve_imgui PRIVATE + ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}-debug/include/SDL2 + ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}/include/SDL2 + ) + endif() target_compile_definitions(eve_imgui PUBLIC IMGUI_DISABLE_DEMO_WINDOWS) endif() @@ -84,8 +129,10 @@ target_include_directories(EVUI BEFORE PUBLIC create_module(EVScene scene) -create_module(EVParticles particles) -target_compile_definitions(EVParticles PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) +if(NOT EVENGINE_WEBGPU_TRIM) + create_module(EVParticles particles) + target_compile_definitions(EVParticles PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) +endif() create_module(EVPhysics physics) # Box2D headers use ; prefer source tree over flattened install. target_include_directories(EVPhysics PUBLIC ${CMAKE_SOURCE_DIR}/third-party) @@ -97,43 +144,56 @@ set(BOX3D_DOCS OFF CACHE BOOL "" FORCE) set(BOX3D_VALIDATE OFF CACHE BOOL "" FORCE) add_subdirectory(${CMAKE_SOURCE_DIR}/external/box3d ${CMAKE_BINARY_DIR}/external/box3d) target_link_libraries(EVPhysics PUBLIC box3d::box3d) -create_module(EVMap map) -target_compile_definitions(EVMap PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) +if(NOT EVENGINE_WEBGPU_TRIM) + create_module(EVMap map) + target_compile_definitions(EVMap PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) +endif() create_module(EVGpgpu gpgpu) target_include_directories(EVGpgpu BEFORE PRIVATE ${CMAKE_SOURCE_DIR}/third-party/VKBuilder/include) -create_module(EVModel3D model3d) -create_module(EVFont font) -# ft2build.h lives under include/freetype2/ (installed by third-party FreeType). -if(EVENGINE_THIRD_PARTY_BINARY_ONLY) - target_include_directories(EVFont PRIVATE ${EVENGINE_THIRD_PARTY_ROOT}/include/freetype2) -else() - target_include_directories(EVFont PRIVATE - ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}-debug/include/freetype2 - ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}/include/freetype2 - ) +if(NOT EVENGINE_WEBGPU_TRIM) + create_module(EVModel3D model3d) + create_module(EVFont font) + # ft2build.h lives under include/freetype2/ (installed by third-party FreeType). + if(EVENGINE_THIRD_PARTY_BINARY_ONLY) + target_include_directories(EVFont PRIVATE + ${EVENGINE_THIRD_PARTY_BINARY_DIR}/include/freetype2) + else() + target_include_directories(EVFont PRIVATE + ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}-debug/include/freetype2 + ${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}/include/freetype2 + ) + endif() endif() create_module(EVThread thread) -create_module(EVPlugins plugins) -create_module(EVRPG rpg) -target_compile_definitions(EVRPG PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) -create_module(EVInventory inventory) -target_compile_definitions(EVInventory PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) -create_module(EVBuilding building) -target_compile_definitions(EVBuilding PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) -create_module(EVAvatar avatar) -create_module(EVDialogue dialogue) -create_module(EVProcgen procgen) +if(NOT EVENGINE_WEBGPU_TRIM) + create_module(EVPlugins plugins) + create_module(EVRPG rpg) + target_compile_definitions(EVRPG PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) + create_module(EVInventory inventory) + target_compile_definitions(EVInventory PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) + create_module(EVBuilding building) + target_compile_definitions(EVBuilding PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) + create_module(EVAvatar avatar) +endif() +if(NOT EVENGINE_WEBGPU_TRIM) + create_module(EVDialogue dialogue) + create_module(EVProcgen procgen) +endif() # Compat shims first (Apple Clang: missing drawOperatorArray in upstream DTL), then DTL. # BEFORE must precede the visibility keyword so -I dtl_compat wins over the submodule. -target_include_directories(EVProcgen BEFORE PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/procgen/dtl_compat) -target_include_directories(EVProcgen PUBLIC ${CMAKE_SOURCE_DIR}/external/DungeonTemplateLibrary/include) -create_module(EVHouseGen housegen) -target_compile_definitions(EVHouseGen PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) -create_module(EVVoxel voxel) -target_compile_definitions(EVVoxel PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) -create_module(EVStylize stylize) +if(NOT EVENGINE_WEBGPU_TRIM) + target_include_directories(EVProcgen BEFORE PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/dtl_compat) + target_include_directories(EVProcgen PUBLIC + ${CMAKE_SOURCE_DIR}/external/DungeonTemplateLibrary/include) + create_module(EVHouseGen housegen) + target_compile_definitions(EVHouseGen PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) + create_module(EVVoxel voxel) + target_compile_definitions(EVVoxel PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS) + create_module(EVStylize stylize) +endif() create_module(EVEditor editor) -if(EVENGINE_BUILD_DEMO) +if(EVENGINE_BUILD_DEMO AND NOT EVENGINE_WEBGPU_TRIM) create_module(EVDemo demo) endif() diff --git a/src/modules/animation/Animation.cpp b/src/modules/animation/Animation.cpp index a932075d..68512d37 100644 --- a/src/modules/animation/Animation.cpp +++ b/src/modules/animation/Animation.cpp @@ -740,6 +740,7 @@ void Animation::expose(ssq::Table &table) { spanim.addFunc("getAnimationDuration", &SpineAnim::getAnimationDuration); spanim.addFunc("apply", &SpineAnim::apply); spanim.addFunc("update", &SpineAnim::update); + spanim.addFunc("draw", &SpineAnim::draw); // collectDrawItems is C++-only (shared DrawItem2D queue); scripts use getDrawSlot*. spanim.addFunc("getDrawSlotCount", &SpineAnim::getDrawSlotCount); spanim.addFunc("getDrawSlotX", &SpineAnim::getDrawSlotX); diff --git a/src/modules/animation/SpineAnim.cpp b/src/modules/animation/SpineAnim.cpp index dc4da5cd..31a265f6 100644 --- a/src/modules/animation/SpineAnim.cpp +++ b/src/modules/animation/SpineAnim.cpp @@ -4,6 +4,8 @@ #include "animation/SpineAtlas.h" #include "animation/SpineSkeleton.h" #include "common/Exception.h" +#include "graphics/Graphics.h" +#include "graphics/RenderSystem.h" #include #include @@ -286,6 +288,13 @@ bool SpineAnim::update(float dt) { return true; } +void SpineAnim::draw(graphics::Graphics *gfx) { + if (!gfx) return; + std::vector items; + collectDrawItems(items); + graphics::RenderSystem::drawItems(*gfx, items, false); +} + void SpineAnim::rebuildDrawSlots() { drawSlots_.clear(); if (!skeleton_) return; @@ -405,6 +414,16 @@ void SpineAnim::collectDrawItems(std::vector &out) { item.v0 = ds.v0; item.u1 = ds.u1; item.v1 = ds.v1; + // A reflected skeleton cannot be represented by rotation plus positive + // quad extents alone. Reconstructing the reflected quad adds a 180-degree + // rotation, so regular regions need a V flip to leave only the requested + // horizontal reflection. Rotated atlas regions exchange the UV axes. + if (scaleX_ < 0.f) { + if (ds.rotated) + std::swap(item.u0, item.u1); + else + std::swap(item.v0, item.v1); + } out.push_back(item); } } diff --git a/src/modules/animation/SpineAnim.h b/src/modules/animation/SpineAnim.h index b65efa59..3eb328f7 100644 --- a/src/modules/animation/SpineAnim.h +++ b/src/modules/animation/SpineAnim.h @@ -7,6 +7,10 @@ #include #include +namespace eve::graphics { +class Graphics; +} + namespace eve::animation { class Animation; @@ -77,6 +81,8 @@ public: /** Append region attachment quads into the shared 2D draw queue. */ void collectDrawItems(std::vector &out); + /** Draw the current pose into an existing frame without clearing or presenting it. */ + void draw(graphics::Graphics *gfx); /** * Query posed draw slots without textures (for unit tests). diff --git a/src/modules/event/Event.cpp b/src/modules/event/Event.cpp index 94c19130..ad28678e 100644 --- a/src/modules/event/Event.cpp +++ b/src/modules/event/Event.cpp @@ -29,8 +29,11 @@ Event::~Event() {} void Event::push(Message* msg) { if (!msg) return; - std::lock_guard lock(queueMu_); - queue.push(msg); + { + std::lock_guard lock(queueMu_); + queue.push(msg); + } + wakeWaiters(); } void Event::pushData(std::string name, std::string data) { diff --git a/src/modules/event/Event.h b/src/modules/event/Event.h index 779d8a00..e45b22e7 100644 --- a/src/modules/event/Event.h +++ b/src/modules/event/Event.h @@ -78,6 +78,9 @@ public: virtual Message* wait() = 0; protected: + /** Wake a platform-specific blocking wait after a message is queued. */ + virtual void wakeWaiters() {} + std::mutex queueMu_; std::queue queue; std::string lastData_; diff --git a/src/modules/event/sdl/Event.cpp b/src/modules/event/sdl/Event.cpp index 3135f652..c1ef7b0f 100644 --- a/src/modules/event/sdl/Event.cpp +++ b/src/modules/event/sdl/Event.cpp @@ -5,7 +5,9 @@ #include "graphics/Graphics.h" #include "window/Window.h" #include "window/sdl/Window.h" +#ifndef EVENGINE_WEBGPU #include "audio/Audio.h" +#endif #include "touch/Touch.h" #include "touch/sdl/Touch.h" #include "keyboard/Keyboard.h" @@ -115,6 +117,11 @@ Event::Event() { if (SDL_InitSubSystem(SDL_INIT_EVENTS) < 0) throw eve::Exception("Could not initialize SDL events subsystem (%s)", SDL_GetError()); + wakeEventType_ = SDL_RegisterEvents(1); + if (wakeEventType_ == static_cast(-1)) { + SDL_QuitSubSystem(SDL_INIT_EVENTS); + throw eve::Exception("Could not reserve SDL wake event (%s)", SDL_GetError()); + } SDL_AddEventWatch(watchAppEvents, this); } @@ -141,20 +148,38 @@ void Event::pump() // Ownership stays in the queue until poll()/clear() — do not delete here. } +#ifndef EVENGINE_WEBGPU if (auto *audio = eve::ModuleManager::getInstance("Audio")) audio->pump(); +#endif } Message *Event::wait() { exceptionIfInRenderPass("eve.event.wait"); - SDL_Event e; + for (;;) { + if (Message *queued = poll()) + return queued; - if (SDL_WaitEvent(&e) != 1) - return nullptr; + SDL_Event e; + if (SDL_WaitEvent(&e) != 1) + return nullptr; + if (e.type == wakeEventType_) + continue; - return convert(e); + if (Message *message = convert(e)) + return message; + } +} + +void Event::wakeWaiters() +{ + SDL_Event e{}; + e.type = wakeEventType_; + e.user.type = wakeEventType_; + e.user.data1 = this; + SDL_PushEvent(&e); } void Event::clear() diff --git a/src/modules/event/sdl/Event.h b/src/modules/event/sdl/Event.h index 520d01f0..37e28cda 100644 --- a/src/modules/event/sdl/Event.h +++ b/src/modules/event/sdl/Event.h @@ -34,8 +34,10 @@ public: void clear() override; private: + void wakeWaiters() override; void exceptionIfInRenderPass(const char *name); Message *convert(const SDL_Event &e); + Uint32 wakeEventType_ = static_cast(-1); }; // Event diff --git a/src/modules/filesystem/FileWatch.cpp b/src/modules/filesystem/FileWatch.cpp index a7cfc509..389ad844 100644 --- a/src/modules/filesystem/FileWatch.cpp +++ b/src/modules/filesystem/FileWatch.cpp @@ -1,12 +1,26 @@ #include "filesystem/FileWatch.h" +#include "common/config.h" + +#ifndef EVENGINE_WEBGPU #include +#include #include #include #include +#endif + namespace eve::filesystem { + +#ifdef EVENGINE_WEBGPU +// WebGPU (browser) build has no native directory watching; DirWatch stays an +// empty struct so the unique_ptr members in the header compile. +struct FileWatch::DirWatch {}; +#endif + +#ifndef EVENGINE_WEBGPU namespace { std::string basenameOf(const std::string &path) { @@ -41,6 +55,7 @@ struct FileWatch::DirWatch { std::unordered_map filters; // filter → reportPath int refs = 0; }; +#endif // !EVENGINE_WEBGPU FileWatch::FileWatch() = default; @@ -48,6 +63,14 @@ FileWatch::~FileWatch() { clear(); } bool FileWatch::add(const std::string &realDir, const std::string &filterName, const std::string &reportPath, int scanInterval) { +#ifdef EVENGINE_WEBGPU + // Browser VFS has no native directory watching; no-op stub. + (void)realDir; + (void)filterName; + (void)reportPath; + (void)scanInterval; + return false; +#else if (realDir.empty() || reportPath.empty()) return false; const std::string dir = normalizeDir(realDir); try { @@ -112,9 +135,14 @@ bool FileWatch::add(const std::string &realDir, const std::string &filterName, ++dw->refs; reportToDir_[reportPath] = dir; return true; +#endif } bool FileWatch::remove(const std::string &reportPath) { +#ifdef EVENGINE_WEBGPU + (void)reportPath; + return false; +#else std::lock_guard lock(mu_); auto prev = reportToDir_.find(reportPath); if (prev == reportToDir_.end()) return false; @@ -143,10 +171,15 @@ bool FileWatch::remove(const std::string &reportPath) { byDir_.erase(it); } return true; +#endif } void FileWatch::clear() { std::lock_guard lock(mu_); +#ifdef EVENGINE_WEBGPU + queue_.clear(); + return; +#else for (auto &kv : byDir_) { DirWatch *dw = kv.second.get(); if (dw && dw->watcher) { @@ -160,11 +193,16 @@ void FileWatch::clear() { byDir_.clear(); reportToDir_.clear(); queue_.clear(); +#endif } int FileWatch::count() const { std::lock_guard lock(mu_); +#ifdef EVENGINE_WEBGPU + return 0; +#else return int(reportToDir_.size()); +#endif } bool FileWatch::poll(Event &out) { @@ -175,6 +213,7 @@ bool FileWatch::poll(Event &out) { return true; } +#ifndef EVENGINE_WEBGPU void FileWatch::handlePocoEvent(const std::string &kind, const std::string &itemPath) { const std::string name = basenameOf(itemPath); Poco::Path parent(itemPath); @@ -233,5 +272,6 @@ void FileWatch::onMovedFrom(const void *, const Poco::DirectoryWatcher::Director void FileWatch::onMovedTo(const void *, const Poco::DirectoryWatcher::DirectoryEvent &event) { handlePocoEvent("movedTo", event.item.path()); } +#endif // !EVENGINE_WEBGPU } // namespace eve::filesystem diff --git a/src/modules/filesystem/FileWatch.h b/src/modules/filesystem/FileWatch.h index e97decc8..7e829bc2 100644 --- a/src/modules/filesystem/FileWatch.h +++ b/src/modules/filesystem/FileWatch.h @@ -1,6 +1,8 @@ #pragma once +#ifndef EVENGINE_WEBGPU #include +#endif #include #include @@ -11,8 +13,10 @@ namespace eve::filesystem { /** - * OS directory watch (Poco::DirectoryWatcher) with a main-thread event queue. - * Watch a directory (all children) or a single file (parent dir + name filter). + * OS directory watch with a main-thread event queue. + * Desktop/mobile: backed by Poco::DirectoryWatcher (see FileWatch.cpp). + * WebGPU (Emscripten): the browser VFS has no native file watching, so this is + * a no-op stub — add()/remove() return false, poll() returns false. */ class FileWatch { public: @@ -49,11 +53,13 @@ public: private: struct DirWatch; +#ifndef EVENGINE_WEBGPU void onAdded(const void *sender, const Poco::DirectoryWatcher::DirectoryEvent &event); void onRemoved(const void *sender, const Poco::DirectoryWatcher::DirectoryEvent &event); void onModified(const void *sender, const Poco::DirectoryWatcher::DirectoryEvent &event); void onMovedFrom(const void *sender, const Poco::DirectoryWatcher::DirectoryEvent &event); void onMovedTo(const void *sender, const Poco::DirectoryWatcher::DirectoryEvent &event); +#endif void handlePocoEvent(const std::string &kind, const std::string &itemPath); diff --git a/src/modules/filesystem/Filesystem.cpp b/src/modules/filesystem/Filesystem.cpp index 7f07738a..f151e690 100644 --- a/src/modules/filesystem/Filesystem.cpp +++ b/src/modules/filesystem/Filesystem.cpp @@ -15,6 +15,8 @@ #include "ios/ios.h" #elif defined(EVENGINE_ANDROID) #include "android/android.h" +#elif defined(EVENGINE_WEBGPU) +#include "webgpu/webplatform.h" #elif defined(EVENGINE_WINDOWS) #include @@ -44,6 +46,14 @@ void Filesystem::expose(ssq::Class &cls) { cls.addFunc("getIdentity", &Filesystem::getIdentity); cls.addFunc("setSource", &Filesystem::setSource); cls.addFunc("getSource", &Filesystem::getSource); + cls.addFunc("allowMountingForPath", &Filesystem::allowMountingForPath); + cls.addFunc("mountPath", [](Filesystem *self, const std::string &archive, + const std::string &mountpoint, bool appendToPath) { + return self && self->mount(archive, mountpoint, appendToPath); + }); + cls.addFunc("unmountPath", [](Filesystem *self, const std::string &archive) { + return self && self->unmount(archive); + }); cls.addFunc("newFile", &Filesystem::newFile); cls.addFunc("newFileData", &Filesystem::newFileData); cls.addFunc("getWorkingDirectory", &Filesystem::getWorkingDirectory); @@ -103,6 +113,8 @@ std::string Filesystem::getExecutablePath() const { return ios::getExecutablePath(); #elif defined(EVENGINE_ANDROID) return android::getExecutablePath(); +#elif defined(EVENGINE_WEBGPU) + return eve::webgpu_platform::getExecutablePath(); #elif defined(EVENGINE_WINDOWS) wchar_t buffer[MAX_PATH + 1] = {0}; if (GetModuleFileNameW(nullptr, buffer, MAX_PATH) == 0) return ""; diff --git a/src/modules/filesystem/HotReload.cpp b/src/modules/filesystem/HotReload.cpp index 53f32a55..d4321918 100644 --- a/src/modules/filesystem/HotReload.cpp +++ b/src/modules/filesystem/HotReload.cpp @@ -3,10 +3,12 @@ #include "common/Module.h" #include "filesystem/Filesystem.h" #include "graphics/Graphics.h" +#ifndef EVENGINE_WEBGPU #include "map/TileConfig.h" #include "map/TileLayer.h" #include "particles/ParticleConfig.h" #include "particles/ParticleEmitter.h" +#endif #include #include @@ -67,6 +69,7 @@ bool HotReload::isJsonPath(const std::string &normPath) const { return extensionOf(normPath) == ".json"; } +#ifndef EVENGINE_WEBGPU bool HotReload::reloadParticles(const std::string &normPath) { if (ecs::current()->getManager() == nullptr) return false; @@ -95,6 +98,10 @@ bool HotReload::reloadTilemaps(const std::string &normPath) { } return reloaded > 0; } +#else +bool HotReload::reloadParticles(const std::string &) { return false; } +bool HotReload::reloadTilemaps(const std::string &) { return false; } +#endif bool HotReload::reloadTextures(const std::string &normPath) { auto *gfx = eve::ModuleManager::getInstance("Graphics"); @@ -102,6 +109,7 @@ bool HotReload::reloadTextures(const std::string &normPath) { bool ok = gfx->reloadTextureFromFile(normPath); +#ifndef EVENGINE_WEBGPU // Emitters that reference this texture path: force re-bind via config reload or setTexture. if (ecs::current()->getManager() != nullptr) { auto view = ecs::View #ifdef EVENGINE_ANDROID @@ -400,6 +404,8 @@ std::string Filesystem::getUserDirectory() { #ifdef EVENGINE_IOS // PHYSFS_getUserDir doesn't give exactly the path we want on iOS. static std::string userDir = normalize(ios::getHomeDirectory()); +#elif defined(EVENGINE_WEBGPU) + static std::string userDir = normalize(eve::webgpu_platform::getHomeDirectory()); #else const char* dir = PHYSFS_getUserDir(); static std::string userDir; @@ -412,6 +418,8 @@ std::string Filesystem::getAppdataDirectory() { if (appdata.empty()) { #ifdef EVENGINE_WINDOWS_UWP appdata = getUserDirectory(); +#elif defined(EVENGINE_WEBGPU) + appdata = normalize(eve::webgpu_platform::getAppdataDirectory()); #elif defined(EVENGINE_WINDOWS) wchar_t *w_appdata = _wgetenv(L"APPDATA"); appdata = to_utf8(w_appdata); diff --git a/src/modules/gpgpu/Gpgpu.cpp b/src/modules/gpgpu/Gpgpu.cpp index 91ebc726..a17df99d 100644 --- a/src/modules/gpgpu/Gpgpu.cpp +++ b/src/modules/gpgpu/Gpgpu.cpp @@ -2,14 +2,20 @@ #include "gpgpu/ComputeShader.h" #include "gpgpu/EcsScriptPack.h" #include "gpgpu/GpuBuffer.h" -#include "gpgpu/vulkan/VulkanGpgpu.h" -#include "gpgpu/vulkan/VulkanUtil.h" #include "gpgpu/ShaderSystem.h" #include "common/Exception.h" #include "common/Module.h" +#include "filesystem/Filesystem.h" #include "graphics/Graphics.h" +#ifdef EVENGINE_WEBGPU +#include "gpgpu/webgpu/WebGpuGpgpu.h" +#else +#include "gpgpu/vulkan/VulkanGpgpu.h" +#include "gpgpu/vulkan/VulkanUtil.h" +#endif + #include #include @@ -29,39 +35,82 @@ std::string currentGraphicsBackend() { Module_IMPL(Gpgpu, new Gpgpu()); bool Gpgpu::isAvailable() const { +#ifdef EVENGINE_WEBGPU + if (currentGraphicsBackend() != "webgpu") return false; + return webgpuGpgpuReady(); +#else if (currentGraphicsBackend() != "vulkan") return false; return vulkanGpgpuReady(); +#endif } ComputeShader *Gpgpu::newShader(const std::string &source) { +#ifdef EVENGINE_WEBGPU + if (currentGraphicsBackend() != "webgpu") + throw Exception("Gpgpu.newShader: requires webgpu Graphics backend"); + // Source is WGSL on the WebGPU backend (browsers cannot compile GLSL). + return webgpuNewShaderFromWgsl(source); +#else if (currentGraphicsBackend() != "vulkan") throw Exception("Gpgpu.newShader: requires vulkan Graphics backend"); return vulkanNewShaderFromSpirv(compileComputeGlsl(source)); +#endif } ComputeShader *Gpgpu::newShaderFromBytecode(const std::string &path) { +#ifdef EVENGINE_WEBGPU + if (currentGraphicsBackend() != "webgpu") + throw Exception("Gpgpu.newShaderFromBytecode: requires webgpu Graphics backend"); + // Load WGSL text from the given path. + auto *fs = eve::filesystem::Filesystem::create(); + if (!fs) throw Exception("Gpgpu.newShaderFromBytecode: no filesystem"); + std::unique_ptr file(fs->read(path)); + if (!file) throw Exception("Gpgpu.newShaderFromBytecode: cannot open '%s'", path.c_str()); + std::string src(reinterpret_cast(file->getData()), file->getSize()); + return webgpuNewShaderFromWgsl(src); +#else if (currentGraphicsBackend() != "vulkan") throw Exception("Gpgpu.newShaderFromBytecode: requires vulkan Graphics backend"); return vulkanNewShaderFromSpirv(loadSpirvFile(path)); +#endif } ComputeShader *Gpgpu::newShaderFromSpvFile(const std::string &path) { +#ifdef EVENGINE_WEBGPU + if (currentGraphicsBackend() != "webgpu") + throw Exception("Gpgpu.newShaderFromSpvFile: requires webgpu Graphics backend"); + throw Exception("Gpgpu.newShaderFromSpvFile: SPIR-V is only supported on vulkan; " + "use newShaderFromBytecode with a .wgsl file on the WebGPU backend"); +#else if (currentGraphicsBackend() != "vulkan") throw Exception("Gpgpu.newShaderFromSpvFile: SPIR-V is only supported on vulkan"); return newShaderFromBytecode(path); +#endif } GpuBuffer *Gpgpu::newBuffer(int byteSize, const std::string &usage) { +#ifdef EVENGINE_WEBGPU + if (currentGraphicsBackend() != "webgpu") + throw Exception("Gpgpu.newBuffer: requires webgpu Graphics backend"); + return webgpuNewBuffer(byteSize, usage); +#else if (currentGraphicsBackend() != "vulkan") throw Exception("Gpgpu.newBuffer: requires vulkan Graphics backend"); return vulkanNewBuffer(byteSize, usage); +#endif } void Gpgpu::dispatch(ComputeShader *shader, int groupsX, int groupsY, int groupsZ) { if (!shader) return; +#ifdef EVENGINE_WEBGPU + if (currentGraphicsBackend() != "webgpu") + throw Exception("Gpgpu.dispatch: requires webgpu Graphics backend"); + webgpuDispatch(shader, groupsX, groupsY, groupsZ); +#else if (currentGraphicsBackend() != "vulkan") throw Exception("Gpgpu.dispatch: requires vulkan Graphics backend"); vulkanDispatch(shader, groupsX, groupsY, groupsZ); +#endif } void Gpgpu::expose(ssq::Table &table) { diff --git a/src/modules/gpgpu/webgpu/WebGpuGpgpu.cpp b/src/modules/gpgpu/webgpu/WebGpuGpgpu.cpp new file mode 100644 index 00000000..4400d3bf --- /dev/null +++ b/src/modules/gpgpu/webgpu/WebGpuGpgpu.cpp @@ -0,0 +1,316 @@ +#include "gpgpu/webgpu/WebGpuGpgpu.h" + +#include "common/Exception.h" +#include "common/Module.h" +#include "data/ByteData.h" +#include "graphics/Graphics.h" +#include "graphics/webgpu/Graphics.h" + +#if defined(__EMSCRIPTEN__) +#include +#endif + +#include +#include + +namespace eve::gpgpu { + +namespace { + +graphics::webgpu::Graphics *requireWebGpuGraphics() { + auto *gfx = eve::ModuleManager::getInstance("Graphics"); + if (!gfx) gfx = eve::graphics::Graphics::create(); + auto *wgg = dynamic_cast(gfx); + if (!wgg) throw Exception("Gpgpu: requires WebGPU Graphics backend"); + if (!wgg->getDevice()) + throw Exception("Gpgpu: Graphics device not initialized (create a window first)"); + return wgg; +} + +wgpu::Device &gpuDevice() { return requireWebGpuGraphics()->getDevice(); } +wgpu::Queue &gpuQueue() { return requireWebGpuGraphics()->getQueue(); } +wgpu::Instance &gpuInstance() { return requireWebGpuGraphics()->getInstance(); } + +/** Build a WGPUStringView from a C string (null-safe, length auto-computed). */ +WGPUStringView sv(const char *s) { return WGPUStringView{s, s ? std::strlen(s) : 0}; } + +} // namespace + +bool webgpuGpgpuReady() { + try { + auto *gfx = eve::ModuleManager::getInstance("Graphics"); + if (!gfx) return false; + auto *wgg = dynamic_cast(gfx); + if (!wgg) return false; + return bool(wgg->getDevice()); + } catch (...) { + return false; + } +} + +WebGpuComputeShader::~WebGpuComputeShader() = default; + +void WebGpuComputeShader::bindBuffer(int binding, GpuBuffer *buffer) { + if (binding < 0 || binding >= kMaxBindings) return; + bindings_[size_t(binding)] = buffer; +} + +GpuBuffer *WebGpuComputeShader::getBoundBuffer(int binding) const { + if (binding < 0 || binding >= kMaxBindings) return nullptr; + return bindings_[size_t(binding)]; +} + +void WebGpuComputeShader::setFloat(int index, float value) { + if (index < 0 || index >= kMaxFloats) return; + push_[size_t(index)] = value; +} + +float WebGpuComputeShader::getFloat(int index) const { + if (index < 0 || index >= kMaxFloats) return 0.f; + return push_[size_t(index)]; +} + +void WebGpuComputeShader::clearBindings() { + bindings_.fill(nullptr); +} + +WebGpuComputeShader *webgpuNewShaderFromWgsl(const std::string &wgsl) { + auto &device = gpuDevice(); + auto *shader = new WebGpuComputeShader(); + + // Bind group layout: 8 storage buffers (bindings 0..7) + push UBO (binding 8). + WGPUBindGroupLayoutEntry entries[ComputeShader::kMaxBindings + 1]{}; + for (int i = 0; i < ComputeShader::kMaxBindings; ++i) { + entries[size_t(i)].binding = uint32_t(i); + entries[size_t(i)].visibility = WGPUShaderStage_Compute; + entries[size_t(i)].buffer.type = WGPUBufferBindingType_Storage; + entries[size_t(i)].buffer.hasDynamicOffset = false; + entries[size_t(i)].buffer.minBindingSize = 0; + } + entries[ComputeShader::kMaxBindings].binding = uint32_t(ComputeShader::kMaxBindings); + entries[ComputeShader::kMaxBindings].visibility = WGPUShaderStage_Compute; + entries[ComputeShader::kMaxBindings].buffer.type = WGPUBufferBindingType_Uniform; + entries[ComputeShader::kMaxBindings].buffer.hasDynamicOffset = true; + entries[ComputeShader::kMaxBindings].buffer.minBindingSize = ComputeShader::kPushConstantBytes; + + WGPUBindGroupLayoutDescriptor bglDesc{}; + bglDesc.label = sv("eve_compute"); + bglDesc.entryCount = ComputeShader::kMaxBindings + 1; + bglDesc.entries = entries; + shader->setLayout = device.CreateBindGroupLayout(reinterpret_cast(&bglDesc)); + + WGPUBindGroupLayout bgl = shader->setLayout.Get(); + WGPUPipelineLayoutDescriptor plDesc{}; + plDesc.label = sv("eve_compute_layout"); + plDesc.bindGroupLayoutCount = 1; + plDesc.bindGroupLayouts = &bgl; + shader->pipelineLayout = device.CreatePipelineLayout(reinterpret_cast(&plDesc)); + + WGPUShaderSourceWGSL wd{}; + wd.chain.sType = WGPUSType_ShaderSourceWGSL; + wd.code = sv(wgsl.c_str()); + WGPUShaderModuleDescriptor md{}; + md.label = sv("eve_compute_module"); + md.nextInChain = &wd.chain; + wgpu::ShaderModule module = device.CreateShaderModule(reinterpret_cast(&md)); + + WGPUComputePipelineDescriptor pd{}; + pd.label = sv("eve_compute_pipeline"); + pd.layout = shader->pipelineLayout.Get(); + pd.compute.module = module.Get(); + pd.compute.entryPoint = sv("main"); + shader->pipeline = device.CreateComputePipeline(reinterpret_cast(&pd)); + + // Push-constant UBO (128 bytes, 256-aligned for dynamic offsets). + WGPUBufferDescriptor bd{}; + bd.label = sv("eve_compute_push"); + bd.size = ComputeShader::kPushConstantBytes; + bd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_Uniform; + bd.mappedAtCreation = false; + shader->pushUbo = device.CreateBuffer(reinterpret_cast(&bd)); + shader->ready = true; + return shader; +} + +WebGpuComputeShader *webgpuNewShaderFromSpirv(const std::vector &spv) { + (void)spv; + throw Exception("Gpgpu.newShaderFromSpirv: SPIR-V compute shaders are not supported on the " + "WebGPU backend; use WGSL source instead."); +} + +WebGpuGpuBuffer *webgpuNewBuffer(int byteSize, const std::string &usage) { + if (byteSize <= 0) throw Exception("Gpgpu.newBuffer: byteSize must be > 0"); + auto &device = gpuDevice(); + bool staging = usage == "staging"; + WGPUBufferUsage bufUsage = staging ? (WGPUBufferUsage_CopySrc | WGPUBufferUsage_MapRead) + : (WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst | + WGPUBufferUsage_CopySrc | WGPUBufferUsage_Vertex); + WGPUBufferDescriptor bd{}; + bd.label = sv("eve_compute_buffer"); + bd.size = uint64_t(byteSize); + bd.usage = bufUsage; + bd.mappedAtCreation = false; + auto *b = new WebGpuGpuBuffer(); + b->buffer = device.CreateBuffer(reinterpret_cast(&bd)); + b->size_ = uint64_t(byteSize); + b->usage_ = usage; + return b; +} + +namespace { + +bool readBufferToCpu(wgpu::Buffer src, uint64_t srcOffset, void *dst, uint64_t nbytes) { + auto &device = gpuDevice(); + auto &queue = gpuQueue(); + if (!src || nbytes == 0) return false; + + WGPUBufferDescriptor bd{}; + bd.label = sv("eve_compute_readback"); + bd.size = nbytes; + bd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead; + bd.mappedAtCreation = false; + wgpu::Buffer staging = device.CreateBuffer(reinterpret_cast(&bd)); + + wgpu::CommandEncoder enc = device.CreateCommandEncoder(); + enc.CopyBufferToBuffer(src, srcOffset, staging, 0, nbytes); + wgpu::CommandBuffer cmd = enc.Finish(); + queue.Submit(1, &cmd); + + bool mapped = false; + WGPUBufferMapCallbackInfo cbInfo{}; + cbInfo.mode = WGPUCallbackMode_AllowProcessEvents; + cbInfo.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*message*/, void *userdata1, + void * /*userdata2*/) { + bool *ok = static_cast(userdata1); + *ok = (status == WGPUMapAsyncStatus_Success); + }; + cbInfo.userdata1 = &mapped; + wgpuBufferMapAsync(staging.Get(), WGPUMapMode_Read, 0, nbytes, cbInfo); + + int guard = 0; + while (!mapped && guard < 2000) { +#if defined(__EMSCRIPTEN__) + emscripten_sleep(0); +#endif + wgpuInstanceProcessEvents(gpuInstance().Get()); + ++guard; + } + if (!mapped) return false; + const uint8_t *data = static_cast(staging.GetConstMappedRange(0, nbytes)); + if (!data) return false; + std::memcpy(dst, data, size_t(nbytes)); + staging.Unmap(); + return true; +} + +} // namespace + +void webgpuDispatch(ComputeShader *shader, int groupsX, int groupsY, int groupsZ) { + auto *ws = dynamic_cast(shader); + if (!ws || !ws->ready || !ws->pipeline) return; + if (groupsX <= 0) groupsX = 1; + if (groupsY <= 0) groupsY = 1; + if (groupsZ <= 0) groupsZ = 1; + + auto &device = gpuDevice(); + auto &queue = gpuQueue(); + + // Push constants -> uniform buffer (dynamic offset 0). + queue.WriteBuffer(ws->pushUbo, 0, shader->pushConstantData(), ComputeShader::kPushConstantBytes); + + // Build a bind group for the current storage-buffer bindings. + WGPUBindGroupEntry entries[ComputeShader::kMaxBindings + 1]{}; + WebGpuGpuBuffer *dummy = webgpuNewBuffer(4, "storage"); + for (int i = 0; i < ComputeShader::kMaxBindings; ++i) { + auto *vb = dynamic_cast(ws->getBoundBuffer(i)); + entries[size_t(i)].binding = uint32_t(i); + entries[size_t(i)].buffer = (vb && vb->buffer) ? vb->buffer.Get() : dummy->buffer.Get(); + entries[size_t(i)].size = (vb && vb->buffer) ? vb->size_ : 4; + } + entries[ComputeShader::kMaxBindings].binding = uint32_t(ComputeShader::kMaxBindings); + entries[ComputeShader::kMaxBindings].buffer = ws->pushUbo.Get(); + entries[ComputeShader::kMaxBindings].size = ComputeShader::kPushConstantBytes; + WGPUBindGroupDescriptor bgDesc{}; + bgDesc.layout = ws->setLayout.Get(); + bgDesc.entryCount = ComputeShader::kMaxBindings + 1; + bgDesc.entries = entries; + wgpu::BindGroup bg = device.CreateBindGroup(reinterpret_cast(&bgDesc)); + delete dummy; + + wgpu::CommandEncoder enc = device.CreateCommandEncoder(); + WGPUComputePassDescriptor cpDesc{}; + wgpu::ComputePassEncoder pass = enc.BeginComputePass(reinterpret_cast(&cpDesc)); + pass.SetPipeline(ws->pipeline); + uint32_t offsets[1] = {0}; + pass.SetBindGroup(0, bg, 1, offsets); + pass.DispatchWorkgroups(uint32_t(groupsX), uint32_t(groupsY), uint32_t(groupsZ)); + pass.End(); + wgpu::CommandBuffer cmd = enc.Finish(); + queue.Submit(1, &cmd); +} + +// --------------------------------------------------------------------------- +// WebGpuGpuBuffer +// --------------------------------------------------------------------------- + +WebGpuGpuBuffer::~WebGpuGpuBuffer() = default; + +void WebGpuGpuBuffer::uploadBytes(const void *src, uint64_t nbytes, uint64_t dstOffset) { + if (!buffer || !src || nbytes == 0) return; + if (dstOffset + nbytes > size_) + throw Exception("GpuBuffer.write: out of range"); + gpuQueue().WriteBuffer(buffer, dstOffset, src, nbytes); +} + +void WebGpuGpuBuffer::downloadBytes(void *dst, uint64_t nbytes, uint64_t srcOffset) const { + if (!buffer || !dst || nbytes == 0) return; + if (srcOffset + nbytes > size_) + throw Exception("GpuBuffer.read: out of range"); + readBufferToCpu(buffer, srcOffset, dst, nbytes); +} + +void WebGpuGpuBuffer::writeData(data::ByteData *data, int dstOffset) { + if (!data) return; + uploadBytes(data->getData(), data->getSize(), uint64_t(dstOffset < 0 ? 0 : dstOffset)); +} + +data::ByteData *WebGpuGpuBuffer::readData(int srcOffset, int size) { + const int off = srcOffset < 0 ? 0 : srcOffset; + int nbytes = size; + if (nbytes < 0) nbytes = int(size_) - off; + if (nbytes <= 0) return new data::ByteData(size_t(0)); + auto *out = new data::ByteData(size_t(nbytes)); + downloadBytes(out->getData(), uint64_t(nbytes), uint64_t(off)); + return out; +} + +void WebGpuGpuBuffer::writeFloat32(int floatIndex, float value) { + if (floatIndex < 0) return; + uploadBytes(&value, sizeof(float), uint64_t(floatIndex) * sizeof(float)); +} + +float WebGpuGpuBuffer::readFloat32(int floatIndex) { + if (floatIndex < 0) return 0.f; + float v = 0.f; + downloadBytes(&v, sizeof(float), uint64_t(floatIndex) * sizeof(float)); + return v; +} + +void WebGpuGpuBuffer::writeFloat32s(const float *data, int count, int startIndex) { + if (!data || count <= 0 || startIndex < 0) return; + uploadBytes(data, uint64_t(count) * sizeof(float), uint64_t(startIndex) * sizeof(float)); +} + +void WebGpuGpuBuffer::readFloat32s(float *out, int count, int startIndex) const { + if (!out || count <= 0 || startIndex < 0) return; + downloadBytes(out, uint64_t(count) * sizeof(float), uint64_t(startIndex) * sizeof(float)); +} + +void WebGpuGpuBuffer::fillFloat32(float value) { + if (size_ < sizeof(float)) return; + const size_t count = size_t(size_ / sizeof(float)); + std::vector tmp(count, value); + uploadBytes(tmp.data(), count * sizeof(float), 0); +} + +} // namespace eve::gpgpu diff --git a/src/modules/gpgpu/webgpu/WebGpuGpgpu.h b/src/modules/gpgpu/webgpu/WebGpuGpgpu.h new file mode 100644 index 00000000..e38b632a --- /dev/null +++ b/src/modules/gpgpu/webgpu/WebGpuGpgpu.h @@ -0,0 +1,77 @@ +#pragma once + +#include "gpgpu/ComputeShader.h" +#include "gpgpu/GpuBuffer.h" + +#include + +#include +#include +#include + +namespace eve::gpgpu { + +class WebGpuGpuBuffer; + +/** + * Compute program for the WebGPU backend. Accepts WGSL source; GLSL/SPIR-V + * input is rejected (browsers only accept WGSL at runtime). + */ +class WebGpuComputeShader final : public ComputeShader { +public: + WebGpuComputeShader() = default; + ~WebGpuComputeShader() override; + + void bindBuffer(int binding, GpuBuffer *buffer) override; + GpuBuffer *getBoundBuffer(int binding) const override; + + void setFloat(int index, float value) override; + float getFloat(int index) const override; + + void clearBindings() override; + + wgpu::ComputePipeline pipeline; + wgpu::PipelineLayout pipelineLayout; + wgpu::BindGroupLayout setLayout; + wgpu::Buffer pushUbo; + bool ready = false; + +private: + std::array bindings_{}; +}; + +/** Storage / staging buffer for the WebGPU backend. */ +class WebGpuGpuBuffer final : public GpuBuffer { +public: + WebGpuGpuBuffer() = default; + ~WebGpuGpuBuffer() override; + + int getSize() const override { return int(size_); } + std::string getUsage() const override { return usage_; } + + void writeData(data::ByteData *data, int dstOffset = 0) override; + data::ByteData *readData(int srcOffset = 0, int size = -1) override; + + void writeFloat32(int floatIndex, float value) override; + float readFloat32(int floatIndex) override; + void fillFloat32(float value) override; + + void writeFloat32s(const float *data, int count, int startIndex = 0) override; + void readFloat32s(float *out, int count, int startIndex = 0) const override; + + void uploadBytes(const void *src, uint64_t nbytes, uint64_t dstOffset = 0) override; + void downloadBytes(void *dst, uint64_t nbytes, uint64_t srcOffset = 0) const override; + + wgpu::Buffer buffer; + uint64_t size_ = 0; + std::string usage_; +}; + +// Backend entry points (mirror gpgpu/vulkan/VulkanGpgpu.h). +bool webgpuGpgpuReady(); +WebGpuComputeShader *webgpuNewShaderFromWgsl(const std::string &wgsl); +WebGpuComputeShader *webgpuNewShaderFromSpirv(const std::vector &spv); +WebGpuGpuBuffer *webgpuNewBuffer(int byteSize, const std::string &usage); +void webgpuDispatch(ComputeShader *shader, int groupsX, int groupsY, int groupsZ); + +} // namespace eve::gpgpu diff --git a/src/modules/graphics/Graphics.cpp b/src/modules/graphics/Graphics.cpp index a41b2553..2776dd41 100644 --- a/src/modules/graphics/Graphics.cpp +++ b/src/modules/graphics/Graphics.cpp @@ -1,6 +1,12 @@ #include "graphics/Graphics.h" #include "graphics/HairShader.h" +#include "graphics/Grass.h" +#include "common/config.h" +#ifdef EVENGINE_WEBGPU +#include "graphics/webgpu/Graphics.h" +#else #include "graphics/vulkan/Graphics.h" +#endif #include "graphics/RenderSystem3D.h" #include "graphics/RenderSystem.h" #include "graphics/Light.h" @@ -14,7 +20,9 @@ #include "graphics/GlobalIllumination.h" #include "graphics/Material.h" #include "graphics/RenderControl.h" +#ifndef EVENGINE_WEBGPU #include "font/FontData.h" +#endif #include "image/ImageData.h" #include "common/Exception.h" #include "common/RenderTrace.h" @@ -27,7 +35,11 @@ namespace eve::graphics { +#ifdef EVENGINE_WEBGPU +Module_IMPL(Graphics, new eve::graphics::webgpu::Graphics()); +#else Module_IMPL(Graphics, new vulkan::Graphics()); +#endif void Graphics::render3D() { eve::debug::rtFrameBegin(); @@ -331,6 +343,23 @@ void Graphics::expose(ssq::Table &table) { vol.addFunc("getRayMarchShader", &Volumetric::getRayMarchShader); vol.addFunc("getFogShader", &Volumetric::getFogShader); + auto grassField = table.addClass( + "GrassField", std::function([]() -> GrassField * { return nullptr; }), true); + grassField.addFunc("bakePlane", + static_cast(&GrassField::bakePlane)); + grassField.addFunc("update", &GrassField::update); + grassField.addFunc("setTime", &GrassField::setTime); + grassField.addFunc("getTime", &GrassField::getTime); + grassField.addFunc("setFrameDuration", &GrassField::setFrameDuration); + grassField.addFunc("getFrameDuration", &GrassField::getFrameDuration); + grassField.addFunc("draw", static_cast(&GrassField::draw)); + grassField.addFunc("getDenseMesh", &GrassField::getDenseMesh); + grassField.addFunc("getSparseMesh", &GrassField::getSparseMesh); + grassField.addFunc("getShader", &GrassField::getShader); + grassField.addFunc("getAtlas", &GrassField::getAtlas); + grassField.addFunc("getDenseCount", &GrassField::getDenseCount); + grassField.addFunc("getSparseCount", &GrassField::getSparseCount); + auto ao = table.addClass( "AmbientOcclusion", std::function([]() -> AmbientOcclusion * { return nullptr; }), true); @@ -423,6 +452,7 @@ void Graphics::expose(ssq::Class &cls) { cls.addFunc("setBackgroundColor", &Graphics::setBackgroundColorRGBA); cls.addFunc("drawSolidRect", &Graphics::drawSolidRectRGBA); cls.addFunc("drawTexturedRect", &Graphics::drawTexturedRectRGBA); + cls.addFunc("newTextureFromFile", &Graphics::newTextureFromFile); cls.addFunc("newTexture", static_cast( &Graphics::newTextureFromImageData)); @@ -437,6 +467,8 @@ void Graphics::expose(ssq::Class &cls) { cls.addFunc("newMeshShader", static_cast(&Graphics::newMeshShader)); cls.addFunc("newHairShader", &Graphics::newHairShader); + cls.addFunc("newGrassShader", &Graphics::newGrassShader); + cls.addFunc("newGrassField", &Graphics::newGrassField); cls.addFunc("newShaderFromSpvFile", static_cast(&Graphics::newShaderFromSpvFile)); cls.addFunc("setShader", static_cast(&Graphics::setShader)); @@ -491,6 +523,10 @@ AntiAliasing *Graphics::newAntiAliasing() { return new AntiAliasing(this); } Shader *Graphics::newHairShader() { return hair::createShader(this); } +Shader *Graphics::newGrassShader() { return grass::createShader(this); } + +GrassField *Graphics::newGrassField() { return new GrassField(this); } + void Graphics::draw(Drawable *drawable, const glm::mat4 &m) { if (drawable) drawable->draw(this, m); } @@ -578,6 +614,7 @@ void Graphics::setTextureSamplerParams(Texture *texture, const std::string &filt Quad *Graphics::newQuad(int x, int y, int w, int h) { return new Quad(x, y, w, h); } +#ifndef EVENGINE_WEBGPU Font *Graphics::newFont(font::FontData *data, std::string charset) { return new Font(this, data, std::move(charset)); } @@ -619,5 +656,6 @@ void Graphics::print(const std::string &text, float x, float y, const Color &col prevCodepoint = code; } } +#endif // !EVENGINE_WEBGPU } // namespace eve::graphics diff --git a/src/modules/graphics/Graphics.h b/src/modules/graphics/Graphics.h index 662649b0..2e16f4e5 100644 --- a/src/modules/graphics/Graphics.h +++ b/src/modules/graphics/Graphics.h @@ -16,6 +16,7 @@ #include "graphics/Volumetric.h" #include "graphics/AmbientOcclusion.h" #include "graphics/GlobalIllumination.h" +#include "graphics/Grass.h" #include "graphics/Material.h" #include "graphics/GBuffer.h" #include "graphics/RenderControl.h" @@ -419,6 +420,41 @@ public: */ virtual void requestSurfaceRecreate() {} + /** + * Called by the Window module when the native window backing the render + * surface is destroyed (window close / recreation). Backends that keep a + * platform surface tied to the native window must drop it here so the next + * initWithWindow() rebuilds it against a fresh window — even when SDL hands + * back the same pointer for the recreated window. + * + * The present overlay is bound to the destroyed window, so it is also + * dropped here: a stale ImGui callback would otherwise keep drawing into + * later presents of unrelated windows. Registered window-destroyed + * callbacks (e.g. the UI backend's ImGui teardown) fire first so they can + * release their ImGui context while the surface is still valid. + */ + virtual void onNativeWindowDestroyed() { + for (auto &cb : windowDestroyedCallbacks_) cb.first(cb.second); + windowDestroyedCallbacks_.clear(); + clearPresentOverlay(); + } + + /** Drop the present overlay callback (window gone; re-register on UI init). */ + void clearPresentOverlay() { + presentOverlayFn_ = nullptr; + presentOverlayUser_ = nullptr; + } + + /** + * Register a callback invoked when the native window is destroyed. + * Used by the UI backend to tear down its ImGui context; callbacks are + * cleared (and invoked) on onNativeWindowDestroyed(). + */ + using WindowDestroyedCallback = void (*)(void *userdata); + void addWindowDestroyedCallback(WindowDestroyedCallback cb, void *userdata) { + windowDestroyedCallbacks_.emplace_back(cb, userdata); + } + /** * Optional overlay drawn inside the swapchain render pass (before end). * Used by declarative UI (ImGui). `commandBuffer` is a VkCommandBuffer as void*. @@ -495,6 +531,18 @@ public: /** Built-in hair shader with default anisotropic parameters. */ Shader *newHairShader(); + /** + * t3ssel8r-style grass billboard shader (alpha test + shadow two-tone). + * Owned by Graphics. See grass:: / GrassField. + */ + Shader *newGrassShader(); + + /** + * Dense + sparse stylized grass field. Caller owns GrassField*; + * its Mesh / Shader / Texture are owned by Graphics. + */ + GrassField *newGrassField(); + /** Create an offscreen render target (sampleable). Owned by Graphics. */ virtual Canvas *newCanvas(int width, int height) = 0; @@ -667,6 +715,7 @@ protected: int msaaSamples = 4; PresentOverlayFn presentOverlayFn_ = nullptr; void *presentOverlayUser_ = nullptr; + std::vector> windowDestroyedCallbacks_; Shader *currentShader = nullptr; Font *currentFont = nullptr; std::unique_ptr renderControl_; diff --git a/src/modules/graphics/Grass.cpp b/src/modules/graphics/Grass.cpp new file mode 100644 index 00000000..3d6daae7 --- /dev/null +++ b/src/modules/graphics/Grass.cpp @@ -0,0 +1,774 @@ +#include "graphics/Grass.h" + +#include "common/Exception.h" +#include "data/ByteData.h" +#include "graphics/Graphics.h" +#include "graphics/Mesh.h" +#include "graphics/Shader.h" +#include "graphics/Texture.h" +#include "graphics/TextureSampler.h" +#include "graphics/shaders/mesh3d_grass_frag_spv.inc" +#include "graphics/shaders/mesh3d_grass_vert_spv.inc" +#include "image/Image.h" +#include "image/ImageData.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace eve::graphics::grass { +namespace { + +const std::array kParamSlots = { + "time", "frameDuration", "grassWidth", "grassHeight", "alphaCutoff", + "alwaysDark", "lightGreenX", "lightGreenY", "lightGreenZ", "darkGreenX", + "darkGreenY", "darkGreenZ", "frameCount", "atlasCols", "atlasRows", + "grassVariantCount", "leafVariantCount", "leafRowOffset"}; + +std::vector copySpv(const uint32_t *data, size_t count) { + return std::vector(data, data + count); +} + +float radicalInverse(uint32_t n, uint32_t base) { + const float inv = 1.f / float(base); + float f = inv; + float val = 0.f; + while (n > 0) { + val += float(n % base) * f; + n /= base; + f *= inv; + } + return val; +} + +float wrap01(float x) { + x = x - std::floor(x); + return x < 0.f ? x + 1.f : x; +} + +uint32_t mixSeed(uint32_t seed, uint32_t i) { + seed ^= 0x9e3779b9u + (i << 6) + (i >> 2); + seed *= 0x85ebca6bu; + return seed ^ (seed >> 13); +} + +struct Triangle { + uint32_t i0 = 0, i1 = 0, i2 = 0; + float area = 0.f; + glm::vec3 n{0.f, 1.f, 0.f}; +}; + +glm::vec3 readPos(const float *pos, int i) { + return glm::vec3(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]); +} + +glm::vec3 readNrm(const float *nrm, int i) { + if (!nrm) return glm::vec3(0.f, 1.f, 0.f); + return glm::vec3(nrm[i * 3], nrm[i * 3 + 1], nrm[i * 3 + 2]); +} + +bool buildTriangles(const float *posXYZ, const float *nrmXYZ, int vertexCount, + const uint32_t *indices, int indexCount, float minSlopeDot, + std::vector &tris, std::vector &cdf) { + tris.clear(); + cdf.clear(); + if (!posXYZ || !indices || vertexCount < 3 || indexCount < 3) return false; + if (indexCount % 3 != 0) return false; + + float total = 0.f; + for (int t = 0; t + 2 < indexCount; t += 3) { + const uint32_t i0 = indices[t]; + const uint32_t i1 = indices[t + 1]; + const uint32_t i2 = indices[t + 2]; + if (int(i0) >= vertexCount || int(i1) >= vertexCount || int(i2) >= vertexCount) continue; + const glm::vec3 a = readPos(posXYZ, int(i0)); + const glm::vec3 b = readPos(posXYZ, int(i1)); + const glm::vec3 c = readPos(posXYZ, int(i2)); + glm::vec3 n = glm::cross(b - a, c - a); + const float twiceArea = glm::length(n); + if (twiceArea < 1e-10f) continue; + n /= twiceArea; + if (nrmXYZ) { + glm::vec3 ns = readNrm(nrmXYZ, int(i0)) + readNrm(nrmXYZ, int(i1)) + readNrm(nrmXYZ, int(i2)); + if (glm::dot(ns, ns) > 1e-8f) n = glm::normalize(ns); + } + if (n.y < minSlopeDot) continue; + Triangle tri; + tri.i0 = i0; + tri.i1 = i1; + tri.i2 = i2; + tri.area = 0.5f * twiceArea; + tri.n = n; + total += tri.area; + tris.push_back(tri); + cdf.push_back(total); + } + return total > 1e-12f && !tris.empty(); +} + +int pickTriangle(const std::vector &cdf, float u) { + const float target = u * cdf.back(); + auto it = std::lower_bound(cdf.begin(), cdf.end(), target); + int idx = int(it - cdf.begin()); + if (idx >= int(cdf.size())) idx = int(cdf.size()) - 1; + return idx; +} + +void sampleOnTriangle(const float *posXYZ, const Triangle &tri, float u, float v, glm::vec3 &p) { + if (u + v > 1.f) { + u = 1.f - u; + v = 1.f - v; + } + const glm::vec3 a = readPos(posXYZ, int(tri.i0)); + const glm::vec3 b = readPos(posXYZ, int(tri.i1)); + const glm::vec3 c = readPos(posXYZ, int(tri.i2)); + p = a + u * (b - a) + v * (c - a); +} + +struct GridKey { + int x = 0, y = 0, z = 0; + bool operator==(const GridKey &o) const { return x == o.x && y == o.y && z == o.z; } +}; + +struct GridKeyHash { + size_t operator()(const GridKey &k) const { + return size_t(k.x) * 73856093u ^ size_t(k.y) * 19349663u ^ size_t(k.z) * 83492791u; + } +}; + +GridKey toKey(const glm::vec3 &p, float cell) { + return {int(std::floor(p.x / cell)), int(std::floor(p.y / cell)), int(std::floor(p.z / cell))}; +} + +bool tooClose(const glm::vec3 &p, float radius, + const std::vector &accepted, + const std::unordered_map, GridKeyHash> &grid, float cell) { + const GridKey c = toKey(p, cell); + const float r2 = radius * radius; + for (int dz = -1; dz <= 1; ++dz) { + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + GridKey k{c.x + dx, c.y + dy, c.z + dz}; + auto it = grid.find(k); + if (it == grid.end()) continue; + for (int idx : it->second) { + const glm::vec3 d = accepted[size_t(idx)].position - p; + if (glm::dot(d, d) < r2) return true; + } + } + } + } + return false; +} + +float hash01(uint32_t id) { + float h = std::fmod(float(id) * 0.61803398875f, 1.f); + if (h < 0.f) h += 1.f; + return h; +} + +float clampf(float x, float lo, float hi) { return std::min(hi, std::max(lo, x)); } + +float coverEdge(float distPx, float radiusPx) { + // 1 inside the stroke, 0 outside, ~1px antialiased fringe for nearest upscale. + return clampf(radiusPx + 0.65f - distPx, 0.f, 1.f); +} + +void stampOver(std::vector &rgba, int w, int h, int x, int y, float luma, float a) { + if (x < 0 || y < 0 || x >= w || y >= h) return; + a = clampf(a, 0.f, 1.f); + luma = clampf(luma, 0.f, 1.f); + if (a < 0.02f) return; + const size_t i = (size_t(y) * size_t(w) + size_t(x)) * 4u; + const float oa = float(rgba[i + 3]) / 255.f; + const float or_ = float(rgba[i + 0]) / 255.f; + const float og = float(rgba[i + 1]) / 255.f; + const float ob = float(rgba[i + 2]) / 255.f; + const float outA = a + oa * (1.f - a); + if (outA < 1e-4f) return; + const float nr = (luma * a + or_ * oa * (1.f - a)) / outA; + const float ng = (luma * a + og * oa * (1.f - a)) / outA; + const float nb = (luma * a + ob * oa * (1.f - a)) / outA; + rgba[i + 0] = uint8_t(std::round(clampf(nr, 0.f, 1.f) * 255.f)); + rgba[i + 1] = uint8_t(std::round(clampf(ng, 0.f, 1.f) * 255.f)); + rgba[i + 2] = uint8_t(std::round(clampf(nb, 0.f, 1.f) * 255.f)); + rgba[i + 3] = uint8_t(std::round(clampf(outA, 0.f, 1.f) * 255.f)); +} + +void stampBlade(std::vector &rgba, int atlasW, int atlasH, int ox, int frameW, int frameH, + float baseU, float height, float lean, float baseHalf, float tipHalf, float luma0, + float luma1) { + const float fw = float(frameW); + const float fh = float(frameH); + height = clampf(height, 0.12f, 1.f); + for (int y = 0; y < frameH; ++y) { + for (int x = 0; x < frameW; ++x) { + const float u = (float(x) + 0.5f) / fw; + const float v = 1.f - (float(y) + 0.5f) / fh; + if (v < -0.02f || v > height + 0.04f) continue; + const float t = clampf(v / height, 0.f, 1.f); + const float cx = baseU + lean * t * t; + const float half = baseHalf * (1.f - t * 0.82f) + tipHalf * t; + const float dist = std::abs(u - cx) * fw; + float cov = coverEdge(dist, half * fw); + cov *= 1.f - clampf((v - height) / 0.03f, 0.f, 1.f); + cov *= clampf((v + 0.02f) / 0.05f, 0.f, 1.f); + if (cov > 0.5f) cov = 1.f; + else if (cov < 0.25f) cov = 0.f; + if (cov <= 0.f) continue; + const float side = (u - cx) * fw; // +right + float luma = luma0 + (luma1 - luma0) * t; + if (side > 0.15f) luma *= 0.78f; // 1px-ish self-shadow on the right + stampOver(rgba, atlasW, atlasH, ox + x, y, luma, cov); + } + } +} + +struct BladeDesc { + float u; + float height; + float lean; + float baseHalf; + float tipHalf; + float luma0; + float luma1; +}; + +void stampTuft(std::vector &rgba, int atlasW, int atlasH, int ox, int frameW, int frameH, + float wind) { + // Tiny root pad only — a solid mound turns overlapping cards into a flat lime slab. + const float fw = float(frameW); + const float fh = float(frameH); + for (int y = 0; y < frameH; ++y) { + for (int x = 0; x < frameW; ++x) { + const float u = (float(x) + 0.5f) / fw; + const float v = 1.f - (float(y) + 0.5f) / fh; + if (v > 0.16f) continue; + const float cx = 0.50f + wind * 0.02f; + const float hw = 0.16f * (1.f - v / 0.16f); + float cov = coverEdge(std::abs(u - cx) * fw, hw * fw); + if (cov > 0.5f) cov = 1.f; + else if (cov < 0.25f) cov = 0.f; + if (cov > 0.f) stampOver(rgba, atlasW, atlasH, ox + x, y, 0.72f, cov); + } + } + + // 7 separated blades with gaps so later cards show through. Outline then fill. + const BladeDesc blades[] = { + {0.50f, 0.98f, 0.02f, 0.070f, 0.018f, 0.86f, 1.00f}, {0.38f, 0.90f, -0.14f, 0.062f, 0.016f, 0.82f, 0.97f}, + {0.62f, 0.92f, 0.16f, 0.062f, 0.016f, 0.82f, 0.97f}, {0.28f, 0.74f, -0.26f, 0.056f, 0.016f, 0.78f, 0.93f}, + {0.72f, 0.76f, 0.28f, 0.056f, 0.016f, 0.78f, 0.93f}, {0.44f, 0.84f, -0.06f, 0.050f, 0.014f, 0.84f, 0.98f}, + {0.56f, 0.86f, 0.08f, 0.050f, 0.014f, 0.84f, 0.98f}, + }; + const float outline = 1.15f / fw; + for (const BladeDesc &b : blades) { + const float lean = b.lean + wind * 0.32f; + const float u = b.u + wind * 0.03f; + stampBlade(rgba, atlasW, atlasH, ox, frameW, frameH, u, b.height, lean, b.baseHalf + outline, + b.tipHalf + outline * 0.5f, 0.42f, 0.55f); + stampBlade(rgba, atlasW, atlasH, ox, frameW, frameH, u, b.height, lean, b.baseHalf, b.tipHalf, + b.luma0, b.luma1); + } +} + +} // namespace + +int paramCount() { return int(kParamSlots.size()); } + +std::string paramName(int index) { + if (index < 0 || index >= int(kParamSlots.size())) return {}; + return kParamSlots[size_t(index)]; +} + +void bindDefaults(Shader *shader) { + if (!shader) throw eve::Exception("grass::bindDefaults: null shader"); + shader->declareFloat("time"); + shader->declareFloat("frameDuration"); + shader->declareFloat("grassWidth"); + shader->declareFloat("grassHeight"); + shader->declareFloat("alphaCutoff"); + shader->declareFloat("alwaysDark"); + shader->declareVec3("lightGreen"); + shader->declareVec3("darkGreen"); + shader->declareFloat("frameCount"); + shader->declareFloat("atlasCols"); + shader->declareFloat("atlasRows"); + shader->declareFloat("grassVariantCount"); + shader->declareFloat("leafVariantCount"); + shader->declareFloat("leafRowOffset"); + shader->sendFloat("time", 0.f); + shader->sendFloat("frameDuration", 0.12f); + shader->sendFloat("grassWidth", 0.62f); + shader->sendFloat("grassHeight", 0.95f); + shader->sendFloat("alphaCutoff", 0.35f); + shader->sendFloat("alwaysDark", 0.f); + shader->sendVec3("lightGreen", 0.58f, 0.84f, 0.26f); + shader->sendVec3("darkGreen", 0.10f, 0.28f, 0.12f); + shader->sendFloat("frameCount", 4.f); + shader->sendFloat("atlasCols", 2.f); + shader->sendFloat("atlasRows", 2.f); + shader->sendFloat("grassVariantCount", 1.f); + shader->sendFloat("leafVariantCount", 1.f); + shader->sendFloat("leafRowOffset", 0.f); +} + +void bindAtlasLayout(Shader *shader, const PackedAtlasInfo &info) { + if (!shader) throw eve::Exception("grass::bindAtlasLayout: null shader"); + shader->sendFloat("frameCount", float(std::max(info.frames, 1))); + shader->sendFloat("atlasCols", float(std::max(info.atlasCols, 1))); + shader->sendFloat("atlasRows", float(std::max(info.atlasRows, 1))); + shader->sendFloat("grassVariantCount", float(std::max(info.grassVariants, 1))); + shader->sendFloat("leafVariantCount", float(std::max(info.leafVariants, 1))); + shader->sendFloat("leafRowOffset", float(std::max(info.leafRowOffset, 0))); +} + +void bindLayer(Shader *shader, bool alwaysDark) { + if (!shader) throw eve::Exception("grass::bindLayer: null shader"); + shader->sendFloat("alwaysDark", alwaysDark ? 1.f : 0.f); +} + +void setTime(Shader *shader, float seconds) { + if (!shader) throw eve::Exception("grass::setTime: null shader"); + shader->sendFloat("time", seconds); +} + +void setFrameDuration(Shader *shader, float seconds) { + if (!shader) throw eve::Exception("grass::setFrameDuration: null shader"); + shader->sendFloat("frameDuration", seconds > 1e-4f ? seconds : 1e-4f); +} + +Shader *createShader(Graphics *gfx) { + if (!gfx) throw eve::Exception("grass::createShader: null graphics"); + auto vert = copySpv(mesh3d_grass_vert_spv, mesh3d_grass_vert_spv_count); + auto frag = copySpv(mesh3d_grass_frag_spv, mesh3d_grass_frag_spv_count); + Shader *sh = gfx->newMeshShaderFromSpv(vert, frag); + if (!sh || !sh->gpuHandle) + throw eve::Exception("grass::createShader: failed to create grass shader"); + bindDefaults(sh); + return sh; +} + +int swayFrame(float time, float frameDuration, uint32_t instanceId, int frameCount) { + if (frameCount < 1) frameCount = 1; + const float dur = frameDuration > 1e-4f ? frameDuration : 1e-4f; + const float t = time / dur + hash01(instanceId) * float(frameCount); + int f = int(std::floor(t)) % frameCount; + if (f < 0) f += frameCount; + return f; +} + +int swayAtlasWidth(int frameW, int frames) { return std::max(frameW, 1) * std::max(frames, 1); } +int swayAtlasHeight(int frameH) { return std::max(frameH, 1); } + +void makeSwayAtlasRGBA(int frameW, int frameH, int frames, std::vector &rgbaOut) { + frameW = std::max(frameW, 8); + frameH = std::max(frameH, 8); + frames = std::max(frames, 1); + const int w = swayAtlasWidth(frameW, frames); + const int h = swayAtlasHeight(frameH); + rgbaOut.assign(size_t(w * h * 4), 0); + + for (int f = 0; f < frames; ++f) { + const float wind = (float(f) - 1.5f) / 1.5f; // -1 .. +1 across 4 frames + stampTuft(rgbaOut, w, h, f * frameW, frameW, frameH, wind); + } +} + +Texture *createSwayAtlas(Graphics *gfx, int frameW, int frameH, int frames) { + if (!gfx) throw eve::Exception("grass::createSwayAtlas: null graphics"); + std::vector rgba; + makeSwayAtlasRGBA(frameW, frameH, frames, rgba); + TextureCreateInfo info; + info.sampler = TextureSampler::nearest(); + return gfx->newTexture(swayAtlasWidth(frameW, frames), swayAtlasHeight(frameH), rgba.data(), + info); +} + +namespace { + +bool readWholeFile(const std::string &path, std::vector &out) { + std::ifstream in(path, std::ios::binary); + if (!in) return false; + out.assign(std::istreambuf_iterator(in), std::istreambuf_iterator()); + return in.good() || !out.empty(); +} + +void maskToTintable(std::vector &rgba) { + for (size_t i = 0; i + 3 < rgba.size(); i += 4) { + const uint8_t luma = std::max(rgba[i], std::max(rgba[i + 1], rgba[i + 2])); + const uint8_t a = std::max(luma, rgba[i + 3]); + rgba[i + 0] = 255; + rgba[i + 1] = 255; + rgba[i + 2] = 255; + rgba[i + 3] = a; + } +} + +bool loadSwayMaskPng(const std::string &path, std::vector &rgba, int &w, int &h) { + std::vector raw; + if (!readWholeFile(path, raw)) return false; + eve::image::Image::create(); + eve::data::ByteData bytes(raw.data(), raw.size()); + std::unique_ptr img(eve::image::Image::create()->newImageData(&bytes)); + if (!img) return false; + w = img->getWidth(); + h = img->getHeight(); + if (w < 2 || h < 2) return false; + const size_t n = size_t(w) * size_t(h) * 4u; + if (img->getSize() < n) return false; + rgba.resize(n); + std::memcpy(rgba.data(), img->getData(), n); + maskToTintable(rgba); + return true; +} + +void blitRgba(std::vector &dst, int dw, int dh, int dx, int dy, + const std::vector &src, int sw, int sh) { + for (int y = 0; y < sh; ++y) { + const int ty = dy + y; + if (ty < 0 || ty >= dh) continue; + for (int x = 0; x < sw; ++x) { + const int tx = dx + x; + if (tx < 0 || tx >= dw) continue; + const size_t si = (size_t(y) * size_t(sw) + size_t(x)) * 4u; + const size_t di = (size_t(ty) * size_t(dw) + size_t(tx)) * 4u; + dst[di + 0] = src[si + 0]; + dst[di + 1] = src[si + 1]; + dst[di + 2] = src[si + 2]; + dst[di + 3] = src[si + 3]; + } + } +} + +} // namespace + +void packSwayAtlasRGBA(const std::vector &grassFiles, + const std::vector &leafFiles, std::vector &rgbaOut, + PackedAtlasInfo &info) { + if (grassFiles.empty()) throw eve::Exception("grass::packSwayAtlasRGBA: no grass atlas files"); + + struct Loaded { + std::vector rgba; + int w = 0; + int h = 0; + }; + auto loadOne = [](const std::string &path) { + Loaded img; + if (!loadSwayMaskPng(path, img.rgba, img.w, img.h)) + throw eve::Exception("grass::packSwayAtlasRGBA: failed to load '%s'", path.c_str()); + return img; + }; + + std::vector grass; + grass.reserve(grassFiles.size()); + for (const auto &p : grassFiles) grass.push_back(loadOne(p)); + std::vector leaf; + leaf.reserve(leafFiles.size()); + for (const auto &p : leafFiles) leaf.push_back(loadOne(p)); + + const int tw = grass.front().w; + const int th = grass.front().h; + auto checkSize = [tw, th](const Loaded &img, const char *kind) { + if (img.w != tw || img.h != th) + throw eve::Exception("grass::packSwayAtlasRGBA: %s atlas size mismatch (%dx%d vs %dx%d)", + kind, img.w, img.h, tw, th); + }; + for (const auto &img : grass) checkSize(img, "grass"); + for (const auto &img : leaf) checkSize(img, "leaf"); + + const int nGrass = int(grass.size()); + const int nLeaf = int(leaf.size()); + const int nX = std::max(nGrass, std::max(nLeaf, 1)); + const int nY = nLeaf > 0 ? 2 : 1; + info.frames = 4; + info.grassVariants = nGrass; + info.leafVariants = nLeaf > 0 ? nLeaf : 1; + info.leafRowOffset = nLeaf > 0 ? 2 : 0; + info.atlasCols = nX * 2; + info.atlasRows = nY * 2; + info.width = nX * tw; + info.height = nY * th; + rgbaOut.assign(size_t(info.width) * size_t(info.height) * 4u, 0); + + for (int i = 0; i < nGrass; ++i) + blitRgba(rgbaOut, info.width, info.height, i * tw, 0, grass[size_t(i)].rgba, tw, th); + for (int i = 0; i < nLeaf; ++i) + blitRgba(rgbaOut, info.width, info.height, i * tw, th, leaf[size_t(i)].rgba, tw, th); +} + +Texture *createSwayAtlasFromFiles(Graphics *gfx, const std::vector &grassFiles, + const std::vector &leafFiles, + PackedAtlasInfo *infoOut) { + if (!gfx) throw eve::Exception("grass::createSwayAtlasFromFiles: null graphics"); + PackedAtlasInfo info; + std::vector rgba; + packSwayAtlasRGBA(grassFiles, leafFiles, rgba, info); + if (infoOut) *infoOut = info; + TextureCreateInfo texInfo; + texInfo.sampler = TextureSampler::linear(); + return gfx->newTexture(info.width, info.height, rgba.data(), texInfo); +} + +std::vector sampleHalton(const float *posXYZ, const float *nrmXYZ, int vertexCount, + const uint32_t *indices, int indexCount, int count, uint32_t seed, + float minSlopeDot) { + std::vector out; + if (count <= 0) return out; + std::vector tris; + std::vector cdf; + if (!buildTriangles(posXYZ, nrmXYZ, vertexCount, indices, indexCount, minSlopeDot, tris, cdf)) + return out; + + out.reserve(size_t(count)); + for (int i = 0; i < count; ++i) { + const uint32_t n = mixSeed(seed, uint32_t(i + 1)); + const float uTri = wrap01(radicalInverse(n, 2)); + const float u = wrap01(radicalInverse(n, 3)); + const float v = wrap01(radicalInverse(n, 5)); + const Triangle &tri = tris[size_t(pickTriangle(cdf, uTri))]; + Point p; + sampleOnTriangle(posXYZ, tri, u, v, p.position); + p.normal = tri.n; + p.id = uint32_t(i); + p.scale = 0.85f + 0.3f * hash01(p.id + seed); + out.push_back(p); + } + return out; +} + +std::vector samplePoisson(const float *posXYZ, const float *nrmXYZ, int vertexCount, + const uint32_t *indices, int indexCount, + const SampleParams ¶ms) { + std::vector accepted; + if (params.maxPoints <= 0 || params.radius <= 1e-6f) return accepted; + + std::vector tris; + std::vector cdf; + if (!buildTriangles(posXYZ, nrmXYZ, vertexCount, indices, indexCount, params.minSlopeDot, tris, + cdf)) + return accepted; + + const float cell = params.radius; + std::unordered_map, GridKeyHash> grid; + const int attempts = std::max(params.maxPoints * 24, params.maxPoints + 16); + + for (int i = 0; i < attempts && int(accepted.size()) < params.maxPoints; ++i) { + const uint32_t n = mixSeed(params.seed, uint32_t(i + 1)); + const float uTri = wrap01(radicalInverse(n, 2)); + const float u = wrap01(radicalInverse(n, 3)); + const float v = wrap01(radicalInverse(n, 5)); + const Triangle &tri = tris[size_t(pickTriangle(cdf, uTri))]; + glm::vec3 pos; + sampleOnTriangle(posXYZ, tri, u, v, pos); + if (tooClose(pos, params.radius, accepted, grid, cell)) continue; + + Point p; + p.position = pos; + p.normal = tri.n; + p.id = uint32_t(accepted.size()); + p.scale = 0.85f + 0.3f * hash01(p.id + params.seed); + const int idx = int(accepted.size()); + accepted.push_back(p); + grid[toKey(pos, cell)].push_back(idx); + } + return accepted; +} + +BillboardMesh buildBillboards(const std::vector &points, float width, float height, + bool alwaysDark) { + (void)width; + (void)height; + BillboardMesh mesh; + const size_t n = points.size(); + mesh.posXYZ.reserve(n * 4 * 3); + mesh.nrmXYZ.reserve(n * 4 * 3); + mesh.uvST.reserve(n * 4 * 2); + mesh.indices.reserve(n * 6); + + // Local corners: (0,0) bottom-left, (1,0) bottom-right, (1,1) top-right, (0,1) top-left. + // Root is the bottom-center of the rectangle, i.e. UV (0.5, 0). + const float cu[4] = {0.f, 1.f, 1.f, 0.f}; + const float cv[4] = {0.f, 0.f, 1.f, 1.f}; + const uint32_t corners[6] = {0, 1, 2, 0, 2, 3}; + + for (size_t i = 0; i < n; ++i) { + const Point &p = points[i]; + const uint32_t base = uint32_t(i * 4); + for (int c = 0; c < 4; ++c) { + mesh.posXYZ.push_back(p.position.x); + mesh.posXYZ.push_back(p.position.y); + mesh.posXYZ.push_back(p.position.z); + mesh.nrmXYZ.push_back(float(p.id)); + mesh.nrmXYZ.push_back(p.scale > 1e-3f ? p.scale : 1.f); + mesh.nrmXYZ.push_back(alwaysDark ? 1.f : 0.f); + mesh.uvST.push_back(cu[c]); + mesh.uvST.push_back(cv[c]); + } + for (uint32_t k : corners) mesh.indices.push_back(base + k); + } + return mesh; +} + +void makePlane(float sizeX, float sizeZ, int segX, int segZ, std::vector &posXYZ, + std::vector &nrmXYZ, std::vector &indices) { + segX = std::max(segX, 1); + segZ = std::max(segZ, 1); + sizeX = std::max(sizeX, 1e-3f); + sizeZ = std::max(sizeZ, 1e-3f); + const int nx = segX + 1; + const int nz = segZ + 1; + posXYZ.clear(); + nrmXYZ.clear(); + indices.clear(); + posXYZ.reserve(size_t(nx * nz * 3)); + nrmXYZ.reserve(size_t(nx * nz * 3)); + indices.reserve(size_t(segX * segZ * 6)); + + for (int z = 0; z < nz; ++z) { + for (int x = 0; x < nx; ++x) { + const float px = (float(x) / float(segX) - 0.5f) * sizeX; + const float pz = (float(z) / float(segZ) - 0.5f) * sizeZ; + posXYZ.push_back(px); + posXYZ.push_back(0.f); + posXYZ.push_back(pz); + nrmXYZ.push_back(0.f); + nrmXYZ.push_back(1.f); + nrmXYZ.push_back(0.f); + } + } + for (int z = 0; z < segZ; ++z) { + for (int x = 0; x < segX; ++x) { + const uint32_t i0 = uint32_t(z * nx + x); + const uint32_t i1 = i0 + 1; + const uint32_t i2 = i0 + uint32_t(nx); + const uint32_t i3 = i2 + 1; + indices.push_back(i0); + indices.push_back(i2); + indices.push_back(i1); + indices.push_back(i1); + indices.push_back(i2); + indices.push_back(i3); + } + } +} + +} // namespace eve::graphics::grass + +namespace eve::graphics { + +GrassField::GrassField(Graphics *gfx) : gfx_(gfx) { + if (!gfx_) throw eve::Exception("GrassField: null graphics"); +} + +void GrassField::bake(const float *posXYZ, const float *nrmXYZ, int vertexCount, + const uint32_t *indices, int indexCount, const BakeParams ¶ms) { + if (!gfx_) throw eve::Exception("GrassField::bake: null graphics"); + + grass::SampleParams denseP; + denseP.radius = params.denseRadius; + denseP.maxPoints = params.maxDense; + denseP.seed = params.seed; + denseP.minSlopeDot = params.minSlopeDot; + + grass::SampleParams sparseP = denseP; + sparseP.radius = params.sparseRadius; + sparseP.maxPoints = params.maxSparse; + sparseP.seed = params.seed * 7477u + 13u; + + const auto densePts = + grass::samplePoisson(posXYZ, nrmXYZ, vertexCount, indices, indexCount, denseP); + const auto sparsePts = + grass::samplePoisson(posXYZ, nrmXYZ, vertexCount, indices, indexCount, sparseP); + denseCount_ = int(densePts.size()); + sparseCount_ = int(sparsePts.size()); + + const auto denseMesh = grass::buildBillboards(densePts, params.width, params.height, false); + const auto sparseMesh = grass::buildBillboards(sparsePts, params.width, params.height, true); + + if (!shader_) shader_ = grass::createShader(gfx_); + grass::PackedAtlasInfo layout; + if (!params.grassAtlasFiles.empty()) { + atlas_ = grass::createSwayAtlasFromFiles(gfx_, params.grassAtlasFiles, params.leafAtlasFiles, + &layout); + } else { + if (!atlas_) + atlas_ = grass::createSwayAtlas(gfx_, params.atlasFrameW, params.atlasFrameH, + params.atlasFrames); + layout.frames = std::max(params.atlasFrames, 1); + layout.atlasCols = layout.frames; + layout.atlasRows = 1; + layout.grassVariants = 1; + layout.leafVariants = 1; + layout.leafRowOffset = 0; + } + + grass::bindDefaults(shader_); + grass::bindAtlasLayout(shader_, layout); + shader_->sendFloat("grassWidth", params.width); + shader_->sendFloat("grassHeight", params.height); + shader_->sendFloat("frameDuration", frameDuration_); + shader_->sendFloat("time", time_); + + denseMesh_ = nullptr; + sparseMesh_ = nullptr; + if (!denseMesh.indices.empty()) + denseMesh_ = gfx_->newMeshFromArrays(denseMesh.posXYZ.data(), denseMesh.nrmXYZ.data(), + denseMesh.uvST.data(), int(denseMesh.posXYZ.size() / 3), + denseMesh.indices.data(), int(denseMesh.indices.size())); + if (!sparseMesh.indices.empty()) + sparseMesh_ = + gfx_->newMeshFromArrays(sparseMesh.posXYZ.data(), sparseMesh.nrmXYZ.data(), + sparseMesh.uvST.data(), int(sparseMesh.posXYZ.size() / 3), + sparseMesh.indices.data(), int(sparseMesh.indices.size())); +} + +void GrassField::bakePlane(float sizeX, float sizeZ, int segX, int segZ) { + bakePlane(sizeX, sizeZ, segX, segZ, BakeParams{}); +} + +void GrassField::bakePlane(float sizeX, float sizeZ, int segX, int segZ, const BakeParams ¶ms) { + std::vector pos, nrm; + std::vector idx; + grass::makePlane(sizeX, sizeZ, segX, segZ, pos, nrm, idx); + bake(pos.data(), nrm.data(), int(pos.size() / 3), idx.data(), int(idx.size()), params); +} + +void GrassField::update(float dt) { + time_ += dt; + if (shader_) grass::setTime(shader_, time_); +} + +void GrassField::setTime(float seconds) { + time_ = seconds; + if (shader_) grass::setTime(shader_, time_); +} + +void GrassField::setFrameDuration(float seconds) { + frameDuration_ = seconds > 1e-4f ? seconds : 1e-4f; + if (shader_) grass::setFrameDuration(shader_, frameDuration_); +} + +void GrassField::draw() { draw(glm::mat4(1.f)); } + +void GrassField::draw(const glm::mat4 &model) { + if (!gfx_ || !shader_ || !atlas_) return; + const Color tint(1.f, 1.f, 1.f, 1.f); + grass::setTime(shader_, time_); + grass::setFrameDuration(shader_, frameDuration_); + if (denseMesh_) { + grass::bindLayer(shader_, false); + gfx_->drawMeshShader(denseMesh_, model, atlas_, tint, shader_); + } + if (sparseMesh_) { + grass::bindLayer(shader_, true); + gfx_->drawMeshShader(sparseMesh_, model, atlas_, tint, shader_); + } +} + +} // namespace eve::graphics diff --git a/src/modules/graphics/Grass.h b/src/modules/graphics/Grass.h new file mode 100644 index 00000000..3cb80544 --- /dev/null +++ b/src/modules/graphics/Grass.h @@ -0,0 +1,184 @@ +#pragma once + +#include "graphics/Shader.h" + +#include +#include +#include + +#include + +namespace eve::graphics { + +class Graphics; +class Mesh; +class Texture; + +/** + * t3ssel8r-style stylized grass. + * + * Pipeline: + * 1. Blue-noise (Poisson disk) or Halton samples on a terrain mesh + * 2. Expand a root-pivoted rectangle into a GPU mesh (one quad per sample) + * 3. Vertex shader cylindrical-billboards each card around its root + * 4. 4-frame sway atlas, phase-offset by instance id + * 5. Shadow map → mix(light green, dark green) + * 6. Sparse second pass, always dark, as decoration + */ +namespace grass { + +struct Point { + glm::vec3 position{0.f}; + glm::vec3 normal{0.f, 1.f, 0.f}; + uint32_t id = 0; + float scale = 1.f; +}; + +struct SampleParams { + float radius = 0.14f; + int maxPoints = 8192; + uint32_t seed = 1; + /** Skip faces whose Y-up slope is below this (0 = keep walls). */ + float minSlopeDot = 0.25f; +}; + +struct BillboardMesh { + std::vector posXYZ; + std::vector nrmXYZ; + std::vector uvST; + std::vector indices; +}; + +/** Fast Poisson-disk / dart-throwing blue noise on a triangle mesh. */ +std::vector samplePoisson(const float *posXYZ, const float *nrmXYZ, int vertexCount, + const uint32_t *indices, int indexCount, + const SampleParams ¶ms = {}); + +/** Area-weighted Halton samples (deterministic, evenly spread). */ +std::vector sampleHalton(const float *posXYZ, const float *nrmXYZ, int vertexCount, + const uint32_t *indices, int indexCount, int count, + uint32_t seed = 1, float minSlopeDot = 0.25f); + +/** + * Expand a unit rectangle per point. Vertex layout: + * pos = grass root + * uv = quad corner in [0,1]^2 (0.5, 0) is the root + * normal.x = instance id, normal.y = scale, normal.z = alwaysDark (0/1) + */ +BillboardMesh buildBillboards(const std::vector &points, float width = 0.62f, + float height = 0.95f, bool alwaysDark = false); + +/** Discrete 4-frame index with a per-instance phase offset. */ +int swayFrame(float time, float frameDuration, uint32_t instanceId, int frameCount = 4); + +/** + * Procedural 4-frame fallback atlas (horizontal strip). GPU paths should load + * authored 2x2 PNG masks via packSwayAtlasRGBA / createSwayAtlasFromFiles. + */ +void makeSwayAtlasRGBA(int frameW, int frameH, int frames, std::vector &rgbaOut); +int swayAtlasWidth(int frameW, int frames); +int swayAtlasHeight(int frameH); + +Texture *createSwayAtlas(Graphics *gfx, int frameW = 64, int frameH = 64, int frames = 4); + +/** Layout of a packed 2x2-per-variant sway atlas (4 grass + 2 leaf typical). */ +struct PackedAtlasInfo { + int width = 0; + int height = 0; + int atlasCols = 2; + int atlasRows = 2; + int grassVariants = 1; + int leafVariants = 1; + int leafRowOffset = 0; + int frames = 4; +}; + +/** Load white-on-black (or RGBA) 2x2 sway PNGs and pack them into one atlas. */ +void packSwayAtlasRGBA(const std::vector &grassFiles, + const std::vector &leafFiles, std::vector &rgbaOut, + PackedAtlasInfo &info); +Texture *createSwayAtlasFromFiles(Graphics *gfx, const std::vector &grassFiles, + const std::vector &leafFiles, + PackedAtlasInfo *infoOut = nullptr); + +Shader *createShader(Graphics *gfx); +void bindDefaults(Shader *shader); +void bindLayer(Shader *shader, bool alwaysDark); +void bindAtlasLayout(Shader *shader, const PackedAtlasInfo &info); +void setTime(Shader *shader, float seconds); +void setFrameDuration(Shader *shader, float seconds); + +int paramCount(); +std::string paramName(int index); + +/** Unit XZ plane (Y-up) for tests / demos. */ +void makePlane(float sizeX, float sizeZ, int segX, int segZ, std::vector &posXYZ, + std::vector &nrmXYZ, std::vector &indices); + +} // namespace grass + +/** + * Dense grass + sparse dark tufts on a mesh. Caller owns GrassField*; + * GPU Mesh / Shader / Texture are owned by Graphics. + */ +class GrassField { +public: + struct BakeParams { + /** Poisson spacing. Keep this well below `width` so tufts overlap. */ + float denseRadius = 0.14f; + float sparseRadius = 0.62f; + int maxDense = 8192; + int maxSparse = 384; + uint32_t seed = 1; + float width = 0.62f; + float height = 0.95f; + float minSlopeDot = 0.25f; + int atlasFrameW = 64; + int atlasFrameH = 64; + int atlasFrames = 4; + /** Authored 2x2 sway masks (4 grass + 2 leaf). Empty = procedural strip. */ + std::vector grassAtlasFiles; + std::vector leafAtlasFiles; + }; + + explicit GrassField(Graphics *gfx); + ~GrassField() = default; + + GrassField(const GrassField &) = delete; + GrassField &operator=(const GrassField &) = delete; + + void bake(const float *posXYZ, const float *nrmXYZ, int vertexCount, const uint32_t *indices, + int indexCount, const BakeParams ¶ms); + void bakePlane(float sizeX, float sizeZ, int segX, int segZ); + void bakePlane(float sizeX, float sizeZ, int segX, int segZ, const BakeParams ¶ms); + + void update(float dt); + void setTime(float seconds); + float getTime() const { return time_; } + void setFrameDuration(float seconds); + float getFrameDuration() const { return frameDuration_; } + + void draw(const glm::mat4 &model); + void draw(); + + Mesh *getDenseMesh() const { return denseMesh_; } + Mesh *getSparseMesh() const { return sparseMesh_; } + Shader *getShader() const { return shader_; } + Texture *getAtlas() const { return atlas_; } + + int getDenseCount() const { return denseCount_; } + int getSparseCount() const { return sparseCount_; } + +private: + Graphics *gfx_ = nullptr; + Shader *shader_ = nullptr; + Texture *atlas_ = nullptr; + Mesh *denseMesh_ = nullptr; + Mesh *sparseMesh_ = nullptr; + int denseCount_ = 0; + int sparseCount_ = 0; + float time_ = 0.f; + float frameDuration_ = 0.12f; +}; + +} // namespace eve::graphics diff --git a/src/modules/graphics/Shader.h b/src/modules/graphics/Shader.h index f03de8c2..5382da7f 100644 --- a/src/modules/graphics/Shader.h +++ b/src/modules/graphics/Shader.h @@ -23,6 +23,9 @@ namespace eve::graphics { * * **Hair/fur cards** (`newHairShader` / `newHairShaderFromSpv`): * Same layout as mesh shaders; pipeline uses alpha blend, no back-face cull, no depth write. + * + * **Stylized grass** (`newGrassShader`): Mesh3D layout + CSM shadow bindings. + * Alpha-tested billboards (depth write on). See graphics/Grass.h. */ class Shader { public: diff --git a/src/modules/graphics/shaders/mesh3d_grass.frag b/src/modules/graphics/shaders/mesh3d_grass.frag new file mode 100644 index 00000000..b5c89079 --- /dev/null +++ b/src/modules/graphics/shaders/mesh3d_grass.frag @@ -0,0 +1,143 @@ +#version 450 +// t3ssel8r-style grass fragment: 4-frame sway atlas + shadow-mapped two-tone green. +// Push: [0] time [1] frameDuration [2] width [3] height +// [4] alphaCutoff [5] alwaysDark +// [6..8] lightGreen [9..11] darkGreen [12] frameCount +// [13] atlasCols [14] atlasRows +// [15] grassVariantCount [16] leafVariantCount [17] leafRowOffset +// Authored atlases are 2x2 per variant. Packed layout (4 grass + 2 leaf): +// 8 frame-cols x 4 frame-rows; grass variants along the top two rows, +// leaf variants along the bottom two. Strip atlases use atlasRows=1. + +layout(location = 0) in vec2 vUV; +layout(location = 1) in vec3 vWorldPos; +layout(location = 2) in vec3 vViewPos; +layout(location = 3) in vec3 vRootPos; +layout(location = 4) in float vInstanceId; +layout(location = 5) in vec4 vTint; +layout(location = 6) in float vAlwaysDark; + +struct Light3D { + vec4 posRadius; + vec4 color; +}; + +layout(set = 0, binding = 0, std140) uniform Frame { + mat4 mvp; + mat4 model; + vec4 lightDirIntensity; + vec4 lightColor; + vec4 tint; + vec4 cameraPos; + vec4 ambient; + Light3D lights[8]; + vec4 texBomb; + vec4 parallax; + mat4 view; + vec4 clipInfo; +} ubo; + +layout(set = 0, binding = 1) uniform sampler2D albedoSampler; + +layout(set = 0, binding = 4, std140) uniform ShadowFrame { + mat4 lightVP[3]; + vec4 splits; + vec4 bias; + vec4 cascadeBias; + vec4 cascadeTexel; +} shadow; + +layout(set = 0, binding = 5) uniform sampler2DArrayShadow shadowMap; + +layout(push_constant) uniform Externals { + float data[32]; +} u; + +layout(location = 0) out vec4 outColor; + +float sampleShadowCascade(vec3 worldPos, int cascade, float ndcBias) { + vec4 lightClip = shadow.lightVP[cascade] * vec4(worldPos, 1.0); + vec3 ndc = lightClip.xyz / max(lightClip.w, 1e-6); + vec2 uv = ndc.xy * 0.5 + 0.5; + float depth = ndc.z; + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 || depth < 0.0 || depth > 1.0) + return 1.0; + + vec2 texel = 1.0 / vec2(textureSize(shadowMap, 0).xy); + const vec2 kRotOffsets[9] = vec2[9]( + vec2(-0.5, -0.5), vec2(0.0, -0.6), vec2(0.5, -0.5), + vec2(-0.6, 0.0), vec2(0.0, 0.0), vec2(0.6, 0.0), + vec2(-0.5, 0.5), vec2(0.0, 0.6), vec2(0.5, 0.5) + ); + float sum = 0.0; + float zref = depth - ndcBias; + for (int i = 0; i < 9; ++i) { + vec2 s = uv + kRotOffsets[i] * texel; + if (s.x < 0.0 || s.x > 1.0 || s.y < 0.0 || s.y > 1.0) + continue; + sum += texture(shadowMap, vec4(s, float(cascade), zref)); + } + return sum / 9.0; +} + +float cascadeNdcBias(int cascade) { + float b = cascade == 0 ? shadow.cascadeBias.x + : (cascade == 1 ? shadow.cascadeBias.y : shadow.cascadeBias.z); + return b > 1e-8 ? b : shadow.bias.x; +} + +float sampleGrassShadow(vec3 worldPos, float viewDepth) { + if (shadow.bias.y < 0.5 || shadow.bias.z < 0.5 || shadow.splits.w < 1e-4) + return 1.0; + + int cascade = 2; + if (viewDepth < shadow.splits.x) cascade = 0; + else if (viewDepth < shadow.splits.y) cascade = 1; + + float vis = sampleShadowCascade(worldPos, cascade, cascadeNdcBias(cascade)); + vis = mix(1.0, vis, clamp(shadow.splits.w, 0.0, 1.0)); + return vis; +} + +void main() { + float frames = max(u.data[12], 1.0); + float duration = max(u.data[1], 1e-4); + float hash = fract(sin(vInstanceId * 12.9898) * 43758.5453); + float t = u.data[0] / duration + hash * frames; + int frame = int(mod(floor(t), frames)); + + int cols = int(max(u.data[13], 1.0)); + int rows = int(max(u.data[14], 1.0)); + int grassVars = int(max(u.data[15], 1.0)); + int leafVars = int(max(u.data[16], 1.0)); + int leafRowOffset = int(u.data[17] + 0.5); + bool leaf = (vAlwaysDark > 0.5) || (u.data[5] > 0.5); + int nVar = leaf ? leafVars : grassVars; + int variant = int(mod(vInstanceId, float(nVar))); + + // inUV.y = 0 at the root (bottom). Vulkan/image v=0 is the top row. + vec2 atlasUV; + if (rows <= 1) { + atlasUV = vec2((vUV.x + float(frame)) / frames, 1.0 - vUV.y); + } else { + int localCol = frame % 2; + int localRow = frame / 2; + int col = variant * 2 + localCol; + int row = (leaf ? leafRowOffset : 0) + localRow; + atlasUV = vec2((float(col) + vUV.x) / float(cols), + (float(row) + (1.0 - vUV.y)) / float(rows)); + } + vec4 mask = texture(albedoSampler, atlasUV) * vTint; + if (mask.a < max(u.data[4], 0.01)) + discard; + + float viewDepth = max(-vViewPos.z, 0.0); + float vis = sampleGrassShadow(vRootPos, viewDepth); + if (u.data[5] > 0.5 || vAlwaysDark > 0.5) + vis = 0.0; + + vec3 lightGreen = vec3(u.data[6], u.data[7], u.data[8]); + vec3 darkGreen = vec3(u.data[9], u.data[10], u.data[11]); + vec3 grassCol = mix(darkGreen, lightGreen, vis); + outColor = vec4(grassCol * mask.rgb, mask.a); +} diff --git a/src/modules/graphics/shaders/mesh3d_grass.frag.spv b/src/modules/graphics/shaders/mesh3d_grass.frag.spv new file mode 100644 index 00000000..c3a5b710 Binary files /dev/null and b/src/modules/graphics/shaders/mesh3d_grass.frag.spv differ diff --git a/src/modules/graphics/shaders/mesh3d_grass.vert b/src/modules/graphics/shaders/mesh3d_grass.vert new file mode 100644 index 00000000..98ec2b0e --- /dev/null +++ b/src/modules/graphics/shaders/mesh3d_grass.vert @@ -0,0 +1,77 @@ +#version 450 +// t3ssel8r-style grass billboard vertex. +// inPos = grass ROOT (object space) +// inNormal.x = instance id, inNormal.y = scale, inNormal.z = alwaysDark (0/1) +// inUV = quad corner in [0,1]^2; (0.5, 0) is the root (bottom center) + +struct Light3D { + vec4 posRadius; + vec4 color; +}; + +layout(location = 0) in vec3 inPos; +layout(location = 1) in vec3 inNormal; +layout(location = 2) in vec2 inUV; + +layout(set = 0, binding = 0, std140) uniform Frame { + mat4 mvp; + mat4 model; + vec4 lightDirIntensity; + vec4 lightColor; + vec4 tint; + vec4 cameraPos; + vec4 ambient; + Light3D lights[8]; + vec4 texBomb; + vec4 parallax; + mat4 view; + vec4 clipInfo; +} ubo; + +layout(push_constant) uniform Externals { + float data[32]; +} u; + +layout(location = 0) out vec2 vUV; +layout(location = 1) out vec3 vWorldPos; +layout(location = 2) out vec3 vViewPos; +layout(location = 3) out vec3 vRootPos; +layout(location = 4) out float vInstanceId; +layout(location = 5) out vec4 vTint; +layout(location = 6) out float vAlwaysDark; + +void main() { + float width = max(u.data[2], 0.01); + float height = max(u.data[3], 0.01); + float scale = max(inNormal.y, 0.05); + + mat4 invModel = inverse(ubo.model); + vec3 worldUpObj = inverse(mat3(ubo.model)) * vec3(0.0, 1.0, 0.0); + float upLen = length(worldUpObj); + worldUpObj = upLen > 1e-5 ? worldUpObj / upLen : vec3(0.0, 1.0, 0.0); + + vec3 camObj = (invModel * vec4(ubo.cameraPos.xyz, 1.0)).xyz; + vec3 toCam = camObj - inPos; + toCam = toCam - worldUpObj * dot(toCam, worldUpObj); + vec3 right; + float toCamLen = length(toCam); + if (toCamLen > 1e-4) { + right = normalize(cross(worldUpObj, toCam)); + } else { + vec3 fallback = abs(worldUpObj.y) < 0.95 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); + right = normalize(cross(worldUpObj, fallback)); + } + + vec3 obj = inPos + right * (inUV.x - 0.5) * width * scale + worldUpObj * inUV.y * height * scale; + vec4 world = ubo.model * vec4(obj, 1.0); + vec4 rootWorld = ubo.model * vec4(inPos, 1.0); + + gl_Position = ubo.mvp * vec4(obj, 1.0); + vUV = inUV; + vWorldPos = world.xyz; + vViewPos = (ubo.view * world).xyz; + vRootPos = rootWorld.xyz; + vInstanceId = inNormal.x; + vTint = ubo.tint; + vAlwaysDark = inNormal.z; +} diff --git a/src/modules/graphics/shaders/mesh3d_grass.vert.spv b/src/modules/graphics/shaders/mesh3d_grass.vert.spv new file mode 100644 index 00000000..dc7b9cdf Binary files /dev/null and b/src/modules/graphics/shaders/mesh3d_grass.vert.spv differ diff --git a/src/modules/graphics/shaders/mesh3d_grass_frag_spv.inc b/src/modules/graphics/shaders/mesh3d_grass_frag_spv.inc new file mode 100644 index 00000000..b33b4df6 --- /dev/null +++ b/src/modules/graphics/shaders/mesh3d_grass_frag_spv.inc @@ -0,0 +1,425 @@ +static const uint32_t mesh3d_grass_frag_spv[] = { + 0x07230203, 0x00010000, 0x0008000b, 0x0000021c, 0x00000000, 0x00020011, 0x00000001, 0x00020011, + 0x00000032, 0x0006000b, 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e, 0x00000000, 0x0003000e, + 0x00000000, 0x00000001, 0x000d000f, 0x00000004, 0x00000004, 0x6e69616d, 0x00000000, 0x0000013b, + 0x00000172, 0x0000018e, 0x000001c7, 0x000001d7, 0x000001dd, 0x0000020a, 0x00000215, 0x00030010, + 0x00000004, 0x00000007, 0x00030003, 0x00000002, 0x000001c2, 0x00040005, 0x00000004, 0x6e69616d, + 0x00000000, 0x000a0005, 0x00000010, 0x706d6173, 0x6853656c, 0x776f6461, 0x63736143, 0x28656461, + 0x3b336676, 0x663b3169, 0x00003b31, 0x00050005, 0x0000000d, 0x6c726f77, 0x736f5064, 0x00000000, + 0x00040005, 0x0000000e, 0x63736163, 0x00656461, 0x00040005, 0x0000000f, 0x4263646e, 0x00736169, + 0x00070005, 0x00000014, 0x63736163, 0x4e656461, 0x69426364, 0x69287361, 0x00003b31, 0x00040005, + 0x00000013, 0x63736163, 0x00656461, 0x00090005, 0x00000019, 0x706d6173, 0x7247656c, 0x53737361, + 0x6f646168, 0x66762877, 0x31663b33, 0x0000003b, 0x00050005, 0x00000017, 0x6c726f77, 0x736f5064, + 0x00000000, 0x00050005, 0x00000018, 0x77656976, 0x74706544, 0x00000068, 0x00050005, 0x0000001d, + 0x6867696c, 0x696c4374, 0x00000070, 0x00050005, 0x00000022, 0x64616853, 0x7246776f, 0x00656d61, + 0x00050006, 0x00000022, 0x00000000, 0x6867696c, 0x00505674, 0x00050006, 0x00000022, 0x00000001, + 0x696c7073, 0x00007374, 0x00050006, 0x00000022, 0x00000002, 0x73616962, 0x00000000, 0x00060006, + 0x00000022, 0x00000003, 0x63736163, 0x42656461, 0x00736169, 0x00070006, 0x00000022, 0x00000004, + 0x63736163, 0x54656461, 0x6c657865, 0x00000000, 0x00040005, 0x00000024, 0x64616873, 0x0000776f, + 0x00030005, 0x00000031, 0x0063646e, 0x00030005, 0x0000003c, 0x00007675, 0x00040005, 0x00000043, + 0x74706564, 0x00000068, 0x00040005, 0x0000006c, 0x65786574, 0x0000006c, 0x00050005, 0x00000070, + 0x64616873, 0x614d776f, 0x00000070, 0x00030005, 0x0000007a, 0x006d7573, 0x00040005, 0x0000007b, + 0x6665727a, 0x00000000, 0x00030005, 0x0000007f, 0x00000069, 0x00030005, 0x00000088, 0x00000073, + 0x00050005, 0x0000009b, 0x65646e69, 0x6c626178, 0x00000065, 0x00030005, 0x000000d0, 0x00000062, + 0x00040005, 0x0000010a, 0x63736163, 0x00656461, 0x00030005, 0x00000118, 0x00736976, 0x00040005, + 0x00000119, 0x61726170, 0x0000006d, 0x00040005, 0x0000011c, 0x61726170, 0x0000006d, 0x00040005, + 0x0000011e, 0x61726170, 0x0000006d, 0x00040005, 0x00000120, 0x61726170, 0x0000006d, 0x00040005, + 0x0000012a, 0x6d617266, 0x00007365, 0x00050005, 0x0000012d, 0x65747845, 0x6c616e72, 0x00000073, + 0x00050006, 0x0000012d, 0x00000000, 0x61746164, 0x00000000, 0x00030005, 0x0000012f, 0x00000075, + 0x00050005, 0x00000135, 0x61727564, 0x6e6f6974, 0x00000000, 0x00040005, 0x00000139, 0x68736168, + 0x00000000, 0x00050005, 0x0000013b, 0x736e4976, 0x636e6174, 0x00644965, 0x00030005, 0x00000143, + 0x00000074, 0x00040005, 0x0000014c, 0x6d617266, 0x00000065, 0x00040005, 0x00000152, 0x736c6f63, + 0x00000000, 0x00040005, 0x00000158, 0x73776f72, 0x00000000, 0x00050005, 0x0000015e, 0x73617267, + 0x72615673, 0x00000073, 0x00050005, 0x00000164, 0x6661656c, 0x73726156, 0x00000000, 0x00060005, + 0x0000016a, 0x6661656c, 0x4f776f52, 0x65736666, 0x00000074, 0x00040005, 0x00000171, 0x6661656c, + 0x00000000, 0x00050005, 0x00000172, 0x776c4176, 0x44737961, 0x006b7261, 0x00040005, 0x0000017d, + 0x7261566e, 0x00000000, 0x00040005, 0x00000182, 0x69726176, 0x00746e61, 0x00040005, 0x0000018c, + 0x616c7461, 0x00565573, 0x00030005, 0x0000018e, 0x00565576, 0x00050005, 0x0000019b, 0x61636f6c, + 0x6c6f436c, 0x00000000, 0x00050005, 0x0000019e, 0x61636f6c, 0x776f526c, 0x00000000, 0x00030005, + 0x000001a1, 0x006c6f63, 0x00030005, 0x000001a6, 0x00776f72, 0x00040005, 0x000001be, 0x6b73616d, + 0x00000000, 0x00060005, 0x000001c2, 0x65626c61, 0x61536f64, 0x656c706d, 0x00000072, 0x00040005, + 0x000001c7, 0x6e695476, 0x00000074, 0x00050005, 0x000001d5, 0x77656976, 0x74706544, 0x00000068, + 0x00050005, 0x000001d7, 0x65695676, 0x736f5077, 0x00000000, 0x00030005, 0x000001dc, 0x00736976, + 0x00050005, 0x000001dd, 0x6f6f5276, 0x736f5074, 0x00000000, 0x00040005, 0x000001de, 0x61726170, + 0x0000006d, 0x00040005, 0x000001e0, 0x61726170, 0x0000006d, 0x00050005, 0x000001ee, 0x6867696c, + 0x65724774, 0x00006e65, 0x00050005, 0x000001f9, 0x6b726164, 0x65657247, 0x0000006e, 0x00050005, + 0x00000203, 0x73617267, 0x6c6f4373, 0x00000000, 0x00050005, 0x0000020a, 0x4374756f, 0x726f6c6f, + 0x00000000, 0x00050005, 0x00000215, 0x726f5776, 0x6f50646c, 0x00000073, 0x00040005, 0x00000216, + 0x6867694c, 0x00443374, 0x00060006, 0x00000216, 0x00000000, 0x52736f70, 0x75696461, 0x00000073, + 0x00050006, 0x00000216, 0x00000001, 0x6f6c6f63, 0x00000072, 0x00040005, 0x00000219, 0x6d617246, + 0x00000065, 0x00040006, 0x00000219, 0x00000000, 0x0070766d, 0x00050006, 0x00000219, 0x00000001, + 0x65646f6d, 0x0000006c, 0x00080006, 0x00000219, 0x00000002, 0x6867696c, 0x72694474, 0x65746e49, + 0x7469736e, 0x00000079, 0x00060006, 0x00000219, 0x00000003, 0x6867696c, 0x6c6f4374, 0x0000726f, + 0x00050006, 0x00000219, 0x00000004, 0x746e6974, 0x00000000, 0x00060006, 0x00000219, 0x00000005, + 0x656d6163, 0x6f506172, 0x00000073, 0x00050006, 0x00000219, 0x00000006, 0x69626d61, 0x00746e65, + 0x00050006, 0x00000219, 0x00000007, 0x6867696c, 0x00007374, 0x00050006, 0x00000219, 0x00000008, + 0x42786574, 0x00626d6f, 0x00060006, 0x00000219, 0x00000009, 0x61726170, 0x78616c6c, 0x00000000, + 0x00050006, 0x00000219, 0x0000000a, 0x77656976, 0x00000000, 0x00060006, 0x00000219, 0x0000000b, + 0x70696c63, 0x6f666e49, 0x00000000, 0x00030005, 0x0000021b, 0x006f6275, 0x00040047, 0x00000021, + 0x00000006, 0x00000040, 0x00030047, 0x00000022, 0x00000002, 0x00040048, 0x00000022, 0x00000000, + 0x00000005, 0x00050048, 0x00000022, 0x00000000, 0x00000007, 0x00000010, 0x00050048, 0x00000022, + 0x00000000, 0x00000023, 0x00000000, 0x00050048, 0x00000022, 0x00000001, 0x00000023, 0x000000c0, + 0x00050048, 0x00000022, 0x00000002, 0x00000023, 0x000000d0, 0x00050048, 0x00000022, 0x00000003, + 0x00000023, 0x000000e0, 0x00050048, 0x00000022, 0x00000004, 0x00000023, 0x000000f0, 0x00040047, + 0x00000024, 0x00000021, 0x00000004, 0x00040047, 0x00000024, 0x00000022, 0x00000000, 0x00040047, + 0x00000070, 0x00000021, 0x00000005, 0x00040047, 0x00000070, 0x00000022, 0x00000000, 0x00040047, + 0x0000012c, 0x00000006, 0x00000004, 0x00030047, 0x0000012d, 0x00000002, 0x00050048, 0x0000012d, + 0x00000000, 0x00000023, 0x00000000, 0x00040047, 0x0000013b, 0x0000001e, 0x00000004, 0x00040047, + 0x00000172, 0x0000001e, 0x00000006, 0x00040047, 0x0000018e, 0x0000001e, 0x00000000, 0x00040047, + 0x000001c2, 0x00000021, 0x00000001, 0x00040047, 0x000001c2, 0x00000022, 0x00000000, 0x00040047, + 0x000001c7, 0x0000001e, 0x00000005, 0x00040047, 0x000001d7, 0x0000001e, 0x00000002, 0x00040047, + 0x000001dd, 0x0000001e, 0x00000003, 0x00040047, 0x0000020a, 0x0000001e, 0x00000000, 0x00040047, + 0x00000215, 0x0000001e, 0x00000001, 0x00050048, 0x00000216, 0x00000000, 0x00000023, 0x00000000, + 0x00050048, 0x00000216, 0x00000001, 0x00000023, 0x00000010, 0x00040047, 0x00000218, 0x00000006, + 0x00000020, 0x00030047, 0x00000219, 0x00000002, 0x00040048, 0x00000219, 0x00000000, 0x00000005, + 0x00050048, 0x00000219, 0x00000000, 0x00000007, 0x00000010, 0x00050048, 0x00000219, 0x00000000, + 0x00000023, 0x00000000, 0x00040048, 0x00000219, 0x00000001, 0x00000005, 0x00050048, 0x00000219, + 0x00000001, 0x00000007, 0x00000010, 0x00050048, 0x00000219, 0x00000001, 0x00000023, 0x00000040, + 0x00050048, 0x00000219, 0x00000002, 0x00000023, 0x00000080, 0x00050048, 0x00000219, 0x00000003, + 0x00000023, 0x00000090, 0x00050048, 0x00000219, 0x00000004, 0x00000023, 0x000000a0, 0x00050048, + 0x00000219, 0x00000005, 0x00000023, 0x000000b0, 0x00050048, 0x00000219, 0x00000006, 0x00000023, + 0x000000c0, 0x00050048, 0x00000219, 0x00000007, 0x00000023, 0x000000d0, 0x00050048, 0x00000219, + 0x00000008, 0x00000023, 0x000001d0, 0x00050048, 0x00000219, 0x00000009, 0x00000023, 0x000001e0, + 0x00040048, 0x00000219, 0x0000000a, 0x00000005, 0x00050048, 0x00000219, 0x0000000a, 0x00000007, + 0x00000010, 0x00050048, 0x00000219, 0x0000000a, 0x00000023, 0x000001f0, 0x00050048, 0x00000219, + 0x0000000b, 0x00000023, 0x00000230, 0x00040047, 0x0000021b, 0x00000021, 0x00000000, 0x00040047, + 0x0000021b, 0x00000022, 0x00000000, 0x00020013, 0x00000002, 0x00030021, 0x00000003, 0x00000002, + 0x00030016, 0x00000006, 0x00000020, 0x00040017, 0x00000007, 0x00000006, 0x00000003, 0x00040020, + 0x00000008, 0x00000007, 0x00000007, 0x00040015, 0x00000009, 0x00000020, 0x00000001, 0x00040020, + 0x0000000a, 0x00000007, 0x00000009, 0x00040020, 0x0000000b, 0x00000007, 0x00000006, 0x00060021, + 0x0000000c, 0x00000006, 0x00000008, 0x0000000a, 0x0000000b, 0x00040021, 0x00000012, 0x00000006, + 0x0000000a, 0x00050021, 0x00000016, 0x00000006, 0x00000008, 0x0000000b, 0x00040017, 0x0000001b, + 0x00000006, 0x00000004, 0x00040020, 0x0000001c, 0x00000007, 0x0000001b, 0x00040018, 0x0000001e, + 0x0000001b, 0x00000004, 0x00040015, 0x0000001f, 0x00000020, 0x00000000, 0x0004002b, 0x0000001f, + 0x00000020, 0x00000003, 0x0004001c, 0x00000021, 0x0000001e, 0x00000020, 0x0007001e, 0x00000022, + 0x00000021, 0x0000001b, 0x0000001b, 0x0000001b, 0x0000001b, 0x00040020, 0x00000023, 0x00000002, + 0x00000022, 0x0004003b, 0x00000023, 0x00000024, 0x00000002, 0x0004002b, 0x00000009, 0x00000025, + 0x00000000, 0x00040020, 0x00000027, 0x00000002, 0x0000001e, 0x0004002b, 0x00000006, 0x0000002b, + 0x3f800000, 0x0004002b, 0x00000006, 0x00000036, 0x358637bd, 0x00040017, 0x0000003a, 0x00000006, + 0x00000002, 0x00040020, 0x0000003b, 0x00000007, 0x0000003a, 0x0004002b, 0x00000006, 0x0000003f, + 0x3f000000, 0x0004002b, 0x0000001f, 0x00000044, 0x00000002, 0x00020014, 0x00000047, 0x0004002b, + 0x0000001f, 0x00000048, 0x00000000, 0x0004002b, 0x00000006, 0x0000004b, 0x00000000, 0x0004002b, + 0x0000001f, 0x00000057, 0x00000001, 0x00090019, 0x0000006d, 0x00000006, 0x00000001, 0x00000001, + 0x00000001, 0x00000000, 0x00000001, 0x00000000, 0x0003001b, 0x0000006e, 0x0000006d, 0x00040020, + 0x0000006f, 0x00000000, 0x0000006e, 0x0004003b, 0x0000006f, 0x00000070, 0x00000000, 0x00040017, + 0x00000073, 0x00000009, 0x00000003, 0x00040017, 0x00000075, 0x00000009, 0x00000002, 0x0004002b, + 0x00000009, 0x00000086, 0x00000009, 0x0004002b, 0x0000001f, 0x0000008a, 0x00000009, 0x0004001c, + 0x0000008b, 0x0000003a, 0x0000008a, 0x0004002b, 0x00000006, 0x0000008c, 0xbf000000, 0x0005002c, + 0x0000003a, 0x0000008d, 0x0000008c, 0x0000008c, 0x0004002b, 0x00000006, 0x0000008e, 0xbf19999a, + 0x0005002c, 0x0000003a, 0x0000008f, 0x0000004b, 0x0000008e, 0x0005002c, 0x0000003a, 0x00000090, + 0x0000003f, 0x0000008c, 0x0005002c, 0x0000003a, 0x00000091, 0x0000008e, 0x0000004b, 0x0005002c, + 0x0000003a, 0x00000092, 0x0000004b, 0x0000004b, 0x0004002b, 0x00000006, 0x00000093, 0x3f19999a, + 0x0005002c, 0x0000003a, 0x00000094, 0x00000093, 0x0000004b, 0x0005002c, 0x0000003a, 0x00000095, + 0x0000008c, 0x0000003f, 0x0005002c, 0x0000003a, 0x00000096, 0x0000004b, 0x00000093, 0x0005002c, + 0x0000003a, 0x00000097, 0x0000003f, 0x0000003f, 0x000c002c, 0x0000008b, 0x00000098, 0x0000008d, + 0x0000008f, 0x00000090, 0x00000091, 0x00000092, 0x00000094, 0x00000095, 0x00000096, 0x00000097, + 0x00040020, 0x0000009a, 0x00000007, 0x0000008b, 0x0004002b, 0x00000009, 0x000000c9, 0x00000001, + 0x0004002b, 0x00000006, 0x000000cc, 0x41100000, 0x0004002b, 0x00000009, 0x000000d6, 0x00000003, + 0x00040020, 0x000000d7, 0x00000002, 0x00000006, 0x0004002b, 0x00000006, 0x000000e8, 0x322bcc77, + 0x0004002b, 0x00000009, 0x000000ef, 0x00000002, 0x0004002b, 0x00000006, 0x00000104, 0x38d1b717, + 0x0004002b, 0x0000001f, 0x0000012b, 0x00000020, 0x0004001c, 0x0000012c, 0x00000006, 0x0000012b, + 0x0003001e, 0x0000012d, 0x0000012c, 0x00040020, 0x0000012e, 0x00000009, 0x0000012d, 0x0004003b, + 0x0000012e, 0x0000012f, 0x00000009, 0x0004002b, 0x00000009, 0x00000130, 0x0000000c, 0x00040020, + 0x00000131, 0x00000009, 0x00000006, 0x00040020, 0x0000013a, 0x00000001, 0x00000006, 0x0004003b, + 0x0000013a, 0x0000013b, 0x00000001, 0x0004002b, 0x00000006, 0x0000013d, 0x414fd639, 0x0004002b, + 0x00000006, 0x00000140, 0x472aee8c, 0x0004002b, 0x00000009, 0x00000153, 0x0000000d, 0x0004002b, + 0x00000009, 0x00000159, 0x0000000e, 0x0004002b, 0x00000009, 0x0000015f, 0x0000000f, 0x0004002b, + 0x00000009, 0x00000165, 0x00000010, 0x0004002b, 0x00000009, 0x0000016b, 0x00000011, 0x00040020, + 0x00000170, 0x00000007, 0x00000047, 0x0004003b, 0x0000013a, 0x00000172, 0x00000001, 0x0004002b, + 0x00000009, 0x00000178, 0x00000005, 0x00040020, 0x0000018d, 0x00000001, 0x0000003a, 0x0004003b, + 0x0000018d, 0x0000018e, 0x00000001, 0x00090019, 0x000001bf, 0x00000006, 0x00000001, 0x00000000, + 0x00000000, 0x00000000, 0x00000001, 0x00000000, 0x0003001b, 0x000001c0, 0x000001bf, 0x00040020, + 0x000001c1, 0x00000000, 0x000001c0, 0x0004003b, 0x000001c1, 0x000001c2, 0x00000000, 0x00040020, + 0x000001c6, 0x00000001, 0x0000001b, 0x0004003b, 0x000001c6, 0x000001c7, 0x00000001, 0x0004002b, + 0x00000009, 0x000001cc, 0x00000004, 0x0004002b, 0x00000006, 0x000001cf, 0x3c23d70a, 0x00040020, + 0x000001d6, 0x00000001, 0x00000007, 0x0004003b, 0x000001d6, 0x000001d7, 0x00000001, 0x0004003b, + 0x000001d6, 0x000001dd, 0x00000001, 0x0004002b, 0x00000009, 0x000001ef, 0x00000006, 0x0004002b, + 0x00000009, 0x000001f2, 0x00000007, 0x0004002b, 0x00000009, 0x000001f5, 0x00000008, 0x0004002b, + 0x00000009, 0x000001fc, 0x0000000a, 0x0004002b, 0x00000009, 0x000001ff, 0x0000000b, 0x00040020, + 0x00000209, 0x00000003, 0x0000001b, 0x0004003b, 0x00000209, 0x0000020a, 0x00000003, 0x0004003b, + 0x000001d6, 0x00000215, 0x00000001, 0x0004001e, 0x00000216, 0x0000001b, 0x0000001b, 0x0004002b, + 0x0000001f, 0x00000217, 0x00000008, 0x0004001c, 0x00000218, 0x00000216, 0x00000217, 0x000e001e, + 0x00000219, 0x0000001e, 0x0000001e, 0x0000001b, 0x0000001b, 0x0000001b, 0x0000001b, 0x0000001b, + 0x00000218, 0x0000001b, 0x0000001b, 0x0000001e, 0x0000001b, 0x00040020, 0x0000021a, 0x00000002, + 0x00000219, 0x0004003b, 0x0000021a, 0x0000021b, 0x00000002, 0x00050036, 0x00000002, 0x00000004, + 0x00000000, 0x00000003, 0x000200f8, 0x00000005, 0x0004003b, 0x0000000b, 0x0000012a, 0x00000007, + 0x0004003b, 0x0000000b, 0x00000135, 0x00000007, 0x0004003b, 0x0000000b, 0x00000139, 0x00000007, + 0x0004003b, 0x0000000b, 0x00000143, 0x00000007, 0x0004003b, 0x0000000a, 0x0000014c, 0x00000007, + 0x0004003b, 0x0000000a, 0x00000152, 0x00000007, 0x0004003b, 0x0000000a, 0x00000158, 0x00000007, + 0x0004003b, 0x0000000a, 0x0000015e, 0x00000007, 0x0004003b, 0x0000000a, 0x00000164, 0x00000007, + 0x0004003b, 0x0000000a, 0x0000016a, 0x00000007, 0x0004003b, 0x00000170, 0x00000171, 0x00000007, + 0x0004003b, 0x0000000a, 0x0000017d, 0x00000007, 0x0004003b, 0x0000000a, 0x00000182, 0x00000007, + 0x0004003b, 0x0000003b, 0x0000018c, 0x00000007, 0x0004003b, 0x0000000a, 0x0000019b, 0x00000007, + 0x0004003b, 0x0000000a, 0x0000019e, 0x00000007, 0x0004003b, 0x0000000a, 0x000001a1, 0x00000007, + 0x0004003b, 0x0000000a, 0x000001a6, 0x00000007, 0x0004003b, 0x0000001c, 0x000001be, 0x00000007, + 0x0004003b, 0x0000000b, 0x000001d5, 0x00000007, 0x0004003b, 0x0000000b, 0x000001dc, 0x00000007, + 0x0004003b, 0x00000008, 0x000001de, 0x00000007, 0x0004003b, 0x0000000b, 0x000001e0, 0x00000007, + 0x0004003b, 0x00000008, 0x000001ee, 0x00000007, 0x0004003b, 0x00000008, 0x000001f9, 0x00000007, + 0x0004003b, 0x00000008, 0x00000203, 0x00000007, 0x00060041, 0x00000131, 0x00000132, 0x0000012f, + 0x00000025, 0x00000130, 0x0004003d, 0x00000006, 0x00000133, 0x00000132, 0x0007000c, 0x00000006, + 0x00000134, 0x00000001, 0x00000028, 0x00000133, 0x0000002b, 0x0003003e, 0x0000012a, 0x00000134, + 0x00060041, 0x00000131, 0x00000136, 0x0000012f, 0x00000025, 0x000000c9, 0x0004003d, 0x00000006, + 0x00000137, 0x00000136, 0x0007000c, 0x00000006, 0x00000138, 0x00000001, 0x00000028, 0x00000137, + 0x00000104, 0x0003003e, 0x00000135, 0x00000138, 0x0004003d, 0x00000006, 0x0000013c, 0x0000013b, + 0x00050085, 0x00000006, 0x0000013e, 0x0000013c, 0x0000013d, 0x0006000c, 0x00000006, 0x0000013f, + 0x00000001, 0x0000000d, 0x0000013e, 0x00050085, 0x00000006, 0x00000141, 0x0000013f, 0x00000140, + 0x0006000c, 0x00000006, 0x00000142, 0x00000001, 0x0000000a, 0x00000141, 0x0003003e, 0x00000139, + 0x00000142, 0x00060041, 0x00000131, 0x00000144, 0x0000012f, 0x00000025, 0x00000025, 0x0004003d, + 0x00000006, 0x00000145, 0x00000144, 0x0004003d, 0x00000006, 0x00000146, 0x00000135, 0x00050088, + 0x00000006, 0x00000147, 0x00000145, 0x00000146, 0x0004003d, 0x00000006, 0x00000148, 0x00000139, + 0x0004003d, 0x00000006, 0x00000149, 0x0000012a, 0x00050085, 0x00000006, 0x0000014a, 0x00000148, + 0x00000149, 0x00050081, 0x00000006, 0x0000014b, 0x00000147, 0x0000014a, 0x0003003e, 0x00000143, + 0x0000014b, 0x0004003d, 0x00000006, 0x0000014d, 0x00000143, 0x0006000c, 0x00000006, 0x0000014e, + 0x00000001, 0x00000008, 0x0000014d, 0x0004003d, 0x00000006, 0x0000014f, 0x0000012a, 0x0005008d, + 0x00000006, 0x00000150, 0x0000014e, 0x0000014f, 0x0004006e, 0x00000009, 0x00000151, 0x00000150, + 0x0003003e, 0x0000014c, 0x00000151, 0x00060041, 0x00000131, 0x00000154, 0x0000012f, 0x00000025, + 0x00000153, 0x0004003d, 0x00000006, 0x00000155, 0x00000154, 0x0007000c, 0x00000006, 0x00000156, + 0x00000001, 0x00000028, 0x00000155, 0x0000002b, 0x0004006e, 0x00000009, 0x00000157, 0x00000156, + 0x0003003e, 0x00000152, 0x00000157, 0x00060041, 0x00000131, 0x0000015a, 0x0000012f, 0x00000025, + 0x00000159, 0x0004003d, 0x00000006, 0x0000015b, 0x0000015a, 0x0007000c, 0x00000006, 0x0000015c, + 0x00000001, 0x00000028, 0x0000015b, 0x0000002b, 0x0004006e, 0x00000009, 0x0000015d, 0x0000015c, + 0x0003003e, 0x00000158, 0x0000015d, 0x00060041, 0x00000131, 0x00000160, 0x0000012f, 0x00000025, + 0x0000015f, 0x0004003d, 0x00000006, 0x00000161, 0x00000160, 0x0007000c, 0x00000006, 0x00000162, + 0x00000001, 0x00000028, 0x00000161, 0x0000002b, 0x0004006e, 0x00000009, 0x00000163, 0x00000162, + 0x0003003e, 0x0000015e, 0x00000163, 0x00060041, 0x00000131, 0x00000166, 0x0000012f, 0x00000025, + 0x00000165, 0x0004003d, 0x00000006, 0x00000167, 0x00000166, 0x0007000c, 0x00000006, 0x00000168, + 0x00000001, 0x00000028, 0x00000167, 0x0000002b, 0x0004006e, 0x00000009, 0x00000169, 0x00000168, + 0x0003003e, 0x00000164, 0x00000169, 0x00060041, 0x00000131, 0x0000016c, 0x0000012f, 0x00000025, + 0x0000016b, 0x0004003d, 0x00000006, 0x0000016d, 0x0000016c, 0x00050081, 0x00000006, 0x0000016e, + 0x0000016d, 0x0000003f, 0x0004006e, 0x00000009, 0x0000016f, 0x0000016e, 0x0003003e, 0x0000016a, + 0x0000016f, 0x0004003d, 0x00000006, 0x00000173, 0x00000172, 0x000500ba, 0x00000047, 0x00000174, + 0x00000173, 0x0000003f, 0x000400a8, 0x00000047, 0x00000175, 0x00000174, 0x000300f7, 0x00000177, + 0x00000000, 0x000400fa, 0x00000175, 0x00000176, 0x00000177, 0x000200f8, 0x00000176, 0x00060041, + 0x00000131, 0x00000179, 0x0000012f, 0x00000025, 0x00000178, 0x0004003d, 0x00000006, 0x0000017a, + 0x00000179, 0x000500ba, 0x00000047, 0x0000017b, 0x0000017a, 0x0000003f, 0x000200f9, 0x00000177, + 0x000200f8, 0x00000177, 0x000700f5, 0x00000047, 0x0000017c, 0x00000174, 0x00000005, 0x0000017b, + 0x00000176, 0x0003003e, 0x00000171, 0x0000017c, 0x0004003d, 0x00000047, 0x0000017e, 0x00000171, + 0x0004003d, 0x00000009, 0x0000017f, 0x00000164, 0x0004003d, 0x00000009, 0x00000180, 0x0000015e, + 0x000600a9, 0x00000009, 0x00000181, 0x0000017e, 0x0000017f, 0x00000180, 0x0003003e, 0x0000017d, + 0x00000181, 0x0004003d, 0x00000006, 0x00000183, 0x0000013b, 0x0004003d, 0x00000009, 0x00000184, + 0x0000017d, 0x0004006f, 0x00000006, 0x00000185, 0x00000184, 0x0005008d, 0x00000006, 0x00000186, + 0x00000183, 0x00000185, 0x0004006e, 0x00000009, 0x00000187, 0x00000186, 0x0003003e, 0x00000182, + 0x00000187, 0x0004003d, 0x00000009, 0x00000188, 0x00000158, 0x000500b3, 0x00000047, 0x00000189, + 0x00000188, 0x000000c9, 0x000300f7, 0x0000018b, 0x00000000, 0x000400fa, 0x00000189, 0x0000018a, + 0x0000019a, 0x000200f8, 0x0000018a, 0x00050041, 0x0000013a, 0x0000018f, 0x0000018e, 0x00000048, + 0x0004003d, 0x00000006, 0x00000190, 0x0000018f, 0x0004003d, 0x00000009, 0x00000191, 0x0000014c, + 0x0004006f, 0x00000006, 0x00000192, 0x00000191, 0x00050081, 0x00000006, 0x00000193, 0x00000190, + 0x00000192, 0x0004003d, 0x00000006, 0x00000194, 0x0000012a, 0x00050088, 0x00000006, 0x00000195, + 0x00000193, 0x00000194, 0x00050041, 0x0000013a, 0x00000196, 0x0000018e, 0x00000057, 0x0004003d, + 0x00000006, 0x00000197, 0x00000196, 0x00050083, 0x00000006, 0x00000198, 0x0000002b, 0x00000197, + 0x00050050, 0x0000003a, 0x00000199, 0x00000195, 0x00000198, 0x0003003e, 0x0000018c, 0x00000199, + 0x000200f9, 0x0000018b, 0x000200f8, 0x0000019a, 0x0004003d, 0x00000009, 0x0000019c, 0x0000014c, + 0x0005008b, 0x00000009, 0x0000019d, 0x0000019c, 0x000000ef, 0x0003003e, 0x0000019b, 0x0000019d, + 0x0004003d, 0x00000009, 0x0000019f, 0x0000014c, 0x00050087, 0x00000009, 0x000001a0, 0x0000019f, + 0x000000ef, 0x0003003e, 0x0000019e, 0x000001a0, 0x0004003d, 0x00000009, 0x000001a2, 0x00000182, + 0x00050084, 0x00000009, 0x000001a3, 0x000001a2, 0x000000ef, 0x0004003d, 0x00000009, 0x000001a4, + 0x0000019b, 0x00050080, 0x00000009, 0x000001a5, 0x000001a3, 0x000001a4, 0x0003003e, 0x000001a1, + 0x000001a5, 0x0004003d, 0x00000047, 0x000001a7, 0x00000171, 0x0004003d, 0x00000009, 0x000001a8, + 0x0000016a, 0x000600a9, 0x00000009, 0x000001a9, 0x000001a7, 0x000001a8, 0x00000025, 0x0004003d, + 0x00000009, 0x000001aa, 0x0000019e, 0x00050080, 0x00000009, 0x000001ab, 0x000001a9, 0x000001aa, + 0x0003003e, 0x000001a6, 0x000001ab, 0x0004003d, 0x00000009, 0x000001ac, 0x000001a1, 0x0004006f, + 0x00000006, 0x000001ad, 0x000001ac, 0x00050041, 0x0000013a, 0x000001ae, 0x0000018e, 0x00000048, + 0x0004003d, 0x00000006, 0x000001af, 0x000001ae, 0x00050081, 0x00000006, 0x000001b0, 0x000001ad, + 0x000001af, 0x0004003d, 0x00000009, 0x000001b1, 0x00000152, 0x0004006f, 0x00000006, 0x000001b2, + 0x000001b1, 0x00050088, 0x00000006, 0x000001b3, 0x000001b0, 0x000001b2, 0x0004003d, 0x00000009, + 0x000001b4, 0x000001a6, 0x0004006f, 0x00000006, 0x000001b5, 0x000001b4, 0x00050041, 0x0000013a, + 0x000001b6, 0x0000018e, 0x00000057, 0x0004003d, 0x00000006, 0x000001b7, 0x000001b6, 0x00050083, + 0x00000006, 0x000001b8, 0x0000002b, 0x000001b7, 0x00050081, 0x00000006, 0x000001b9, 0x000001b5, + 0x000001b8, 0x0004003d, 0x00000009, 0x000001ba, 0x00000158, 0x0004006f, 0x00000006, 0x000001bb, + 0x000001ba, 0x00050088, 0x00000006, 0x000001bc, 0x000001b9, 0x000001bb, 0x00050050, 0x0000003a, + 0x000001bd, 0x000001b3, 0x000001bc, 0x0003003e, 0x0000018c, 0x000001bd, 0x000200f9, 0x0000018b, + 0x000200f8, 0x0000018b, 0x0004003d, 0x000001c0, 0x000001c3, 0x000001c2, 0x0004003d, 0x0000003a, + 0x000001c4, 0x0000018c, 0x00050057, 0x0000001b, 0x000001c5, 0x000001c3, 0x000001c4, 0x0004003d, + 0x0000001b, 0x000001c8, 0x000001c7, 0x00050085, 0x0000001b, 0x000001c9, 0x000001c5, 0x000001c8, + 0x0003003e, 0x000001be, 0x000001c9, 0x00050041, 0x0000000b, 0x000001ca, 0x000001be, 0x00000020, + 0x0004003d, 0x00000006, 0x000001cb, 0x000001ca, 0x00060041, 0x00000131, 0x000001cd, 0x0000012f, + 0x00000025, 0x000001cc, 0x0004003d, 0x00000006, 0x000001ce, 0x000001cd, 0x0007000c, 0x00000006, + 0x000001d0, 0x00000001, 0x00000028, 0x000001ce, 0x000001cf, 0x000500b8, 0x00000047, 0x000001d1, + 0x000001cb, 0x000001d0, 0x000300f7, 0x000001d3, 0x00000000, 0x000400fa, 0x000001d1, 0x000001d2, + 0x000001d3, 0x000200f8, 0x000001d2, 0x000100fc, 0x000200f8, 0x000001d3, 0x00050041, 0x0000013a, + 0x000001d8, 0x000001d7, 0x00000044, 0x0004003d, 0x00000006, 0x000001d9, 0x000001d8, 0x0004007f, + 0x00000006, 0x000001da, 0x000001d9, 0x0007000c, 0x00000006, 0x000001db, 0x00000001, 0x00000028, + 0x000001da, 0x0000004b, 0x0003003e, 0x000001d5, 0x000001db, 0x0004003d, 0x00000007, 0x000001df, + 0x000001dd, 0x0003003e, 0x000001de, 0x000001df, 0x0004003d, 0x00000006, 0x000001e1, 0x000001d5, + 0x0003003e, 0x000001e0, 0x000001e1, 0x00060039, 0x00000006, 0x000001e2, 0x00000019, 0x000001de, + 0x000001e0, 0x0003003e, 0x000001dc, 0x000001e2, 0x00060041, 0x00000131, 0x000001e3, 0x0000012f, + 0x00000025, 0x00000178, 0x0004003d, 0x00000006, 0x000001e4, 0x000001e3, 0x000500ba, 0x00000047, + 0x000001e5, 0x000001e4, 0x0000003f, 0x000400a8, 0x00000047, 0x000001e6, 0x000001e5, 0x000300f7, + 0x000001e8, 0x00000000, 0x000400fa, 0x000001e6, 0x000001e7, 0x000001e8, 0x000200f8, 0x000001e7, + 0x0004003d, 0x00000006, 0x000001e9, 0x00000172, 0x000500ba, 0x00000047, 0x000001ea, 0x000001e9, + 0x0000003f, 0x000200f9, 0x000001e8, 0x000200f8, 0x000001e8, 0x000700f5, 0x00000047, 0x000001eb, + 0x000001e5, 0x000001d3, 0x000001ea, 0x000001e7, 0x000300f7, 0x000001ed, 0x00000000, 0x000400fa, + 0x000001eb, 0x000001ec, 0x000001ed, 0x000200f8, 0x000001ec, 0x0003003e, 0x000001dc, 0x0000004b, + 0x000200f9, 0x000001ed, 0x000200f8, 0x000001ed, 0x00060041, 0x00000131, 0x000001f0, 0x0000012f, + 0x00000025, 0x000001ef, 0x0004003d, 0x00000006, 0x000001f1, 0x000001f0, 0x00060041, 0x00000131, + 0x000001f3, 0x0000012f, 0x00000025, 0x000001f2, 0x0004003d, 0x00000006, 0x000001f4, 0x000001f3, + 0x00060041, 0x00000131, 0x000001f6, 0x0000012f, 0x00000025, 0x000001f5, 0x0004003d, 0x00000006, + 0x000001f7, 0x000001f6, 0x00060050, 0x00000007, 0x000001f8, 0x000001f1, 0x000001f4, 0x000001f7, + 0x0003003e, 0x000001ee, 0x000001f8, 0x00060041, 0x00000131, 0x000001fa, 0x0000012f, 0x00000025, + 0x00000086, 0x0004003d, 0x00000006, 0x000001fb, 0x000001fa, 0x00060041, 0x00000131, 0x000001fd, + 0x0000012f, 0x00000025, 0x000001fc, 0x0004003d, 0x00000006, 0x000001fe, 0x000001fd, 0x00060041, + 0x00000131, 0x00000200, 0x0000012f, 0x00000025, 0x000001ff, 0x0004003d, 0x00000006, 0x00000201, + 0x00000200, 0x00060050, 0x00000007, 0x00000202, 0x000001fb, 0x000001fe, 0x00000201, 0x0003003e, + 0x000001f9, 0x00000202, 0x0004003d, 0x00000007, 0x00000204, 0x000001f9, 0x0004003d, 0x00000007, + 0x00000205, 0x000001ee, 0x0004003d, 0x00000006, 0x00000206, 0x000001dc, 0x00060050, 0x00000007, + 0x00000207, 0x00000206, 0x00000206, 0x00000206, 0x0008000c, 0x00000007, 0x00000208, 0x00000001, + 0x0000002e, 0x00000204, 0x00000205, 0x00000207, 0x0003003e, 0x00000203, 0x00000208, 0x0004003d, + 0x00000007, 0x0000020b, 0x00000203, 0x0004003d, 0x0000001b, 0x0000020c, 0x000001be, 0x0008004f, + 0x00000007, 0x0000020d, 0x0000020c, 0x0000020c, 0x00000000, 0x00000001, 0x00000002, 0x00050085, + 0x00000007, 0x0000020e, 0x0000020b, 0x0000020d, 0x00050041, 0x0000000b, 0x0000020f, 0x000001be, + 0x00000020, 0x0004003d, 0x00000006, 0x00000210, 0x0000020f, 0x00050051, 0x00000006, 0x00000211, + 0x0000020e, 0x00000000, 0x00050051, 0x00000006, 0x00000212, 0x0000020e, 0x00000001, 0x00050051, + 0x00000006, 0x00000213, 0x0000020e, 0x00000002, 0x00070050, 0x0000001b, 0x00000214, 0x00000211, + 0x00000212, 0x00000213, 0x00000210, 0x0003003e, 0x0000020a, 0x00000214, 0x000100fd, 0x00010038, + 0x00050036, 0x00000006, 0x00000010, 0x00000000, 0x0000000c, 0x00030037, 0x00000008, 0x0000000d, + 0x00030037, 0x0000000a, 0x0000000e, 0x00030037, 0x0000000b, 0x0000000f, 0x000200f8, 0x00000011, + 0x0004003b, 0x0000001c, 0x0000001d, 0x00000007, 0x0004003b, 0x00000008, 0x00000031, 0x00000007, + 0x0004003b, 0x0000003b, 0x0000003c, 0x00000007, 0x0004003b, 0x0000000b, 0x00000043, 0x00000007, + 0x0004003b, 0x0000003b, 0x0000006c, 0x00000007, 0x0004003b, 0x0000000b, 0x0000007a, 0x00000007, + 0x0004003b, 0x0000000b, 0x0000007b, 0x00000007, 0x0004003b, 0x0000000a, 0x0000007f, 0x00000007, + 0x0004003b, 0x0000003b, 0x00000088, 0x00000007, 0x0004003b, 0x0000009a, 0x0000009b, 0x00000007, + 0x0004003d, 0x00000009, 0x00000026, 0x0000000e, 0x00060041, 0x00000027, 0x00000028, 0x00000024, + 0x00000025, 0x00000026, 0x0004003d, 0x0000001e, 0x00000029, 0x00000028, 0x0004003d, 0x00000007, + 0x0000002a, 0x0000000d, 0x00050051, 0x00000006, 0x0000002c, 0x0000002a, 0x00000000, 0x00050051, + 0x00000006, 0x0000002d, 0x0000002a, 0x00000001, 0x00050051, 0x00000006, 0x0000002e, 0x0000002a, + 0x00000002, 0x00070050, 0x0000001b, 0x0000002f, 0x0000002c, 0x0000002d, 0x0000002e, 0x0000002b, + 0x00050091, 0x0000001b, 0x00000030, 0x00000029, 0x0000002f, 0x0003003e, 0x0000001d, 0x00000030, + 0x0004003d, 0x0000001b, 0x00000032, 0x0000001d, 0x0008004f, 0x00000007, 0x00000033, 0x00000032, + 0x00000032, 0x00000000, 0x00000001, 0x00000002, 0x00050041, 0x0000000b, 0x00000034, 0x0000001d, + 0x00000020, 0x0004003d, 0x00000006, 0x00000035, 0x00000034, 0x0007000c, 0x00000006, 0x00000037, + 0x00000001, 0x00000028, 0x00000035, 0x00000036, 0x00060050, 0x00000007, 0x00000038, 0x00000037, + 0x00000037, 0x00000037, 0x00050088, 0x00000007, 0x00000039, 0x00000033, 0x00000038, 0x0003003e, + 0x00000031, 0x00000039, 0x0004003d, 0x00000007, 0x0000003d, 0x00000031, 0x0007004f, 0x0000003a, + 0x0000003e, 0x0000003d, 0x0000003d, 0x00000000, 0x00000001, 0x0005008e, 0x0000003a, 0x00000040, + 0x0000003e, 0x0000003f, 0x00050050, 0x0000003a, 0x00000041, 0x0000003f, 0x0000003f, 0x00050081, + 0x0000003a, 0x00000042, 0x00000040, 0x00000041, 0x0003003e, 0x0000003c, 0x00000042, 0x00050041, + 0x0000000b, 0x00000045, 0x00000031, 0x00000044, 0x0004003d, 0x00000006, 0x00000046, 0x00000045, + 0x0003003e, 0x00000043, 0x00000046, 0x00050041, 0x0000000b, 0x00000049, 0x0000003c, 0x00000048, + 0x0004003d, 0x00000006, 0x0000004a, 0x00000049, 0x000500b8, 0x00000047, 0x0000004c, 0x0000004a, + 0x0000004b, 0x000400a8, 0x00000047, 0x0000004d, 0x0000004c, 0x000300f7, 0x0000004f, 0x00000000, + 0x000400fa, 0x0000004d, 0x0000004e, 0x0000004f, 0x000200f8, 0x0000004e, 0x00050041, 0x0000000b, + 0x00000050, 0x0000003c, 0x00000048, 0x0004003d, 0x00000006, 0x00000051, 0x00000050, 0x000500ba, + 0x00000047, 0x00000052, 0x00000051, 0x0000002b, 0x000200f9, 0x0000004f, 0x000200f8, 0x0000004f, + 0x000700f5, 0x00000047, 0x00000053, 0x0000004c, 0x00000011, 0x00000052, 0x0000004e, 0x000400a8, + 0x00000047, 0x00000054, 0x00000053, 0x000300f7, 0x00000056, 0x00000000, 0x000400fa, 0x00000054, + 0x00000055, 0x00000056, 0x000200f8, 0x00000055, 0x00050041, 0x0000000b, 0x00000058, 0x0000003c, + 0x00000057, 0x0004003d, 0x00000006, 0x00000059, 0x00000058, 0x000500b8, 0x00000047, 0x0000005a, + 0x00000059, 0x0000004b, 0x000200f9, 0x00000056, 0x000200f8, 0x00000056, 0x000700f5, 0x00000047, + 0x0000005b, 0x00000053, 0x0000004f, 0x0000005a, 0x00000055, 0x000400a8, 0x00000047, 0x0000005c, + 0x0000005b, 0x000300f7, 0x0000005e, 0x00000000, 0x000400fa, 0x0000005c, 0x0000005d, 0x0000005e, + 0x000200f8, 0x0000005d, 0x00050041, 0x0000000b, 0x0000005f, 0x0000003c, 0x00000057, 0x0004003d, + 0x00000006, 0x00000060, 0x0000005f, 0x000500ba, 0x00000047, 0x00000061, 0x00000060, 0x0000002b, + 0x000200f9, 0x0000005e, 0x000200f8, 0x0000005e, 0x000700f5, 0x00000047, 0x00000062, 0x0000005b, + 0x00000056, 0x00000061, 0x0000005d, 0x0004003d, 0x00000006, 0x00000063, 0x00000043, 0x000500b8, + 0x00000047, 0x00000064, 0x00000063, 0x0000004b, 0x000500a6, 0x00000047, 0x00000065, 0x00000062, + 0x00000064, 0x0004003d, 0x00000006, 0x00000066, 0x00000043, 0x000500ba, 0x00000047, 0x00000067, + 0x00000066, 0x0000002b, 0x000500a6, 0x00000047, 0x00000068, 0x00000065, 0x00000067, 0x000300f7, + 0x0000006a, 0x00000000, 0x000400fa, 0x00000068, 0x00000069, 0x0000006a, 0x000200f8, 0x00000069, + 0x000200fe, 0x0000002b, 0x000200f8, 0x0000006a, 0x0004003d, 0x0000006e, 0x00000071, 0x00000070, + 0x00040064, 0x0000006d, 0x00000072, 0x00000071, 0x00050067, 0x00000073, 0x00000074, 0x00000072, + 0x00000025, 0x0007004f, 0x00000075, 0x00000076, 0x00000074, 0x00000074, 0x00000000, 0x00000001, + 0x0004006f, 0x0000003a, 0x00000077, 0x00000076, 0x00050050, 0x0000003a, 0x00000078, 0x0000002b, + 0x0000002b, 0x00050088, 0x0000003a, 0x00000079, 0x00000078, 0x00000077, 0x0003003e, 0x0000006c, + 0x00000079, 0x0003003e, 0x0000007a, 0x0000004b, 0x0004003d, 0x00000006, 0x0000007c, 0x00000043, + 0x0004003d, 0x00000006, 0x0000007d, 0x0000000f, 0x00050083, 0x00000006, 0x0000007e, 0x0000007c, + 0x0000007d, 0x0003003e, 0x0000007b, 0x0000007e, 0x0003003e, 0x0000007f, 0x00000025, 0x000200f9, + 0x00000080, 0x000200f8, 0x00000080, 0x000400f6, 0x00000082, 0x00000083, 0x00000000, 0x000200f9, + 0x00000084, 0x000200f8, 0x00000084, 0x0004003d, 0x00000009, 0x00000085, 0x0000007f, 0x000500b1, + 0x00000047, 0x00000087, 0x00000085, 0x00000086, 0x000400fa, 0x00000087, 0x00000081, 0x00000082, + 0x000200f8, 0x00000081, 0x0004003d, 0x0000003a, 0x00000089, 0x0000003c, 0x0004003d, 0x00000009, + 0x00000099, 0x0000007f, 0x0003003e, 0x0000009b, 0x00000098, 0x00050041, 0x0000003b, 0x0000009c, + 0x0000009b, 0x00000099, 0x0004003d, 0x0000003a, 0x0000009d, 0x0000009c, 0x0004003d, 0x0000003a, + 0x0000009e, 0x0000006c, 0x00050085, 0x0000003a, 0x0000009f, 0x0000009d, 0x0000009e, 0x00050081, + 0x0000003a, 0x000000a0, 0x00000089, 0x0000009f, 0x0003003e, 0x00000088, 0x000000a0, 0x00050041, + 0x0000000b, 0x000000a1, 0x00000088, 0x00000048, 0x0004003d, 0x00000006, 0x000000a2, 0x000000a1, + 0x000500b8, 0x00000047, 0x000000a3, 0x000000a2, 0x0000004b, 0x000400a8, 0x00000047, 0x000000a4, + 0x000000a3, 0x000300f7, 0x000000a6, 0x00000000, 0x000400fa, 0x000000a4, 0x000000a5, 0x000000a6, + 0x000200f8, 0x000000a5, 0x00050041, 0x0000000b, 0x000000a7, 0x00000088, 0x00000048, 0x0004003d, + 0x00000006, 0x000000a8, 0x000000a7, 0x000500ba, 0x00000047, 0x000000a9, 0x000000a8, 0x0000002b, + 0x000200f9, 0x000000a6, 0x000200f8, 0x000000a6, 0x000700f5, 0x00000047, 0x000000aa, 0x000000a3, + 0x00000081, 0x000000a9, 0x000000a5, 0x000400a8, 0x00000047, 0x000000ab, 0x000000aa, 0x000300f7, + 0x000000ad, 0x00000000, 0x000400fa, 0x000000ab, 0x000000ac, 0x000000ad, 0x000200f8, 0x000000ac, + 0x00050041, 0x0000000b, 0x000000ae, 0x00000088, 0x00000057, 0x0004003d, 0x00000006, 0x000000af, + 0x000000ae, 0x000500b8, 0x00000047, 0x000000b0, 0x000000af, 0x0000004b, 0x000200f9, 0x000000ad, + 0x000200f8, 0x000000ad, 0x000700f5, 0x00000047, 0x000000b1, 0x000000aa, 0x000000a6, 0x000000b0, + 0x000000ac, 0x000400a8, 0x00000047, 0x000000b2, 0x000000b1, 0x000300f7, 0x000000b4, 0x00000000, + 0x000400fa, 0x000000b2, 0x000000b3, 0x000000b4, 0x000200f8, 0x000000b3, 0x00050041, 0x0000000b, + 0x000000b5, 0x00000088, 0x00000057, 0x0004003d, 0x00000006, 0x000000b6, 0x000000b5, 0x000500ba, + 0x00000047, 0x000000b7, 0x000000b6, 0x0000002b, 0x000200f9, 0x000000b4, 0x000200f8, 0x000000b4, + 0x000700f5, 0x00000047, 0x000000b8, 0x000000b1, 0x000000ad, 0x000000b7, 0x000000b3, 0x000300f7, + 0x000000ba, 0x00000000, 0x000400fa, 0x000000b8, 0x000000b9, 0x000000ba, 0x000200f8, 0x000000b9, + 0x000200f9, 0x00000083, 0x000200f8, 0x000000ba, 0x0004003d, 0x0000006e, 0x000000bc, 0x00000070, + 0x0004003d, 0x0000003a, 0x000000bd, 0x00000088, 0x0004003d, 0x00000009, 0x000000be, 0x0000000e, + 0x0004006f, 0x00000006, 0x000000bf, 0x000000be, 0x0004003d, 0x00000006, 0x000000c0, 0x0000007b, + 0x00050051, 0x00000006, 0x000000c1, 0x000000bd, 0x00000000, 0x00050051, 0x00000006, 0x000000c2, + 0x000000bd, 0x00000001, 0x00070050, 0x0000001b, 0x000000c3, 0x000000c1, 0x000000c2, 0x000000bf, + 0x000000c0, 0x00050051, 0x00000006, 0x000000c4, 0x000000c3, 0x00000003, 0x00060059, 0x00000006, + 0x000000c5, 0x000000bc, 0x000000c3, 0x000000c4, 0x0004003d, 0x00000006, 0x000000c6, 0x0000007a, + 0x00050081, 0x00000006, 0x000000c7, 0x000000c6, 0x000000c5, 0x0003003e, 0x0000007a, 0x000000c7, + 0x000200f9, 0x00000083, 0x000200f8, 0x00000083, 0x0004003d, 0x00000009, 0x000000c8, 0x0000007f, + 0x00050080, 0x00000009, 0x000000ca, 0x000000c8, 0x000000c9, 0x0003003e, 0x0000007f, 0x000000ca, + 0x000200f9, 0x00000080, 0x000200f8, 0x00000082, 0x0004003d, 0x00000006, 0x000000cb, 0x0000007a, + 0x00050088, 0x00000006, 0x000000cd, 0x000000cb, 0x000000cc, 0x000200fe, 0x000000cd, 0x00010038, + 0x00050036, 0x00000006, 0x00000014, 0x00000000, 0x00000012, 0x00030037, 0x0000000a, 0x00000013, + 0x000200f8, 0x00000015, 0x0004003b, 0x0000000b, 0x000000d0, 0x00000007, 0x0004003b, 0x0000000b, + 0x000000d3, 0x00000007, 0x0004003b, 0x0000000b, 0x000000dd, 0x00000007, 0x0004003b, 0x0000000b, + 0x000000ea, 0x00000007, 0x0004003d, 0x00000009, 0x000000d1, 0x00000013, 0x000500aa, 0x00000047, + 0x000000d2, 0x000000d1, 0x00000025, 0x000300f7, 0x000000d5, 0x00000000, 0x000400fa, 0x000000d2, + 0x000000d4, 0x000000da, 0x000200f8, 0x000000d4, 0x00060041, 0x000000d7, 0x000000d8, 0x00000024, + 0x000000d6, 0x00000048, 0x0004003d, 0x00000006, 0x000000d9, 0x000000d8, 0x0003003e, 0x000000d3, + 0x000000d9, 0x000200f9, 0x000000d5, 0x000200f8, 0x000000da, 0x0004003d, 0x00000009, 0x000000db, + 0x00000013, 0x000500aa, 0x00000047, 0x000000dc, 0x000000db, 0x000000c9, 0x000300f7, 0x000000df, + 0x00000000, 0x000400fa, 0x000000dc, 0x000000de, 0x000000e2, 0x000200f8, 0x000000de, 0x00060041, + 0x000000d7, 0x000000e0, 0x00000024, 0x000000d6, 0x00000057, 0x0004003d, 0x00000006, 0x000000e1, + 0x000000e0, 0x0003003e, 0x000000dd, 0x000000e1, 0x000200f9, 0x000000df, 0x000200f8, 0x000000e2, + 0x00060041, 0x000000d7, 0x000000e3, 0x00000024, 0x000000d6, 0x00000044, 0x0004003d, 0x00000006, + 0x000000e4, 0x000000e3, 0x0003003e, 0x000000dd, 0x000000e4, 0x000200f9, 0x000000df, 0x000200f8, + 0x000000df, 0x0004003d, 0x00000006, 0x000000e5, 0x000000dd, 0x0003003e, 0x000000d3, 0x000000e5, + 0x000200f9, 0x000000d5, 0x000200f8, 0x000000d5, 0x0004003d, 0x00000006, 0x000000e6, 0x000000d3, + 0x0003003e, 0x000000d0, 0x000000e6, 0x0004003d, 0x00000006, 0x000000e7, 0x000000d0, 0x000500ba, + 0x00000047, 0x000000e9, 0x000000e7, 0x000000e8, 0x000300f7, 0x000000ec, 0x00000000, 0x000400fa, + 0x000000e9, 0x000000eb, 0x000000ee, 0x000200f8, 0x000000eb, 0x0004003d, 0x00000006, 0x000000ed, + 0x000000d0, 0x0003003e, 0x000000ea, 0x000000ed, 0x000200f9, 0x000000ec, 0x000200f8, 0x000000ee, + 0x00060041, 0x000000d7, 0x000000f0, 0x00000024, 0x000000ef, 0x00000048, 0x0004003d, 0x00000006, + 0x000000f1, 0x000000f0, 0x0003003e, 0x000000ea, 0x000000f1, 0x000200f9, 0x000000ec, 0x000200f8, + 0x000000ec, 0x0004003d, 0x00000006, 0x000000f2, 0x000000ea, 0x000200fe, 0x000000f2, 0x00010038, + 0x00050036, 0x00000006, 0x00000019, 0x00000000, 0x00000016, 0x00030037, 0x00000008, 0x00000017, + 0x00030037, 0x0000000b, 0x00000018, 0x000200f8, 0x0000001a, 0x0004003b, 0x0000000a, 0x0000010a, + 0x00000007, 0x0004003b, 0x0000000b, 0x00000118, 0x00000007, 0x0004003b, 0x0000000a, 0x00000119, + 0x00000007, 0x0004003b, 0x00000008, 0x0000011c, 0x00000007, 0x0004003b, 0x0000000a, 0x0000011e, + 0x00000007, 0x0004003b, 0x0000000b, 0x00000120, 0x00000007, 0x00060041, 0x000000d7, 0x000000f5, + 0x00000024, 0x000000ef, 0x00000057, 0x0004003d, 0x00000006, 0x000000f6, 0x000000f5, 0x000500b8, + 0x00000047, 0x000000f7, 0x000000f6, 0x0000003f, 0x000400a8, 0x00000047, 0x000000f8, 0x000000f7, + 0x000300f7, 0x000000fa, 0x00000000, 0x000400fa, 0x000000f8, 0x000000f9, 0x000000fa, 0x000200f8, + 0x000000f9, 0x00060041, 0x000000d7, 0x000000fb, 0x00000024, 0x000000ef, 0x00000044, 0x0004003d, + 0x00000006, 0x000000fc, 0x000000fb, 0x000500b8, 0x00000047, 0x000000fd, 0x000000fc, 0x0000003f, + 0x000200f9, 0x000000fa, 0x000200f8, 0x000000fa, 0x000700f5, 0x00000047, 0x000000fe, 0x000000f7, + 0x0000001a, 0x000000fd, 0x000000f9, 0x000400a8, 0x00000047, 0x000000ff, 0x000000fe, 0x000300f7, + 0x00000101, 0x00000000, 0x000400fa, 0x000000ff, 0x00000100, 0x00000101, 0x000200f8, 0x00000100, + 0x00060041, 0x000000d7, 0x00000102, 0x00000024, 0x000000c9, 0x00000020, 0x0004003d, 0x00000006, + 0x00000103, 0x00000102, 0x000500b8, 0x00000047, 0x00000105, 0x00000103, 0x00000104, 0x000200f9, + 0x00000101, 0x000200f8, 0x00000101, 0x000700f5, 0x00000047, 0x00000106, 0x000000fe, 0x000000fa, + 0x00000105, 0x00000100, 0x000300f7, 0x00000108, 0x00000000, 0x000400fa, 0x00000106, 0x00000107, + 0x00000108, 0x000200f8, 0x00000107, 0x000200fe, 0x0000002b, 0x000200f8, 0x00000108, 0x0003003e, + 0x0000010a, 0x000000ef, 0x0004003d, 0x00000006, 0x0000010b, 0x00000018, 0x00060041, 0x000000d7, + 0x0000010c, 0x00000024, 0x000000c9, 0x00000048, 0x0004003d, 0x00000006, 0x0000010d, 0x0000010c, + 0x000500b8, 0x00000047, 0x0000010e, 0x0000010b, 0x0000010d, 0x000300f7, 0x00000110, 0x00000000, + 0x000400fa, 0x0000010e, 0x0000010f, 0x00000111, 0x000200f8, 0x0000010f, 0x0003003e, 0x0000010a, + 0x00000025, 0x000200f9, 0x00000110, 0x000200f8, 0x00000111, 0x0004003d, 0x00000006, 0x00000112, + 0x00000018, 0x00060041, 0x000000d7, 0x00000113, 0x00000024, 0x000000c9, 0x00000057, 0x0004003d, + 0x00000006, 0x00000114, 0x00000113, 0x000500b8, 0x00000047, 0x00000115, 0x00000112, 0x00000114, + 0x000300f7, 0x00000117, 0x00000000, 0x000400fa, 0x00000115, 0x00000116, 0x00000117, 0x000200f8, + 0x00000116, 0x0003003e, 0x0000010a, 0x000000c9, 0x000200f9, 0x00000117, 0x000200f8, 0x00000117, + 0x000200f9, 0x00000110, 0x000200f8, 0x00000110, 0x0004003d, 0x00000009, 0x0000011a, 0x0000010a, + 0x0003003e, 0x00000119, 0x0000011a, 0x00050039, 0x00000006, 0x0000011b, 0x00000014, 0x00000119, + 0x0004003d, 0x00000007, 0x0000011d, 0x00000017, 0x0003003e, 0x0000011c, 0x0000011d, 0x0004003d, + 0x00000009, 0x0000011f, 0x0000010a, 0x0003003e, 0x0000011e, 0x0000011f, 0x0003003e, 0x00000120, + 0x0000011b, 0x00070039, 0x00000006, 0x00000121, 0x00000010, 0x0000011c, 0x0000011e, 0x00000120, + 0x0003003e, 0x00000118, 0x00000121, 0x0004003d, 0x00000006, 0x00000122, 0x00000118, 0x00060041, + 0x000000d7, 0x00000123, 0x00000024, 0x000000c9, 0x00000020, 0x0004003d, 0x00000006, 0x00000124, + 0x00000123, 0x0008000c, 0x00000006, 0x00000125, 0x00000001, 0x0000002b, 0x00000124, 0x0000004b, + 0x0000002b, 0x0008000c, 0x00000006, 0x00000126, 0x00000001, 0x0000002e, 0x0000002b, 0x00000122, + 0x00000125, 0x0003003e, 0x00000118, 0x00000126, 0x0004003d, 0x00000006, 0x00000127, 0x00000118, + 0x000200fe, 0x00000127, 0x00010038, +}; +static const size_t mesh3d_grass_frag_spv_count = sizeof(mesh3d_grass_frag_spv)/sizeof(mesh3d_grass_frag_spv[0]); diff --git a/src/modules/graphics/shaders/mesh3d_grass_vert_spv.inc b/src/modules/graphics/shaders/mesh3d_grass_vert_spv.inc new file mode 100644 index 00000000..808cb910 --- /dev/null +++ b/src/modules/graphics/shaders/mesh3d_grass_vert_spv.inc @@ -0,0 +1,204 @@ +static const uint32_t mesh3d_grass_vert_spv[] = { + 0x07230203, 0x00010000, 0x0008000b, 0x000000e3, 0x00000000, 0x00020011, 0x00000001, 0x0006000b, + 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e, 0x00000000, 0x0003000e, 0x00000000, 0x00000001, + 0x0010000f, 0x00000000, 0x00000004, 0x6e69616d, 0x00000000, 0x0000001f, 0x00000065, 0x00000090, + 0x000000bb, 0x000000c7, 0x000000ca, 0x000000cd, 0x000000d4, 0x000000d8, 0x000000db, 0x000000df, + 0x00030003, 0x00000002, 0x000001c2, 0x00040005, 0x00000004, 0x6e69616d, 0x00000000, 0x00040005, + 0x00000008, 0x74646977, 0x00000068, 0x00050005, 0x0000000c, 0x65747845, 0x6c616e72, 0x00000073, + 0x00050006, 0x0000000c, 0x00000000, 0x61746164, 0x00000000, 0x00030005, 0x0000000e, 0x00000075, + 0x00040005, 0x00000017, 0x67696568, 0x00007468, 0x00040005, 0x0000001c, 0x6c616373, 0x00000065, + 0x00050005, 0x0000001f, 0x6f4e6e69, 0x6c616d72, 0x00000000, 0x00050005, 0x00000029, 0x4d766e69, + 0x6c65646f, 0x00000000, 0x00040005, 0x0000002a, 0x6867694c, 0x00443374, 0x00060006, 0x0000002a, + 0x00000000, 0x52736f70, 0x75696461, 0x00000073, 0x00050006, 0x0000002a, 0x00000001, 0x6f6c6f63, + 0x00000072, 0x00040005, 0x0000002d, 0x6d617246, 0x00000065, 0x00040006, 0x0000002d, 0x00000000, + 0x0070766d, 0x00050006, 0x0000002d, 0x00000001, 0x65646f6d, 0x0000006c, 0x00080006, 0x0000002d, + 0x00000002, 0x6867696c, 0x72694474, 0x65746e49, 0x7469736e, 0x00000079, 0x00060006, 0x0000002d, + 0x00000003, 0x6867696c, 0x6c6f4374, 0x0000726f, 0x00050006, 0x0000002d, 0x00000004, 0x746e6974, + 0x00000000, 0x00060006, 0x0000002d, 0x00000005, 0x656d6163, 0x6f506172, 0x00000073, 0x00050006, + 0x0000002d, 0x00000006, 0x69626d61, 0x00746e65, 0x00050006, 0x0000002d, 0x00000007, 0x6867696c, + 0x00007374, 0x00050006, 0x0000002d, 0x00000008, 0x42786574, 0x00626d6f, 0x00060006, 0x0000002d, + 0x00000009, 0x61726170, 0x78616c6c, 0x00000000, 0x00050006, 0x0000002d, 0x0000000a, 0x77656976, + 0x00000000, 0x00060006, 0x0000002d, 0x0000000b, 0x70696c63, 0x6f666e49, 0x00000000, 0x00030005, + 0x0000002f, 0x006f6275, 0x00050005, 0x00000036, 0x6c726f77, 0x4f705564, 0x00006a62, 0x00040005, + 0x00000046, 0x654c7075, 0x0000006e, 0x00040005, 0x00000056, 0x4f6d6163, 0x00006a62, 0x00040005, + 0x00000063, 0x61436f74, 0x0000006d, 0x00040005, 0x00000065, 0x6f506e69, 0x00000073, 0x00050005, + 0x0000006f, 0x61436f74, 0x6e654c6d, 0x00000000, 0x00040005, 0x00000077, 0x68676972, 0x00000074, + 0x00050005, 0x0000007d, 0x6c6c6166, 0x6b636162, 0x00000000, 0x00030005, 0x0000008b, 0x006a626f, + 0x00040005, 0x00000090, 0x56556e69, 0x00000000, 0x00040005, 0x000000a6, 0x6c726f77, 0x00000064, + 0x00050005, 0x000000af, 0x746f6f72, 0x6c726f57, 0x00000064, 0x00060005, 0x000000b9, 0x505f6c67, + 0x65567265, 0x78657472, 0x00000000, 0x00060006, 0x000000b9, 0x00000000, 0x505f6c67, 0x7469736f, + 0x006e6f69, 0x00070006, 0x000000b9, 0x00000001, 0x505f6c67, 0x746e696f, 0x657a6953, 0x00000000, + 0x00070006, 0x000000b9, 0x00000002, 0x435f6c67, 0x4470696c, 0x61747369, 0x0065636e, 0x00070006, + 0x000000b9, 0x00000003, 0x435f6c67, 0x446c6c75, 0x61747369, 0x0065636e, 0x00030005, 0x000000bb, + 0x00000000, 0x00030005, 0x000000c7, 0x00565576, 0x00050005, 0x000000ca, 0x726f5776, 0x6f50646c, + 0x00000073, 0x00050005, 0x000000cd, 0x65695676, 0x736f5077, 0x00000000, 0x00050005, 0x000000d4, + 0x6f6f5276, 0x736f5074, 0x00000000, 0x00050005, 0x000000d8, 0x736e4976, 0x636e6174, 0x00644965, + 0x00040005, 0x000000db, 0x6e695476, 0x00000074, 0x00050005, 0x000000df, 0x776c4176, 0x44737961, + 0x006b7261, 0x00040047, 0x0000000b, 0x00000006, 0x00000004, 0x00030047, 0x0000000c, 0x00000002, + 0x00050048, 0x0000000c, 0x00000000, 0x00000023, 0x00000000, 0x00040047, 0x0000001f, 0x0000001e, + 0x00000001, 0x00050048, 0x0000002a, 0x00000000, 0x00000023, 0x00000000, 0x00050048, 0x0000002a, + 0x00000001, 0x00000023, 0x00000010, 0x00040047, 0x0000002c, 0x00000006, 0x00000020, 0x00030047, + 0x0000002d, 0x00000002, 0x00040048, 0x0000002d, 0x00000000, 0x00000005, 0x00050048, 0x0000002d, + 0x00000000, 0x00000007, 0x00000010, 0x00050048, 0x0000002d, 0x00000000, 0x00000023, 0x00000000, + 0x00040048, 0x0000002d, 0x00000001, 0x00000005, 0x00050048, 0x0000002d, 0x00000001, 0x00000007, + 0x00000010, 0x00050048, 0x0000002d, 0x00000001, 0x00000023, 0x00000040, 0x00050048, 0x0000002d, + 0x00000002, 0x00000023, 0x00000080, 0x00050048, 0x0000002d, 0x00000003, 0x00000023, 0x00000090, + 0x00050048, 0x0000002d, 0x00000004, 0x00000023, 0x000000a0, 0x00050048, 0x0000002d, 0x00000005, + 0x00000023, 0x000000b0, 0x00050048, 0x0000002d, 0x00000006, 0x00000023, 0x000000c0, 0x00050048, + 0x0000002d, 0x00000007, 0x00000023, 0x000000d0, 0x00050048, 0x0000002d, 0x00000008, 0x00000023, + 0x000001d0, 0x00050048, 0x0000002d, 0x00000009, 0x00000023, 0x000001e0, 0x00040048, 0x0000002d, + 0x0000000a, 0x00000005, 0x00050048, 0x0000002d, 0x0000000a, 0x00000007, 0x00000010, 0x00050048, + 0x0000002d, 0x0000000a, 0x00000023, 0x000001f0, 0x00050048, 0x0000002d, 0x0000000b, 0x00000023, + 0x00000230, 0x00040047, 0x0000002f, 0x00000021, 0x00000000, 0x00040047, 0x0000002f, 0x00000022, + 0x00000000, 0x00040047, 0x00000065, 0x0000001e, 0x00000000, 0x00040047, 0x00000090, 0x0000001e, + 0x00000002, 0x00030047, 0x000000b9, 0x00000002, 0x00050048, 0x000000b9, 0x00000000, 0x0000000b, + 0x00000000, 0x00050048, 0x000000b9, 0x00000001, 0x0000000b, 0x00000001, 0x00050048, 0x000000b9, + 0x00000002, 0x0000000b, 0x00000003, 0x00050048, 0x000000b9, 0x00000003, 0x0000000b, 0x00000004, + 0x00040047, 0x000000c7, 0x0000001e, 0x00000000, 0x00040047, 0x000000ca, 0x0000001e, 0x00000001, + 0x00040047, 0x000000cd, 0x0000001e, 0x00000002, 0x00040047, 0x000000d4, 0x0000001e, 0x00000003, + 0x00040047, 0x000000d8, 0x0000001e, 0x00000004, 0x00040047, 0x000000db, 0x0000001e, 0x00000005, + 0x00040047, 0x000000df, 0x0000001e, 0x00000006, 0x00020013, 0x00000002, 0x00030021, 0x00000003, + 0x00000002, 0x00030016, 0x00000006, 0x00000020, 0x00040020, 0x00000007, 0x00000007, 0x00000006, + 0x00040015, 0x00000009, 0x00000020, 0x00000000, 0x0004002b, 0x00000009, 0x0000000a, 0x00000020, + 0x0004001c, 0x0000000b, 0x00000006, 0x0000000a, 0x0003001e, 0x0000000c, 0x0000000b, 0x00040020, + 0x0000000d, 0x00000009, 0x0000000c, 0x0004003b, 0x0000000d, 0x0000000e, 0x00000009, 0x00040015, + 0x0000000f, 0x00000020, 0x00000001, 0x0004002b, 0x0000000f, 0x00000010, 0x00000000, 0x0004002b, + 0x0000000f, 0x00000011, 0x00000002, 0x00040020, 0x00000012, 0x00000009, 0x00000006, 0x0004002b, + 0x00000006, 0x00000015, 0x3c23d70a, 0x0004002b, 0x0000000f, 0x00000018, 0x00000003, 0x00040017, + 0x0000001d, 0x00000006, 0x00000003, 0x00040020, 0x0000001e, 0x00000001, 0x0000001d, 0x0004003b, + 0x0000001e, 0x0000001f, 0x00000001, 0x0004002b, 0x00000009, 0x00000020, 0x00000001, 0x00040020, + 0x00000021, 0x00000001, 0x00000006, 0x0004002b, 0x00000006, 0x00000024, 0x3d4ccccd, 0x00040017, + 0x00000026, 0x00000006, 0x00000004, 0x00040018, 0x00000027, 0x00000026, 0x00000004, 0x00040020, + 0x00000028, 0x00000007, 0x00000027, 0x0004001e, 0x0000002a, 0x00000026, 0x00000026, 0x0004002b, + 0x00000009, 0x0000002b, 0x00000008, 0x0004001c, 0x0000002c, 0x0000002a, 0x0000002b, 0x000e001e, + 0x0000002d, 0x00000027, 0x00000027, 0x00000026, 0x00000026, 0x00000026, 0x00000026, 0x00000026, + 0x0000002c, 0x00000026, 0x00000026, 0x00000027, 0x00000026, 0x00040020, 0x0000002e, 0x00000002, + 0x0000002d, 0x0004003b, 0x0000002e, 0x0000002f, 0x00000002, 0x0004002b, 0x0000000f, 0x00000030, + 0x00000001, 0x00040020, 0x00000031, 0x00000002, 0x00000027, 0x00040020, 0x00000035, 0x00000007, + 0x0000001d, 0x00040018, 0x00000039, 0x0000001d, 0x00000003, 0x0004002b, 0x00000006, 0x00000042, + 0x00000000, 0x0004002b, 0x00000006, 0x00000043, 0x3f800000, 0x0006002c, 0x0000001d, 0x00000044, + 0x00000042, 0x00000043, 0x00000042, 0x0004002b, 0x00000006, 0x0000004a, 0x3727c5ac, 0x00020014, + 0x0000004b, 0x0004002b, 0x0000000f, 0x00000058, 0x00000005, 0x00040020, 0x00000059, 0x00000002, + 0x00000026, 0x0004003b, 0x0000001e, 0x00000065, 0x00000001, 0x0004002b, 0x00000006, 0x00000073, + 0x38d1b717, 0x0004002b, 0x00000006, 0x00000081, 0x3f733333, 0x0006002c, 0x0000001d, 0x00000083, + 0x00000043, 0x00000042, 0x00000042, 0x00040017, 0x00000084, 0x0000004b, 0x00000003, 0x00040017, + 0x0000008e, 0x00000006, 0x00000002, 0x00040020, 0x0000008f, 0x00000001, 0x0000008e, 0x0004003b, + 0x0000008f, 0x00000090, 0x00000001, 0x0004002b, 0x00000009, 0x00000091, 0x00000000, 0x0004002b, + 0x00000006, 0x00000094, 0x3f000000, 0x00040020, 0x000000a5, 0x00000007, 0x00000026, 0x0004001c, + 0x000000b8, 0x00000006, 0x00000020, 0x0006001e, 0x000000b9, 0x00000026, 0x00000006, 0x000000b8, + 0x000000b8, 0x00040020, 0x000000ba, 0x00000003, 0x000000b9, 0x0004003b, 0x000000ba, 0x000000bb, + 0x00000003, 0x00040020, 0x000000c4, 0x00000003, 0x00000026, 0x00040020, 0x000000c6, 0x00000003, + 0x0000008e, 0x0004003b, 0x000000c6, 0x000000c7, 0x00000003, 0x00040020, 0x000000c9, 0x00000003, + 0x0000001d, 0x0004003b, 0x000000c9, 0x000000ca, 0x00000003, 0x0004003b, 0x000000c9, 0x000000cd, + 0x00000003, 0x0004002b, 0x0000000f, 0x000000ce, 0x0000000a, 0x0004003b, 0x000000c9, 0x000000d4, + 0x00000003, 0x00040020, 0x000000d7, 0x00000003, 0x00000006, 0x0004003b, 0x000000d7, 0x000000d8, + 0x00000003, 0x0004003b, 0x000000c4, 0x000000db, 0x00000003, 0x0004002b, 0x0000000f, 0x000000dc, + 0x00000004, 0x0004003b, 0x000000d7, 0x000000df, 0x00000003, 0x0004002b, 0x00000009, 0x000000e0, + 0x00000002, 0x00050036, 0x00000002, 0x00000004, 0x00000000, 0x00000003, 0x000200f8, 0x00000005, + 0x0004003b, 0x00000007, 0x00000008, 0x00000007, 0x0004003b, 0x00000007, 0x00000017, 0x00000007, + 0x0004003b, 0x00000007, 0x0000001c, 0x00000007, 0x0004003b, 0x00000028, 0x00000029, 0x00000007, + 0x0004003b, 0x00000035, 0x00000036, 0x00000007, 0x0004003b, 0x00000007, 0x00000046, 0x00000007, + 0x0004003b, 0x00000035, 0x0000004d, 0x00000007, 0x0004003b, 0x00000035, 0x00000056, 0x00000007, + 0x0004003b, 0x00000035, 0x00000063, 0x00000007, 0x0004003b, 0x00000007, 0x0000006f, 0x00000007, + 0x0004003b, 0x00000035, 0x00000077, 0x00000007, 0x0004003b, 0x00000035, 0x0000007d, 0x00000007, + 0x0004003b, 0x00000035, 0x0000008b, 0x00000007, 0x0004003b, 0x000000a5, 0x000000a6, 0x00000007, + 0x0004003b, 0x000000a5, 0x000000af, 0x00000007, 0x00060041, 0x00000012, 0x00000013, 0x0000000e, + 0x00000010, 0x00000011, 0x0004003d, 0x00000006, 0x00000014, 0x00000013, 0x0007000c, 0x00000006, + 0x00000016, 0x00000001, 0x00000028, 0x00000014, 0x00000015, 0x0003003e, 0x00000008, 0x00000016, + 0x00060041, 0x00000012, 0x00000019, 0x0000000e, 0x00000010, 0x00000018, 0x0004003d, 0x00000006, + 0x0000001a, 0x00000019, 0x0007000c, 0x00000006, 0x0000001b, 0x00000001, 0x00000028, 0x0000001a, + 0x00000015, 0x0003003e, 0x00000017, 0x0000001b, 0x00050041, 0x00000021, 0x00000022, 0x0000001f, + 0x00000020, 0x0004003d, 0x00000006, 0x00000023, 0x00000022, 0x0007000c, 0x00000006, 0x00000025, + 0x00000001, 0x00000028, 0x00000023, 0x00000024, 0x0003003e, 0x0000001c, 0x00000025, 0x00050041, + 0x00000031, 0x00000032, 0x0000002f, 0x00000030, 0x0004003d, 0x00000027, 0x00000033, 0x00000032, + 0x0006000c, 0x00000027, 0x00000034, 0x00000001, 0x00000022, 0x00000033, 0x0003003e, 0x00000029, + 0x00000034, 0x00050041, 0x00000031, 0x00000037, 0x0000002f, 0x00000030, 0x0004003d, 0x00000027, + 0x00000038, 0x00000037, 0x00050051, 0x00000026, 0x0000003a, 0x00000038, 0x00000000, 0x0008004f, + 0x0000001d, 0x0000003b, 0x0000003a, 0x0000003a, 0x00000000, 0x00000001, 0x00000002, 0x00050051, + 0x00000026, 0x0000003c, 0x00000038, 0x00000001, 0x0008004f, 0x0000001d, 0x0000003d, 0x0000003c, + 0x0000003c, 0x00000000, 0x00000001, 0x00000002, 0x00050051, 0x00000026, 0x0000003e, 0x00000038, + 0x00000002, 0x0008004f, 0x0000001d, 0x0000003f, 0x0000003e, 0x0000003e, 0x00000000, 0x00000001, + 0x00000002, 0x00060050, 0x00000039, 0x00000040, 0x0000003b, 0x0000003d, 0x0000003f, 0x0006000c, + 0x00000039, 0x00000041, 0x00000001, 0x00000022, 0x00000040, 0x00050091, 0x0000001d, 0x00000045, + 0x00000041, 0x00000044, 0x0003003e, 0x00000036, 0x00000045, 0x0004003d, 0x0000001d, 0x00000047, + 0x00000036, 0x0006000c, 0x00000006, 0x00000048, 0x00000001, 0x00000042, 0x00000047, 0x0003003e, + 0x00000046, 0x00000048, 0x0004003d, 0x00000006, 0x00000049, 0x00000046, 0x000500ba, 0x0000004b, + 0x0000004c, 0x00000049, 0x0000004a, 0x000300f7, 0x0000004f, 0x00000000, 0x000400fa, 0x0000004c, + 0x0000004e, 0x00000054, 0x000200f8, 0x0000004e, 0x0004003d, 0x0000001d, 0x00000050, 0x00000036, + 0x0004003d, 0x00000006, 0x00000051, 0x00000046, 0x00060050, 0x0000001d, 0x00000052, 0x00000051, + 0x00000051, 0x00000051, 0x00050088, 0x0000001d, 0x00000053, 0x00000050, 0x00000052, 0x0003003e, + 0x0000004d, 0x00000053, 0x000200f9, 0x0000004f, 0x000200f8, 0x00000054, 0x0003003e, 0x0000004d, + 0x00000044, 0x000200f9, 0x0000004f, 0x000200f8, 0x0000004f, 0x0004003d, 0x0000001d, 0x00000055, + 0x0000004d, 0x0003003e, 0x00000036, 0x00000055, 0x0004003d, 0x00000027, 0x00000057, 0x00000029, + 0x00050041, 0x00000059, 0x0000005a, 0x0000002f, 0x00000058, 0x0004003d, 0x00000026, 0x0000005b, + 0x0000005a, 0x0008004f, 0x0000001d, 0x0000005c, 0x0000005b, 0x0000005b, 0x00000000, 0x00000001, + 0x00000002, 0x00050051, 0x00000006, 0x0000005d, 0x0000005c, 0x00000000, 0x00050051, 0x00000006, + 0x0000005e, 0x0000005c, 0x00000001, 0x00050051, 0x00000006, 0x0000005f, 0x0000005c, 0x00000002, + 0x00070050, 0x00000026, 0x00000060, 0x0000005d, 0x0000005e, 0x0000005f, 0x00000043, 0x00050091, + 0x00000026, 0x00000061, 0x00000057, 0x00000060, 0x0008004f, 0x0000001d, 0x00000062, 0x00000061, + 0x00000061, 0x00000000, 0x00000001, 0x00000002, 0x0003003e, 0x00000056, 0x00000062, 0x0004003d, + 0x0000001d, 0x00000064, 0x00000056, 0x0004003d, 0x0000001d, 0x00000066, 0x00000065, 0x00050083, + 0x0000001d, 0x00000067, 0x00000064, 0x00000066, 0x0003003e, 0x00000063, 0x00000067, 0x0004003d, + 0x0000001d, 0x00000068, 0x00000063, 0x0004003d, 0x0000001d, 0x00000069, 0x00000036, 0x0004003d, + 0x0000001d, 0x0000006a, 0x00000063, 0x0004003d, 0x0000001d, 0x0000006b, 0x00000036, 0x00050094, + 0x00000006, 0x0000006c, 0x0000006a, 0x0000006b, 0x0005008e, 0x0000001d, 0x0000006d, 0x00000069, + 0x0000006c, 0x00050083, 0x0000001d, 0x0000006e, 0x00000068, 0x0000006d, 0x0003003e, 0x00000063, + 0x0000006e, 0x0004003d, 0x0000001d, 0x00000070, 0x00000063, 0x0006000c, 0x00000006, 0x00000071, + 0x00000001, 0x00000042, 0x00000070, 0x0003003e, 0x0000006f, 0x00000071, 0x0004003d, 0x00000006, + 0x00000072, 0x0000006f, 0x000500ba, 0x0000004b, 0x00000074, 0x00000072, 0x00000073, 0x000300f7, + 0x00000076, 0x00000000, 0x000400fa, 0x00000074, 0x00000075, 0x0000007c, 0x000200f8, 0x00000075, + 0x0004003d, 0x0000001d, 0x00000078, 0x00000036, 0x0004003d, 0x0000001d, 0x00000079, 0x00000063, + 0x0007000c, 0x0000001d, 0x0000007a, 0x00000001, 0x00000044, 0x00000078, 0x00000079, 0x0006000c, + 0x0000001d, 0x0000007b, 0x00000001, 0x00000045, 0x0000007a, 0x0003003e, 0x00000077, 0x0000007b, + 0x000200f9, 0x00000076, 0x000200f8, 0x0000007c, 0x00050041, 0x00000007, 0x0000007e, 0x00000036, + 0x00000020, 0x0004003d, 0x00000006, 0x0000007f, 0x0000007e, 0x0006000c, 0x00000006, 0x00000080, + 0x00000001, 0x00000004, 0x0000007f, 0x000500b8, 0x0000004b, 0x00000082, 0x00000080, 0x00000081, + 0x00060050, 0x00000084, 0x00000085, 0x00000082, 0x00000082, 0x00000082, 0x000600a9, 0x0000001d, + 0x00000086, 0x00000085, 0x00000044, 0x00000083, 0x0003003e, 0x0000007d, 0x00000086, 0x0004003d, + 0x0000001d, 0x00000087, 0x00000036, 0x0004003d, 0x0000001d, 0x00000088, 0x0000007d, 0x0007000c, + 0x0000001d, 0x00000089, 0x00000001, 0x00000044, 0x00000087, 0x00000088, 0x0006000c, 0x0000001d, + 0x0000008a, 0x00000001, 0x00000045, 0x00000089, 0x0003003e, 0x00000077, 0x0000008a, 0x000200f9, + 0x00000076, 0x000200f8, 0x00000076, 0x0004003d, 0x0000001d, 0x0000008c, 0x00000065, 0x0004003d, + 0x0000001d, 0x0000008d, 0x00000077, 0x00050041, 0x00000021, 0x00000092, 0x00000090, 0x00000091, + 0x0004003d, 0x00000006, 0x00000093, 0x00000092, 0x00050083, 0x00000006, 0x00000095, 0x00000093, + 0x00000094, 0x0005008e, 0x0000001d, 0x00000096, 0x0000008d, 0x00000095, 0x0004003d, 0x00000006, + 0x00000097, 0x00000008, 0x0005008e, 0x0000001d, 0x00000098, 0x00000096, 0x00000097, 0x0004003d, + 0x00000006, 0x00000099, 0x0000001c, 0x0005008e, 0x0000001d, 0x0000009a, 0x00000098, 0x00000099, + 0x00050081, 0x0000001d, 0x0000009b, 0x0000008c, 0x0000009a, 0x0004003d, 0x0000001d, 0x0000009c, + 0x00000036, 0x00050041, 0x00000021, 0x0000009d, 0x00000090, 0x00000020, 0x0004003d, 0x00000006, + 0x0000009e, 0x0000009d, 0x0005008e, 0x0000001d, 0x0000009f, 0x0000009c, 0x0000009e, 0x0004003d, + 0x00000006, 0x000000a0, 0x00000017, 0x0005008e, 0x0000001d, 0x000000a1, 0x0000009f, 0x000000a0, + 0x0004003d, 0x00000006, 0x000000a2, 0x0000001c, 0x0005008e, 0x0000001d, 0x000000a3, 0x000000a1, + 0x000000a2, 0x00050081, 0x0000001d, 0x000000a4, 0x0000009b, 0x000000a3, 0x0003003e, 0x0000008b, + 0x000000a4, 0x00050041, 0x00000031, 0x000000a7, 0x0000002f, 0x00000030, 0x0004003d, 0x00000027, + 0x000000a8, 0x000000a7, 0x0004003d, 0x0000001d, 0x000000a9, 0x0000008b, 0x00050051, 0x00000006, + 0x000000aa, 0x000000a9, 0x00000000, 0x00050051, 0x00000006, 0x000000ab, 0x000000a9, 0x00000001, + 0x00050051, 0x00000006, 0x000000ac, 0x000000a9, 0x00000002, 0x00070050, 0x00000026, 0x000000ad, + 0x000000aa, 0x000000ab, 0x000000ac, 0x00000043, 0x00050091, 0x00000026, 0x000000ae, 0x000000a8, + 0x000000ad, 0x0003003e, 0x000000a6, 0x000000ae, 0x00050041, 0x00000031, 0x000000b0, 0x0000002f, + 0x00000030, 0x0004003d, 0x00000027, 0x000000b1, 0x000000b0, 0x0004003d, 0x0000001d, 0x000000b2, + 0x00000065, 0x00050051, 0x00000006, 0x000000b3, 0x000000b2, 0x00000000, 0x00050051, 0x00000006, + 0x000000b4, 0x000000b2, 0x00000001, 0x00050051, 0x00000006, 0x000000b5, 0x000000b2, 0x00000002, + 0x00070050, 0x00000026, 0x000000b6, 0x000000b3, 0x000000b4, 0x000000b5, 0x00000043, 0x00050091, + 0x00000026, 0x000000b7, 0x000000b1, 0x000000b6, 0x0003003e, 0x000000af, 0x000000b7, 0x00050041, + 0x00000031, 0x000000bc, 0x0000002f, 0x00000010, 0x0004003d, 0x00000027, 0x000000bd, 0x000000bc, + 0x0004003d, 0x0000001d, 0x000000be, 0x0000008b, 0x00050051, 0x00000006, 0x000000bf, 0x000000be, + 0x00000000, 0x00050051, 0x00000006, 0x000000c0, 0x000000be, 0x00000001, 0x00050051, 0x00000006, + 0x000000c1, 0x000000be, 0x00000002, 0x00070050, 0x00000026, 0x000000c2, 0x000000bf, 0x000000c0, + 0x000000c1, 0x00000043, 0x00050091, 0x00000026, 0x000000c3, 0x000000bd, 0x000000c2, 0x00050041, + 0x000000c4, 0x000000c5, 0x000000bb, 0x00000010, 0x0003003e, 0x000000c5, 0x000000c3, 0x0004003d, + 0x0000008e, 0x000000c8, 0x00000090, 0x0003003e, 0x000000c7, 0x000000c8, 0x0004003d, 0x00000026, + 0x000000cb, 0x000000a6, 0x0008004f, 0x0000001d, 0x000000cc, 0x000000cb, 0x000000cb, 0x00000000, + 0x00000001, 0x00000002, 0x0003003e, 0x000000ca, 0x000000cc, 0x00050041, 0x00000031, 0x000000cf, + 0x0000002f, 0x000000ce, 0x0004003d, 0x00000027, 0x000000d0, 0x000000cf, 0x0004003d, 0x00000026, + 0x000000d1, 0x000000a6, 0x00050091, 0x00000026, 0x000000d2, 0x000000d0, 0x000000d1, 0x0008004f, + 0x0000001d, 0x000000d3, 0x000000d2, 0x000000d2, 0x00000000, 0x00000001, 0x00000002, 0x0003003e, + 0x000000cd, 0x000000d3, 0x0004003d, 0x00000026, 0x000000d5, 0x000000af, 0x0008004f, 0x0000001d, + 0x000000d6, 0x000000d5, 0x000000d5, 0x00000000, 0x00000001, 0x00000002, 0x0003003e, 0x000000d4, + 0x000000d6, 0x00050041, 0x00000021, 0x000000d9, 0x0000001f, 0x00000091, 0x0004003d, 0x00000006, + 0x000000da, 0x000000d9, 0x0003003e, 0x000000d8, 0x000000da, 0x00050041, 0x00000059, 0x000000dd, + 0x0000002f, 0x000000dc, 0x0004003d, 0x00000026, 0x000000de, 0x000000dd, 0x0003003e, 0x000000db, + 0x000000de, 0x00050041, 0x00000021, 0x000000e1, 0x0000001f, 0x000000e0, 0x0004003d, 0x00000006, + 0x000000e2, 0x000000e1, 0x0003003e, 0x000000df, 0x000000e2, 0x000100fd, 0x00010038, +}; +static const size_t mesh3d_grass_vert_spv_count = sizeof(mesh3d_grass_vert_spv)/sizeof(mesh3d_grass_vert_spv[0]); diff --git a/src/modules/graphics/vulkan/Graphics.cpp b/src/modules/graphics/vulkan/Graphics.cpp index 6d369af8..bbdfdd7b 100644 --- a/src/modules/graphics/vulkan/Graphics.cpp +++ b/src/modules/graphics/vulkan/Graphics.cpp @@ -262,11 +262,18 @@ Graphics::~Graphics() { void Graphics::initWithWindow(void *nativeWindow) { if (initialized) { // Window module may destroy/recreate the SDL window (tests, setWindowSettings). - // The Vulkan surface is tied to the native window — rebuild it when the - // handle changes, otherwise acquire/present use a destroyed surface. - if (sdlWindow == nativeWindow) return; + // The Vulkan surface is tied to the native window. SDL can hand back the + // same pointer for a freshly recreated window, so pointer identity alone + // can't tell whether the surface is still valid: Window::close() drops it + // via onNativeWindowDestroyed(), and a missing surface must be rebuilt too. + if (sdlWindow == nativeWindow && surface) return; sdlWindow = nativeWindow; recreateSurfaceForResume(); + // Restore the initWithWindow contract (device + swapchain valid on + // return): rebuild the swapchain synchronously. On desktop this runs + // immediately; on Android/iOS isRenderSurfaceStable() defers it until + // the native surface settles, leaving swapchainDirty set (same as before). + rebuildSwapchainIfNeeded(); return; } sdlWindow = nativeWindow; @@ -357,6 +364,26 @@ void Graphics::initWithWindow(void *nativeWindow) { whiteTexture = newTexture(1, 1, whitePixel); } +void Graphics::onNativeWindowDestroyed() { + if (!initialized) return; + // Fire registered window-destroyed callbacks (UI backend tears down its + // ImGui context here) before the device/surface resources are released. + for (auto &cb : windowDestroyedCallbacks_) cb.first(cb.second); + windowDestroyedCallbacks_.clear(); + clearPresentOverlay(); + + device->waitIdle(); + destroySwapchainResources(); + swapchain.destroy(); + swapchain = vkb::Swapchain{}; + if (surface) { + inst.instance.destroySurfaceKHR(surface); + surface = VK_NULL_HANDLE; + } + sdlWindow = nullptr; + swapchainDirty = true; +} + void Graphics::destroySwapchainResources() { presentRecording = {}; swapchainPass = {}; @@ -994,6 +1021,7 @@ void Graphics::destroyGBufferResources() { void Graphics::destroySceneColorResources() { sceneColorPassOpen = false; + if (device.instance) device->waitIdle(); for (auto &slot : sceneColorSlots) { slot.colorTex.gpuHandle = nullptr; if (slot.framebuffer) { @@ -1032,8 +1060,9 @@ void Graphics::createUiColorResources(int width, int height) { if (samples != vk::SampleCountFlagBits::e1) { uiRenderPass = device.createRenderPass() - .addSampledColorAttachment(colorFmt, vk::AttachmentLoadOp::eClear, samples) - .addResolveColorAttachment(colorFmt) + .addColorAttachment(colorFmt, vk::AttachmentLoadOp::eClear, + vk::AttachmentStoreOp::eDontCare, samples) + .addResolveColorAttachment(colorFmt, vk::AttachmentLoadOp::eDontCare) .addSubpass(vkb::SubpassBuilder() .addAttachmentRef(0, vk::ImageLayout::eColorAttachmentOptimal) .addResolveAttachment(1, vk::ImageLayout::eColorAttachmentOptimal)) @@ -1049,6 +1078,9 @@ void Graphics::createUiColorResources(int width, int height) { .build(); } } + // Keep sample count in sync with the render pass even if target creation + // returns early (ImGui builds its pipeline from getUiMsaaSamples()). + uiColorSamples = samples; if (!texSetLayout || !descriptorPool) return; @@ -1107,6 +1139,7 @@ void Graphics::createUiColorResources(int width, int height) { } void Graphics::destroyUiColorTargets() { + if (device.instance) device->waitIdle(); for (auto &slot : uiColorSlots) { slot.colorTex.gpuHandle = nullptr; if (slot.framebuffer) { @@ -1119,7 +1152,7 @@ void Graphics::destroyUiColorTargets() { uiColorWidth = 0; uiColorHeight = 0; uiColorFormat = vk::Format::eUndefined; - uiColorSamples = vk::SampleCountFlagBits::e1; + // Keep uiColorSamples matching uiRenderPass; ImGui's pipeline is bound to it. } void Graphics::destroyUiColorResources() { @@ -1318,10 +1351,14 @@ void Graphics::createSceneColorResources(int width, int height) { } if (msaa) { + // MSAA color is transient (DONT_CARE store). Sampling the 1x resolve. + // addSampledColorAttachment on the MSAA target makes MoltenVK treat it + // as a shader-readable texture2d and segfaults on macOS. sceneColorRenderPass = device.createRenderPass() - .addSampledColorAttachment(colorFmt, vk::AttachmentLoadOp::eClear, samples) - .addResolveColorAttachment(colorFmt) + .addColorAttachment(colorFmt, vk::AttachmentLoadOp::eClear, + vk::AttachmentStoreOp::eDontCare, samples) + .addResolveColorAttachment(colorFmt, vk::AttachmentLoadOp::eDontCare) .addDepthAttachment(depthFmt, vk::AttachmentLoadOp::eClear, vk::AttachmentStoreOp::eDontCare, samples) .addSubpass(vkb::SubpassBuilder() @@ -3169,6 +3206,7 @@ vk::Pipeline Graphics::buildVoxelRectPipeline(const vkb::BuiltRenderPass &rp, vk::FrontFace::eCounterClockwise) .setMultisampler(false, samples) .setDepthStencil(true, true, vk::CompareOp::eLess) + .setColorAttachmentCount(1) .build(rp); } diff --git a/src/modules/graphics/vulkan/Graphics.h b/src/modules/graphics/vulkan/Graphics.h index 580dd65d..7cc14f0c 100644 --- a/src/modules/graphics/vulkan/Graphics.h +++ b/src/modules/graphics/vulkan/Graphics.h @@ -155,6 +155,7 @@ public: void initWithWindow(void *nativeWindow) override; void present() override; void requestSurfaceRecreate() override { surfaceNeedsRecreate = true; } + void onNativeWindowDestroyed() override; void setScreenReadbackEnabled(bool enabled) override { eve::graphics::Graphics::setScreenReadbackEnabled(enabled); ensurePresentCaptureHook(); diff --git a/src/modules/graphics/webgpu/Canvas.cpp b/src/modules/graphics/webgpu/Canvas.cpp new file mode 100644 index 00000000..2f4e08c8 --- /dev/null +++ b/src/modules/graphics/webgpu/Canvas.cpp @@ -0,0 +1,82 @@ +#include "graphics/webgpu/Canvas.h" +#include "graphics/webgpu/Graphics.h" + +#include "common/Exception.h" +#include "image/ImageData.h" + +#include + +namespace eve::graphics::webgpu { + +namespace { +/** Build a WGPUStringView from a C string (null-safe). */ +WGPUStringView sv(const char *s) { return WGPUStringView{s, s ? std::strlen(s) : 0}; } +} // namespace + +OffscreenCanvas::OffscreenCanvas(Graphics *gfx, int width, int height) + : gfx(gfx), width(width), height(height) { + if (!gfx || !gfx->getDevice() || width <= 0 || height <= 0) + throw Exception("OffscreenCanvas: invalid size or uninitialized device"); + + auto &device = gfx->getDevice(); + WGPUTextureDescriptor cd{}; + cd.label = sv("eve_canvas_color"); + cd.dimension = WGPUTextureDimension_2D; + cd.size = {static_cast(width), static_cast(height), 1}; + cd.sampleCount = 1; + cd.format = WGPUTextureFormat_RGBA8Unorm; + cd.mipLevelCount = 1; + cd.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_RenderAttachment | + WGPUTextureUsage_CopySrc; + color = device.CreateTexture(reinterpret_cast(&cd)); + colorView = color.CreateView(); + + WGPUTextureDescriptor dd{}; + dd.label = sv("eve_canvas_depth"); + dd.dimension = WGPUTextureDimension_2D; + dd.size = {static_cast(width), static_cast(height), 1}; + dd.sampleCount = 1; + dd.format = WGPUTextureFormat_Depth32Float; + dd.mipLevelCount = 1; + dd.usage = WGPUTextureUsage_RenderAttachment; + depth = device.CreateTexture(reinterpret_cast(&dd)); + depthView = depth.CreateView(); + + colorGpu.texture = color; + colorGpu.view = colorView; + colorGpu.width = width; + colorGpu.height = height; + colorGpu.samplerState = TextureSampler::linearMipmap(); + WGPUSamplerDescriptor sd{}; + sd.label = sv("eve_canvas_sampler"); + sd.addressModeU = WGPUAddressMode_ClampToEdge; + sd.addressModeV = WGPUAddressMode_ClampToEdge; + sd.addressModeW = WGPUAddressMode_ClampToEdge; + sd.magFilter = WGPUFilterMode_Linear; + sd.minFilter = WGPUFilterMode_Linear; + sd.mipmapFilter = WGPUMipmapFilterMode_Nearest; + colorGpu.sampler = device.CreateSampler(reinterpret_cast(&sd)); + + colorTex.gpuHandle = &colorGpu; + colorTex.width = width; + colorTex.height = height; + colorTex.mipmapCount = 1; +} + +OffscreenCanvas::~OffscreenCanvas() = default; + +void OffscreenCanvas::clear(std::optional color, std::optional /*stencil*/, + std::optional /*depth*/) { + clearRequested = true; + clearColor = color ? *color : Color(0.f, 0.f, 0.f, 1.f); +} + +Color OffscreenCanvas::getPixel(int x, int y) { + return gfx->getPixelImpl(this, x, y); +} + +image::ImageData *OffscreenCanvas::newImageData() { + return gfx->newImageDataImpl(this); +} + +} // namespace eve::graphics::webgpu diff --git a/src/modules/graphics/webgpu/Canvas.h b/src/modules/graphics/webgpu/Canvas.h new file mode 100644 index 00000000..5f110c2c --- /dev/null +++ b/src/modules/graphics/webgpu/Canvas.h @@ -0,0 +1,50 @@ +#pragma once +#include "graphics/Canvas.h" +#include "graphics/Texture.h" +#include "graphics/webgpu/Graphics.h" + +#include + +#include + +namespace eve::graphics::webgpu { + +/** + * Offscreen render target (RGBA8Unorm color, optional Depth32Float). + * 2D batches are flushed into the canvas texture by Graphics::flush2DToCanvas. + */ +class OffscreenCanvas final : public eve::graphics::Canvas { +public: + OffscreenCanvas(Graphics *gfx, int width, int height); + ~OffscreenCanvas() override; + + int getWidth() const override { return width; } + int getHeight() const override { return height; } + Texture *getTexture() override { return &colorTex; } + + void clear(std::optional color, std::optional stencil, + std::optional depth) override; + Color getPixel(int x, int y) override; + image::ImageData *newImageData() override; + + void draw(Canvas *, const glm::mat4 &) const override {} + void draw(eve::graphics::Graphics *, const glm::mat4 &) const override {} + + bool clearRequested = false; + Color clearColor{0.f, 0.f, 0.f, 1.f}; + + friend class Graphics; + +private: + Graphics *gfx; + int width = 0; + int height = 0; + wgpu::Texture color; + wgpu::TextureView colorView; + wgpu::Texture depth; + wgpu::TextureView depthView; + GpuTexture colorGpu; + Texture colorTex; +}; + +} // namespace eve::graphics::webgpu diff --git a/src/modules/graphics/webgpu/Graphics.cpp b/src/modules/graphics/webgpu/Graphics.cpp new file mode 100644 index 00000000..1048620d --- /dev/null +++ b/src/modules/graphics/webgpu/Graphics.cpp @@ -0,0 +1,3018 @@ +#include "graphics/webgpu/Graphics.h" +#include "graphics/webgpu/wgsl_shaders.h" +#include "graphics/webgpu/Canvas.h" +#include "graphics/Batcher.h" +#include "graphics/Texture.h" +#include "graphics/Mesh.h" +#include "graphics/Shader.h" +#include "graphics/Light.h" +#include "graphics/ClusteredLight.h" +#include "graphics/Shadow.h" + +#include "common/Exception.h" +#include "common/config.h" +#include "filesystem/Filesystem.h" +#include "image/Image.h" +#include "image/ImageData.h" + +#include + +#include +#include +#include +#include + +#include + +#include +#include + +#if defined(_WIN32) +#include +#endif + +#if defined(__EMSCRIPTEN__) +#include +#endif + +namespace eve::graphics::webgpu { + +namespace { + +/** Build a WGPUStringView from a C string (null-safe, length auto-computed). */ +WGPUStringView sv(const char *s) { + return WGPUStringView{s, s ? std::strlen(s) : 0}; +} + +// A shared default sampler used when no texture is provided. +wgpu::Sampler createLinearSampler(wgpu::Device &dev) { + WGPUSamplerDescriptor d{}; + d.label = sv("eve_linear"); + d.addressModeU = WGPUAddressMode_ClampToEdge; + d.addressModeV = WGPUAddressMode_ClampToEdge; + d.addressModeW = WGPUAddressMode_ClampToEdge; + d.magFilter = WGPUFilterMode_Linear; + d.minFilter = WGPUFilterMode_Linear; + d.mipmapFilter = WGPUMipmapFilterMode_Linear; + d.lodMinClamp = 0.f; + d.lodMaxClamp = 1000.f; + d.maxAnisotropy = 1; + return dev.CreateSampler(reinterpret_cast(&d)); +} + +int nextPOT(int v) { + int p = 1; + while (p < v) p <<= 1; + return p; +} + +} // namespace + +Graphics::Graphics() { + for (uint32_t i = 0; i < kFramesInFlight; ++i) { + uboArenas.emplace_back(); + vertexArenas.emplace_back(); + } +} + +Graphics::~Graphics() = default; + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +void Graphics::initWithWindow(void *nativeWindow) { + sdlWindow = nativeWindow; + if (deviceInitDone) return; + + createInstanceAndAdapter(); + requestDevice(); + if (!device) throw Exception("WebGPU: device request failed"); + + queue = device.GetQueue(); + + // ---- Surface ---- + WGPUSurfaceDescriptor surfDesc{}; + surfDesc.label = sv("eve_surface"); +#if defined(__EMSCRIPTEN__) + WGPUEmscriptenSurfaceSourceCanvasHTMLSelector canvasSel{}; + canvasSel.chain.sType = WGPUSType_EmscriptenSurfaceSourceCanvasHTMLSelector; + canvasSel.selector = sv("#canvas"); + surfDesc.nextInChain = &canvasSel.chain; + surface = instance.CreateSurface(reinterpret_cast(&surfDesc)); + surfaceFormat = WGPUTextureFormat_BGRA8Unorm; +#else + SDL_SysWMinfo wminfo; + SDL_VERSION(&wminfo.version); + if (!SDL_GetWindowWMInfo(static_cast(sdlWindow), &wminfo)) + throw Exception("WebGPU: SDL_GetWindowWMInfo failed: %s", SDL_GetError()); +#if defined(_WIN32) + WGPUSurfaceSourceWindowsHWND winChain{}; + winChain.chain.sType = WGPUSType_SurfaceSourceWindowsHWND; + winChain.hwnd = wminfo.info.win.window; + winChain.hinstance = GetModuleHandle(nullptr); + surfDesc.nextInChain = &winChain.chain; + surface = instance.CreateSurface(reinterpret_cast(&surfDesc)); +#elif defined(__linux__) + if (wminfo.subsystem == SDL_SYSWM_X11) { + WGPUSurfaceSourceXlibWindow x11Chain{}; + x11Chain.chain.sType = WGPUSType_SurfaceSourceXlibWindow; + x11Chain.display = wminfo.info.x11.display; + x11Chain.window = wminfo.info.x11.window; + surfDesc.nextInChain = &x11Chain.chain; + surface = instance.CreateSurface(reinterpret_cast(&surfDesc)); + } else if (wminfo.subsystem == SDL_SYSWM_WAYLAND) { + WGPUSurfaceSourceWaylandSurface wlChain{}; + wlChain.chain.sType = WGPUSType_SurfaceSourceWaylandSurface; + wlChain.display = wminfo.info.wl.display; + wlChain.surface = wminfo.info.wl.surface; + surfDesc.nextInChain = &wlChain.chain; + surface = instance.CreateSurface(reinterpret_cast(&surfDesc)); + } else { + throw Exception("WebGPU: unsupported SDL window subsystem on Linux"); + } +#elif defined(__APPLE__) + throw Exception("WebGPU: native macOS surface (Metal layer) not yet supported; " + "use the Emscripten browser build or the Vulkan backend on macOS"); +#else + throw Exception("WebGPU: unsupported native platform for surface creation"); +#endif + if (!surface) throw Exception("WebGPU: surface creation failed"); + // Preferred surface format comes from wgpuSurfaceGetCapabilities in the + // current ABI (Surface::GetPreferredFormat was removed). + WGPUSurfaceCapabilities caps{}; + if (wgpuSurfaceGetCapabilities(surface.Get(), adapter.Get(), &caps) == WGPUStatus_Success && + caps.formatCount > 0) { + surfaceFormat = caps.formats[0]; + } + wgpuSurfaceCapabilitiesFreeMembers(&caps); +#endif + + swapchainConfigured = false; + // Bind group layouts / pipelines must exist before the default textures + // (texture bind groups reference the 2D / mesh3d layouts), and the shadow + // depth array must exist before mesh bind groups are built at flush time. + createPipelineResources(); + createShadowResources(); + createDefaultTextures(); + initialized = true; +} + +void Graphics::createInstanceAndAdapter() { + instance = wgpu::CreateInstance(); + if (!instance) throw Exception("WebGPU: wgpuCreateInstance failed"); + + WGPURequestAdapterOptions opts{}; + opts.compatibleSurface = surface.Get(); + opts.powerPreference = WGPUPowerPreference_HighPerformance; + + adapterReceived = false; + WGPURequestAdapterCallbackInfo cbInfo{}; + cbInfo.nextInChain = nullptr; + cbInfo.mode = WGPUCallbackMode_AllowProcessEvents; + cbInfo.callback = [](WGPURequestAdapterStatus status, WGPUAdapter a, WGPUStringView msg, + void *userdata1, void * /*userdata2*/) { + auto *self = static_cast(userdata1); + if (status == WGPURequestAdapterStatus_Success && a) { + self->adapter = wgpu::Adapter(a); + } else { + self->adapterError = + msg.data ? std::string(msg.data, msg.length) : "unknown adapter error"; + } + self->adapterReceived.store(true); + }; + cbInfo.userdata1 = this; + cbInfo.userdata2 = nullptr; + + wgpuInstanceRequestAdapter(instance.Get(), &opts, cbInfo); + waitForAdapter(); + if (!adapter) { + throw Exception("WebGPU: no adapter found (%s)", adapterError.c_str()); + } +} + +void Graphics::requestDevice() { + WGPUDeviceDescriptor devDesc{}; + devDesc.label = sv("eve_device"); + + deviceReceived = false; + WGPURequestDeviceCallbackInfo cbInfo{}; + cbInfo.nextInChain = nullptr; + cbInfo.mode = WGPUCallbackMode_AllowProcessEvents; + cbInfo.callback = [](WGPURequestDeviceStatus status, WGPUDevice d, WGPUStringView msg, + void *userdata1, void * /*userdata2*/) { + auto *self = static_cast(userdata1); + if (status == WGPURequestDeviceStatus_Success && d) { + self->device = wgpu::Device(d); + // Default device limits support 8+ uniform/vertex/storage buffers. + self->deviceReceived.store(true); + self->deviceError.clear(); + } else { + self->deviceError = + msg.data ? std::string(msg.data, msg.length) : "unknown device error"; + self->deviceReceived.store(true); + } + }; + cbInfo.userdata1 = this; + cbInfo.userdata2 = nullptr; + + wgpuAdapterRequestDevice(adapter.Get(), &devDesc, cbInfo); + waitForDevice(); + if (!device) { + throw Exception("WebGPU: device request failed (%s)", deviceError.c_str()); + } +} + +void Graphics::waitForAdapter() { + while (!adapterReceived.load()) { +#if defined(__EMSCRIPTEN__) + // Yield to the browser event loop so the JS requestAdapter promise can + // resolve and fire the callback, then flush it with ProcessEvents. + emscripten_sleep(0); +#endif + wgpuInstanceProcessEvents(instance.Get()); + } +} + +void Graphics::waitForDevice() { + while (!deviceReceived.load()) { +#if defined(__EMSCRIPTEN__) + emscripten_sleep(0); +#endif + wgpuInstanceProcessEvents(instance.Get()); + } +} + +void Graphics::setVSync(bool enabled) { + if (vsyncEnabled == enabled) return; + eve::graphics::Graphics::setVSync(enabled); + markSwapchainDirty(); +} + +void Graphics::setViewportSize(int width, int height, int pixelwidth, int pixelheight) { + logicalW = width; + logicalH = height; + pixelW = pixelwidth; + pixelH = pixelheight; + if (width <= 0 || height <= 0) return; + if (initialized) configureSurface(width, height); +} + +void Graphics::configureSurface(int width, int height) { + if (!surface || !device || width <= 0 || height <= 0) return; + WGPUSurfaceConfiguration cfg{}; + cfg.nextInChain = nullptr; + cfg.device = device.Get(); + cfg.width = static_cast(width); + cfg.height = static_cast(height); + cfg.format = surfaceFormat; + cfg.usage = WGPUTextureUsage_RenderAttachment; + cfg.viewFormatCount = 0; + cfg.viewFormats = nullptr; + // emdawnwebgpu only accepts Fifo / Undefined present modes. + cfg.presentMode = WGPUPresentMode_Fifo; + cfg.alphaMode = WGPUCompositeAlphaMode_Auto; + surface.Configure(reinterpret_cast(&cfg)); + swapchainConfigured = true; + // Recreate the offscreen targets at the new size. + int w = pixelW > 0 ? pixelW : width; + int h = pixelH > 0 ? pixelH : height; + if (w > 0 && h > 0) { + createSceneColorResources(w, h); + createShadowResources(); + } +} + +void Graphics::rebuildSwapchainIfNeeded() { + if (!swapchainConfigured && surface && logicalW > 0 && logicalH > 0) { + configureSurface(logicalW, logicalH); + } +} + +// --------------------------------------------------------------------------- +// Default / placeholder resources +// --------------------------------------------------------------------------- + +void Graphics::createDefaultTextures() { + uint8_t white[4] = {255, 255, 255, 255}; + whiteTexture = static_cast(newTexture(1, 1, white, false, false)->gpuHandle); + + // Flat normal (0.5,0.5,1) in RGBA8 → sampled as (0,0,1) normal. + uint8_t flatNrm[4] = {128, 128, 255, 255}; + flatNormalTexture = static_cast(newTexture(1, 1, flatNrm, false, false)->gpuHandle); + flatNormalTexture3D = flatNormalTexture; + + uint8_t flatH[4] = {0, 0, 0, 255}; + flatHeightTexture3D = static_cast(newTexture(1, 1, flatH, false, false)->gpuHandle); + + // 1x1 white cubemap. + uint8_t cubeFace[4] = {255, 255, 255, 255}; + uint8_t cubeData[24]; + for (int f = 0; f < 6; ++f) std::memcpy(cubeData + f * 4, cubeFace, 4); + defaultEnvCubemap = + static_cast(newCubemap(1, cubeData)->gpuHandle); + + // Shared filtering sampler for WGSL bindings declared as plain `sampler`. + TextureSampler def; + mainSampler = makeSampler(def, 1); +} + +// --------------------------------------------------------------------------- +// Pipeline resource creation +// --------------------------------------------------------------------------- + +void Graphics::createPipelineResources() { + create2DPipelines(); + createMesh3DPipelines(); + createShadowPipelines(); + createGbufferPipelines(); + createVoxelPipelines(); +} + +// --------------------------------------------------------------------------- +// Bind group layouts +// --------------------------------------------------------------------------- + +wgpu::BindGroupLayout Graphics::make2DBindGroupLayout() { + WGPUBindGroupLayoutEntry entries[5]{}; + // 0: color texture + entries[0].binding = 0; + entries[0].visibility = WGPUShaderStage_Fragment; + entries[0].texture.sampleType = WGPUTextureSampleType_Float; + entries[0].texture.viewDimension = WGPUTextureViewDimension_2D; + entries[0].texture.multisampled = false; + // 1: depth texture + entries[1].binding = 1; + entries[1].visibility = WGPUShaderStage_Fragment; + entries[1].texture.sampleType = WGPUTextureSampleType_Float; + entries[1].texture.viewDimension = WGPUTextureViewDimension_2D; + // 2: color sampler + entries[2].binding = 2; + entries[2].visibility = WGPUShaderStage_Fragment; + entries[2].sampler.type = WGPUSamplerBindingType_Filtering; + // 3: depth sampler + entries[3].binding = 3; + entries[3].visibility = WGPUShaderStage_Fragment; + entries[3].sampler.type = WGPUSamplerBindingType_Filtering; + // 4: Externals UBO (push-constant replacement, dynamic offset). Sized for + // the largest consumer: custom 2D shaders (128 B) and lit2D (Lighting2DUBO). + entries[4].binding = 4; + entries[4].visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; + entries[4].buffer.type = WGPUBufferBindingType_Uniform; + entries[4].buffer.hasDynamicOffset = true; + entries[4].buffer.minBindingSize = + std::max(Shader::kPushConstantBytes, uint32_t(sizeof(Lighting2DUBO))); + + WGPUBindGroupLayoutDescriptor desc{}; + desc.label = sv("eve_2d"); + desc.entryCount = 5; + desc.entries = entries; + return device.CreateBindGroupLayout(reinterpret_cast(&desc)); +} + +wgpu::BindGroupLayout Graphics::makeMesh3DBindGroupLayout() { + WGPUBindGroupLayoutEntry entries[9]{}; + // 0: Frame UBO (dynamic) + entries[0].binding = 0; + entries[0].visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; + entries[0].buffer.type = WGPUBufferBindingType_Uniform; + entries[0].buffer.hasDynamicOffset = true; + entries[0].buffer.minBindingSize = sizeof(Mesh3DUBO); + // 1: albedo + entries[1].binding = 1; + entries[1].visibility = WGPUShaderStage_Fragment; + entries[1].texture.sampleType = WGPUTextureSampleType_Float; + entries[1].texture.viewDimension = WGPUTextureViewDimension_2D; + // 2: normal + entries[2].binding = 2; + entries[2].visibility = WGPUShaderStage_Fragment; + entries[2].texture.sampleType = WGPUTextureSampleType_Float; + entries[2].texture.viewDimension = WGPUTextureViewDimension_2D; + // 3: env cubemap + entries[3].binding = 3; + entries[3].visibility = WGPUShaderStage_Fragment; + entries[3].texture.sampleType = WGPUTextureSampleType_Float; + entries[3].texture.viewDimension = WGPUTextureViewDimension_Cube; + // 4: Shadow UBO (dynamic) + entries[4].binding = 4; + entries[4].visibility = WGPUShaderStage_Fragment; + entries[4].buffer.type = WGPUBufferBindingType_Uniform; + entries[4].buffer.hasDynamicOffset = true; + entries[4].buffer.minBindingSize = sizeof(ShadowUBO); + // 5: shadow depth array + entries[5].binding = 5; + entries[5].visibility = WGPUShaderStage_Fragment; + entries[5].texture.sampleType = WGPUTextureSampleType_Depth; + entries[5].texture.viewDimension = WGPUTextureViewDimension_2DArray; + // 6: height + entries[6].binding = 6; + entries[6].visibility = WGPUShaderStage_Fragment; + entries[6].texture.sampleType = WGPUTextureSampleType_Float; + entries[6].texture.viewDimension = WGPUTextureViewDimension_2D; + // 7: shared filtering sampler (WGSL `mainSamp`) + entries[7].binding = 7; + entries[7].visibility = WGPUShaderStage_Fragment; + entries[7].sampler.type = WGPUSamplerBindingType_Filtering; + // 8: shadow comparison sampler + entries[8].binding = 8; + entries[8].visibility = WGPUShaderStage_Fragment; + entries[8].sampler.type = WGPUSamplerBindingType_Comparison; + + WGPUBindGroupLayoutDescriptor desc{}; + desc.label = sv("eve_mesh3d"); + desc.entryCount = 9; + desc.entries = entries; + return device.CreateBindGroupLayout(reinterpret_cast(&desc)); +} + +wgpu::BindGroupLayout Graphics::makeShadowBindGroupLayout() { + WGPUBindGroupLayoutEntry entries[1]{}; + entries[0].binding = 0; + entries[0].visibility = WGPUShaderStage_Vertex; + entries[0].buffer.type = WGPUBufferBindingType_Uniform; + entries[0].buffer.hasDynamicOffset = true; + entries[0].buffer.minBindingSize = 64; // mat4 mvp + + WGPUBindGroupLayoutDescriptor desc{}; + desc.label = sv("eve_shadow"); + desc.entryCount = 1; + desc.entries = entries; + return device.CreateBindGroupLayout(reinterpret_cast(&desc)); +} + +wgpu::BindGroupLayout Graphics::makeGbufferBindGroupLayout() { + WGPUBindGroupLayoutEntry entries[3]{}; + // 0: Push UBO (dynamic; 128 bytes) + entries[0].binding = 0; + entries[0].visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; + entries[0].buffer.type = WGPUBufferBindingType_Uniform; + entries[0].buffer.hasDynamicOffset = true; + entries[0].buffer.minBindingSize = 128; + // 1: albedo + entries[1].binding = 1; + entries[1].visibility = WGPUShaderStage_Fragment; + entries[1].texture.sampleType = WGPUTextureSampleType_Float; + entries[1].texture.viewDimension = WGPUTextureViewDimension_2D; + // 2: sampler + entries[2].binding = 2; + entries[2].visibility = WGPUShaderStage_Fragment; + entries[2].sampler.type = WGPUSamplerBindingType_Filtering; + + WGPUBindGroupLayoutDescriptor desc{}; + desc.label = sv("eve_gbuffer"); + desc.entryCount = 3; + desc.entries = entries; + return device.CreateBindGroupLayout(reinterpret_cast(&desc)); +} + +wgpu::BindGroupLayout Graphics::makeVoxelBindGroupLayout() { + WGPUBindGroupLayoutEntry entries[3]{}; + // 0: PC UBO (dynamic) + entries[0].binding = 0; + entries[0].visibility = WGPUShaderStage_Vertex; + entries[0].buffer.type = WGPUBufferBindingType_Uniform; + entries[0].buffer.hasDynamicOffset = true; + entries[0].buffer.minBindingSize = 112; + // 1: atlas texture + entries[1].binding = 1; + entries[1].visibility = WGPUShaderStage_Fragment; + entries[1].texture.sampleType = WGPUTextureSampleType_Float; + entries[1].texture.viewDimension = WGPUTextureViewDimension_2D; + // 2: atlas sampler + entries[2].binding = 2; + entries[2].visibility = WGPUShaderStage_Fragment; + entries[2].sampler.type = WGPUSamplerBindingType_Filtering; + + WGPUBindGroupLayoutDescriptor desc{}; + desc.label = sv("eve_voxel"); + desc.entryCount = 3; + desc.entries = entries; + return device.CreateBindGroupLayout(reinterpret_cast(&desc)); +} + +wgpu::PipelineLayout Graphics::make2DPipelineLayout() { + WGPUBindGroupLayout bgl = tex2DSetLayout.Get(); + WGPUPipelineLayoutDescriptor d{}; + d.label = sv("eve_2d_layout"); + d.bindGroupLayoutCount = 1; + d.bindGroupLayouts = &bgl; + return device.CreatePipelineLayout(reinterpret_cast(&d)); +} + +wgpu::PipelineLayout Graphics::makeMesh3DPipelineLayout() { + WGPUBindGroupLayout bgl = mesh3dSetLayout.Get(); + WGPUPipelineLayoutDescriptor d{}; + d.label = sv("eve_mesh3d_layout"); + d.bindGroupLayoutCount = 1; + d.bindGroupLayouts = &bgl; + return device.CreatePipelineLayout(reinterpret_cast(&d)); +} + +wgpu::PipelineLayout Graphics::makeShadowPipelineLayout() { + WGPUBindGroupLayout bgl = shadowSetLayout.Get(); + WGPUPipelineLayoutDescriptor d{}; + d.label = sv("eve_shadow_layout"); + d.bindGroupLayoutCount = 1; + d.bindGroupLayouts = &bgl; + return device.CreatePipelineLayout(reinterpret_cast(&d)); +} + +wgpu::PipelineLayout Graphics::makeGbufferPipelineLayout() { + WGPUBindGroupLayout bgl = gbufferSetLayout.Get(); + WGPUPipelineLayoutDescriptor d{}; + d.label = sv("eve_gbuffer_layout"); + d.bindGroupLayoutCount = 1; + d.bindGroupLayouts = &bgl; + return device.CreatePipelineLayout(reinterpret_cast(&d)); +} + +wgpu::PipelineLayout Graphics::makeVoxelPipelineLayout() { + WGPUBindGroupLayout bgl = voxelSetLayout.Get(); + WGPUPipelineLayoutDescriptor d{}; + d.label = sv("eve_voxel_layout"); + d.bindGroupLayoutCount = 1; + d.bindGroupLayouts = &bgl; + return device.CreatePipelineLayout(reinterpret_cast(&d)); +} + +// --------------------------------------------------------------------------- +// Pipelines +// --------------------------------------------------------------------------- + +namespace { + +// Returns a RAII-held module: the caller must keep it alive across +// CreateRenderPipeline. Returning a raw WGPUShaderModule would let the +// temporary wgpu::ShaderModule destructor Release() the handle, dropping it +// from emdawnwebgpu's jsObjects before the pipeline can reference it. +wgpu::ShaderModule makeWgslModule(wgpu::Device &dev, const char *wgsl) { + WGPUShaderSourceWGSL wgslDesc{}; + wgslDesc.chain.sType = WGPUSType_ShaderSourceWGSL; + wgslDesc.code = sv(wgsl); + WGPUShaderModuleDescriptor desc{}; + desc.nextInChain = &wgslDesc.chain; + return dev.CreateShaderModule(reinterpret_cast(&desc)); +} + +// Builds a WGSL shader-module descriptor (the chained WGPUShaderSourceWGSL is +// static so its chain pointer stays valid). The returned descriptor is a value +// that must outlive the CreateShaderModule call. +WGPUShaderModuleDescriptor mdDesc(const std::string &code) { + static WGPUShaderSourceWGSL wd{}; + wd.chain.sType = WGPUSType_ShaderSourceWGSL; + wd.code = sv(code.c_str()); + WGPUShaderModuleDescriptor md{}; + md.nextInChain = &wd.chain; + return md; +} + +void fillVertexLayout(WGPUVertexBufferLayout &layout, uint64_t stride, + const WGPUVertexAttribute *attrs, uint32_t attrCount) { + layout.arrayStride = stride; + layout.stepMode = WGPUVertexStepMode_Vertex; + layout.attributeCount = attrCount; + layout.attributes = attrs; +} + +WGPUBlendState alphaBlend() { + WGPUBlendState b{}; + b.color.srcFactor = WGPUBlendFactor_SrcAlpha; + b.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha; + b.color.operation = WGPUBlendOperation_Add; + b.alpha.srcFactor = WGPUBlendFactor_One; + b.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha; + b.alpha.operation = WGPUBlendOperation_Add; + return b; +} + +WGPUBlendState noBlend() { + WGPUBlendState b{}; + b.color.srcFactor = WGPUBlendFactor_One; + b.color.dstFactor = WGPUBlendFactor_Zero; + b.color.operation = WGPUBlendOperation_Add; + b.alpha.srcFactor = WGPUBlendFactor_One; + b.alpha.dstFactor = WGPUBlendFactor_Zero; + b.alpha.operation = WGPUBlendOperation_Add; + return b; +} + +} // namespace + +namespace { + +wgpu::RenderPipeline make2DColorPipeline(wgpu::Device &dev, WGPUTextureFormat format, bool blend) { + WGPUVertexAttribute attrs[2] = {}; + attrs[0].format = WGPUVertexFormat_Float32x2; + attrs[0].offset = 0; + attrs[0].shaderLocation = 0; + attrs[1].format = WGPUVertexFormat_Float32x4; + attrs[1].offset = 8; + attrs[1].shaderLocation = 1; + WGPUVertexBufferLayout vb{}; + fillVertexLayout(vb, 24, attrs, 2); + + WGPUColorTargetState target{}; + target.format = format; + target.blend = nullptr; + WGPUBlendState bs = alphaBlend(); + if (blend) target.blend = &bs; + + // The solid-color shader declares no bindings, so it must use an empty + // pipeline layout. Reusing the textured 2D layout here would require a + // bind group for every solid draw (WebGPU validation fails the whole + // command buffer otherwise). + WGPUPipelineLayoutDescriptor pld{}; + pld.label = sv("eve_2d_color_layout"); + pld.bindGroupLayoutCount = 0; + pld.bindGroupLayouts = nullptr; + wgpu::PipelineLayout emptyLayout = dev.CreatePipelineLayout(reinterpret_cast(&pld)); + + WGPURenderPipelineDescriptor pd{}; + pd.label = sv("eve_color2d"); + pd.layout = emptyLayout.Get(); + wgpu::ShaderModule vertModule = makeWgslModule(dev, kColorVertWgsl); + wgpu::ShaderModule fragModule = makeWgslModule(dev, kColorFragWgsl); + pd.vertex.module = vertModule.Get(); + pd.vertex.entryPoint = sv("vs_main"); + pd.vertex.bufferCount = 1; + pd.vertex.buffers = &vb; + WGPUFragmentState fs{}; + fs.module = fragModule.Get(); + fs.entryPoint = sv("fs_main"); + fs.targetCount = 1; + fs.targets = ⌖ + pd.fragment = &fs; + pd.primitive.topology = WGPUPrimitiveTopology_TriangleList; + pd.primitive.frontFace = WGPUFrontFace_CCW; + pd.primitive.cullMode = WGPUCullMode_None; + pd.primitive.stripIndexFormat = WGPUIndexFormat_Undefined; + pd.depthStencil = nullptr; + pd.multisample.count = 1; + return dev.CreateRenderPipeline(reinterpret_cast(&pd)); +} + +wgpu::RenderPipeline make2DTexturedPipeline(wgpu::Device &dev, wgpu::PipelineLayout layout, + WGPUTextureFormat format, bool blend) { + WGPUVertexAttribute attrs[3] = {}; + attrs[0].format = WGPUVertexFormat_Float32x2; + attrs[0].offset = 0; + attrs[0].shaderLocation = 0; + attrs[1].format = WGPUVertexFormat_Float32x4; + attrs[1].offset = 8; + attrs[1].shaderLocation = 1; + attrs[2].format = WGPUVertexFormat_Float32x2; + attrs[2].offset = 24; + attrs[2].shaderLocation = 2; + WGPUVertexBufferLayout vb{}; + fillVertexLayout(vb, 32, attrs, 3); + + WGPUColorTargetState target{}; + target.format = format; + WGPUBlendState bs = alphaBlend(); + if (blend) target.blend = &bs; + + WGPURenderPipelineDescriptor pd{}; + pd.label = sv("eve_textured2d"); + pd.layout = layout.Get(); + wgpu::ShaderModule vertModule = makeWgslModule(dev, kTexturedVertWgsl); + wgpu::ShaderModule fragModule = makeWgslModule(dev, kTexturedFragWgsl); + pd.vertex.module = vertModule.Get(); + pd.vertex.entryPoint = sv("vs_main"); + pd.vertex.bufferCount = 1; + pd.vertex.buffers = &vb; + WGPUFragmentState fs{}; + fs.module = fragModule.Get(); + fs.entryPoint = sv("fs_main"); + fs.targetCount = 1; + fs.targets = ⌖ + pd.fragment = &fs; + pd.primitive.topology = WGPUPrimitiveTopology_TriangleList; + pd.primitive.frontFace = WGPUFrontFace_CCW; + pd.primitive.cullMode = WGPUCullMode_None; + pd.primitive.stripIndexFormat = WGPUIndexFormat_Undefined; + pd.depthStencil = nullptr; + pd.multisample.count = 1; + return dev.CreateRenderPipeline(reinterpret_cast(&pd)); +} + +wgpu::RenderPipeline make2DLitPipeline(wgpu::Device &dev, wgpu::PipelineLayout layout, + WGPUTextureFormat format, bool blend) { + WGPUVertexAttribute attrs[3] = {}; + attrs[0].format = WGPUVertexFormat_Float32x2; + attrs[0].offset = 0; + attrs[0].shaderLocation = 0; + attrs[1].format = WGPUVertexFormat_Float32x4; + attrs[1].offset = 8; + attrs[1].shaderLocation = 1; + attrs[2].format = WGPUVertexFormat_Float32x2; + attrs[2].offset = 24; + attrs[2].shaderLocation = 2; + WGPUVertexBufferLayout vb{}; + fillVertexLayout(vb, 32, attrs, 3); + + WGPUColorTargetState target{}; + target.format = format; + WGPUBlendState bs = alphaBlend(); + if (blend) target.blend = &bs; + + WGPURenderPipelineDescriptor pd{}; + pd.label = sv("eve_lit2d"); + pd.layout = layout.Get(); + wgpu::ShaderModule vertModule = makeWgslModule(dev, kTexturedVertWgsl); + wgpu::ShaderModule fragModule = makeWgslModule(dev, kLit2DFragWgsl); + pd.vertex.module = vertModule.Get(); + pd.vertex.entryPoint = sv("vs_main"); + pd.vertex.bufferCount = 1; + pd.vertex.buffers = &vb; + WGPUFragmentState fs{}; + fs.module = fragModule.Get(); + fs.entryPoint = sv("fs_main"); + fs.targetCount = 1; + fs.targets = ⌖ + pd.fragment = &fs; + pd.primitive.topology = WGPUPrimitiveTopology_TriangleList; + pd.primitive.frontFace = WGPUFrontFace_CCW; + pd.primitive.cullMode = WGPUCullMode_None; + pd.primitive.stripIndexFormat = WGPUIndexFormat_Undefined; + pd.depthStencil = nullptr; + pd.multisample.count = 1; + return dev.CreateRenderPipeline(reinterpret_cast(&pd)); +} + +} // namespace + +void Graphics::create2DPipelines() { + tex2DSetLayout = make2DBindGroupLayout(); + tex2DPipelineLayout = make2DPipelineLayout(); + + colorPipeline = make2DColorPipeline(device, surfaceFormat, true); + texturedPipeline = make2DTexturedPipeline(device, tex2DPipelineLayout, surfaceFormat, true); + lit2dPipeline = make2DLitPipeline(device, tex2DPipelineLayout, surfaceFormat, true); + + offscreenColorPipeline = make2DColorPipeline(device, WGPUTextureFormat_RGBA8Unorm, true); + offscreenTexturedPipeline = make2DTexturedPipeline(device, tex2DPipelineLayout, WGPUTextureFormat_RGBA8Unorm, true); + offscreenLitPipeline = make2DLitPipeline(device, tex2DPipelineLayout, WGPUTextureFormat_RGBA8Unorm, true); +} + +void Graphics::createMesh3DPipelines() { + mesh3dSetLayout = makeMesh3DBindGroupLayout(); + mesh3dPipelineLayout = makeMesh3DPipelineLayout(); + + WGPUVertexAttribute attrs[3] = {}; + attrs[0].format = WGPUVertexFormat_Float32x3; // pos + attrs[0].offset = 0; + attrs[0].shaderLocation = 0; + attrs[1].format = WGPUVertexFormat_Float32x3; // normal + attrs[1].offset = 12; + attrs[1].shaderLocation = 1; + attrs[2].format = WGPUVertexFormat_Float32x2; // uv + attrs[2].offset = 24; + attrs[2].shaderLocation = 2; + WGPUVertexBufferLayout vb{}; + fillVertexLayout(vb, 32, attrs, 3); + + WGPUDepthStencilState ds{}; + ds.format = WGPUTextureFormat_Depth32Float; + ds.depthWriteEnabled = WGPUOptionalBool_True; + ds.depthCompare = WGPUCompareFunction_Less; + ds.stencilReadMask = 0; + ds.stencilWriteMask = 0; + + WGPUColorTargetState target{}; + target.format = sceneColorFormat; + target.blend = nullptr; + + WGPURenderPipelineDescriptor pd{}; + pd.label = sv("eve_mesh3d"); + pd.layout = mesh3dPipelineLayout.Get(); + wgpu::ShaderModule vertModule = makeWgslModule(device, kMesh3DVertWgsl); + wgpu::ShaderModule fragModule = makeWgslModule(device, kMesh3DFragWgsl); + pd.vertex.module = vertModule.Get(); + pd.vertex.entryPoint = sv("vs_main"); + pd.vertex.bufferCount = 1; + pd.vertex.buffers = &vb; + WGPUFragmentState fs{}; + fs.module = fragModule.Get(); + fs.entryPoint = sv("fs_main"); + fs.targetCount = 1; + fs.targets = ⌖ + pd.fragment = &fs; + pd.primitive.topology = WGPUPrimitiveTopology_TriangleList; + pd.primitive.frontFace = WGPUFrontFace_CCW; + pd.primitive.cullMode = WGPUCullMode_None; + pd.primitive.stripIndexFormat = WGPUIndexFormat_Undefined; + pd.depthStencil = &ds; + pd.multisample.count = 1; + mesh3dPipeline = device.CreateRenderPipeline(reinterpret_cast(&pd)); +} + +void Graphics::createShadowPipelines() { + shadowSetLayout = makeShadowBindGroupLayout(); + shadowPipelineLayout = makeShadowPipelineLayout(); + + WGPUVertexAttribute attrs[3] = {}; + attrs[0].format = WGPUVertexFormat_Float32x3; + attrs[0].offset = 0; + attrs[0].shaderLocation = 0; + attrs[1].format = WGPUVertexFormat_Float32x3; + attrs[1].offset = 12; + attrs[1].shaderLocation = 1; + attrs[2].format = WGPUVertexFormat_Float32x2; + attrs[2].offset = 24; + attrs[2].shaderLocation = 2; + WGPUVertexBufferLayout vb{}; + fillVertexLayout(vb, 32, attrs, 3); + + WGPUDepthStencilState ds{}; + ds.format = WGPUTextureFormat_Depth32Float; + ds.depthWriteEnabled = WGPUOptionalBool_True; + ds.depthCompare = WGPUCompareFunction_Less; + ds.stencilReadMask = 0; + ds.stencilWriteMask = 0; + + WGPURenderPipelineDescriptor pd{}; + pd.label = sv("eve_shadow"); + pd.layout = shadowPipelineLayout.Get(); + wgpu::ShaderModule vertModule = makeWgslModule(device, kMesh3DShadowVertWgsl); + pd.vertex.module = vertModule.Get(); + pd.vertex.entryPoint = sv("vs_main"); + pd.vertex.bufferCount = 1; + pd.vertex.buffers = &vb; + pd.fragment = nullptr; + pd.primitive.topology = WGPUPrimitiveTopology_TriangleList; + pd.primitive.frontFace = WGPUFrontFace_CCW; + pd.primitive.cullMode = WGPUCullMode_None; + pd.primitive.stripIndexFormat = WGPUIndexFormat_Undefined; + pd.depthStencil = &ds; + pd.multisample.count = 1; + mesh3dShadowPipeline = device.CreateRenderPipeline(reinterpret_cast(&pd)); +} + +void Graphics::createGbufferPipelines() { + gbufferSetLayout = makeGbufferBindGroupLayout(); + gbufferPipelineLayout = makeGbufferPipelineLayout(); + + WGPUVertexAttribute attrs[3] = {}; + attrs[0].format = WGPUVertexFormat_Float32x3; + attrs[0].offset = 0; + attrs[0].shaderLocation = 0; + attrs[1].format = WGPUVertexFormat_Float32x3; + attrs[1].offset = 12; + attrs[1].shaderLocation = 1; + attrs[2].format = WGPUVertexFormat_Float32x2; + attrs[2].offset = 24; + attrs[2].shaderLocation = 2; + WGPUVertexBufferLayout vb{}; + fillVertexLayout(vb, 32, attrs, 3); + + WGPUDepthStencilState ds{}; + ds.format = WGPUTextureFormat_Depth32Float; + ds.depthWriteEnabled = WGPUOptionalBool_True; + ds.depthCompare = WGPUCompareFunction_Less; + ds.stencilReadMask = 0; + ds.stencilWriteMask = 0; + + WGPUColorTargetState targets[3] = {}; + for (int i = 0; i < 3; ++i) { + targets[i].format = WGPUTextureFormat_RGBA8Unorm; + targets[i].blend = nullptr; + } + + WGPURenderPipelineDescriptor pd{}; + pd.label = sv("eve_gbuffer"); + pd.layout = gbufferPipelineLayout.Get(); + wgpu::ShaderModule vertModule = makeWgslModule(device, kMesh3DGbufferVertWgsl); + wgpu::ShaderModule fragModule = makeWgslModule(device, kMesh3DGbufferFragWgsl); + pd.vertex.module = vertModule.Get(); + pd.vertex.entryPoint = sv("vs_main"); + pd.vertex.bufferCount = 1; + pd.vertex.buffers = &vb; + WGPUFragmentState fs{}; + fs.module = fragModule.Get(); + fs.entryPoint = sv("fs_main"); + fs.targetCount = 3; + fs.targets = targets; + pd.fragment = &fs; + pd.primitive.topology = WGPUPrimitiveTopology_TriangleList; + pd.primitive.frontFace = WGPUFrontFace_CCW; + pd.primitive.cullMode = WGPUCullMode_None; + pd.primitive.stripIndexFormat = WGPUIndexFormat_Undefined; + pd.depthStencil = &ds; + pd.multisample.count = 1; + mesh3dGbufferPipeline = device.CreateRenderPipeline(reinterpret_cast(&pd)); +} + +void Graphics::createVoxelPipelines() { + voxelSetLayout = makeVoxelBindGroupLayout(); + voxelPipelineLayout = makeVoxelPipelineLayout(); + + WGPUVertexAttribute attrs[2] = {}; + attrs[0].format = WGPUVertexFormat_Float32x2; // corner + attrs[0].offset = 0; + attrs[0].shaderLocation = 0; + attrs[1].format = WGPUVertexFormat_Uint32; // packed + attrs[1].offset = 8; + attrs[1].shaderLocation = 1; + WGPUVertexBufferLayout cornerVb{}; + fillVertexLayout(cornerVb, 12, attrs, 2); + WGPUVertexBufferLayout instanceVb{}; + instanceVb.arrayStride = 4; + instanceVb.stepMode = WGPUVertexStepMode_Instance; + instanceVb.attributeCount = 0; + instanceVb.attributes = nullptr; + WGPUVertexBufferLayout vbs[2] = {cornerVb, instanceVb}; + + WGPUDepthStencilState ds{}; + ds.format = WGPUTextureFormat_Depth32Float; + ds.depthWriteEnabled = WGPUOptionalBool_True; + ds.depthCompare = WGPUCompareFunction_Less; + ds.stencilReadMask = 0; + ds.stencilWriteMask = 0; + + WGPUColorTargetState target{}; + target.format = sceneColorFormat; + target.blend = nullptr; + + WGPURenderPipelineDescriptor pd{}; + pd.label = sv("eve_voxel"); + pd.layout = voxelPipelineLayout.Get(); + wgpu::ShaderModule vertModule = makeWgslModule(device, kVoxelRectVertWgsl); + wgpu::ShaderModule fragModule = makeWgslModule(device, kVoxelRectFragWgsl); + pd.vertex.module = vertModule.Get(); + pd.vertex.entryPoint = sv("vs_main"); + pd.vertex.bufferCount = 2; + pd.vertex.buffers = vbs; + WGPUFragmentState fs{}; + fs.module = fragModule.Get(); + fs.entryPoint = sv("fs_main"); + fs.targetCount = 1; + fs.targets = ⌖ + pd.fragment = &fs; + pd.primitive.topology = WGPUPrimitiveTopology_TriangleList; + pd.primitive.frontFace = WGPUFrontFace_CCW; + pd.primitive.cullMode = WGPUCullMode_None; + pd.primitive.stripIndexFormat = WGPUIndexFormat_Undefined; + pd.depthStencil = &ds; + pd.multisample.count = 1; + voxelRectPipeline = device.CreateRenderPipeline(reinterpret_cast(&pd)); + + // Unit quad for instanced voxel faces (2 triangles, corner + packed uv slot). + float quad[8] = {0, 0, 1, 0, 1, 1, 0, 1}; + WGPUBufferDescriptor bd{}; + bd.label = sv("eve_voxel_quad"); + bd.size = sizeof(quad); + bd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_Vertex; + bd.mappedAtCreation = false; + voxelUnitQuadVerts = device.CreateBuffer(reinterpret_cast(&bd)); + queue.WriteBuffer(voxelUnitQuadVerts, 0, quad, sizeof(quad)); + + uint32_t indices[6] = {0, 1, 2, 2, 3, 0}; + WGPUBufferDescriptor ibd{}; + ibd.label = sv("eve_voxel_quad_idx"); + ibd.size = sizeof(indices); + ibd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_Index; + ibd.mappedAtCreation = false; + voxelUnitQuadIndices = device.CreateBuffer(reinterpret_cast(&ibd)); + queue.WriteBuffer(voxelUnitQuadIndices, 0, indices, sizeof(indices)); +} + +// --------------------------------------------------------------------------- +// Arena helpers +// --------------------------------------------------------------------------- + +uint32_t Graphics::UboArena::alloc(uint64_t size, uint64_t alignment) { + uint64_t aligned = (used + alignment - 1) / alignment * alignment; + used = aligned + size; + return static_cast(aligned); +} + +uint64_t Graphics::VertexArena::alloc(uint64_t bytes) { + uint64_t at = used; + used += bytes; + return at; +} + +Graphics::UboArena &Graphics::currentUboArena() { return uboArenas[currentFrameSlot()]; } +Graphics::VertexArena &Graphics::currentVertexArena() { return vertexArenas[currentFrameSlot()]; } + +void Graphics::ensureUboArena(UboArena &arena, uint64_t bytes) { + if (arena.buffer && arena.capacity >= bytes) return; + uint64_t size = arena.capacity; + while (size < bytes) size = size ? size * 2 : (64u << 10); + WGPUBufferDescriptor bd{}; + bd.label = sv("eve_ubo_arena"); + bd.size = size; + bd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_Uniform; + bd.mappedAtCreation = false; + arena.buffer = device.CreateBuffer(reinterpret_cast(&bd)); + arena.capacity = size; + arena.used = 0; +} + +void Graphics::ensureVertexArena(VertexArena &arena, uint64_t bytes) { + if (arena.buffer && arena.capacity >= bytes) return; + uint64_t size = arena.capacity; + while (size < bytes) size = size ? size * 2 : (256u << 10); + WGPUBufferDescriptor bd{}; + bd.label = sv("eve_vertex_arena"); + bd.size = size; + bd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_Vertex; + bd.mappedAtCreation = false; + arena.buffer = device.CreateBuffer(reinterpret_cast(&bd)); + arena.capacity = size; + arena.used = 0; +} + +// --------------------------------------------------------------------------- +// Sampler +// --------------------------------------------------------------------------- + +wgpu::Sampler Graphics::makeSampler(const TextureSampler &s, uint32_t mipLevels) const { + WGPUSamplerDescriptor d{}; + d.label = sv("eve_sampler"); + d.addressModeU = s.repeatU ? WGPUAddressMode_Repeat : WGPUAddressMode_ClampToEdge; + d.addressModeV = s.repeatV ? WGPUAddressMode_Repeat : WGPUAddressMode_ClampToEdge; + d.addressModeW = s.repeatW ? WGPUAddressMode_Repeat : WGPUAddressMode_ClampToEdge; + d.magFilter = s.mag == FilterMode::Nearest ? WGPUFilterMode_Nearest : WGPUFilterMode_Linear; + d.minFilter = s.min == FilterMode::Nearest ? WGPUFilterMode_Nearest : WGPUFilterMode_Linear; + if (s.mipmap == MipmapMode::Disabled || mipLevels <= 1) { + d.mipmapFilter = WGPUMipmapFilterMode_Nearest; + d.lodMinClamp = 0.f; + d.lodMaxClamp = 0.f; + } else { + d.mipmapFilter = + s.mipmap == MipmapMode::Nearest ? WGPUMipmapFilterMode_Nearest : WGPUMipmapFilterMode_Linear; + d.lodMinClamp = s.minLod; + d.lodMaxClamp = std::min(std::max(s.maxLod, 0.f), float(mipLevels)); + } + float aniso = s.maxAnisotropy > 1.f ? s.maxAnisotropy : 1.f; + d.maxAnisotropy = std::min(std::max(aniso, 1.f), std::max(maxSamplerAnisotropy, 1.f)); + return device.CreateSampler(reinterpret_cast(&d)); +} + +// --------------------------------------------------------------------------- +// Texture creation +// --------------------------------------------------------------------------- + +Texture *Graphics::newTexture(int width, int height, const uint8_t *rgba, bool repeatU, + bool repeatV) { + TextureCreateInfo info; + info.sampler.repeatU = repeatU; + info.sampler.repeatV = repeatV; + return newTexture(width, height, rgba, info); +} + +Texture *Graphics::newTexture(image::ImageData *data) { + if (!data) throw Exception("newTexture: null ImageData"); + TextureCreateInfo info; + return newTexture(data->getWidth(), data->getHeight(), + static_cast(data->getData()), info); +} + +Texture *Graphics::newTexture(image::ImageData *data, const TextureCreateInfo &info) { + if (!data) throw Exception("newTexture: null ImageData"); + if (data->getFormat() != "RGBA8") + throw Exception("newTexture: only RGBA8 supported"); + return newTexture(data->getWidth(), data->getHeight(), + static_cast(data->getData()), info); +} + +Texture *Graphics::newTexture(int width, int height, const uint8_t *rgba, + const TextureCreateInfo &info) { + if (width <= 0 || height <= 0) throw Exception("newTexture: invalid size %dx%d", width, height); + + auto gpu = std::make_unique(); + gpu->width = width; + gpu->height = height; + gpu->samplerState = info.sampler; + gpu->mipLevels = info.generateMipmaps ? uint32_t(mipmapCountForSize(width, height)) : 1u; + + WGPUTextureDescriptor td{}; + td.label = sv("eve_tex2d"); + td.dimension = WGPUTextureDimension_2D; + td.size.width = static_cast(width); + td.size.height = static_cast(height); + td.size.depthOrArrayLayers = 1; + td.sampleCount = 1; + td.format = WGPUTextureFormat_RGBA8Unorm; + td.mipLevelCount = gpu->mipLevels; + td.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_CopyDst | + WGPUTextureUsage_RenderAttachment; + gpu->texture = device.CreateTexture(reinterpret_cast(&td)); + + uploadTexturePixelsMips(gpu.get(), rgba, width, height); + + WGPUTextureViewDescriptor vd{}; + vd.format = WGPUTextureFormat_RGBA8Unorm; + vd.dimension = WGPUTextureViewDimension_2D; + vd.baseMipLevel = 0; + vd.mipLevelCount = gpu->mipLevels; + vd.baseArrayLayer = 0; + vd.arrayLayerCount = 1; + gpu->view = gpu->texture.CreateView(reinterpret_cast(&vd)); + gpu->sampler = makeSampler(info.sampler, gpu->mipLevels); + + auto *tex = new Texture(); + tex->width = width; + tex->height = height; + tex->mipmapCount = int(gpu->mipLevels); + tex->sampler = info.sampler; + tex->gpuHandle = gpu.get(); + + ownedGpuTextures.push_back(std::move(gpu)); + ownedTextures.push_back(std::unique_ptr(tex)); + return tex; +} + +void Graphics::uploadTexturePixels(GpuTexture *gt, const uint8_t *rgba, int w, int h, + const TextureCreateInfo &info) { + (void)info; + uploadTexturePixelsMips(gt, rgba, w, h); +} + +void Graphics::uploadTexturePixelsMips(GpuTexture *gt, const uint8_t *rgba, int w, int h) { + if (!rgba) return; + WGPUTexelCopyBufferLayout layout{}; + layout.offset = 0; + layout.bytesPerRow = static_cast(w * 4); + layout.rowsPerImage = static_cast(h); + WGPUExtent3D extent{static_cast(w), static_cast(h), 1}; + + for (uint32_t m = 0; m < gt->mipLevels; ++m) { + WGPUTexelCopyTextureInfo dst{}; + dst.texture = gt->texture.Get(); + dst.mipLevel = m; + dst.aspect = WGPUTextureAspect_All; + queue.WriteTexture(reinterpret_cast(&dst), rgba, + static_cast(w) * h * 4, + reinterpret_cast(&layout), + reinterpret_cast(&extent)); + if (m + 1 < gt->mipLevels) { + // Box-filter downsample into the CPU buffer for the next level. + std::vector next((w / 2) * (h / 2) * 4); + for (int y = 0; y < h / 2; ++y) { + for (int x = 0; x < w / 2; ++x) { + for (int c = 0; c < 4; ++c) { + uint32_t acc = 0; + acc += rgba[((y * 2 + 0) * w + (x * 2 + 0)) * 4 + c]; + acc += rgba[((y * 2 + 0) * w + (x * 2 + 1)) * 4 + c]; + acc += rgba[((y * 2 + 1) * w + (x * 2 + 0)) * 4 + c]; + acc += rgba[((y * 2 + 1) * w + (x * 2 + 1)) * 4 + c]; + next[(y * (w / 2) + x) * 4 + c] = uint8_t(acc / 4); + } + } + } + w /= 2; + h /= 2; + rgba = next.data(); + layout.bytesPerRow = static_cast(w * 4); + layout.rowsPerImage = static_cast(h); + extent = {static_cast(w), static_cast(h), 1}; + } + } +} + +Texture *Graphics::newCubemap(int faceSize, const uint8_t *rgbaFaces) { + TextureCreateInfo info; + return newCubemap(faceSize, rgbaFaces, info); +} + +Texture *Graphics::newCubemap(int faceSize, const uint8_t *rgbaFaces, + const TextureCreateInfo &info) { + if (faceSize <= 0 || !rgbaFaces) + throw Exception("newCubemap: invalid size or null data"); + + auto gpu = std::make_unique(); + gpu->width = faceSize; + gpu->height = faceSize; + gpu->samplerState = info.sampler; + gpu->isCube = true; + gpu->mipLevels = info.generateMipmaps ? uint32_t(mipmapCountForSize(faceSize, faceSize)) : 1u; + + WGPUTextureDescriptor td{}; + td.label = sv("eve_cubemap"); + td.dimension = WGPUTextureDimension_2D; + td.size.width = static_cast(faceSize); + td.size.height = static_cast(faceSize); + td.size.depthOrArrayLayers = 6; + td.sampleCount = 1; + td.format = WGPUTextureFormat_RGBA8Unorm; + td.mipLevelCount = gpu->mipLevels; + td.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_CopyDst; + gpu->texture = device.CreateTexture(reinterpret_cast(&td)); + + WGPUTexelCopyBufferLayout layout{}; + layout.offset = 0; + layout.bytesPerRow = static_cast(faceSize * 4); + layout.rowsPerImage = static_cast(faceSize); + WGPUExtent3D extent{static_cast(faceSize), static_cast(faceSize), 1}; + for (int f = 0; f < 6; ++f) { + WGPUTexelCopyTextureInfo dst{}; + dst.texture = gpu->texture.Get(); + dst.mipLevel = 0; + dst.aspect = WGPUTextureAspect_All; + dst.origin = {0, 0, static_cast(f)}; + queue.WriteTexture(reinterpret_cast(&dst), + rgbaFaces + f * faceSize * faceSize * 4, + static_cast(faceSize) * faceSize * 4, + reinterpret_cast(&layout), + reinterpret_cast(&extent)); + } + + WGPUTextureViewDescriptor vd{}; + vd.format = WGPUTextureFormat_RGBA8Unorm; + vd.dimension = WGPUTextureViewDimension_Cube; + vd.baseMipLevel = 0; + vd.mipLevelCount = gpu->mipLevels; + vd.baseArrayLayer = 0; + vd.arrayLayerCount = 6; + gpu->view = gpu->texture.CreateView(reinterpret_cast(&vd)); + gpu->sampler = makeSampler(info.sampler, gpu->mipLevels); + + auto *tex = new Texture(); + tex->width = faceSize; + tex->height = faceSize; + tex->mipmapCount = int(gpu->mipLevels); + tex->sampler = info.sampler; + tex->gpuHandle = gpu.get(); + + ownedGpuTextures.push_back(std::move(gpu)); + ownedTextures.push_back(std::unique_ptr(tex)); + return tex; +} + +void Graphics::setTextureSampler(Texture *texture, const TextureSampler &sampler) { + if (!texture) return; + auto *gpu = gpuForTexture(texture); + if (!gpu) return; + gpu->samplerState = sampler; + gpu->sampler = makeSampler(sampler, gpu->mipLevels); + texture->sampler = sampler; +} + +float Graphics::getMaxAnisotropy() const { return maxSamplerAnisotropy; } + +Texture *Graphics::newTextureFromFile(const std::string &filename) { + if (filename.empty()) throw Exception("newTextureFromFile: empty filename"); + auto it = texturesByPath.find(filename); + if (it != texturesByPath.end()) { + reloadTextureFromFile(filename); + return it->second; + } + auto *fs = eve::filesystem::Filesystem::create(); + if (!fs) throw Exception("newTextureFromFile: no filesystem"); + std::unique_ptr fileData(fs->read(filename)); + if (!fileData) throw Exception("newTextureFromFile: failed to read '%s'", filename.c_str()); + auto *imgMod = image::Image::create(); + std::unique_ptr data(imgMod->newImageData(fileData.get())); + if (!data) throw Exception("newTextureFromFile: cannot decode '%s'", filename.c_str()); + Texture *tex = newTexture(data.get()); + texturesByPath[filename] = tex; + return tex; +} + +bool Graphics::reloadTextureFromFile(const std::string &filename) { + auto it = texturesByPath.find(filename); + if (it == texturesByPath.end()) return false; + auto *fs = eve::filesystem::Filesystem::create(); + if (!fs) return false; + std::unique_ptr fileData; + try { + fileData.reset(fs->read(filename)); + } catch (...) { + return false; + } + if (!fileData) return false; + auto *imgMod = image::Image::create(); + std::unique_ptr data(imgMod->newImageData(fileData.get())); + if (!data) return false; + Texture *tex = it->second; + auto *gpu = gpuForTexture(tex); + if (gpu && data->getWidth() == tex->width && data->getHeight() == tex->height) { + uploadTexturePixelsMips(gpu, static_cast(data->getData()), tex->width, + tex->height); + } + return true; +} + +GpuTexture *Graphics::gpuForTexture(Texture *t) const { + return t ? static_cast(t->gpuHandle) : nullptr; +} + +GpuTexture *Graphics::gpuForTextureOrWhite(Texture *t) const { + GpuTexture *g = gpuForTexture(t); + return g ? g : whiteTexture; +} + +wgpu::BindGroup Graphics::makeTex2DBindGroup(GpuTexture *color, GpuTexture *depth) { + GpuTexture *c = color ? color : whiteTexture; + GpuTexture *d = depth ? depth : c; + WGPUBindGroupEntry entries[5]{}; + entries[0].binding = 0; + entries[0].textureView = c->view.Get(); + entries[1].binding = 1; + entries[1].textureView = d->view.Get(); + entries[2].binding = 2; + entries[2].sampler = c->sampler.Get(); + entries[3].binding = 3; + entries[3].sampler = d->sampler.Get(); + entries[4].binding = 4; + entries[4].buffer = currentUboArena().buffer.Get(); // replaced per-draw via dynamic offset + entries[4].size = + std::max(Shader::kPushConstantBytes, uint32_t(sizeof(Lighting2DUBO))); + WGPUBindGroupDescriptor desc{}; + desc.label = sv("eve_tex2d_group"); + desc.layout = tex2DSetLayout.Get(); + desc.entryCount = 5; + desc.entries = entries; + return device.CreateBindGroup(reinterpret_cast(&desc)); +} + +wgpu::BindGroup Graphics::makeMeshBindGroup(GpuTexture *albedo, GpuTexture *normal, GpuTexture *env, + GpuTexture *height, uint32_t frameUboOffset, + uint32_t shadowUboOffset, uint32_t pushUboOffset) { + GpuTexture *a = albedo ? albedo : whiteTexture; + GpuTexture *n = normal ? normal : flatNormalTexture; + GpuTexture *e = env ? env : defaultEnvCubemap; + GpuTexture *h = height ? height : flatHeightTexture3D; + + WGPUBindGroupEntry entries[9]{}; + entries[0].binding = 0; + entries[0].buffer = currentUboArena().buffer.Get(); + entries[0].size = sizeof(Mesh3DUBO); + entries[1].binding = 1; + entries[1].textureView = a->view.Get(); + entries[2].binding = 2; + entries[2].textureView = n->view.Get(); + entries[3].binding = 3; + entries[3].textureView = e->view.Get(); + entries[4].binding = 4; + entries[4].buffer = currentUboArena().buffer.Get(); + entries[4].size = sizeof(ShadowUBO); + entries[5].binding = 5; + entries[5].textureView = shadowDepthArray ? shadowDepthArray->view.Get() : nullptr; + entries[6].binding = 6; + entries[6].textureView = h->view.Get(); + entries[7].binding = 7; + entries[7].sampler = mainSampler.Get(); + entries[8].binding = 8; + entries[8].sampler = shadowDepthArray ? shadowDepthArray->sampler.Get() : nullptr; + + (void)frameUboOffset; + (void)shadowUboOffset; + (void)pushUboOffset; + WGPUBindGroupDescriptor desc{}; + desc.label = sv("eve_mesh_group"); + desc.layout = mesh3dSetLayout.Get(); + desc.entryCount = 9; + desc.entries = entries; + return device.CreateBindGroup(reinterpret_cast(&desc)); +} + +// --------------------------------------------------------------------------- +// Mesh creation +// --------------------------------------------------------------------------- + +Mesh *Graphics::newMeshFromArrays(const float *posXYZ, const float *nrmXYZ, const float *uvST, + int vertexCount, const uint32_t *indices, int indexCount) { + if (vertexCount <= 0 || !posXYZ) throw Exception("newMeshFromArrays: invalid vertex data"); + + std::vector verts; + verts.reserve(vertexCount * 8); + for (int i = 0; i < vertexCount; ++i) { + verts.push_back(posXYZ[i * 3 + 0]); + verts.push_back(posXYZ[i * 3 + 1]); + verts.push_back(posXYZ[i * 3 + 2]); + if (nrmXYZ) { + verts.push_back(nrmXYZ[i * 3 + 0]); + verts.push_back(nrmXYZ[i * 3 + 1]); + verts.push_back(nrmXYZ[i * 3 + 2]); + } else { + verts.push_back(0.f); + verts.push_back(0.f); + verts.push_back(1.f); + } + if (uvST) { + verts.push_back(uvST[i * 2 + 0]); + verts.push_back(uvST[i * 2 + 1]); + } else { + verts.push_back(0.f); + verts.push_back(0.f); + } + } + + auto gpu = std::make_unique(); + gpu->vertexCount = uint32_t(vertexCount); + gpu->indexCount = indexCount > 0 ? uint32_t(indexCount) : 0; + gpu->vertexStride = 32; + + WGPUBufferDescriptor vbd{}; + vbd.label = sv("eve_mesh_vb"); + vbd.size = verts.size() * sizeof(float); + vbd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_Vertex; + vbd.mappedAtCreation = false; + gpu->vertexBuffer = device.CreateBuffer(reinterpret_cast(&vbd)); + queue.WriteBuffer(gpu->vertexBuffer, 0, verts.data(), vbd.size); + + if (indexCount > 0) { + WGPUBufferDescriptor ibd{}; + ibd.label = sv("eve_mesh_ib"); + ibd.size = uint64_t(indexCount) * sizeof(uint32_t); + ibd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_Index; + ibd.mappedAtCreation = false; + gpu->indexBuffer = device.CreateBuffer(reinterpret_cast(&ibd)); + queue.WriteBuffer(gpu->indexBuffer, 0, indices, ibd.size); + } + + auto *mesh = new Mesh(); + mesh->indexCount = indexCount; + mesh->gpuHandle = gpu.get(); + ownedGpuMeshes.push_back(std::move(gpu)); + ownedMeshes.push_back(std::unique_ptr(mesh)); + return mesh; +} + +Mesh *Graphics::newMeshFromAssimp(const ::aiMesh &mesh) { + aiMatrix4x4 ident; + return newMeshFromAssimp(mesh, ident); +} + +Mesh *Graphics::newMeshFromAssimp(const ::aiMesh &mesh, const aiMatrix4x4 &worldTransform) { + std::vector pos, nrm, uv; + std::vector idx; + pos.reserve(mesh.mNumVertices * 3); + nrm.reserve(mesh.mNumVertices * 3); + uv.reserve(mesh.mNumVertices * 2); + idx.reserve(mesh.mNumFaces * 3); + + for (unsigned i = 0; i < mesh.mNumVertices; ++i) { + aiVector3D p = worldTransform * mesh.mVertices[i]; + pos.push_back(p.x); + pos.push_back(p.y); + pos.push_back(p.z); + if (mesh.mNormals) { + aiMatrix3x3 rot(worldTransform); + aiVector3D n = rot * mesh.mNormals[i]; + n.Normalize(); + nrm.push_back(n.x); + nrm.push_back(n.y); + nrm.push_back(n.z); + } else { + nrm.push_back(0.f); + nrm.push_back(0.f); + nrm.push_back(1.f); + } + if (mesh.mTextureCoords[0]) { + uv.push_back(mesh.mTextureCoords[0][i].x); + uv.push_back(mesh.mTextureCoords[0][i].y); + } else { + uv.push_back(0.f); + uv.push_back(0.f); + } + } + for (unsigned f = 0; f < mesh.mNumFaces; ++f) { + for (unsigned v = 0; v < mesh.mFaces[f].mNumIndices; ++v) + idx.push_back(mesh.mFaces[f].mIndices[v]); + } + Mesh *m = newMeshFromArrays(pos.data(), nrm.data(), uv.data(), int(mesh.mNumVertices), idx.data(), + int(idx.size())); + if (m && mesh.mNumAnimMeshes > 0) { + m->initMorphBase(int(mesh.mNumVertices), pos.data(), nrm.data(), uv.data()); + for (unsigned a = 0; a < mesh.mNumAnimMeshes; ++a) { + const aiAnimMesh *am = mesh.mAnimMeshes[a]; + std::vector absPos(am->mNumVertices * 3); + for (unsigned i = 0; i < am->mNumVertices; ++i) { + absPos[i * 3 + 0] = am->mVertices[i].x; + absPos[i * 3 + 1] = am->mVertices[i].y; + absPos[i * 3 + 2] = am->mVertices[i].z; + } + std::string name = am->mName.C_Str(); + if (name.empty()) name = "morph" + std::to_string(a); + m->addMorphTargetAbsolute(name, absPos.data()); + } + } + return m; +} + +bool Graphics::bakeMeshMorph(Mesh *mesh) { + if (!mesh || !mesh->hasMorphData() || !mesh->isMorphDirty()) return false; + auto *gpu = static_cast(mesh->gpuHandle); + if (!gpu || !gpu->vertexBuffer) return false; + std::vector pos, nrm; + mesh->computeMorphedPositions(pos, nrm); + if (pos.empty()) return false; + std::vector verts; + verts.reserve(mesh->getVertexCount() * 8); + const auto &uvs = mesh->baseUv(); + for (int i = 0; i < mesh->getVertexCount(); ++i) { + verts.push_back(pos[i * 3 + 0]); + verts.push_back(pos[i * 3 + 1]); + verts.push_back(pos[i * 3 + 2]); + if (nrm.size() >= size_t((i + 1) * 3)) { + verts.push_back(nrm[i * 3 + 0]); + verts.push_back(nrm[i * 3 + 1]); + verts.push_back(nrm[i * 3 + 2]); + } else { + verts.push_back(0.f); + verts.push_back(0.f); + verts.push_back(1.f); + } + if (uvs.size() >= size_t((i + 1) * 2)) { + verts.push_back(uvs[i * 2 + 0]); + verts.push_back(uvs[i * 2 + 1]); + } else { + verts.push_back(0.f); + verts.push_back(0.f); + } + } + queue.WriteBuffer(gpu->vertexBuffer, 0, verts.data(), verts.size() * sizeof(float)); + mesh->markMorphClean(); + return true; +} + +Mesh *Graphics::newMeshSphere(int slices, int stacks) { + std::vector pos, nrm, uv; + std::vector idx; + for (int y = 0; y <= stacks; ++y) { + for (int x = 0; x <= slices; ++x) { + float u = float(x) / float(slices); + float v = float(y) / float(stacks); + float theta = u * 2.f * glm::pi(); + float phi = v * glm::pi(); + float sx = std::sin(phi) * std::cos(theta); + float sy = std::cos(phi); + float sz = std::sin(phi) * std::sin(theta); + pos.push_back(sx); + pos.push_back(sy); + pos.push_back(sz); + nrm.push_back(sx); + nrm.push_back(sy); + nrm.push_back(sz); + uv.push_back(u); + uv.push_back(v); + } + } + for (int y = 0; y < stacks; ++y) { + for (int x = 0; x < slices; ++x) { + int a = y * (slices + 1) + x; + int b = a + slices + 1; + idx.push_back(a); + idx.push_back(b); + idx.push_back(a + 1); + idx.push_back(b); + idx.push_back(b + 1); + idx.push_back(a + 1); + } + } + return newMeshFromArrays(pos.data(), nrm.data(), uv.data(), int(pos.size() / 3), idx.data(), + int(idx.size())); +} + +Mesh *Graphics::newMeshCylinder(int slices, int stacks, bool caps) { + std::vector pos, nrm, uv; + std::vector idx; + for (int y = 0; y <= stacks; ++y) { + for (int x = 0; x <= slices; ++x) { + float u = float(x) / float(slices); + float v = float(y) / float(stacks); + float theta = u * 2.f * glm::pi(); + pos.push_back(std::cos(theta)); + pos.push_back(v * 2.f - 1.f); + pos.push_back(std::sin(theta)); + nrm.push_back(std::cos(theta)); + nrm.push_back(0.f); + nrm.push_back(std::sin(theta)); + uv.push_back(u); + uv.push_back(v); + } + } + for (int y = 0; y < stacks; ++y) { + for (int x = 0; x < slices; ++x) { + int a = y * (slices + 1) + x; + int b = a + slices + 1; + idx.push_back(a); + idx.push_back(b); + idx.push_back(a + 1); + idx.push_back(b); + idx.push_back(b + 1); + idx.push_back(a + 1); + } + } + if (caps) { + int base = int(pos.size() / 3); + pos.push_back(0.f); pos.push_back(-1.f); pos.push_back(0.f); + nrm.push_back(0.f); nrm.push_back(-1.f); nrm.push_back(0.f); + uv.push_back(0.5f); uv.push_back(0.5f); + for (int x = 0; x < slices; ++x) { + float theta0 = float(x) / float(slices) * 2.f * glm::pi(); + float theta1 = float(x + 1) / float(slices) * 2.f * glm::pi(); + int i0 = int(pos.size() / 3); + pos.push_back(std::cos(theta0)); pos.push_back(-1.f); pos.push_back(std::sin(theta0)); + nrm.push_back(0.f); nrm.push_back(-1.f); nrm.push_back(0.f); + uv.push_back(0.5f + 0.5f * std::cos(theta0)); uv.push_back(0.5f + 0.5f * std::sin(theta0)); + int i1 = int(pos.size() / 3); + pos.push_back(std::cos(theta1)); pos.push_back(-1.f); pos.push_back(std::sin(theta1)); + nrm.push_back(0.f); nrm.push_back(-1.f); nrm.push_back(0.f); + uv.push_back(0.5f + 0.5f * std::cos(theta1)); uv.push_back(0.5f + 0.5f * std::sin(theta1)); + idx.push_back(base); + idx.push_back(i1); + idx.push_back(i0); + } + base = int(pos.size() / 3); + pos.push_back(0.f); pos.push_back(1.f); pos.push_back(0.f); + nrm.push_back(0.f); nrm.push_back(1.f); nrm.push_back(0.f); + uv.push_back(0.5f); uv.push_back(0.5f); + for (int x = 0; x < slices; ++x) { + float theta0 = float(x) / float(slices) * 2.f * glm::pi(); + float theta1 = float(x + 1) / float(slices) * 2.f * glm::pi(); + int i0 = int(pos.size() / 3); + pos.push_back(std::cos(theta0)); pos.push_back(1.f); pos.push_back(std::sin(theta0)); + nrm.push_back(0.f); nrm.push_back(1.f); nrm.push_back(0.f); + uv.push_back(0.5f + 0.5f * std::cos(theta0)); uv.push_back(0.5f + 0.5f * std::sin(theta0)); + int i1 = int(pos.size() / 3); + pos.push_back(std::cos(theta1)); pos.push_back(1.f); pos.push_back(std::sin(theta1)); + nrm.push_back(0.f); nrm.push_back(1.f); nrm.push_back(0.f); + uv.push_back(0.5f + 0.5f * std::cos(theta1)); uv.push_back(0.5f + 0.5f * std::sin(theta1)); + idx.push_back(base); + idx.push_back(i0); + idx.push_back(i1); + } + } + return newMeshFromArrays(pos.data(), nrm.data(), uv.data(), int(pos.size() / 3), idx.data(), + int(idx.size())); +} + +// --------------------------------------------------------------------------- +// 2D drawing +// --------------------------------------------------------------------------- + +void Graphics::drawSolidRect(float x, float y, float w, float h, const Color &color) { + solidBatch.addRect(x, y, w, h, color); +} + +void Graphics::drawTexturedRect(Texture *texture, float x, float y, float w, float h, + const Color &color) { + drawTexturedRectUV(texture, x, y, w, h, 0.f, 0.f, 1.f, 1.f, color); +} + +void Graphics::drawTexturedRectShader(Texture *texture, Shader *shader, float x, float y, float w, + float h, const Color &color) { + drawTexturedRectShaderUV(texture, shader, x, y, w, h, 0.f, 0.f, 1.f, 1.f, color); +} + +void Graphics::drawTexturedRectUV(Texture *texture, float x, float y, float w, float h, float u0, + float v0, float u1, float v1, const Color &color) { + drawTexturedRectShaderUV(texture, currentShader, x, y, w, h, u0, v0, u1, v1, color); +} + +void Graphics::drawTexturedRectShaderUV(Texture *texture, Shader *shader, float x, float y, float w, + float h, float u0, float v0, float u1, float v1, + const Color &color, bool rotatedUV) { + if (!texture) { + drawSolidRect(x, y, w, h, color); + return; + } + auto it = std::find_if(texturedBatches.begin(), texturedBatches.end(), + [&](const TexturedBatch &tb) { + return tb.texture == texture && tb.shader == shader && + tb.depth == nullptr; + }); + if (it == texturedBatches.end()) { + texturedBatches.push_back(TexturedBatch{texture, nullptr, shader, Batcher{}}); + it = texturedBatches.end() - 1; + } + it->batch.addTexturedRect(x, y, w, h, color, u0, v0, u1, v1, rotatedUV); +} + +void Graphics::drawTexturedRectShaderUVRotated(Texture *texture, Shader *shader, float cx, float cy, + float w, float h, float degrees, float u0, float v0, + float u1, float v1, const Color &color, + bool rotatedUV) { + if (!texture) { + drawSolidRect(cx - w * 0.5f, cy - h * 0.5f, w, h, color); + return; + } + auto it = std::find_if(texturedBatches.begin(), texturedBatches.end(), + [&](const TexturedBatch &tb) { + return tb.texture == texture && tb.shader == shader && + tb.depth == nullptr; + }); + if (it == texturedBatches.end()) { + texturedBatches.push_back(TexturedBatch{texture, nullptr, shader, Batcher{}}); + it = texturedBatches.end() - 1; + } + it->batch.addTexturedRectRotated(cx, cy, w, h, degrees, color, u0, v0, u1, v1, rotatedUV); +} + +void Graphics::drawTexturedRectShaderDepth(Texture *color, Texture *depth, Shader *shader, float x, + float y, float w, float h, const Color &tint) { + if (!color) { + drawSolidRect(x, y, w, h, tint); + return; + } + auto it = std::find_if(texturedBatches.begin(), texturedBatches.end(), + [&](const TexturedBatch &tb) { + return tb.texture == color && tb.shader == shader && + tb.depth == depth; + }); + if (it == texturedBatches.end()) { + texturedBatches.push_back(TexturedBatch{color, depth, shader, Batcher{}}); + it = texturedBatches.end() - 1; + } + it->batch.addTexturedRect(x, y, w, h, tint, 0.f, 0.f, 1.f, 1.f, false); +} + +void Graphics::drawTexturedRectLitUV(Texture *albedo, Texture *normal, float x, float y, float w, + float h, float u0, float v0, float u1, float v1, + const Color &color) { + if (!albedo) { + drawSolidRect(x, y, w, h, color); + return; + } + auto it = std::find_if(litBatches.begin(), litBatches.end(), + [&](const LitBatch &lb) { + return lb.albedo == albedo && lb.normal == normal; + }); + if (it == litBatches.end()) { + litBatches.push_back(LitBatch{albedo, normal, Batcher{}}); + it = litBatches.end() - 1; + } + it->batch.addTexturedRect(x, y, w, h, color, u0, v0, u1, v1, false); +} + +void Graphics::setLighting2D(const Lighting2DUBO &ubo) { + // Uploaded on demand before flushing lit batches. + lighting2dFrame = ubo; +} + +void Graphics::flush2D(wgpu::RenderPassEncoder pass, int viewW, int viewH, + WGPUTextureFormat format) { + // Solid batch. + if (!solidBatch.empty() && (colorPipeline || offscreenColorPipeline)) { + Batcher ndc = solidBatch; + ndc.toNDC(viewW, viewH); + auto verts = ndc.vertices(); + // Batcher::toNDC targets Vulkan's Y-down NDC ((-1,-1)=top-left), but + // WebGPU's clip space is Y-up ((-1,-1)=bottom-left). Flip Y so logical + // top-left maps to the swapchain's top-left. + for (auto &v : verts) v.pos.y = -v.pos.y; + if (!verts.empty()) { + std::vector data; + data.reserve(verts.size() * 6); + for (const auto &v : verts) { + data.push_back(v.pos.x); + data.push_back(v.pos.y); + data.push_back(v.color.r); + data.push_back(v.color.g); + data.push_back(v.color.b); + data.push_back(v.color.a); + } + uint64_t bytes = data.size() * sizeof(float); + auto &arena = currentVertexArena(); + ensureVertexArena(arena, arena.used + bytes); + uint64_t offset = arena.alloc(bytes); + queue.WriteBuffer(arena.buffer, offset, data.data(), bytes); + + wgpu::RenderPipeline pipe = + uint32_t(format) == uint32_t(surfaceFormat) ? colorPipeline : offscreenColorPipeline; + pass.SetPipeline(pipe); + pass.SetVertexBuffer(0, arena.buffer, offset, bytes); + pass.Draw(static_cast(verts.size()), 1, 0, 0); + } + } + solidBatch.clear(); + + // Textured batches. + for (auto &tb : texturedBatches) { + if (tb.batch.empty()) continue; + drawTexturedBatch(pass, tb, viewW, viewH, format, uint32_t(format) != uint32_t(surfaceFormat)); + } + texturedBatches.clear(); + + // Lit batches. + for (auto &lb : litBatches) { + if (lb.batch.empty()) continue; + drawLitBatch(pass, lb, viewW, viewH, format); + } + litBatches.clear(); +} + +void Graphics::drawTexturedBatch(wgpu::RenderPassEncoder pass, TexturedBatch &tb, int viewW, + int viewH, WGPUTextureFormat format, bool offscreen) { + Batcher ndc = tb.batch; + ndc.toNDC(viewW, viewH); + auto verts = ndc.vertices(); + for (auto &v : verts) v.pos.y = -v.pos.y; + if (verts.empty()) return; + + std::vector data; + data.reserve(verts.size() * 8); + for (const auto &v : verts) { + data.push_back(v.pos.x); + data.push_back(v.pos.y); + data.push_back(v.color.r); + data.push_back(v.color.g); + data.push_back(v.color.b); + data.push_back(v.color.a); + data.push_back(v.uv.x); + data.push_back(v.uv.y); + } + uint64_t bytes = data.size() * sizeof(float); + auto &arena = currentVertexArena(); + ensureVertexArena(arena, arena.used + bytes); + uint64_t vtxOffset = arena.alloc(bytes); + queue.WriteBuffer(arena.buffer, vtxOffset, data.data(), bytes); + + GpuTexture *gpu = gpuForTexture(tb.texture); + GpuTexture *depthGpu = gpuForTexture(tb.depth); + auto &uboArena = currentUboArena(); + ensureUboArena(uboArena, uboArena.used + 256); + + // Push-constant (Externals) block for custom shaders. + uint32_t pushOffset = 0; + if (tb.shader && tb.shader->pushConstantSize() > 0) { + pushOffset = uboArena.alloc(Shader::kPushConstantBytes, 256); + queue.WriteBuffer(uboArena.buffer, pushOffset, tb.shader->pushConstantData(), + Shader::kPushConstantBytes); + } + + wgpu::BindGroup bg = makeTex2DBindGroup(gpu, depthGpu); + uint32_t offsets[1] = {pushOffset}; + + wgpu::RenderPipeline pipe; + if (tb.shader && tb.shader->gpuHandle) { + auto *gs = static_cast(tb.shader->gpuHandle); + pipe = offscreen ? gs->offscreenPipeline : gs->swapchainPipeline; + } else { + pipe = offscreen ? offscreenTexturedPipeline : texturedPipeline; + } + if (!pipe) return; + pass.SetPipeline(pipe); + pass.SetBindGroup(0, bg, 1, offsets); + pass.SetVertexBuffer(0, arena.buffer, vtxOffset, bytes); + pass.Draw(static_cast(verts.size()), 1, 0, 0); +} + +void Graphics::drawLitBatch(wgpu::RenderPassEncoder pass, LitBatch &lb, int viewW, int viewH, + WGPUTextureFormat format) { + Batcher ndc = lb.batch; + ndc.toNDC(viewW, viewH); + auto verts = ndc.vertices(); + for (auto &v : verts) v.pos.y = -v.pos.y; + if (verts.empty()) return; + + std::vector data; + data.reserve(verts.size() * 8); + for (const auto &v : verts) { + data.push_back(v.pos.x); + data.push_back(v.pos.y); + data.push_back(v.color.r); + data.push_back(v.color.g); + data.push_back(v.color.b); + data.push_back(v.color.a); + data.push_back(v.uv.x); + data.push_back(v.uv.y); + } + uint64_t bytes = data.size() * sizeof(float); + auto &arena = currentVertexArena(); + ensureVertexArena(arena, arena.used + bytes); + uint64_t vtxOffset = arena.alloc(bytes); + queue.WriteBuffer(arena.buffer, vtxOffset, data.data(), bytes); + + GpuTexture *albedoGpu = gpuForTextureOrWhite(lb.albedo); + GpuTexture *normalGpu = gpuForTextureOrWhite(lb.normal); + + auto &uboArena = currentUboArena(); + ensureUboArena(uboArena, uboArena.used + 512); + uint32_t uboOffset = uboArena.alloc(sizeof(Lighting2DUBO), 256); + queue.WriteBuffer(uboArena.buffer, uboOffset, &lighting2dFrame, sizeof(Lighting2DUBO)); + + wgpu::BindGroup bg = makeTex2DBindGroup(albedoGpu, normalGpu); + uint32_t offsets[1] = {uboOffset}; + wgpu::RenderPipeline pipe = + uint32_t(format) == uint32_t(surfaceFormat) ? lit2dPipeline : offscreenLitPipeline; + if (!pipe) return; + pass.SetPipeline(pipe); + pass.SetBindGroup(0, bg, 1, offsets); + pass.SetVertexBuffer(0, arena.buffer, vtxOffset, bytes); + pass.Draw(static_cast(verts.size()), 1, 0, 0); +} + +// --------------------------------------------------------------------------- +// 3D rendering +// --------------------------------------------------------------------------- + +void Graphics::begin3DFrame() { + if (!initialized || !device) return; + frame3DStarted = true; + frameHad3DThisFrame = false; + frameHad3D = false; + sceneColorPassOpen = false; + mesh3dDraws.clear(); + shadowPassDraws.clear(); + for (int c = 0; c < ShadowConfig::kCascades; ++c) shadowCascadeDraws[c].clear(); + voxelDraws.clear(); + if (surfaceNeedsRecreate.load()) { + surfaceNeedsRecreate.store(false); + markSwapchainDirty(); + } + rebuildSwapchainIfNeeded(); +} + +void Graphics::setMesh3DViewProj(const glm::mat4 &viewProj) { mesh3dViewProj = viewProj; } +void Graphics::setMesh3DView(const glm::mat4 &view) { mesh3dView = view; } +void Graphics::setMesh3DClip(float nearZ, float farZ) { + mesh3dNear = nearZ; + mesh3dFar = farZ; +} + +Texture *Graphics::getSceneColorTexture() { return sceneColorTexture; } + +void Graphics::drawMesh(Mesh *mesh, const glm::mat4 &model, Texture *texture, const Color &tint) { + drawMeshShader(mesh, model, texture, tint, nullptr); +} + +void Graphics::drawMeshShader(Mesh *mesh, const glm::mat4 &model, Texture *texture, const Color &tint, + Shader *shader) { + if (!mesh || !mesh->gpuHandle) return; + frameHad3DThisFrame = true; + frameHad3D = true; + Mesh3dDraw d; + d.mesh = mesh; + d.texture = texture; + d.model = model; + d.tint = tint; + d.shader = shader; + mesh3dDraws.push_back(d); +} + +void Graphics::setMesh3DNormalTexture(Texture *normal) { mesh3dNormalTexture = normal; } +void Graphics::setMesh3DHeightTexture(Texture *height) { mesh3dHeightTexture = height; } +void Graphics::setMesh3DMaterial(float metallic, float roughness) { + mesh3dMetallic = metallic; + mesh3dRoughness = roughness; +} +void Graphics::setMesh3DTexCellBomb(float cellScale, float strength, float rotAmount) { + mesh3dTexBombScale = cellScale; + mesh3dTexBombStrength = strength; + mesh3dTexBombRot = rotAmount; +} +void Graphics::setMesh3DParallax(float scale, float minLayers, float maxLayers) { + mesh3dParallaxScale = scale; + mesh3dParallaxMin = minLayers; + mesh3dParallaxMax = maxLayers; +} +void Graphics::setMesh3DLighting(const Lighting3DPack &pack) { mesh3dLighting = pack; } +void Graphics::setMesh3DClusteredLighting(const ClusteredLightingUpload &upload) { + mesh3dClustered = upload; + mesh3dClusteredActive = upload.active; +} +void Graphics::setMesh3DLight(const glm::vec3 &dir, const glm::vec3 &color) { + mesh3dLighting.lights[0].posRadius = glm::vec4(dir, 0.f); + mesh3dLighting.lights[0].color = glm::vec4(color, 1.f); +} +void Graphics::setMesh3DCameraPos(const glm::vec3 &eye) { mesh3dCameraPos = eye; } +void Graphics::setMesh3DEnv(Texture *cube, float intensity) { + mesh3dEnvTexture = cube; + mesh3dEnvIntensity = intensity; +} +void Graphics::setMesh3DShadows(const ShadowUpload &upload) { mesh3dShadows = upload; } +void Graphics::setMesh3DShadowReceive(bool receive) { mesh3dShadowReceive = receive; } + +void Graphics::beginShadowPass(int cascadeIndex) { + shadowPassCascade = cascadeIndex; + shadowPassDraws.clear(); +} + +void Graphics::drawMeshShadow(Mesh *mesh, const glm::mat4 &lightMVP) { + if (!mesh || !mesh->gpuHandle) return; + ShadowDraw d; + d.mesh = mesh; + d.mvp = lightMVP; + shadowPassDraws.push_back(d); +} + +void Graphics::endShadowPass() { + if (shadowPassCascade < 0 || shadowPassCascade >= ShadowConfig::kCascades) { + shadowPassCascade = -1; + shadowPassDraws.clear(); + return; + } + shadowCascadeDraws[shadowPassCascade] = shadowPassDraws; + shadowPassDraws.clear(); + shadowPassCascade = -1; +} + +// --------------------------------------------------------------------------- +// GBuffer pass +// --------------------------------------------------------------------------- + +void Graphics::beginGBufferPass(int width, int height) { + if (!device) return; + createGbufferResources(width, height); + gbufferPassActive = true; + gbufferPassPending = true; + gbufferPassDraws.clear(); +} + +void Graphics::drawMeshGBuffer(Mesh *mesh, const glm::mat4 &mvp, const glm::mat4 &model, float nearZ, + float farZ, Texture *albedo, float tintR, float tintG, float tintB) { + if (!mesh || !mesh->gpuHandle) return; + GbufferDraw d; + d.mesh = mesh; + d.albedo = albedo; + d.mvp = mvp; + d.model = model; + d.nearZ = nearZ; + d.farZ = farZ; + d.tint = glm::vec4(tintR, tintG, tintB, 1.f); + gbufferPassDraws.push_back(d); +} + +void Graphics::endGBufferPass() { gbufferPassActive = false; } + +// --------------------------------------------------------------------------- +// Voxel +// --------------------------------------------------------------------------- + +void Graphics::drawVoxelFaceInstances(const uint32_t *packed, int count, float originX, + float originY, float originZ, const std::string &faceDir, + Texture *atlas, int tilesPerRow) { + if (!device || count <= 0 || !packed) return; + if (!voxelUnitQuadVerts) return; + frameHad3DThisFrame = true; + frameHad3D = true; + + int face = 5; + if (faceDir == "posX" || faceDir == "+x") face = 0; + else if (faceDir == "negX" || faceDir == "-x") face = 1; + else if (faceDir == "posY" || faceDir == "+y") face = 2; + else if (faceDir == "negY" || faceDir == "-y") face = 3; + else if (faceDir == "posZ" || faceDir == "+z") face = 4; + + VoxelDraw d; + d.count = uint32_t(count); + d.atlas = gpuForTextureOrWhite(atlas); + d.viewProj = mesh3dViewProj; + d.chunkOrigin = glm::vec4(originX, originY, originZ, float(face)); + d.atlasInfo = glm::vec4(float(tilesPerRow), 0.f, 0.f, 0.f); + d.tint = glm::vec4(1.f); + d.instanceBufferOffset = 0; + d.pushUboOffset = 0; + + // Upload packed instances into the voxel instance arena (per frame slot). + auto &arena = voxelInstanceArena; + if (!arena.buffer) { + WGPUBufferDescriptor bd{}; + bd.label = sv("eve_voxel_instances"); + bd.size = 1u << 20; + bd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_Vertex; + bd.mappedAtCreation = false; + arena.buffer = device.CreateBuffer(reinterpret_cast(&bd)); + arena.capacity = 1u << 20; + } + uint64_t need = uint64_t(count) * 4; + if (arena.used + need > arena.capacity) { + uint64_t cap = arena.capacity * 2; + WGPUBufferDescriptor bd{}; + bd.label = sv("eve_voxel_instances"); + bd.size = cap; + bd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_Vertex; + bd.mappedAtCreation = false; + arena.buffer = device.CreateBuffer(reinterpret_cast(&bd)); + arena.capacity = cap; + arena.used = 0; + } + d.instanceBufferOffset = static_cast(arena.used); + queue.WriteBuffer(arena.buffer, arena.used, packed, need); + arena.used += need; + voxelDraws.push_back(d); +} + +// --------------------------------------------------------------------------- +// Scene color / shadow / gbuffer resources +// --------------------------------------------------------------------------- + +void Graphics::createSceneColorResources(int width, int height) { + if (!device) return; + if (sceneColorWidth == width && sceneColorHeight == height && !sceneColorSlots.empty()) return; + + destroySceneColorResources(); + sceneColorWidth = width; + sceneColorHeight = height; + sceneColorSamples = 1; + for (int s = 0; s < int(kFramesInFlight); ++s) { + SceneColorSlot slot; + slot.sampleCount = sceneColorSamples; + + WGPUTextureDescriptor cd{}; + cd.label = sv("eve_scene_color"); + cd.dimension = WGPUTextureDimension_2D; + cd.size = {static_cast(width), static_cast(height), 1}; + cd.sampleCount = sceneColorSamples; + cd.format = sceneColorFormat; + cd.mipLevelCount = 1; + cd.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_RenderAttachment | + WGPUTextureUsage_CopySrc; + slot.color = device.CreateTexture(reinterpret_cast(&cd)); + slot.colorView = slot.color.CreateView(); + + WGPUTextureDescriptor dd{}; + dd.label = sv("eve_scene_depth"); + dd.dimension = WGPUTextureDimension_2D; + dd.size = {static_cast(width), static_cast(height), 1}; + dd.sampleCount = sceneColorSamples; + dd.format = WGPUTextureFormat_Depth32Float; + dd.mipLevelCount = 1; + dd.usage = WGPUTextureUsage_RenderAttachment; + slot.depth = device.CreateTexture(reinterpret_cast(&dd)); + slot.depthView = slot.depth.CreateView(); + + slot.colorGpu.texture = slot.color; + slot.colorGpu.view = slot.colorView; + slot.colorGpu.width = width; + slot.colorGpu.height = height; + slot.colorGpu.sampler = createLinearSampler(device); + slot.colorGpu.samplerState = TextureSampler::linearMipmap(); + + slot.colorTex.gpuHandle = &slot.colorGpu; + slot.colorTex.width = width; + slot.colorTex.height = height; + slot.colorTex.mipmapCount = 1; + + sceneColorSlots.push_back(std::move(slot)); + } + sceneColorTexture = &sceneColorSlots[0].colorTex; +} + +void Graphics::destroySceneColorResources() { + sceneColorSlots.clear(); + sceneColorTexture = nullptr; +} + +void Graphics::createShadowResources() { + if (!device) return; + if (shadowDepthArray) return; + shadowMapSize = ShadowConfig::kMapSize; + + // Depth array: 3 cascade layers, renderable + sampleable. + auto *gpu = new GpuTexture(); + WGPUTextureDescriptor td{}; + td.label = sv("eve_shadow_depth"); + td.dimension = WGPUTextureDimension_2D; + td.size = {static_cast(shadowMapSize), static_cast(shadowMapSize), 3}; + td.sampleCount = 1; + td.format = WGPUTextureFormat_Depth32Float; + td.mipLevelCount = 1; + td.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_RenderAttachment; + gpu->texture = device.CreateTexture(reinterpret_cast(&td)); + + WGPUTextureViewDescriptor avd{}; + avd.format = WGPUTextureFormat_Depth32Float; + avd.dimension = WGPUTextureViewDimension_2DArray; + avd.baseMipLevel = 0; + avd.mipLevelCount = 1; + avd.baseArrayLayer = 0; + avd.arrayLayerCount = 3; + gpu->view = gpu->texture.CreateView(reinterpret_cast(&avd)); + + WGPUSamplerDescriptor sd{}; + sd.label = sv("eve_shadow_sampler"); + sd.addressModeU = WGPUAddressMode_ClampToEdge; + sd.addressModeV = WGPUAddressMode_ClampToEdge; + sd.addressModeW = WGPUAddressMode_ClampToEdge; + sd.magFilter = WGPUFilterMode_Linear; + sd.minFilter = WGPUFilterMode_Linear; + sd.mipmapFilter = WGPUMipmapFilterMode_Nearest; + sd.compare = WGPUCompareFunction_LessEqual; + sd.maxAnisotropy = 1.f; + gpu->sampler = device.CreateSampler(reinterpret_cast(&sd)); + shadowDepthArray = gpu; +} + +void Graphics::destroyShadowResources() { + shadowDepthArray = nullptr; +} + +void Graphics::createGbufferResources(int width, int height) { + if (!device) return; + if (gbufferWidth == width && gbufferHeight == height && !gbufferSlots.empty()) return; + destroyGbufferResources(); + gbufferWidth = width; + gbufferHeight = height; + + WGPUTextureDescriptor td{}; + td.label = sv("eve_gbuffer_target"); + td.dimension = WGPUTextureDimension_2D; + td.size = {static_cast(width), static_cast(height), 1}; + td.sampleCount = 1; + td.format = WGPUTextureFormat_RGBA8Unorm; + td.mipLevelCount = 1; + td.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_RenderAttachment; + + WGPUTextureDescriptor dd{}; + dd.label = sv("eve_gbuffer_depth"); + dd.dimension = WGPUTextureDimension_2D; + dd.size = {static_cast(width), static_cast(height), 1}; + dd.sampleCount = 1; + dd.format = WGPUTextureFormat_Depth32Float; + dd.mipLevelCount = 1; + dd.usage = WGPUTextureUsage_RenderAttachment; + + for (int s = 0; s < int(kFramesInFlight); ++s) { + GbufferSlot slot; + slot.normal = device.CreateTexture(reinterpret_cast(&td)); + slot.normalView = slot.normal.CreateView(); + slot.depthColor = device.CreateTexture(reinterpret_cast(&td)); + slot.depthColorView = slot.depthColor.CreateView(); + slot.albedo = device.CreateTexture(reinterpret_cast(&td)); + slot.albedoView = slot.albedo.CreateView(); + slot.depth = device.CreateTexture(reinterpret_cast(&dd)); + slot.depthView = slot.depth.CreateView(); + + slot.normalGpu.texture = slot.normal; + slot.normalGpu.view = slot.normalView; + slot.normalGpu.sampler = createLinearSampler(device); + slot.depthColorGpu.texture = slot.depthColor; + slot.depthColorGpu.view = slot.depthColorView; + slot.depthColorGpu.sampler = createLinearSampler(device); + slot.albedoGpu.texture = slot.albedo; + slot.albedoGpu.view = slot.albedoView; + slot.albedoGpu.sampler = createLinearSampler(device); + + slot.normalTex.gpuHandle = &slot.normalGpu; + slot.normalTex.width = width; + slot.normalTex.height = height; + slot.depthColorTex.gpuHandle = &slot.depthColorGpu; + slot.depthColorTex.width = width; + slot.depthColorTex.height = height; + slot.albedoTex.gpuHandle = &slot.albedoGpu; + slot.albedoTex.width = width; + slot.albedoTex.height = height; + + gbufferSlots.push_back(std::move(slot)); + } +} + +void Graphics::destroyGbufferResources() { gbufferSlots.clear(); } + +// --------------------------------------------------------------------------- +// Flush helpers +// --------------------------------------------------------------------------- + +void Graphics::flushMesh3D(wgpu::RenderPassEncoder pass, WGPUTextureFormat format) { + if (mesh3dDraws.empty()) return; + + auto &uboArena = currentUboArena(); + ensureUboArena(uboArena, uboArena.used + mesh3dDraws.size() * 2048); + auto &vtxArena = currentVertexArena(); + + // Pre-allocate per-draw UBO slots. + for (auto &d : mesh3dDraws) { + d.frameUboOffset = uboArena.alloc(sizeof(Mesh3DUBO), 256); + d.shadowUboOffset = uboArena.alloc(sizeof(ShadowUBO), 256); + d.pushUboOffset = 0; + if (d.shader && d.shader->pushConstantSize() > 0) + d.pushUboOffset = uboArena.alloc(Shader::kPushConstantBytes, 256); + } + + // Upload the per-draw frame UBO + shared shadow UBO. + for (auto &d : mesh3dDraws) { + Mesh3DUBO ubo; + ubo.mvp = mesh3dViewProj * d.model; + ubo.model = d.model; + ubo.lightDir = glm::vec4(glm::vec3(mesh3dLighting.lights[0].posRadius), float(mesh3dLighting.count)); + ubo.lightColor = mesh3dLighting.lights[0].color; + ubo.tint = d.tint; + ubo.cameraPos = glm::vec4(mesh3dCameraPos, mesh3dRoughness); + ubo.ambient = glm::vec4(glm::vec3(mesh3dLighting.ambient), mesh3dMetallic); + for (int i = 0; i < Lighting3DPack::kMaxLights; ++i) + ubo.lights[i] = mesh3dLighting.lights[i]; + ubo.texBomb = glm::vec4(mesh3dTexBombScale, mesh3dTexBombStrength, mesh3dTexBombRot, 0.f); + ubo.parallax = glm::vec4(mesh3dParallaxScale, mesh3dParallaxMin, mesh3dParallaxMax, 0.f); + ubo.view = mesh3dView; + ubo.clipInfo = glm::vec4(mesh3dNear, mesh3dFar, 0.f, 0.f); + ubo.lightColor.w = mesh3dEnvIntensity; + queue.WriteBuffer(uboArena.buffer, d.frameUboOffset, &ubo, sizeof(ubo)); + } + + // Shared shadow UBO written once. + ShadowUBO shadowUbo = mesh3dShadows.ubo; + if (!mesh3dShadows.active || !mesh3dShadowReceive) shadowUbo.bias.y = 0.f; + for (auto &d : mesh3dDraws) + queue.WriteBuffer(uboArena.buffer, d.shadowUboOffset, &shadowUbo, sizeof(shadowUbo)); + + for (auto &d : mesh3dDraws) { + auto *gpuMesh = static_cast(d.mesh->gpuHandle); + if (!gpuMesh || !gpuMesh->vertexBuffer) continue; + + wgpu::RenderPipeline pipe = mesh3dPipeline; + if (d.shader && d.shader->gpuHandle) { + auto *gs = static_cast(d.shader->gpuHandle); + if (gs->isMesh3D && gs->mesh3dPipeline) pipe = gs->mesh3dPipeline; + } + if (!pipe) continue; + pass.SetPipeline(pipe); + + GpuTexture *albedo = gpuForTexture(d.texture); + GpuTexture *normal = gpuForTexture(mesh3dNormalTexture); + GpuTexture *env = gpuForTexture(mesh3dEnvTexture); + GpuTexture *height = gpuForTexture(mesh3dHeightTexture); + wgpu::BindGroup bg = makeMeshBindGroup(albedo, normal, env, height, d.frameUboOffset, + d.shadowUboOffset, d.pushUboOffset); + uint32_t offsets[2] = {d.frameUboOffset, d.shadowUboOffset}; + pass.SetBindGroup(0, bg, 2, offsets); + + if (gpuMesh->indexBuffer) { + pass.SetVertexBuffer(0, gpuMesh->vertexBuffer, 0, gpuMesh->vertexCount * 32); + pass.SetIndexBuffer(gpuMesh->indexBuffer, wgpu::IndexFormat::Uint32, 0, + uint64_t(gpuMesh->indexCount) * 4); + pass.DrawIndexed(gpuMesh->indexCount, 1, 0, 0, 0); + } else { + pass.SetVertexBuffer(0, gpuMesh->vertexBuffer, 0, gpuMesh->vertexCount * 32); + pass.Draw(gpuMesh->vertexCount, 1, 0, 0); + } + } + mesh3dDraws.clear(); +} + +void Graphics::flushShadowPass(wgpu::RenderPassEncoder pass) { + auto &uboArena = currentUboArena(); + ensureUboArena(uboArena, uboArena.used + 4096); + pass.SetPipeline(mesh3dShadowPipeline); + + for (int c = 0; c < ShadowConfig::kCascades; ++c) { + if (shadowCascadeDraws[c].empty()) continue; + for (auto &d : shadowCascadeDraws[c]) { + auto *gpuMesh = static_cast(d.mesh->gpuHandle); + if (!gpuMesh || !gpuMesh->vertexBuffer) continue; + + uint32_t offset = uboArena.alloc(256, 256); + queue.WriteBuffer(uboArena.buffer, offset, &d.mvp, sizeof(glm::mat4)); + + WGPUBindGroupEntry entry{}; + entry.binding = 0; + entry.buffer = uboArena.buffer.Get(); + entry.size = 64; + WGPUBindGroupDescriptor bgd{}; + bgd.layout = shadowSetLayout.Get(); + bgd.entryCount = 1; + bgd.entries = &entry; + wgpu::BindGroup bg = device.CreateBindGroup(reinterpret_cast(&bgd)); + uint32_t offsets[1] = {offset}; + pass.SetBindGroup(0, bg, 1, offsets); + + if (gpuMesh->indexBuffer) { + pass.SetVertexBuffer(0, gpuMesh->vertexBuffer, 0, gpuMesh->vertexCount * 32); + pass.SetIndexBuffer(gpuMesh->indexBuffer, wgpu::IndexFormat::Uint32, 0, + uint64_t(gpuMesh->indexCount) * 4); + pass.DrawIndexed(gpuMesh->indexCount, 1, 0, 0, 0); + } else { + pass.SetVertexBuffer(0, gpuMesh->vertexBuffer, 0, gpuMesh->vertexCount * 32); + pass.Draw(gpuMesh->vertexCount, 1, 0, 0); + } + } + shadowCascadeDraws[c].clear(); + } +} + +void Graphics::flushGbufferPass(wgpu::RenderPassEncoder pass) { + if (gbufferPassDraws.empty() || gbufferSlots.empty()) return; + auto &uboArena = currentUboArena(); + ensureUboArena(uboArena, uboArena.used + gbufferPassDraws.size() * 512); + pass.SetPipeline(mesh3dGbufferPipeline); + + for (auto &d : gbufferPassDraws) { + auto *gpuMesh = static_cast(d.mesh->gpuHandle); + if (!gpuMesh || !gpuMesh->vertexBuffer) continue; + + struct GbufferPush { + glm::mat4 mvp; + glm::mat4 model; + glm::vec4 clip; + } push; + push.mvp = d.mvp; + push.model = d.model; + push.clip = glm::vec4(d.nearZ, d.farZ, 0.f, 0.f); + + uint32_t offset = uboArena.alloc(256, 256); + queue.WriteBuffer(uboArena.buffer, offset, &push, sizeof(push)); + + GpuTexture *albedo = gpuForTextureOrWhite(d.albedo); + WGPUBindGroupEntry entries[3]{}; + entries[0].binding = 0; + entries[0].buffer = uboArena.buffer.Get(); + entries[0].size = 128; + entries[1].binding = 1; + entries[1].textureView = albedo->view.Get(); + entries[2].binding = 2; + entries[2].sampler = albedo->sampler.Get(); + WGPUBindGroupDescriptor bgd{}; + bgd.layout = gbufferSetLayout.Get(); + bgd.entryCount = 3; + bgd.entries = entries; + wgpu::BindGroup bg = device.CreateBindGroup(reinterpret_cast(&bgd)); + uint32_t offsets[1] = {offset}; + pass.SetBindGroup(0, bg, 1, offsets); + + if (gpuMesh->indexBuffer) { + pass.SetVertexBuffer(0, gpuMesh->vertexBuffer, 0, gpuMesh->vertexCount * 32); + pass.SetIndexBuffer(gpuMesh->indexBuffer, wgpu::IndexFormat::Uint32, 0, + uint64_t(gpuMesh->indexCount) * 4); + pass.DrawIndexed(gpuMesh->indexCount, 1, 0, 0, 0); + } else { + pass.SetVertexBuffer(0, gpuMesh->vertexBuffer, 0, gpuMesh->vertexCount * 32); + pass.Draw(gpuMesh->vertexCount, 1, 0, 0); + } + } + gbufferPassDraws.clear(); + gbufferPassPending = false; +} + +void Graphics::flushVoxelDraws(wgpu::RenderPassEncoder pass, WGPUTextureFormat format) { + if (voxelDraws.empty() || !voxelRectPipeline) return; + auto &uboArena = currentUboArena(); + ensureUboArena(uboArena, uboArena.used + voxelDraws.size() * 512); + pass.SetPipeline(voxelRectPipeline); + + for (auto &d : voxelDraws) { + uint32_t offset = uboArena.alloc(256, 256); + struct VoxelPC { + glm::mat4 viewProj; + glm::vec4 chunkOrigin; + glm::vec4 atlasInfo; + glm::vec4 tint; + } pc; + pc.viewProj = d.viewProj; + pc.chunkOrigin = d.chunkOrigin; + pc.atlasInfo = d.atlasInfo; + pc.tint = d.tint; + queue.WriteBuffer(uboArena.buffer, offset, &pc, sizeof(pc)); + + WGPUBindGroupEntry entries[3]{}; + entries[0].binding = 0; + entries[0].buffer = uboArena.buffer.Get(); + entries[0].size = sizeof(pc); + entries[1].binding = 1; + entries[1].textureView = d.atlas->view.Get(); + entries[2].binding = 2; + entries[2].sampler = d.atlas->sampler.Get(); + WGPUBindGroupDescriptor bgd{}; + bgd.layout = voxelSetLayout.Get(); + bgd.entryCount = 3; + bgd.entries = entries; + wgpu::BindGroup bg = device.CreateBindGroup(reinterpret_cast(&bgd)); + uint32_t offsets[1] = {offset}; + pass.SetBindGroup(0, bg, 1, offsets); + + pass.SetVertexBuffer(0, voxelUnitQuadVerts, 0, 32); + pass.SetVertexBuffer(1, voxelInstanceArena.buffer, d.instanceBufferOffset, + uint64_t(d.count) * 4); + pass.SetIndexBuffer(voxelUnitQuadIndices, wgpu::IndexFormat::Uint32, 0, 24); + pass.DrawIndexed(6, d.count, 0, 0, 0); + } + voxelDraws.clear(); +} + +// --------------------------------------------------------------------------- +// Present +// --------------------------------------------------------------------------- + +bool Graphics::acquireSurfaceTexture(wgpu::TextureView &view, wgpu::Texture &texture) { + if (!surface || !device || !swapchainConfigured) return false; + wgpu::SurfaceTexture surfTex{}; + surface.GetCurrentTexture(&surfTex); + if (!surfTex.texture) return false; + texture = surfTex.texture; + view = texture.CreateView(); + return bool(view); +} + +void Graphics::present() { + if (!device || !surface || !swapchainConfigured) return; + rebuildSwapchainIfNeeded(); + if (!swapchainConfigured) return; + + wgpu::TextureView surfaceView; + wgpu::Texture surfaceTex; + if (!acquireSurfaceTexture(surfaceView, surfaceTex)) { + return; + } + + auto &uboArena = currentUboArena(); + uboArena.reset(); + auto &vtxArena = currentVertexArena(); + vtxArena.reset(); + voxelInstanceArena.used = 0; + ensureUboArena(uboArena, 4096); + ensureVertexArena(vtxArena, 4096); + + wgpu::CommandEncoder encoder = device.CreateCommandEncoder(); + + // 1. Shadow passes (CSM cascade layers). + if (shadowDepthArray) { + bool anyShadow = false; + for (int c = 0; c < ShadowConfig::kCascades; ++c) + if (!shadowCascadeDraws[c].empty()) anyShadow = true; + if (anyShadow) { + for (int c = 0; c < ShadowConfig::kCascades; ++c) { + if (shadowCascadeDraws[c].empty()) continue; + WGPUTextureViewDescriptor lvd{}; + lvd.format = WGPUTextureFormat_Depth32Float; + lvd.dimension = WGPUTextureViewDimension_2D; + lvd.baseMipLevel = 0; + lvd.mipLevelCount = 1; + lvd.baseArrayLayer = static_cast(c); + lvd.arrayLayerCount = 1; + wgpu::TextureView layerView = shadowDepthArray->texture.CreateView( + reinterpret_cast(&lvd)); + WGPURenderPassDepthStencilAttachment ds{}; + ds.view = layerView.Get(); + ds.depthClearValue = 1.f; + ds.depthLoadOp = WGPULoadOp_Clear; + ds.depthStoreOp = WGPUStoreOp_Store; + ds.stencilClearValue = 0; + ds.stencilLoadOp = WGPULoadOp_Clear; + ds.stencilStoreOp = WGPUStoreOp_Discard; + WGPURenderPassDescriptor rp{}; + rp.depthStencilAttachment = &ds; + wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(reinterpret_cast(&rp)); + flushShadowPass(pass); + pass.End(); + } + } + } + + // 2. Scene color pass (3D) into the offscreen target. + wgpu::TextureView sceneView; + wgpu::Texture sceneTex; + if (frameHad3DThisFrame && !sceneColorSlots.empty()) { + SceneColorSlot &slot = sceneColorSlots[currentFrameSlot()]; + WGPURenderPassColorAttachment colorAtt{}; + colorAtt.view = slot.colorView.Get(); + colorAtt.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; + colorAtt.loadOp = WGPULoadOp_Clear; + colorAtt.storeOp = WGPUStoreOp_Store; + colorAtt.clearValue = {clearColor.r, clearColor.g, clearColor.b, 1.f}; + WGPURenderPassDepthStencilAttachment ds{}; + ds.view = slot.depthView.Get(); + ds.depthClearValue = 1.f; + ds.depthLoadOp = WGPULoadOp_Clear; + ds.depthStoreOp = WGPUStoreOp_Store; + ds.stencilClearValue = 0; + ds.stencilLoadOp = WGPULoadOp_Clear; + ds.stencilStoreOp = WGPUStoreOp_Discard; + WGPURenderPassDescriptor rp{}; + rp.colorAttachmentCount = 1; + rp.colorAttachments = &colorAtt; + rp.depthStencilAttachment = &ds; + wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(reinterpret_cast(&rp)); + flushVoxelDraws(pass, sceneColorFormat); + flushMesh3D(pass, sceneColorFormat); + pass.End(); + sceneView = slot.colorView; + sceneTex = slot.color; + } + + // 3. GBuffer pass. + if (gbufferPassPending && !gbufferSlots.empty()) { + GbufferSlot &slot = gbufferSlots[currentFrameSlot()]; + WGPURenderPassColorAttachment colorAtts[3]{}; + for (int i = 0; i < 3; ++i) { + colorAtts[i].depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; + colorAtts[i].loadOp = WGPULoadOp_Clear; + colorAtts[i].storeOp = WGPUStoreOp_Store; + colorAtts[i].clearValue = {0.f, 0.f, 0.f, 1.f}; + } + colorAtts[0].view = slot.normalView.Get(); + colorAtts[1].view = slot.depthColorView.Get(); + colorAtts[2].view = slot.albedoView.Get(); + WGPURenderPassDepthStencilAttachment ds{}; + ds.view = slot.depthView.Get(); + ds.depthClearValue = 1.f; + ds.depthLoadOp = WGPULoadOp_Clear; + ds.depthStoreOp = WGPUStoreOp_Store; + ds.stencilClearValue = 0; + ds.stencilLoadOp = WGPULoadOp_Clear; + ds.stencilStoreOp = WGPUStoreOp_Discard; + WGPURenderPassDescriptor rp{}; + rp.colorAttachmentCount = 3; + rp.colorAttachments = colorAtts; + rp.depthStencilAttachment = &ds; + wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(reinterpret_cast(&rp)); + flushGbufferPass(pass); + pass.End(); + } + + // 4. Active canvas: flush 2D batches into the offscreen target instead. + if (activeCanvas) { + auto *oc = static_cast(activeCanvas); + flush2DToCanvas(oc); + } + + // 5. Swapchain pass: composite scene color, draw 2D, run the overlay. + { + WGPURenderPassColorAttachment colorAtt{}; + colorAtt.view = surfaceView.Get(); + colorAtt.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; + colorAtt.loadOp = WGPULoadOp_Clear; + colorAtt.storeOp = WGPUStoreOp_Store; + colorAtt.clearValue = {clearColor.r, clearColor.g, clearColor.b, 1.f}; + WGPURenderPassDescriptor rp{}; + rp.colorAttachmentCount = 1; + rp.colorAttachments = &colorAtt; + wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(reinterpret_cast(&rp)); + + if (sceneView) { + if (!fullscreenQuadReady) { + // Textured vertex: pos(2) + color(4) + uv(2) = 32 bytes. + float verts[32] = { + -1.f, -1.f, 1.f, 1.f, 1.f, 1.f, 0.f, 0.f, + 1.f, -1.f, 1.f, 1.f, 1.f, 1.f, 1.f, 0.f, + 1.f, 1.f, 1.f, 1.f, 1.f, 1.f, 1.f, 1.f, + -1.f, 1.f, 1.f, 1.f, 1.f, 1.f, 0.f, 1.f, + }; + uint32_t indices[6] = {0, 1, 2, 2, 3, 0}; + WGPUBufferDescriptor vbd{}; + vbd.label = sv("eve_fullscreen_vb"); + vbd.size = sizeof(verts); + vbd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_Vertex; + fullscreenQuadVb = device.CreateBuffer(reinterpret_cast(&vbd)); + queue.WriteBuffer(fullscreenQuadVb, 0, verts, sizeof(verts)); + WGPUBufferDescriptor ibd{}; + ibd.label = sv("eve_fullscreen_ib"); + ibd.size = sizeof(indices); + ibd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_Index; + fullscreenQuadIb = device.CreateBuffer(reinterpret_cast(&ibd)); + queue.WriteBuffer(fullscreenQuadIb, 0, indices, sizeof(indices)); + fullscreenQuadReady = true; + } + + GpuTexture sceneGpu; + sceneGpu.texture = sceneTex; + sceneGpu.view = sceneView; + sceneGpu.sampler = createLinearSampler(device); + wgpu::BindGroup bg = makeTex2DBindGroup(&sceneGpu, nullptr); + uint32_t offsets[1] = {0}; + pass.SetPipeline(texturedPipeline); + pass.SetBindGroup(0, bg, 1, offsets); + pass.SetVertexBuffer(0, fullscreenQuadVb, 0, 4 * 32); + pass.SetIndexBuffer(fullscreenQuadIb, wgpu::IndexFormat::Uint32, 0, 24); + pass.DrawIndexed(6, 1, 0, 0, 0); + } + + if (!activeCanvas) { + flush2D(pass, pixelW > 0 ? pixelW : logicalW, pixelH > 0 ? pixelH : logicalH, + surfaceFormat); + } + + if (presentOverlayFn_) { + WGPURenderPassEncoder cPass = pass.Get(); + presentOverlayFn_(presentOverlayUser_, &cPass); + } + pass.End(); + } + + wgpu::CommandBuffer cmd = encoder.Finish(); + queue.Submit(1, &cmd); + // emdawnwebgpu does not implement wgpuSurfacePresent (it aborts); the + // browser presents the canvas automatically once the command buffer is + // submitted from within a requestAnimationFrame callback. On Emscripten + // the engine frame is driven by emscripten_set_main_loop (rAF), so the + // Squirrel main loop no longer blocks and no sleep is needed here. +#if !defined(__EMSCRIPTEN__) + surface.Present(); +#endif + + frameIndex++; + frame3DStarted = false; + frameHad3DThisFrame = false; + frameHad3D = false; + sceneColorPassOpen = false; + gbufferPassPending = false; +} + +// --------------------------------------------------------------------------- +// Canvas +// --------------------------------------------------------------------------- + +Canvas *Graphics::newCanvas(int width, int height) { + auto *c = new OffscreenCanvas(this, width, height); + ownedCanvases.push_back(std::unique_ptr(c)); + return c; +} + +void Graphics::setCanvas(Canvas *canvas) { + if (activeCanvas && canvas != activeCanvas) { + flush2DToCanvas(static_cast(activeCanvas)); + } + activeCanvas = canvas; +} + +bool Graphics::isCanvasActive() const { return activeCanvas != nullptr; } +Canvas *Graphics::getCanvas() const { return activeCanvas; } + +void Graphics::flush2DToCanvas(OffscreenCanvas *canvas) { + if (!canvas || !canvas->getTexture()) return; + auto &uboArena = currentUboArena(); + uboArena.reset(); + auto &vtxArena = currentVertexArena(); + vtxArena.reset(); + + wgpu::CommandEncoder enc = device.CreateCommandEncoder(); + WGPURenderPassColorAttachment ca{}; + ca.view = canvas->colorView.Get(); + ca.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; + ca.loadOp = canvas->clearRequested ? WGPULoadOp_Clear : WGPULoadOp_Load; + ca.storeOp = WGPUStoreOp_Store; + ca.clearValue = {canvas->clearColor.r, canvas->clearColor.g, canvas->clearColor.b, + canvas->clearColor.a}; + WGPURenderPassDescriptor rp{}; + rp.colorAttachmentCount = 1; + rp.colorAttachments = &ca; + wgpu::RenderPassEncoder pass = enc.BeginRenderPass(reinterpret_cast(&rp)); + flush2D(pass, canvas->getWidth(), canvas->getHeight(), WGPUTextureFormat_RGBA8Unorm); + pass.End(); + wgpu::CommandBuffer cmd = enc.Finish(); + queue.Submit(1, &cmd); + canvas->clearRequested = false; +} + +Texture *Graphics::getTexture() { + if (activeCanvas) return activeCanvas->getTexture(); + return sceneColorTexture; +} + +void Graphics::draw(eve::graphics::Graphics *gfx, const glm::mat4 &matrix) const { + (void)gfx; + (void)matrix; +} + +void Graphics::draw(Canvas *C, const glm::mat4 &matrix) const { + (void)C; + (void)matrix; +} + +void Graphics::clear(std::optional color, std::optional /*stencil*/, + std::optional /*depth*/) { + clearColor = color.value_or(backgroundColor); + hasPendingClear = true; +} + +Color Graphics::getPixel(int x, int y) { + if (activeCanvas) return getPixelImpl(static_cast(activeCanvas), x, y); + if (!sceneColorSlots.empty()) { + return getPixelImpl(nullptr, x, y); + } + return clearColor; +} + +image::ImageData *Graphics::newImageData() { + if (activeCanvas) return newImageDataImpl(static_cast(activeCanvas)); + if (!sceneColorSlots.empty()) return newImageDataImpl(nullptr); + return new image::ImageData(1, 1, "RGBA8"); +} + +// --------------------------------------------------------------------------- +// Readback +// --------------------------------------------------------------------------- + +namespace { + +bool copyTextureToCpu(wgpu::Instance &instance, wgpu::Device &device, wgpu::Queue &queue, + wgpu::Texture src, int width, int height, std::vector &outRgba) { + if (!src) return false; + uint64_t bytesPerRow = static_cast(width * 4); + bytesPerRow = (bytesPerRow + 255) / 256 * 256; // copy alignment 256 + uint64_t size = bytesPerRow * height; + + WGPUBufferDescriptor bd{}; + bd.label = sv("eve_readback"); + bd.size = size; + bd.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead; + bd.mappedAtCreation = false; + wgpu::Buffer dst = device.CreateBuffer(reinterpret_cast(&bd)); + + wgpu::CommandEncoder enc = device.CreateCommandEncoder(); + WGPUTexelCopyTextureInfo from{}; + from.texture = src.Get(); + from.mipLevel = 0; + from.aspect = WGPUTextureAspect_All; + from.origin = {0, 0, 0}; + WGPUTexelCopyBufferInfo to{}; + to.buffer = dst.Get(); + to.layout.offset = 0; + to.layout.bytesPerRow = static_cast(bytesPerRow); + to.layout.rowsPerImage = static_cast(height); + WGPUExtent3D extent{static_cast(width), static_cast(height), 1}; + enc.CopyTextureToBuffer(reinterpret_cast(&from), + reinterpret_cast(&to), + reinterpret_cast(&extent)); + wgpu::CommandBuffer cmd = enc.Finish(); + queue.Submit(1, &cmd); + + bool mapped = false; + WGPUBufferMapCallbackInfo cbInfo{}; + cbInfo.mode = WGPUCallbackMode_AllowProcessEvents; + cbInfo.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*message*/, void *userdata1, + void * /*userdata2*/) { + bool *ok = static_cast(userdata1); + *ok = (status == WGPUMapAsyncStatus_Success); + }; + cbInfo.userdata1 = &mapped; + wgpuBufferMapAsync(dst.Get(), WGPUMapMode_Read, 0, size, cbInfo); + + int guard = 0; + while (!mapped && guard < 2000) { +#if defined(__EMSCRIPTEN__) + emscripten_sleep(0); +#endif + wgpuInstanceProcessEvents(instance.Get()); + ++guard; + } + if (!mapped) return false; + + const uint8_t *data = static_cast(dst.GetConstMappedRange(0, size)); + if (!data) return false; + outRgba.resize(static_cast(width) * height * 4); + for (int y = 0; y < height; ++y) + std::memcpy(outRgba.data() + size_t(y) * width * 4, data + size_t(y) * bytesPerRow, + size_t(width) * 4); + dst.Unmap(); + return true; +} + +} // namespace + +Color Graphics::getPixelImpl(OffscreenCanvas *canvas, int x, int y) { + int w = canvas ? canvas->getWidth() : (sceneColorWidth > 0 ? sceneColorWidth : 1); + int h = canvas ? canvas->getHeight() : (sceneColorHeight > 0 ? sceneColorHeight : 1); + if (x < 0 || y < 0 || x >= w || y >= h) return Color(0.f, 0.f, 0.f, 0.f); + std::vector rgba; + wgpu::Texture src = canvas ? canvas->color : (sceneColorSlots.empty() ? nullptr : sceneColorSlots[0].color); + if (!src || !copyTextureToCpu(instance, device, queue, src, w, h, rgba)) return clearColor; + const uint8_t *p = rgba.data() + (size_t(y) * w + x) * 4; + return Color(p[0] / 255.f, p[1] / 255.f, p[2] / 255.f, p[3] / 255.f); +} + +image::ImageData *Graphics::newImageDataImpl(OffscreenCanvas *canvas) { + int w = canvas ? canvas->getWidth() : (sceneColorWidth > 0 ? sceneColorWidth : 1); + int h = canvas ? canvas->getHeight() : (sceneColorHeight > 0 ? sceneColorHeight : 1); + auto *img = new image::ImageData(w, h, "RGBA8"); + std::vector rgba; + wgpu::Texture src = canvas ? canvas->color : (sceneColorSlots.empty() ? nullptr : sceneColorSlots[0].color); + if (src && copyTextureToCpu(instance, device, queue, src, w, h, rgba)) { + std::memcpy(img->getData(), rgba.data(), rgba.size()); + } + return img; +} + +// --------------------------------------------------------------------------- +// Shader creation +// --------------------------------------------------------------------------- + +namespace { + +wgpu::RenderPipeline buildPipelineFromWgsl(wgpu::Device &dev, wgpu::PipelineLayout layout, + WGPUTextureFormat format, const std::string &vert, + const std::string &frag, bool depth, bool blend, + bool mesh3d, bool hair, bool shadow, bool gbuffer, + uint32_t sampleCount) { + WGPURenderPipelineDescriptor pd{}; + pd.label = sv("eve_custom_shader"); + pd.layout = layout.Get(); + + WGPUVertexAttribute attrs[3]{}; + WGPUVertexBufferLayout vb{}; + if (mesh3d || shadow || gbuffer) { + attrs[0].format = WGPUVertexFormat_Float32x3; + attrs[0].offset = 0; + attrs[0].shaderLocation = 0; + attrs[1].format = WGPUVertexFormat_Float32x3; + attrs[1].offset = 12; + attrs[1].shaderLocation = 1; + attrs[2].format = WGPUVertexFormat_Float32x2; + attrs[2].offset = 24; + attrs[2].shaderLocation = 2; + vb.arrayStride = 32; + vb.stepMode = WGPUVertexStepMode_Vertex; + vb.attributeCount = 3; + vb.attributes = attrs; + } else { + attrs[0].format = WGPUVertexFormat_Float32x2; + attrs[0].offset = 0; + attrs[0].shaderLocation = 0; + attrs[1].format = WGPUVertexFormat_Float32x4; + attrs[1].offset = 8; + attrs[1].shaderLocation = 1; + attrs[2].format = WGPUVertexFormat_Float32x2; + attrs[2].offset = 24; + attrs[2].shaderLocation = 2; + vb.arrayStride = 32; + vb.stepMode = WGPUVertexStepMode_Vertex; + vb.attributeCount = 3; + vb.attributes = attrs; + } + pd.vertex.bufferCount = 1; + pd.vertex.buffers = &vb; + + if (!vert.empty()) { + WGPUShaderModuleDescriptor md = mdDesc(vert); + wgpu::ShaderModule vm = dev.CreateShaderModule( + reinterpret_cast(&md)); + pd.vertex.module = vm.Get(); + pd.vertex.entryPoint = sv("vs_main"); + } else { + // Default textured vertex shader. + WGPUShaderModuleDescriptor md = mdDesc(kTexturedVertWgsl); + wgpu::ShaderModule vm = dev.CreateShaderModule( + reinterpret_cast(&md)); + pd.vertex.module = vm.Get(); + pd.vertex.entryPoint = sv("vs_main"); + } + + if (!frag.empty()) { + WGPUFragmentState fs{}; + WGPUShaderModuleDescriptor md = mdDesc(frag); + wgpu::ShaderModule fm = dev.CreateShaderModule( + reinterpret_cast(&md)); + fs.module = fm.Get(); + fs.entryPoint = sv("fs_main"); + fs.targetCount = 1; + WGPUColorTargetState target{}; + target.format = format; + if (blend) { + static WGPUBlendState bs = alphaBlend(); + target.blend = &bs; + } + fs.targets = ⌖ + pd.fragment = &fs; + } else { + pd.fragment = nullptr; + } + + pd.primitive.topology = WGPUPrimitiveTopology_TriangleList; + pd.primitive.frontFace = WGPUFrontFace_CCW; + pd.primitive.cullMode = hair ? WGPUCullMode_None : WGPUCullMode_None; + pd.primitive.stripIndexFormat = WGPUIndexFormat_Undefined; + + if (depth && !shadow) { + static WGPUDepthStencilState ds{}; + ds.format = WGPUTextureFormat_Depth32Float; + ds.depthWriteEnabled = WGPUOptionalBool_True; + ds.depthCompare = WGPUCompareFunction_Less; + pd.depthStencil = &ds; + } + pd.multisample.count = sampleCount ? sampleCount : 1; + return dev.CreateRenderPipeline(reinterpret_cast(&pd)); +} + +} // namespace + +Shader *Graphics::newShaderFromSpv(const std::vector &vertSpv, + const std::vector &fragSpv) { + if (!device) throw Exception("newShaderFromSpv: device not initialized"); + // Native Dawn can consume Vulkan SPIR-V directly; the browser build cannot + // (browsers only accept WGSL). The WebGPU backend therefore requires WGSL + // for custom shaders; SPIR-V input is rejected with a clear message. + throw Exception("newShaderFromSpv: SPIR-V custom shaders are not supported on the " + "WebGPU backend (browsers accept WGSL only). Recompile the shader " + "with the WebGPU toolchain (glslc+tint) and use newShaderFromWgsl."); +} + +Shader *Graphics::newShaderFromSpvFile(const std::string &vertPath, const std::string &fragPath) { + throw Exception("newShaderFromSpvFile: SPIR-V custom shaders are not supported on the " + "WebGPU backend. Use WGSL shaders instead."); +} + +Shader *Graphics::newShader(const std::string &vertGlsl, const std::string &fragGlsl) { + (void)vertGlsl; + (void)fragGlsl; + throw Exception("newShader: runtime GLSL compilation is not available on the WebGPU " + "backend (browser WGSL only). Ship pre-compiled WGSL shaders."); +} + +Shader *Graphics::newMeshShaderFromSpv(const std::vector &vertSpv, + const std::vector &fragSpv) { + (void)vertSpv; + (void)fragSpv; + throw Exception("newMeshShaderFromSpv: SPIR-V custom mesh shaders are not supported on the " + "WebGPU backend. Use WGSL shaders instead."); +} + +Shader *Graphics::newMeshShader(const std::string &vertGlsl, const std::string &fragGlsl) { + (void)vertGlsl; + (void)fragGlsl; + throw Exception("newMeshShader: runtime GLSL compilation is not available on the WebGPU " + "backend. Ship pre-compiled WGSL shaders."); +} + +Shader *Graphics::newHairShaderFromSpv(const std::vector &vertSpv, + const std::vector &fragSpv) { + (void)vertSpv; + (void)fragSpv; + throw Exception("newHairShaderFromSpv: SPIR-V custom hair shaders are not supported on the " + "WebGPU backend. Use WGSL shaders instead."); +} + +} // namespace eve::graphics::webgpu diff --git a/src/modules/graphics/webgpu/Graphics.h b/src/modules/graphics/webgpu/Graphics.h new file mode 100644 index 00000000..e2fc12bc --- /dev/null +++ b/src/modules/graphics/webgpu/Graphics.h @@ -0,0 +1,539 @@ +#pragma once +#include "graphics/Graphics.h" +#include "graphics/Batcher.h" +#include "graphics/Texture.h" +#include "graphics/Mesh.h" +#include "graphics/Shader.h" +#include "graphics/Light.h" +#include "graphics/ClusteredLight.h" +#include "graphics/Shadow.h" + +#include + +#include +#include +#include +#include +#include + +#include + +namespace eve::graphics::webgpu { + +class OffscreenCanvas; + +/** + * Frame UBO for the mesh3d pipeline. Mirrors the std140 layout of the WGSL + * `Frame` block and the legacy Vulkan Mesh3DUBO (so custom shader prefixes stay + * compatible). See wgsl_shaders.h. + */ +struct Mesh3DUBO { + glm::mat4 mvp{1.f}; + glm::mat4 model{1.f}; + glm::vec4 lightDir{0.4f, 1.f, 0.3f, 0.f}; // xyz = primary dir; w = lightCount + glm::vec4 lightColor{1.f, 1.f, 1.f, 0.f}; // rgb = primary color; w = envIntensity + glm::vec4 tint{1.f, 1.f, 1.f, 1.f}; + glm::vec4 cameraPos{0.f, 0.f, 3.f, 0.45f}; // xyz = eye; w = roughness + glm::vec4 ambient{0.12f, 0.12f, 0.14f, 0.f}; // rgb = ambient; w = metallic + Light3DGpu lights[Lighting3DPack::kMaxLights]{}; + glm::vec4 texBomb{4.f, 0.f, 1.f, 0.f}; // x=cellScale, y=strength, z=rotAmount + glm::vec4 parallax{0.f, 8.f, 32.f, 0.f}; // x=scale, y=minLayers, z=maxLayers + glm::mat4 view{1.f}; + glm::vec4 clipInfo{0.1f, 100.f, 0.f, 0.f}; // x=near, y=far +}; +static_assert(sizeof(Mesh3DUBO) == 576, "Mesh3DUBO layout must match the WGSL Frame block"); + +/** + * Texture resources backed by a wgpu texture + view + sampler + bind groups. + */ +struct GpuTexture { + wgpu::Texture texture; + wgpu::TextureView view; + wgpu::Sampler sampler; + // Bind group for the unified 2D layout (color at 0, depth at 1). + wgpu::BindGroup tex2DGroup; + // Bind group for the mesh3d layout (albedo at 1, normal at 2, env at 3, + // height at 6). Rebuilt when the mesh3d pipeline re-creates its layout. + wgpu::BindGroup meshGroup; + bool isCube = false; + int width = 0; + int height = 0; + uint32_t mipLevels = 1; + TextureSampler samplerState{}; +}; + +/** + * Vertex/index buffers for one mesh. + */ +struct GpuMesh { + wgpu::Buffer vertexBuffer; + wgpu::Buffer indexBuffer; + uint32_t indexCount = 0; + uint32_t vertexCount = 0; + uint32_t vertexStride = 0; +}; + +/** + * A compiled shader: one WebGPU pipeline + layout. Also holds the WGSL + * sources so custom shaders can be re-pipelined for offscreen targets. + */ +struct GpuShader { + wgpu::RenderPipeline swapchainPipeline; // 2D/offscreen color format + wgpu::RenderPipeline offscreenPipeline; // RGBA8Unorm canvas format + wgpu::RenderPipeline mesh3dPipeline; // scene color format + wgpu::RenderPipeline shadowPipeline; // depth-only + wgpu::RenderPipeline gbufferPipeline; // MRT gbuffer + wgpu::PipelineLayout pipelineLayout; + wgpu::BindGroupLayout setLayout; + bool isMesh3D = false; + bool isHair3D = false; + bool isShadow = false; + bool isGbuffer = false; + std::string wgslVert; + std::string wgslFrag; +}; + +class Graphics final : public eve::graphics::Graphics { +public: + Graphics(); + ~Graphics() override; + + std::string getBackendName() const override { return "webgpu"; } + + void initWithWindow(void *nativeWindow) override; + void present() override; + void requestSurfaceRecreate() override { surfaceNeedsRecreate = true; } + void setVSync(bool enabled) override; + int getMsaaSamples() const override { return msaaSamples; } + void setViewportSize(int width, int height, int pixelwidth, int pixelheight) override; + void drawSolidRect(float x, float y, float w, float h, const Color &color) override; + + Texture *newTexture(int width, int height, const uint8_t *rgba, bool repeatU = false, + bool repeatV = false) override; + Texture *newTexture(int width, int height, const uint8_t *rgba, + const TextureCreateInfo &info) override; + Texture *newCubemap(int faceSize, const uint8_t *rgbaFaces) override; + Texture *newCubemap(int faceSize, const uint8_t *rgbaFaces, + const TextureCreateInfo &info) override; + Texture *newTexture(image::ImageData *data) override; + Texture *newTexture(image::ImageData *data, const TextureCreateInfo &info) override; + void setTextureSampler(Texture *texture, const TextureSampler &sampler) override; + float getMaxAnisotropy() const override; + Texture *newTextureFromFile(const std::string &filename) override; + bool reloadTextureFromFile(const std::string &filename) override; + + void drawTexturedRect(Texture *texture, float x, float y, float w, float h, + const Color &color) override; + void drawTexturedRectShader(Texture *texture, Shader *shader, float x, float y, float w, float h, + const Color &color) override; + void drawTexturedRectUV(Texture *texture, float x, float y, float w, float h, float u0, float v0, + float u1, float v1, const Color &color) override; + void drawTexturedRectShaderUV(Texture *texture, Shader *shader, float x, float y, float w, + float h, float u0, float v0, float u1, float v1, + const Color &color, bool rotatedUV = false) override; + void drawTexturedRectShaderUVRotated(Texture *texture, Shader *shader, float cx, float cy, + float w, float h, float degrees, float u0, float v0, + float u1, float v1, const Color &color, + bool rotatedUV = false) override; + void drawTexturedRectShaderDepth(Texture *color, Texture *depth, Shader *shader, float x, float y, + float w, float h, const Color &tint) override; + void drawTexturedRectLitUV(Texture *albedo, Texture *normal, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, const Color &color) override; + void setLighting2D(const Lighting2DUBO &ubo) override; + + Shader *newShaderFromSpv(const std::vector &vertSpv, + const std::vector &fragSpv) override; + Shader *newShaderFromSpvFile(const std::string &vertPath, const std::string &fragPath) override; + Shader *newShader(const std::string &vertGlsl, const std::string &fragGlsl) override; + Shader *newMeshShaderFromSpv(const std::vector &vertSpv, + const std::vector &fragSpv) override; + Shader *newMeshShader(const std::string &vertGlsl, const std::string &fragGlsl) override; + Shader *newHairShaderFromSpv(const std::vector &vertSpv, + const std::vector &fragSpv) override; + + Mesh *newMeshFromAssimp(const ::aiMesh &mesh) override; + Mesh *newMeshFromAssimp(const ::aiMesh &mesh, const aiMatrix4x4 &worldTransform) override; + Mesh *newMeshFromArrays(const float *posXYZ, const float *nrmXYZ, const float *uvST, + int vertexCount, const uint32_t *indices, int indexCount) override; + bool bakeMeshMorph(Mesh *mesh) override; + Mesh *newMeshSphere(int slices = 32, int stacks = 16) override; + Mesh *newMeshCylinder(int slices = 32, int stacks = 1, bool caps = true) override; + + void begin3DFrame() override; + void setMesh3DViewProj(const glm::mat4 &viewProj) override; + void setMesh3DView(const glm::mat4 &view) override; + void setMesh3DClip(float nearZ, float farZ) override; + Texture *getSceneColorTexture() override; + void drawMesh(Mesh *mesh, const glm::mat4 &model, Texture *texture, const Color &tint) override; + void drawMeshShader(Mesh *mesh, const glm::mat4 &model, Texture *texture, const Color &tint, + Shader *shader) override; + void drawVoxelFaceInstances(const uint32_t *packed, int count, float originX, float originY, + float originZ, const std::string &faceDir, Texture *atlas, + int tilesPerRow = 16) override; + void setMesh3DNormalTexture(Texture *normal) override; + void setMesh3DHeightTexture(Texture *height) override; + void setMesh3DMaterial(float metallic, float roughness) override; + void setMesh3DTexCellBomb(float cellScale, float strength, float rotAmount = 1.f) override; + void setMesh3DParallax(float scale, float minLayers = 8.f, float maxLayers = 32.f) override; + void setMesh3DLighting(const Lighting3DPack &pack) override; + void setMesh3DClusteredLighting(const ClusteredLightingUpload &upload) override; + void setMesh3DLight(const glm::vec3 &dir, const glm::vec3 &color) override; + void setMesh3DCameraPos(const glm::vec3 &eye) override; + void setMesh3DEnv(Texture *cube, float intensity) override; + void setMesh3DShadows(const ShadowUpload &upload) override; + void setMesh3DShadowReceive(bool receive) override; + void beginShadowPass(int cascadeIndex) override; + void drawMeshShadow(Mesh *mesh, const glm::mat4 &lightMVP) override; + void endShadowPass() override; + + void beginGBufferPass(int width, int height) override; + void drawMeshGBuffer(Mesh *mesh, const glm::mat4 &mvp, const glm::mat4 &model, float nearZ, + float farZ, Texture *albedo = nullptr, float tintR = 1.f, float tintG = 1.f, + float tintB = 1.f) override; + void endGBufferPass() override; + + Canvas *newCanvas(int width, int height) override; + void setCanvas(Canvas *canvas) override; + bool isCanvasActive() const override; + Canvas *getCanvas() const override; + + Texture *getTexture() override; + image::ImageData *newImageData() override; + void draw(eve::graphics::Graphics *gfx, const glm::mat4 &matrix) const override; + void draw(Canvas *C, const glm::mat4 &matrix) const override; + void clear(std::optional color, std::optional stencil, + std::optional depth) override; + Color getPixel(int x, int y) override; + + /** Flush accumulated 2D batches into an offscreen canvas target. */ + void flush2DToCanvas(OffscreenCanvas *canvas); + /** Blocking CPU readback of an offscreen canvas or scene color target. */ + Color getPixelImpl(OffscreenCanvas *canvas, int x, int y); + image::ImageData *newImageDataImpl(OffscreenCanvas *canvas); + + wgpu::Instance &getInstance() { return instance; } + wgpu::Device &getDevice() { return device; } + wgpu::Queue &getQueue() { return queue; } + wgpu::Surface &getSurface() { return surface; } + WGPUTextureFormat getSurfaceFormat() const { return surfaceFormat; } + void *getSdlWindow() const { return sdlWindow; } + bool isReady() const { return initialized; } + + // The present overlay is rendered inside the swapchain render pass. The + // void* payload is a WGPURenderPassEncoder* on the WebGPU backend. + using PresentOverlayFn = eve::graphics::Graphics::PresentOverlayFn; + + friend class OffscreenCanvas; + + struct TexturedBatch { + Texture *texture = nullptr; + Texture *depth = nullptr; + Shader *shader = nullptr; + Batcher batch; + }; + struct LitBatch { + Texture *albedo = nullptr; + Texture *normal = nullptr; + Batcher batch; + }; + +private: + struct Mesh3dDraw { + Mesh *mesh = nullptr; + Texture *texture = nullptr; + glm::mat4 model{1.f}; + Color tint{1.f}; + Shader *shader = nullptr; + uint32_t frameUboOffset = 0; + uint32_t pushUboOffset = 0; + uint32_t shadowUboOffset = 0; + }; + struct ShadowDraw { + Mesh *mesh = nullptr; + glm::mat4 mvp{1.f}; + }; + struct GbufferDraw { + Mesh *mesh = nullptr; + Texture *albedo = nullptr; + glm::mat4 mvp{1.f}; + glm::mat4 model{1.f}; + float nearZ = 0.1f; + float farZ = 100.f; + glm::vec4 tint{1.f}; + uint32_t pushUboOffset = 0; + }; + struct VoxelDraw { + uint32_t instanceBufferOffset = 0; + uint32_t count = 0; + GpuTexture *atlas = nullptr; + glm::mat4 viewProj{1.f}; + glm::vec4 chunkOrigin{0.f}; + glm::vec4 atlasInfo{16.f, 0.f, 0.f, 0.f}; + glm::vec4 tint{1.f}; + uint32_t pushUboOffset = 0; + }; + + void createInstanceAndAdapter(); + void requestDevice(); + void configureSurface(int width, int height); + void waitForAdapter(); + void waitForDevice(); + void createDefaultTextures(); + void createPipelineResources(); + void create2DPipelines(); + void createMesh3DPipelines(); + void createShadowPipelines(); + void createGbufferPipelines(); + void createVoxelPipelines(); + void createSceneColorResources(int width, int height); + void destroySceneColorResources(); + void createShadowResources(); + void destroyShadowResources(); + void createGbufferResources(int width, int height); + void destroyGbufferResources(); + + wgpu::RenderPipeline createPipelineForShader(GpuShader *gs, wgpu::TextureFormat format, + bool depth, bool mesh3d, bool hair, + bool shadow, bool gbuffer, + wgpu::PipelineLayout layout); + wgpu::BindGroupLayout make2DBindGroupLayout(); + wgpu::BindGroupLayout makeMesh3DBindGroupLayout(); + wgpu::BindGroupLayout makeShadowBindGroupLayout(); + wgpu::BindGroupLayout makeGbufferBindGroupLayout(); + wgpu::BindGroupLayout makeVoxelBindGroupLayout(); + wgpu::PipelineLayout make2DPipelineLayout(); + wgpu::PipelineLayout makeMesh3DPipelineLayout(); + wgpu::PipelineLayout makeShadowPipelineLayout(); + wgpu::PipelineLayout makeGbufferPipelineLayout(); + wgpu::PipelineLayout makeVoxelPipelineLayout(); + + GpuTexture *gpuForTexture(Texture *t) const; + GpuTexture *gpuForTextureOrWhite(Texture *t) const; + wgpu::BindGroup makeTex2DBindGroup(GpuTexture *color, GpuTexture *depth); + wgpu::BindGroup makeMeshBindGroup(GpuTexture *albedo, GpuTexture *normal, GpuTexture *env, + GpuTexture *height, uint32_t frameUboOffset, + uint32_t shadowUboOffset, uint32_t pushUboOffset); + void ensureMeshBindGroupsForDraw(Mesh3dDraw &d); + wgpu::Sampler makeSampler(const TextureSampler &sampler, uint32_t mipLevels) const; + + void uploadTexturePixels(GpuTexture *gt, const uint8_t *rgba, int w, int h, + const TextureCreateInfo &info); + void uploadTexturePixelsMips(GpuTexture *gt, const uint8_t *rgba, int w, int h); + void flush2D(wgpu::RenderPassEncoder pass, int viewW, int viewH, WGPUTextureFormat format); + void drawTexturedBatch(wgpu::RenderPassEncoder pass, TexturedBatch &tb, int viewW, int viewH, + WGPUTextureFormat format, bool offscreen); + void drawLitBatch(wgpu::RenderPassEncoder pass, LitBatch &lb, int viewW, int viewH, + WGPUTextureFormat format); + void flushMesh3D(wgpu::RenderPassEncoder pass, WGPUTextureFormat format); + void flushShadowPass(wgpu::RenderPassEncoder pass); + void flushGbufferPass(wgpu::RenderPassEncoder pass); + void flushVoxelDraws(wgpu::RenderPassEncoder pass, WGPUTextureFormat format); + + // UBO arena: one growable uniform buffer per in-flight frame slot. + struct UboArena { + wgpu::Buffer buffer; + uint64_t capacity = 0; + uint64_t used = 0; + uint32_t alloc(uint64_t size, uint64_t alignment); + void reset() { used = 0; } + }; + UboArena ¤tUboArena(); + void ensureUboArena(UboArena &arena, uint64_t bytes); + + // Vertex arena for batched 2D vertices (one per frame slot). + struct VertexArena { + wgpu::Buffer buffer; + uint64_t capacity = 0; + uint64_t used = 0; + uint64_t alloc(uint64_t bytes); + void reset() { used = 0; } + }; + VertexArena ¤tVertexArena(); + void ensureVertexArena(VertexArena &arena, uint64_t bytes); + + uint32_t frameSlotCount() const { return kFramesInFlight; } + uint32_t currentFrameSlot() const { return frameIndex % kFramesInFlight; } + + // ---- state ---- + bool initialized = false; + bool deviceInitDone = false; + void *sdlWindow = nullptr; + int logicalW = 0, logicalH = 0; + int pixelW = 0, pixelH = 0; + float maxSamplerAnisotropy = 1.f; + + wgpu::Instance instance; + wgpu::Adapter adapter; + wgpu::Device device; + wgpu::Queue queue; + wgpu::Surface surface; + WGPUTextureFormat surfaceFormat = WGPUTextureFormat_BGRA8Unorm; + std::atomic surfaceNeedsRecreate{false}; + bool swapchainConfigured = false; + std::atomic adapterReceived{false}; + std::atomic deviceReceived{false}; + std::string adapterError; + std::string deviceError; + + // Per-frame command state (single command buffer per frame). + uint32_t frameIndex = 0; + static constexpr uint32_t kFramesInFlight = 2; + std::vector uboArenas; + std::vector vertexArenas; + wgpu::Buffer mesh3dFrameUboPool; + uint32_t mesh3dFrameUboSlots[kFramesInFlight]{}; + wgpu::Buffer shadowUboPool; + uint32_t shadowUboSlots[kFramesInFlight]{}; + wgpu::Buffer pushUboPool; + uint32_t pushUboSlots[kFramesInFlight]{}; + + // Default / placeholder resources. + GpuTexture *whiteTexture = nullptr; + GpuTexture *flatNormalTexture = nullptr; + GpuTexture *flatNormalTexture3D = nullptr; + GpuTexture *flatHeightTexture3D = nullptr; + GpuTexture *defaultEnvCubemap = nullptr; + GpuTexture *shadowDepthArray = nullptr; + + // Pipelines / layouts. + wgpu::PipelineLayout tex2DPipelineLayout; + wgpu::PipelineLayout mesh3dPipelineLayout; + wgpu::PipelineLayout shadowPipelineLayout; + wgpu::PipelineLayout gbufferPipelineLayout; + wgpu::PipelineLayout voxelPipelineLayout; + wgpu::BindGroupLayout tex2DSetLayout; + wgpu::BindGroupLayout mesh3dSetLayout; + wgpu::BindGroupLayout shadowSetLayout; + wgpu::BindGroupLayout gbufferSetLayout; + wgpu::BindGroupLayout voxelSetLayout; + // Shared filtering sampler for bindings declared as `sampler` in WGSL + // (e.g. mesh3d's @binding(7) mainSamp). + wgpu::Sampler mainSampler; + wgpu::RenderPipeline colorPipeline; // 2D solid + wgpu::RenderPipeline texturedPipeline; // 2D textured + wgpu::RenderPipeline mesh3dPipeline; + wgpu::RenderPipeline mesh3dShadowPipeline; + wgpu::RenderPipeline mesh3dGbufferPipeline; + wgpu::RenderPipeline voxelRectPipeline; + wgpu::RenderPipeline lit2dPipeline; + // RGBA8Unorm (offscreen canvas / scene) variants of the 2D pipelines. + wgpu::RenderPipeline offscreenColorPipeline; + wgpu::RenderPipeline offscreenTexturedPipeline; + wgpu::RenderPipeline offscreenLitPipeline; + // Fullscreen quad used to composite the scene color into the swapchain. + wgpu::Buffer fullscreenQuadVb; + wgpu::Buffer fullscreenQuadIb; + bool fullscreenQuadReady = false; + + // 2D batch state. + Batcher solidBatch; + std::vector texturedBatches; + std::vector litBatches; + Lighting2DUBO lighting2dFrame{}; + + // 3D frame state. + bool frame3DStarted = false; + bool sceneColorPassOpen = false; + glm::mat4 mesh3dViewProj{1.f}; + glm::mat4 mesh3dView{1.f}; + float mesh3dNear = 0.1f, mesh3dFar = 100.f; + Texture *sceneColorTexture = nullptr; + Texture *mesh3dNormalTexture = nullptr; + Texture *mesh3dHeightTexture = nullptr; + Texture *mesh3dEnvTexture = nullptr; + float mesh3dEnvIntensity = 0.f; + float mesh3dMetallic = 0.f; + float mesh3dRoughness = 0.45f; + float mesh3dTexBombScale = 4.f, mesh3dTexBombStrength = 0.f, mesh3dTexBombRot = 1.f; + float mesh3dParallaxScale = 0.f, mesh3dParallaxMin = 8.f, mesh3dParallaxMax = 32.f; + Lighting3DPack mesh3dLighting{}; + ShadowUpload mesh3dShadows{}; + bool mesh3dShadowReceive = true; + bool mesh3dClusteredActive = false; + ClusteredLightingUpload mesh3dClustered{}; + glm::vec3 mesh3dCameraPos{0.f, 0.f, 3.f}; + bool frameHad3DThisFrame = false; + std::vector mesh3dDraws; + Color clearColor{0.1f, 0.1f, 0.12f, 1.f}; + bool hasPendingClear = true; + + // Shadow pass state. + int shadowPassCascade = -1; + std::vector shadowPassDraws; + std::vector shadowCascadeDraws[ShadowConfig::kCascades]; + + // GBuffer pass state. + bool gbufferPassActive = false; + bool gbufferPassPending = false; + std::vector gbufferPassDraws; + + // Voxel state. + std::vector voxelDraws; + wgpu::Buffer voxelUnitQuadVerts; + wgpu::Buffer voxelUnitQuadIndices; + VertexArena voxelInstanceArena; + + // Scene color (offscreen 3D) target. + struct SceneColorSlot { + wgpu::Texture msaaColor; + wgpu::TextureView msaaView; + wgpu::Texture color; + wgpu::TextureView colorView; + wgpu::Texture depth; + wgpu::TextureView depthView; + GpuTexture colorGpu; + Texture colorTex; + uint32_t sampleCount = 1; + }; + int sceneColorWidth = 0, sceneColorHeight = 0; + WGPUTextureFormat sceneColorFormat = WGPUTextureFormat_RGBA8Unorm; + uint32_t sceneColorSamples = 1; + std::vector sceneColorSlots; + + // Shadow map state (CSM, 3 cascade layers). + struct ShadowMapSlot { + wgpu::Texture texture; + wgpu::TextureView view; + }; + std::vector shadowMaps; + int shadowMapSize = ShadowConfig::kMapSize; + + // GBuffer targets. + struct GbufferSlot { + wgpu::Texture normal; + wgpu::TextureView normalView; + wgpu::Texture depthColor; + wgpu::TextureView depthColorView; + wgpu::Texture albedo; + wgpu::TextureView albedoView; + wgpu::Texture depth; + wgpu::TextureView depthView; + GpuTexture normalGpu; + GpuTexture depthColorGpu; + GpuTexture albedoGpu; + Texture normalTex; + Texture depthColorTex; + Texture albedoTex; + }; + int gbufferWidth = 0, gbufferHeight = 0; + std::vector gbufferSlots; + + // Canvas state. + Canvas *activeCanvas = nullptr; + std::vector> ownedCanvases; + + // Owned resources. + std::vector> ownedTextures; + std::vector> ownedGpuTextures; + std::unordered_map texturesByPath; + std::vector> ownedMeshes; + std::vector> ownedGpuMeshes; + std::vector> ownedShaders; + std::vector> ownedGpuShaders; + + void markSwapchainDirty() override { swapchainConfigured = false; } + void rebuildSwapchainIfNeeded(); + bool acquireSurfaceTexture(wgpu::TextureView &view, wgpu::Texture &texture); +}; + +} // namespace eve::graphics::webgpu diff --git a/src/modules/graphics/webgpu/wgsl_shaders.h b/src/modules/graphics/webgpu/wgsl_shaders.h new file mode 100644 index 00000000..e6530284 --- /dev/null +++ b/src/modules/graphics/webgpu/wgsl_shaders.h @@ -0,0 +1,537 @@ +#pragma once +// WGSL shader sources for the WebGPU graphics backend. +// WebGPU has no push constants or combined image samplers, so every pipeline +// binds uniform buffers / textures / samplers explicitly through bind groups. +// The memory layouts match the Vulkan GLSL sources (std140) exactly so the +// existing C++ UBO structs (Mesh3DUBO, ShadowUBO, Lighting2DUBO) are reused. + +namespace eve::graphics::webgpu { + +// ---- 2D solid (color) ----------------------------------------------------- +inline const char *kColorVertWgsl = R"wgsl( +struct VSIn { + @location(0) pos: vec2f, + @location(1) color: vec4f, +}; +struct VSOut { + @builtin(position) pos: vec4f, + @location(0) color: vec4f, +}; +@vertex +fn vs_main(in: VSIn) -> VSOut { + var out: VSOut; + out.pos = vec4f(in.pos, 0.0, 1.0); + out.color = in.color; + return out; +} +)wgsl"; + +inline const char *kColorFragWgsl = R"wgsl( +struct FSIn { + @location(0) color: vec4f, +}; +@fragment +fn fs_main(in: FSIn) -> @location(0) vec4f { + return in.color; +} +)wgsl"; + +// ---- 2D textured ---------------------------------------------------------- +inline const char *kTexturedVertWgsl = R"wgsl( +struct VSIn { + @location(0) pos: vec2f, + @location(1) color: vec4f, + @location(2) uv: vec2f, +}; +struct VSOut { + @builtin(position) pos: vec4f, + @location(0) color: vec4f, + @location(1) uv: vec2f, +}; +@vertex +fn vs_main(in: VSIn) -> VSOut { + var out: VSOut; + out.pos = vec4f(in.pos, 0.0, 1.0); + out.color = in.color; + out.uv = in.uv; + return out; +} +)wgsl"; + +inline const char *kTexturedFragWgsl = R"wgsl( +struct FSIn { + @location(0) color: vec4f, + @location(1) uv: vec2f, +}; +@group(0) @binding(0) var mainTex: texture_2d; +@group(0) @binding(2) var mainSamp: sampler; +@fragment +fn fs_main(in: FSIn) -> @location(0) vec4f { + return textureSample(mainTex, mainSamp, in.uv) * in.color; +} +)wgsl"; + +// ---- 2D custom / post (Externals UBO: float data[32] == Shader push block) - +inline const char *kCustom2DFragWgsl = R"wgsl( +struct FSIn { + @location(0) color: vec4f, + @location(1) uv: vec2f, +}; +struct Externals { + data: array, +}; +@group(0) @binding(0) var mainTex: texture_2d; +@group(0) @binding(2) var mainSamp: sampler; +@group(0) @binding(4) var u: Externals; +@fragment +fn fs_main(in: FSIn) -> @location(0) vec4f { + return textureSample(mainTex, mainSamp, in.uv) * in.color; +} +)wgsl"; + +// ---- 2D lit --------------------------------------------------------------- +inline const char *kLit2DFragWgsl = R"wgsl( +struct FSIn { + @location(0) color: vec4f, + @location(1) uv: vec2f, +}; +struct Light2D { + posRadius: vec4f, + color: vec4f, +}; +struct Lighting2D { + ambient: vec4f, // rgb ambient + lightInfo: vec4f, // x = count, y = viewW, z = viewH + lights: array, +}; +@group(0) @binding(0) var albedoTex: texture_2d; +@group(0) @binding(1) var normalTex: texture_2d; +@group(0) @binding(2) var mainSamp: sampler; +@group(0) @binding(4) var u: Lighting2D; +@fragment +fn fs_main(in: FSIn) -> @location(0) vec4f { + let base = textureSample(albedoTex, mainSamp, in.uv) * in.color; + var lit = base.rgb * u.ambient.rgb; + let count = i32(u.lightInfo.x + 0.5); + let view = vec2f(u.lightInfo.y, u.lightInfo.z); + for (var i = 0; i < 8; i = i + 1) { + if (i >= count) { break; } + let l = u.lights[i]; + let px = in.uv * view; + if (l.posRadius.w <= 0.0) { + // Directional: light travels along posRadius.xy + let ndl = max(dot(vec3f(0.0, 0.0, 1.0), normalize(vec3f(l.posRadius.xy, 1.0))), 0.0); + lit += base.rgb * l.color.rgb * ndl; + } else { + let toL = l.posRadius.xy - px; + let dist = length(toL); + let att = clamp(1.0 - dist / max(l.posRadius.w, 1e-3), 0.0, 1.0); + lit += base.rgb * l.color.rgb * att * att; + } + } + return vec4f(lit, base.a); +} +)wgsl"; + +// ---- Mesh3D (full PBR, layout matches Mesh3DUBO / ShadowUBO) -------------- +inline const char *kMesh3DVertWgsl = R"wgsl( +struct VSIn { + @location(0) pos: vec3f, + @location(1) normal: vec3f, + @location(2) uv: vec2f, +}; +struct Light3D { + posRadius: vec4f, + color: vec4f, +}; +struct Frame { + mvp: mat4x4f, + model: mat4x4f, + lightDir: vec4f, + lightColor: vec4f, + tint: vec4f, + cameraPos: vec4f, + ambient: vec4f, + lights: array, + texBomb: vec4f, + parallax: vec4f, + view: mat4x4f, + clipInfo: vec4f, +}; +struct VSOut { + @builtin(position) pos: vec4f, + @location(0) vNormal: vec3f, + @location(1) vUV: vec2f, + @location(2) vTint: vec4f, + @location(3) vWorldPos: vec3f, + @location(4) vCameraPos: vec3f, + @location(5) vViewPos: vec3f, +}; +@group(0) @binding(0) var ubo: Frame; +// WGSL has no stdlib inverse(); implement 3x3 inverse for the normal matrix. +fn inverse3x3(m: mat3x3f) -> mat3x3f { + let a = m[0].x; let b = m[1].x; let c = m[2].x; + let d = m[0].y; let e = m[1].y; let f = m[2].y; + let g = m[0].z; let h = m[1].z; let i = m[2].z; + let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); + return mat3x3f( + vec3f((e * i - f * h) / det, (f * g - d * i) / det, (d * h - e * g) / det), + vec3f((c * h - b * i) / det, (a * i - c * g) / det, (b * g - a * h) / det), + vec3f((b * f - c * e) / det, (c * d - a * f) / det, (a * e - b * d) / det), + ); +} +@vertex +fn vs_main(in: VSIn) -> VSOut { + var out: VSOut; + out.pos = ubo.mvp * vec4f(in.pos, 1.0); + let world = ubo.model * vec4f(in.pos, 1.0); + out.vWorldPos = world.xyz; + out.vViewPos = (ubo.view * world).xyz; + let nrm = transpose(inverse3x3(mat3x3f(ubo.model[0].xyz, ubo.model[1].xyz, ubo.model[2].xyz))) * in.normal; + out.vNormal = normalize(nrm); + out.vUV = in.uv; + out.vTint = ubo.tint; + out.vCameraPos = ubo.cameraPos.xyz; + return out; +} +)wgsl"; + +inline const char *kMesh3DFragWgsl = R"wgsl( +struct Light3D { + posRadius: vec4f, + color: vec4f, +}; +struct Frame { + mvp: mat4x4f, + model: mat4x4f, + lightDir: vec4f, + lightColor: vec4f, + tint: vec4f, + cameraPos: vec4f, + ambient: vec4f, + lights: array, + texBomb: vec4f, + parallax: vec4f, + view: mat4x4f, + clipInfo: vec4f, +}; +struct ShadowFrame { + lightVP: array, + splits: vec4f, + bias: vec4f, + cascadeBias: vec4f, + cascadeTexel: vec4f, +}; +struct FSIn { + @location(0) vNormal: vec3f, + @location(1) vUV: vec2f, + @location(2) vTint: vec4f, + @location(3) vWorldPos: vec3f, + @location(4) vCameraPos: vec3f, + @location(5) vViewPos: vec3f, +}; +@group(0) @binding(0) var ubo: Frame; +@group(0) @binding(1) var albedoSampler: texture_2d; +@group(0) @binding(2) var normalSampler: texture_2d; +@group(0) @binding(3) var envSampler: texture_cube; +@group(0) @binding(4) var shadow: ShadowFrame; +@group(0) @binding(5) var shadowMap: texture_depth_2d_array; +@group(0) @binding(6) var heightSampler: texture_2d; +@group(0) @binding(7) var mainSamp: sampler; +@group(0) @binding(8) var shadowSamp: sampler_comparison; + +const PI: f32 = 3.14159265359; + +fn distGGX(n: vec3f, h: vec3f, rough: f32) -> f32 { + let a = max(rough * rough, 0.002); + let a2 = a * a; + let ndh = max(dot(n, h), 0.0); + let denom = (ndh * ndh * (a2 - 1.0) + 1.0); + return a2 / max(PI * denom * denom, 1e-4); +} +fn geomSchlick(ndv: f32, rough: f32) -> f32 { + let r = rough + 1.0; + let k = (r * r) / 8.0; + return ndv / max(ndv * (1.0 - k) + k, 1e-4); +} +fn geomSmith(n: vec3f, v: vec3f, l: vec3f, rough: f32) -> f32 { + return geomSchlick(max(dot(n, v), 0.0), rough) * geomSchlick(max(dot(n, l), 0.0), rough); +} +fn fresnelSchlick(cosT: f32, f0: vec3f) -> vec3f { + return f0 + (1.0 - f0) * pow(clamp(1.0 - cosT, 0.0, 1.0), 5.0); +} +fn shadeLight(n: vec3f, v: vec3f, albedo: vec3f, metallic: f32, rough: f32, l: vec3f, rad: vec3f) -> vec3f { + let ndl = max(dot(n, l), 0.0); + let diffuse = mix(ndl, ndl * 0.5 + 0.5, 0.25); + let h = normalize(v + l); + let f0 = mix(vec3f(0.04), albedo, metallic); + let ndf = distGGX(n, h, rough); + let g = geomSmith(n, v, l, rough); + let f = fresnelSchlick(max(dot(h, v), 0.0), f0); + let spec = (ndf * g * f) / max(4.0 * max(dot(n, v), 0.0) * max(ndl, 0.001), 1e-3); + let kd = (vec3f(1.0) - f) * (1.0 - metallic); + return (kd * albedo * diffuse + spec * ndl) * rad; +} +fn sampleShadowCascade(worldPos: vec3f, cascade: i32, bias: f32) -> f32 { + let lightClip = shadow.lightVP[cascade] * vec4f(worldPos, 1.0); + let ndc = lightClip.xyz / max(lightClip.w, 1e-6); + let uv = ndc.xy * 0.5 + 0.5; + let depth = ndc.z; + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 || depth < 0.0 || depth > 1.0) { + return 1.0; + } + let texel = 1.0 / vec2f(textureDimensions(shadowMap)); + var sum: f32 = 0.0; + let zref = depth - bias; + for (var i = 0; i < 9; i = i + 1) { + var s: vec2f; + if (i == 0) { s = uv + vec2f(-0.5, -0.5) * texel; } + else if (i == 1) { s = uv + vec2f(0.0, -0.6) * texel; } + else if (i == 2) { s = uv + vec2f(0.5, -0.5) * texel; } + else if (i == 3) { s = uv + vec2f(-0.6, 0.0) * texel; } + else if (i == 4) { s = uv; } + else if (i == 5) { s = uv + vec2f(0.6, 0.0) * texel; } + else if (i == 6) { s = uv + vec2f(-0.5, 0.5) * texel; } + else if (i == 7) { s = uv + vec2f(0.0, 0.6) * texel; } + else { s = uv + vec2f(0.5, 0.5) * texel; } + if (s.x >= 0.0 && s.x <= 1.0 && s.y >= 0.0 && s.y <= 1.0) { + sum += textureSampleCompareLevel(shadowMap, shadowSamp, s, cascade, zref); + } + } + return sum / 9.0; +} +fn sampleShadowPCF(worldPos: vec3f, n: vec3f, viewDepth: f32, ndl: f32) -> f32 { + if (shadow.bias.y < 0.5 || shadow.bias.z < 0.5 || shadow.splits.w < 1e-4) { + return 1.0; + } + var cascade: i32 = 2; + if (viewDepth < shadow.splits.x) { cascade = 0; } + else if (viewDepth < shadow.splits.y) { cascade = 1; } + var tw: f32; + if (cascade == 0) { tw = shadow.cascadeTexel.x; } + else if (cascade == 1) { tw = shadow.cascadeTexel.y; } + else { tw = shadow.cascadeTexel.z; } + var cb: f32; + if (cascade == 0) { cb = shadow.cascadeBias.x; } + else if (cascade == 1) { cb = shadow.cascadeBias.y; } + else { cb = shadow.cascadeBias.z; } + if (cb < 1e-8) { cb = shadow.bias.x; } + let bias = cb * mix(0.75, 1.0, clamp(ndl, 0.0, 1.0)); + let p = worldPos + n * ((2.0 * max(tw, 1e-6)) / max(ndl, 0.2)); + let vis = sampleShadowCascade(p, cascade, bias); + return mix(0.04, 1.0, vis); +} +@fragment +fn fs_main(in: FSIn) -> @location(0) vec4f { + var nGeom = normalize(in.vNormal); + let v = normalize(in.vCameraPos - in.vWorldPos); + if (dot(nGeom, v) < 0.0) { nGeom = -nGeom; } + let base = textureSample(albedoSampler, mainSamp, in.vUV) * in.vTint; + if (base.a < 0.5) { discard; } + let albedo = base.rgb; + let metallic = clamp(ubo.ambient.w, 0.0, 1.0); + let rough = clamp(ubo.cameraPos.w, 0.04, 1.0); + let count = i32(ubo.lightDir.w + 0.5); + let nSmp = textureSample(normalSampler, mainSamp, in.vUV).xyz; + var n = nGeom; + if (length(nSmp - vec3f(0.5, 0.5, 1.0)) > 0.04) { + let mapN = nSmp * 2.0 - 1.0; + n = normalize(mapN); + } + var lo = vec3f(0.0); + let viewDepth = max(-in.vViewPos.z, 0.0); + let primaryL = normalize(ubo.lightDir.xyz); + let shadowVis = sampleShadowPCF(in.vWorldPos, n, viewDepth, max(dot(n, primaryL), 0.0)); + if (length(ubo.lightColor.rgb) > 1e-6) { + lo += shadeLight(n, v, albedo, metallic, rough, primaryL, ubo.lightColor.rgb) * shadowVis; + } + for (var i = 0; i < 8; i = i + 1) { + if (i >= count) { break; } + let lgt = ubo.lights[i]; + var rad = lgt.color.rgb; + var l: vec3f; + if (lgt.posRadius.w <= 0.0) { + l = normalize(lgt.posRadius.xyz); + if (length(l - primaryL) < 1e-3) { continue; } + } else { + let toL = lgt.posRadius.xyz - in.vWorldPos; + let dist = length(toL); + l = toL / max(dist, 1e-4); + let atten = clamp(1.0 - dist / max(lgt.posRadius.w, 1e-3), 0.0, 1.0); + rad *= atten * atten; + } + lo += shadeLight(n, v, albedo, metallic, rough, l, rad); + } + let hemi = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); + let skyIrr = ubo.ambient.rgb * 1.1 + ubo.lightColor.rgb * 0.12; + let gndIrr = ubo.ambient.rgb * vec3f(0.72, 0.62, 0.52); + let irr = mix(gndIrr, skyIrr, hemi); + var color = albedo * irr * (1.0 - metallic) + lo; + let envIntensity = ubo.lightColor.w; + if (envIntensity > 1e-4) { + let r = reflect(-v, n); + let envSpec = textureSampleLevel(envSampler, mainSamp, r, rough * 5.0).rgb * envIntensity; + let f0 = mix(vec3f(0.04), albedo, metallic); + let f = fresnelSchlick(max(dot(n, v), 0.0), f0); + color += envSpec * f; + let irr2 = textureSampleLevel(envSampler, mainSamp, n, 5.0).rgb * envIntensity; + color += albedo * irr2 * (1.0 - metallic) * (1.0 - f) * 0.45; + } + color = color / (color + vec3f(1.0)); + let nearZ = max(ubo.clipInfo.x, 1e-4); + let farZ = max(ubo.clipInfo.y, nearZ + 1e-3); + let viewZ = max(-in.vViewPos.z, 0.0); + let linear01 = clamp((viewZ - nearZ) / (farZ - nearZ), 0.0, 1.0); + return vec4f(color, linear01); +} +)wgsl"; + +// ---- Mesh3D depth-only shadow -------------------------------------------------- +inline const char *kMesh3DShadowVertWgsl = R"wgsl( +struct VSIn { + @location(0) pos: vec3f, + @location(1) normal: vec3f, + @location(2) uv: vec2f, +}; +struct Push { + mvp: mat4x4f, +}; +@group(0) @binding(0) var pc: Push; +@vertex +fn vs_main(in: VSIn) -> @builtin(position) vec4f { + return pc.mvp * vec4f(in.pos, 1.0); +} +)wgsl"; + +// ---- GBuffer pass (normal / linear-depth / albedo) ------------------------- +inline const char *kMesh3DGbufferVertWgsl = R"wgsl( +struct VSIn { + @location(0) pos: vec3f, + @location(1) normal: vec3f, + @location(2) uv: vec2f, +}; +struct Push { + mvp: mat4x4f, + modelR0: vec4f, + modelR1: vec4f, + modelR2: vec4f, + clip: vec4f, +}; +struct VSOut { + @builtin(position) pos: vec4f, + @location(0) vNormal: vec3f, + @location(1) vNdcZ: f32, + @location(2) vUV: vec2f, +}; +@group(0) @binding(0) var pc: Push; +@vertex +fn vs_main(in: VSIn) -> VSOut { + var out: VSOut; + out.pos = pc.mvp * vec4f(in.pos, 1.0); + let nrm = mat3x3f(pc.modelR0.xyz, pc.modelR1.xyz, pc.modelR2.xyz) * in.normal; + out.vNormal = normalize(nrm); + out.vNdcZ = out.pos.z; + out.vUV = in.uv; + return out; +} +)wgsl"; + +inline const char *kMesh3DGbufferFragWgsl = R"wgsl( +struct FSIn { + @location(0) vNormal: vec3f, + @location(1) vNdcZ: f32, + @location(2) vUV: vec2f, +}; +struct Push { + mvp: mat4x4f, + modelR0: vec4f, + modelR1: vec4f, + modelR2: vec4f, + clip: vec4f, +}; +struct GBufOut { + @location(0) normal: vec4f, + @location(1) depthColor: vec4f, + @location(2) albedo: vec4f, +}; +@group(0) @binding(0) var pc: Push; +@group(0) @binding(1) var albedoSampler: texture_2d; +@group(0) @binding(2) var mainSamp: sampler; +@fragment +fn fs_main(in: FSIn) -> GBufOut { + var out: GBufOut; + out.normal = vec4f(in.vNormal * 0.5 + 0.5, 1.0); + let nearZ = max(pc.clip.x, 1e-4); + let farZ = max(pc.clip.y, nearZ + 1e-3); + // NDC z -> linear [0,1] over the clip range (matches scene color A). + let ndc = clamp(in.vNdcZ, 0.0, 1.0); + let linear = nearZ * farZ / max(farZ - ndc * (farZ - nearZ), 1e-6); + out.depthColor = vec4f(linear, linear, linear, 1.0); + out.albedo = textureSample(albedoSampler, mainSamp, in.vUV); + return out; +} +)wgsl"; + +// ---- Voxel rect -------------------------------------------------------------- +inline const char *kVoxelRectVertWgsl = R"wgsl( +struct VSIn { + @location(0) corner: vec2f, + @location(1) packed: u32, +}; +struct PC { + viewProj: mat4x4f, + chunkOrigin: vec4f, + atlasInfo: vec4f, + tint: vec4f, +}; +struct VSOut { + @builtin(position) pos: vec4f, + @location(0) uv: vec2f, + @location(1) tint: vec4f, +}; +@group(0) @binding(0) var pc: PC; +@vertex +fn vs_main(in: VSIn) -> VSOut { + var out: VSOut; + // packed: x(5) y(5) z(5) w(5) h(5) tex(7) + let px = f32(in.packed & 31u); + let py = f32((in.packed >> 5u) & 31u); + let pz = f32((in.packed >> 10u) & 31u); + let w = f32((in.packed >> 15u) & 31u); + let h = f32((in.packed >> 20u) & 31u); + let tex = f32((in.packed >> 25u) & 127u); + var world: vec3f = pc.chunkOrigin.xyz + vec3f(px, py, pz); + let face = i32(pc.chunkOrigin.w + 0.5); + var u: f32 = in.corner.x; + var v: f32 = in.corner.y; + if (face == 0) { world += vec3f(0.0, v * h, u * w); } + else if (face == 1) { world += vec3f(0.0, v * h, w - u * w); } + else if (face == 2) { world += vec3f(u * w, 0.0, v * h); } + else if (face == 3) { world += vec3f(w - u * w, 0.0, v * h); } + else if (face == 4) { world += vec3f(u * w, v * h, 0.0); } + else { world += vec3f(w - u * w, v * h, 0.0); } + out.pos = pc.viewProj * vec4f(world, 1.0); + let tiles = max(pc.atlasInfo.x, 1.0); + let tw = 1.0 / tiles; + let col = floor(tex / tiles); + let row = tex - col * tiles; + out.uv = vec2f((u + row) * tw, (v + col) * tw); + out.tint = pc.tint; + return out; +} +)wgsl"; + +inline const char *kVoxelRectFragWgsl = R"wgsl( +struct FSIn { + @location(0) uv: vec2f, + @location(1) tint: vec4f, +}; +@group(0) @binding(1) var atlas: texture_2d; +@group(0) @binding(2) var atlasSamp: sampler; +@fragment +fn fs_main(in: FSIn) -> @location(0) vec4f { + return textureSample(atlas, atlasSamp, in.uv) * in.tint; +} +)wgsl"; + +} // namespace eve::graphics::webgpu diff --git a/src/modules/map/Map.cpp b/src/modules/map/Map.cpp index c255e9ef..d1a10274 100644 --- a/src/modules/map/Map.cpp +++ b/src/modules/map/Map.cpp @@ -49,14 +49,16 @@ TileLayer *Map::newLayerFromFile(const std::string &path) { std::vector objs; auto layers = loadMapFile(path, &objs, nullptr); setObjects(std::move(objs)); - return layers.empty() ? nullptr : layers.front(); + loadedLayers_ = layers; + return loadedLayers_.empty() ? nullptr : loadedLayers_.front(); } int Map::loadFromFile(const std::string &path) { std::vector objs; auto layers = loadMapFile(path, &objs, nullptr); setObjects(std::move(objs)); - return int(layers.size()); + loadedLayers_ = std::move(layers); + return int(loadedLayers_.size()); } void Map::update(float dt) { @@ -82,6 +84,11 @@ int Map::getLayerCount() const { return n; } +TileLayer *Map::getLayer(int index) const { + if (index < 0 || index >= int(loadedLayers_.size())) return nullptr; + return loadedLayers_[size_t(index)]; +} + void Map::setObjects(std::vector objects) { objects_ = std::move(objects); } int Map::getObjectCount() const { return int(objects_.size()); } @@ -326,6 +333,7 @@ void Map::expose(ssq::Class &cls) { cls.addFunc("render", &Map::render); cls.addFunc("pollConfigs", &Map::pollConfigs); cls.addFunc("getLayerCount", &Map::getLayerCount); + cls.addFunc("getLayer", &Map::getLayer); cls.addFunc("getObjectCount", &Map::getObjectCount); cls.addFunc("getObjectName", &Map::getObjectName); cls.addFunc("getObjectType", &Map::getObjectType); diff --git a/src/modules/map/Map.h b/src/modules/map/Map.h index a276b899..d585f9e4 100644 --- a/src/modules/map/Map.h +++ b/src/modules/map/Map.h @@ -56,6 +56,8 @@ public: int pollConfigs(); int getLayerCount() const; + /** Layer created by the most recent loadFromFile/newLayerFromFile call. */ + TileLayer *getLayer(int index) const; int getObjectCount() const; std::string getObjectName(int i) const; @@ -87,6 +89,7 @@ public: private: std::vector objects_; + std::vector loadedLayers_; std::string dualGridError_; }; diff --git a/src/modules/physics/Body.cpp b/src/modules/physics/Body.cpp index e1ae4618..0f1f4437 100644 --- a/src/modules/physics/Body.cpp +++ b/src/modules/physics/Body.cpp @@ -120,6 +120,23 @@ float Body::getLinearVelocityY() const { return world_->toPixels(body_->GetLinearVelocity().y); } +float Body::getLinearSpeed() const { + if (!body_ || !world_) return 0.f; + return world_->toPixels(body_->GetLinearVelocity().Length()); +} + +float Body::getMass() const { return body_ ? body_->GetMass() : 0.f; } + +float Body::getWorldCenterX() const { + if (!body_ || !world_) return 0.f; + return world_->toPixels(body_->GetWorldCenter().x); +} + +float Body::getWorldCenterY() const { + if (!body_ || !world_) return 0.f; + return world_->toPixels(body_->GetWorldCenter().y); +} + void Body::setAngularVelocity(float omega) { if (!body_) return; body_->SetAngularVelocity(omega); @@ -193,12 +210,18 @@ bool Body::isAwake() const { return body_ ? body_->IsAwake() : false; } Fixture *Body::newRectangleFixture(float width, float height, float density, float friction, float restitution) { + return newRectangleFixtureAt(width, height, 0.f, 0.f, density, friction, restitution); +} + +Fixture *Body::newRectangleFixtureAt(float width, float height, float offsetX, float offsetY, + float density, float friction, float restitution) { if (!body_ || !world_) throw eve::Exception("Body.newRectangleFixture: body destroyed"); if (width <= 0.f || height <= 0.f) throw eve::Exception("Body.newRectangleFixture: width/height must be > 0"); b2PolygonShape shape; - shape.SetAsBox(world_->toMeters(width) * 0.5f, world_->toMeters(height) * 0.5f); + shape.SetAsBox(world_->toMeters(width) * 0.5f, world_->toMeters(height) * 0.5f, + b2Vec2(world_->toMeters(offsetX), world_->toMeters(offsetY)), 0.f); b2FixtureDef def; def.shape = &shape; diff --git a/src/modules/physics/Body.h b/src/modules/physics/Body.h index e7268c4b..05ce6088 100644 --- a/src/modules/physics/Body.h +++ b/src/modules/physics/Body.h @@ -29,6 +29,10 @@ public: void setLinearVelocity(float vx, float vy); float getLinearVelocityX() const; float getLinearVelocityY() const; + float getLinearSpeed() const; + float getMass() const; + float getWorldCenterX() const; + float getWorldCenterY() const; void setAngularVelocity(float omega); float getAngularVelocity() const; @@ -56,6 +60,10 @@ public: /** width/height in pixels; density kg/m² (Box2D units). */ Fixture *newRectangleFixture(float width, float height, float density = 1.f, float friction = 0.2f, float restitution = 0.f); + /** Rectangle fixture with a local pixel-space offset from the body origin. */ + Fixture *newRectangleFixtureAt(float width, float height, float offsetX, float offsetY, + float density = 1.f, float friction = 0.2f, + float restitution = 0.f); Fixture *newCircleFixture(float radius, float density = 1.f, float friction = 0.2f, float restitution = 0.f); diff --git a/src/modules/physics/Fixture.cpp b/src/modules/physics/Fixture.cpp index fe63018c..c8621f81 100644 --- a/src/modules/physics/Fixture.cpp +++ b/src/modules/physics/Fixture.cpp @@ -11,9 +11,9 @@ Fixture::Fixture(World *world, Body *body, b2Fixture *fixture) Fixture::~Fixture() { if (fixture_ && body_ && body_->raw()) { + if (world_) world_->forgetFixture(this); fixture_->SetUserData(nullptr); body_->raw()->DestroyFixture(fixture_); - if (world_) world_->forgetFixture(this); } fixture_ = nullptr; body_ = nullptr; @@ -32,9 +32,9 @@ void Fixture::destroy() { invalidate(); return; } + if (world_) world_->forgetFixture(this); fixture_->SetUserData(nullptr); body_->raw()->DestroyFixture(fixture_); - if (world_) world_->forgetFixture(this); fixture_ = nullptr; body_ = nullptr; world_ = nullptr; @@ -69,6 +69,39 @@ void Fixture::setDensity(float density) { float Fixture::getDensity() const { return fixture_ ? fixture_->GetDensity() : 0.f; } +void Fixture::setCategoryBits(int bits) { + if (!fixture_) return; + b2Filter filter = fixture_->GetFilterData(); + filter.categoryBits = static_cast(bits); + fixture_->SetFilterData(filter); +} + +int Fixture::getCategoryBits() const { + return fixture_ ? int(fixture_->GetFilterData().categoryBits) : 0; +} + +void Fixture::setMaskBits(int bits) { + if (!fixture_) return; + b2Filter filter = fixture_->GetFilterData(); + filter.maskBits = static_cast(bits); + fixture_->SetFilterData(filter); +} + +int Fixture::getMaskBits() const { return fixture_ ? int(fixture_->GetFilterData().maskBits) : 0; } + +void Fixture::setGroupIndex(int index) { + if (!fixture_) return; + b2Filter filter = fixture_->GetFilterData(); + filter.groupIndex = static_cast(index); + fixture_->SetFilterData(filter); +} + +int Fixture::getGroupIndex() const { + return fixture_ ? int(fixture_->GetFilterData().groupIndex) : 0; +} + +int Fixture::getBodyId() const { return body_ ? body_->getId() : 0; } + bool Fixture::testPoint(float x, float y) const { if (!fixture_ || !world_) return false; return fixture_->TestPoint(b2Vec2(world_->toMeters(x), world_->toMeters(y))); diff --git a/src/modules/physics/Fixture.h b/src/modules/physics/Fixture.h index 7aed244f..f501bb45 100644 --- a/src/modules/physics/Fixture.h +++ b/src/modules/physics/Fixture.h @@ -1,5 +1,7 @@ #pragma once +#include + class b2Fixture; namespace eve::physics { @@ -27,6 +29,18 @@ public: void setDensity(float density); float getDensity() const; + void setTag(const std::string &tag) { tag_ = tag; } + const std::string &getTag() const { return tag_; } + + void setCategoryBits(int bits); + int getCategoryBits() const; + void setMaskBits(int bits); + int getMaskBits() const; + void setGroupIndex(int index); + int getGroupIndex() const; + + int getBodyId() const; + Body *getBody() { return body_; } /** Pixel-space point-in-fixture test (uses World meter). */ @@ -46,6 +60,7 @@ private: World *world_ = nullptr; Body *body_ = nullptr; b2Fixture *fixture_ = nullptr; + std::string tag_; }; } // namespace eve::physics diff --git a/src/modules/physics/Physics.cpp b/src/modules/physics/Physics.cpp index 03cd1321..26037050 100644 --- a/src/modules/physics/Physics.cpp +++ b/src/modules/physics/Physics.cpp @@ -66,6 +66,29 @@ void Physics::expose(ssq::Table &table) { world.addFunc("queryAABB", &World::queryAABB); world.addFunc("getQueryCount", &World::getQueryCount); world.addFunc("getQueryBodyId", &World::getQueryBodyId); + world.addFunc("getBeginContactCount", &World::getBeginContactCount); + world.addFunc("getBeginContactBodyAId", &World::getBeginContactBodyAId); + world.addFunc("getBeginContactBodyBId", &World::getBeginContactBodyBId); + world.addFunc("getBeginContactFixtureATag", &World::getBeginContactFixtureATag); + world.addFunc("getBeginContactFixtureBTag", &World::getBeginContactFixtureBTag); + world.addFunc("getEndContactCount", &World::getEndContactCount); + world.addFunc("getEndContactBodyAId", &World::getEndContactBodyAId); + world.addFunc("getEndContactBodyBId", &World::getEndContactBodyBId); + world.addFunc("getEndContactFixtureATag", &World::getEndContactFixtureATag); + world.addFunc("getEndContactFixtureBTag", &World::getEndContactFixtureBTag); + world.addFunc("getImpactCount", &World::getImpactCount); + world.addFunc("getImpactBodyAId", &World::getImpactBodyAId); + world.addFunc("getImpactBodyBId", &World::getImpactBodyBId); + world.addFunc("getImpactFixtureATag", &World::getImpactFixtureATag); + world.addFunc("getImpactFixtureBTag", &World::getImpactFixtureBTag); + world.addFunc("getImpactPointX", &World::getImpactPointX); + world.addFunc("getImpactPointY", &World::getImpactPointY); + world.addFunc("getImpactNormalX", &World::getImpactNormalX); + world.addFunc("getImpactNormalY", &World::getImpactNormalY); + world.addFunc("getImpactRelativeNormalSpeed", &World::getImpactRelativeNormalSpeed); + world.addFunc("getImpactNormalImpulse", &World::getImpactNormalImpulse); + world.addFunc("getImpactTangentImpulse", &World::getImpactTangentImpulse); + world.addFunc("clearContactEvents", &World::clearContactEvents); auto world3 = table.addClass( "World3D", std::function([]() -> World3D * { return nullptr; }), true); @@ -103,6 +126,10 @@ void Physics::expose(ssq::Table &table) { body.addFunc("setLinearVelocity", &Body::setLinearVelocity); body.addFunc("getLinearVelocityX", &Body::getLinearVelocityX); body.addFunc("getLinearVelocityY", &Body::getLinearVelocityY); + body.addFunc("getLinearSpeed", &Body::getLinearSpeed); + body.addFunc("getMass", &Body::getMass); + body.addFunc("getWorldCenterX", &Body::getWorldCenterX); + body.addFunc("getWorldCenterY", &Body::getWorldCenterY); body.addFunc("setAngularVelocity", &Body::setAngularVelocity); body.addFunc("getAngularVelocity", &Body::getAngularVelocity); body.addFunc("applyForce", &Body::applyForce); @@ -120,6 +147,7 @@ void Physics::expose(ssq::Table &table) { body.addFunc("setAwake", &Body::setAwake); body.addFunc("isAwake", &Body::isAwake); body.addFunc("newRectangleFixture", &Body::newRectangleFixture); + body.addFunc("newRectangleFixtureAt", &Body::newRectangleFixtureAt); body.addFunc("newCircleFixture", &Body::newCircleFixture); body.addFunc("destroy", &Body::destroy); @@ -172,6 +200,15 @@ void Physics::expose(ssq::Table &table) { fixture.addFunc("getRestitution", &Fixture::getRestitution); fixture.addFunc("setDensity", &Fixture::setDensity); fixture.addFunc("getDensity", &Fixture::getDensity); + fixture.addFunc("setTag", &Fixture::setTag); + fixture.addFunc("getTag", &Fixture::getTag); + fixture.addFunc("setCategoryBits", &Fixture::setCategoryBits); + fixture.addFunc("getCategoryBits", &Fixture::getCategoryBits); + fixture.addFunc("setMaskBits", &Fixture::setMaskBits); + fixture.addFunc("getMaskBits", &Fixture::getMaskBits); + fixture.addFunc("setGroupIndex", &Fixture::setGroupIndex); + fixture.addFunc("getGroupIndex", &Fixture::getGroupIndex); + fixture.addFunc("getBodyId", &Fixture::getBodyId); fixture.addFunc("getBody", &Fixture::getBody); fixture.addFunc("testPoint", &Fixture::testPoint); fixture.addFunc("destroy", &Fixture::destroy); diff --git a/src/modules/physics/World.cpp b/src/modules/physics/World.cpp index 00b3ce36..4d9f877f 100644 --- a/src/modules/physics/World.cpp +++ b/src/modules/physics/World.cpp @@ -31,6 +31,26 @@ Body *bodyFromFixture(b2Fixture *f) { return static_cast(b->GetUserData()); } +Fixture *fixtureFromRaw(b2Fixture *f) { + return f ? static_cast(f->GetUserData()) : nullptr; +} + +World::ContactEvent contactEventFrom(b2Contact *contact) { + World::ContactEvent out; + if (!contact) return out; + Fixture *fa = fixtureFromRaw(contact->GetFixtureA()); + Fixture *fb = fixtureFromRaw(contact->GetFixtureB()); + if (fa) { + out.bodyAId = fa->getBodyId(); + out.fixtureATag = fa->getTag(); + } + if (fb) { + out.bodyBId = fb->getBodyId(); + out.fixtureBTag = fb->getTag(); + } + return out; +} + } // namespace class ContactRelay : public b2ContactListener { @@ -39,6 +59,12 @@ public: void BeginContact(b2Contact *contact) override { world_->onBeginContact(contact); } void EndContact(b2Contact *contact) override { world_->onEndContact(contact); } + void PreSolve(b2Contact *contact, const b2Manifold *oldManifold) override { + world_->onPreSolve(contact, oldManifold); + } + void PostSolve(b2Contact *contact, const b2ContactImpulse *impulse) override { + world_->onPostSolve(contact, impulse); + } private: World *world_; @@ -149,6 +175,7 @@ void World::destroy() { if (f) f->invalidate(); } fixtures_.clear(); + clearContactEvents(); if (world_) { world_->SetContactListener(nullptr); @@ -217,14 +244,50 @@ void World::destroyBody(Body *body) { body->destroy(); } -void World::forgetBody(Body *body) { bodies_.erase(body); } -void World::forgetFixture(Fixture *fixture) { fixtures_.erase(fixture); } +void World::forgetBody(Body *body) { + if (!body) return; + const int id = body->getId(); + bodies_.erase(body); + auto touches = [id](const ContactEvent &e) { return e.bodyAId == id || e.bodyBId == id; }; + beginContacts_.erase(std::remove_if(beginContacts_.begin(), beginContacts_.end(), touches), + beginContacts_.end()); + endContacts_.erase(std::remove_if(endContacts_.begin(), endContacts_.end(), touches), + endContacts_.end()); + impacts_.erase(std::remove_if(impacts_.begin(), impacts_.end(), touches), impacts_.end()); +} +void World::forgetFixture(Fixture *fixture) { + if (!fixture) return; + const int bodyId = fixture->getBodyId(); + const std::string tag = fixture->getTag(); + auto touches = [&](const ContactEvent &e) { + return (e.bodyAId == bodyId && e.fixtureATag == tag) || + (e.bodyBId == bodyId && e.fixtureBTag == tag); + }; + beginContacts_.erase(std::remove_if(beginContacts_.begin(), beginContacts_.end(), touches), + beginContacts_.end()); + endContacts_.erase(std::remove_if(endContacts_.begin(), endContacts_.end(), touches), + endContacts_.end()); + impacts_.erase(std::remove_if(impacts_.begin(), impacts_.end(), touches), impacts_.end()); + b2Fixture *raw = fixture->raw(); + for (auto it = preSolve_.begin(); it != preSolve_.end();) { + b2Contact *contact = it->first; + if (contact && (contact->GetFixtureA() == raw || contact->GetFixtureB() == raw)) + it = preSolve_.erase(it); + else + ++it; + } + fixtures_.erase(fixture); +} void World::onBeginContact(b2Contact *contact) { + if (!contact || !fixtureFromRaw(contact->GetFixtureA()) || + !fixtureFromRaw(contact->GetFixtureB())) return; Body *a = bodyFromFixture(contact->GetFixtureA()); Body *b = bodyFromFixture(contact->GetFixtureB()); if (!a || !b) return; + beginContacts_.push_back(contactEventFrom(contact)); + auto *ev = eve::ModuleManager::getInstance("Event"); if (!ev) return; std::vector args = {eve::event::Variant::makeInt(a->getId()), @@ -233,10 +296,15 @@ void World::onBeginContact(b2Contact *contact) { } void World::onEndContact(b2Contact *contact) { + preSolve_.erase(contact); + if (!contact || !fixtureFromRaw(contact->GetFixtureA()) || + !fixtureFromRaw(contact->GetFixtureB())) return; Body *a = bodyFromFixture(contact->GetFixtureA()); Body *b = bodyFromFixture(contact->GetFixtureB()); if (!a || !b) return; + endContacts_.push_back(contactEventFrom(contact)); + auto *ev = eve::ModuleManager::getInstance("Event"); if (!ev) return; std::vector args = {eve::event::Variant::makeInt(a->getId()), @@ -244,6 +312,121 @@ void World::onEndContact(b2Contact *contact) { ev->push(new eve::event::Message("endcontact", args)); } +void World::onPreSolve(b2Contact *contact, const b2Manifold * /*oldManifold*/) { + if (!contact || !world_) return; + b2WorldManifold manifold; + contact->GetWorldManifold(&manifold); + const b2Manifold *local = contact->GetManifold(); + if (!local || local->pointCount <= 0) return; + + b2Body *a = contact->GetFixtureA()->GetBody(); + b2Body *b = contact->GetFixtureB()->GetBody(); + if (!a || !b) return; + const b2Vec2 point = manifold.points[0]; + const b2Vec2 va = a->GetLinearVelocityFromWorldPoint(point); + const b2Vec2 vb = b->GetLinearVelocityFromWorldPoint(point); + + PreSolveData data; + data.pointX = toPixels(point.x); + data.pointY = toPixels(point.y); + data.normalX = manifold.normal.x; + data.normalY = manifold.normal.y; + data.relativeNormalSpeed = toPixels(std::max(0.f, b2Dot(va - vb, manifold.normal))); + preSolve_[contact] = data; +} + +void World::onPostSolve(b2Contact *contact, const b2ContactImpulse *impulse) { + if (!contact || !impulse) return; + auto found = preSolve_.find(contact); + if (found == preSolve_.end()) return; + + ImpactEvent out; + static_cast(out) = contactEventFrom(contact); + out.pointX = found->second.pointX; + out.pointY = found->second.pointY; + out.normalX = found->second.normalX; + out.normalY = found->second.normalY; + out.relativeNormalSpeed = found->second.relativeNormalSpeed; + const int count = contact->GetManifold() ? contact->GetManifold()->pointCount : 0; + for (int i = 0; i < count; ++i) { + out.normalImpulse += toPixels(impulse->normalImpulses[i]); + out.tangentImpulse += toPixels(std::fabs(impulse->tangentImpulses[i])); + } + if (out.normalImpulse > 0.f) impacts_.push_back(std::move(out)); +} + +namespace { +template +const Event *eventAt(const std::vector &events, int index) { + return index >= 0 && index < int(events.size()) ? &events[size_t(index)] : nullptr; +} +} // namespace + +int World::getBeginContactBodyAId(int index) const { + auto *e = eventAt(beginContacts_, index); return e ? e->bodyAId : 0; +} +int World::getBeginContactBodyBId(int index) const { + auto *e = eventAt(beginContacts_, index); return e ? e->bodyBId : 0; +} +std::string World::getBeginContactFixtureATag(int index) const { + auto *e = eventAt(beginContacts_, index); return e ? e->fixtureATag : std::string(); +} +std::string World::getBeginContactFixtureBTag(int index) const { + auto *e = eventAt(beginContacts_, index); return e ? e->fixtureBTag : std::string(); +} +int World::getEndContactBodyAId(int index) const { + auto *e = eventAt(endContacts_, index); return e ? e->bodyAId : 0; +} +int World::getEndContactBodyBId(int index) const { + auto *e = eventAt(endContacts_, index); return e ? e->bodyBId : 0; +} +std::string World::getEndContactFixtureATag(int index) const { + auto *e = eventAt(endContacts_, index); return e ? e->fixtureATag : std::string(); +} +std::string World::getEndContactFixtureBTag(int index) const { + auto *e = eventAt(endContacts_, index); return e ? e->fixtureBTag : std::string(); +} +int World::getImpactBodyAId(int index) const { + auto *e = eventAt(impacts_, index); return e ? e->bodyAId : 0; +} +int World::getImpactBodyBId(int index) const { + auto *e = eventAt(impacts_, index); return e ? e->bodyBId : 0; +} +std::string World::getImpactFixtureATag(int index) const { + auto *e = eventAt(impacts_, index); return e ? e->fixtureATag : std::string(); +} +std::string World::getImpactFixtureBTag(int index) const { + auto *e = eventAt(impacts_, index); return e ? e->fixtureBTag : std::string(); +} +float World::getImpactPointX(int index) const { + auto *e = eventAt(impacts_, index); return e ? e->pointX : 0.f; +} +float World::getImpactPointY(int index) const { + auto *e = eventAt(impacts_, index); return e ? e->pointY : 0.f; +} +float World::getImpactNormalX(int index) const { + auto *e = eventAt(impacts_, index); return e ? e->normalX : 0.f; +} +float World::getImpactNormalY(int index) const { + auto *e = eventAt(impacts_, index); return e ? e->normalY : 0.f; +} +float World::getImpactRelativeNormalSpeed(int index) const { + auto *e = eventAt(impacts_, index); return e ? e->relativeNormalSpeed : 0.f; +} +float World::getImpactNormalImpulse(int index) const { + auto *e = eventAt(impacts_, index); return e ? e->normalImpulse : 0.f; +} +float World::getImpactTangentImpulse(int index) const { + auto *e = eventAt(impacts_, index); return e ? e->tangentImpulse : 0.f; +} + +void World::clearContactEvents() { + beginContacts_.clear(); + endContacts_.clear(); + impacts_.clear(); + preSolve_.clear(); +} + void World::drawDebug(graphics::Graphics *gfx) { if (!world_ || !draw_ || !gfx) return; draw_->begin(gfx, meter_); diff --git a/src/modules/physics/World.h b/src/modules/physics/World.h index 2f613621..78658f58 100644 --- a/src/modules/physics/World.h +++ b/src/modules/physics/World.h @@ -3,12 +3,15 @@ #include #include #include +#include #include class b2World; class b2Body; class b2Fixture; class b2Contact; +class b2Manifold; +struct b2ContactImpulse; namespace eve::graphics { class Graphics; @@ -23,6 +26,22 @@ class DebugDraw; class World { public: + struct ContactEvent { + int bodyAId = 0; + int bodyBId = 0; + std::string fixtureATag; + std::string fixtureBTag; + }; + + struct ImpactEvent : ContactEvent { + float pointX = 0.f; + float pointY = 0.f; + float normalX = 0.f; + float normalY = 0.f; + float relativeNormalSpeed = 0.f; + float normalImpulse = 0.f; + float tangentImpulse = 0.f; + }; World(float gravityX, float gravityY, bool sleep, float meter); ~World(); @@ -70,6 +89,31 @@ public: int getQueryCount() const { return static_cast(queryBodyIds_.size()); } int getQueryBodyId(int index) const; + int getBeginContactCount() const { return int(beginContacts_.size()); } + int getBeginContactBodyAId(int index) const; + int getBeginContactBodyBId(int index) const; + std::string getBeginContactFixtureATag(int index) const; + std::string getBeginContactFixtureBTag(int index) const; + int getEndContactCount() const { return int(endContacts_.size()); } + int getEndContactBodyAId(int index) const; + int getEndContactBodyBId(int index) const; + std::string getEndContactFixtureATag(int index) const; + std::string getEndContactFixtureBTag(int index) const; + + int getImpactCount() const { return int(impacts_.size()); } + int getImpactBodyAId(int index) const; + int getImpactBodyBId(int index) const; + std::string getImpactFixtureATag(int index) const; + std::string getImpactFixtureBTag(int index) const; + float getImpactPointX(int index) const; + float getImpactPointY(int index) const; + float getImpactNormalX(int index) const; + float getImpactNormalY(int index) const; + float getImpactRelativeNormalSpeed(int index) const; + float getImpactNormalImpulse(int index) const; + float getImpactTangentImpulse(int index) const; + void clearContactEvents(); + float toMeters(float pixels) const; float toPixels(float meters) const; @@ -78,6 +122,8 @@ public: void onBeginContact(b2Contact *contact); void onEndContact(b2Contact *contact); + void onPreSolve(b2Contact *contact, const b2Manifold *oldManifold); + void onPostSolve(b2Contact *contact, const b2ContactImpulse *impulse); void forgetBody(Body *body); void forgetFixture(Fixture *fixture); @@ -106,6 +152,18 @@ private: float rayHitFraction_ = 0.f; std::vector queryBodyIds_; + + struct PreSolveData { + float pointX = 0.f; + float pointY = 0.f; + float normalX = 0.f; + float normalY = 0.f; + float relativeNormalSpeed = 0.f; + }; + std::unordered_map preSolve_; + std::vector beginContacts_; + std::vector endContacts_; + std::vector impacts_; }; } // namespace eve::physics diff --git a/src/modules/procgen/Procgen.cpp b/src/modules/procgen/Procgen.cpp index b46e874c..314a18c7 100644 --- a/src/modules/procgen/Procgen.cpp +++ b/src/modules/procgen/Procgen.cpp @@ -260,6 +260,40 @@ bool Procgen::hasMeshRecipe(const std::string &recipeId) const { return MeshRecipeRegistry::instance().has(recipeId); } +TerrainSampler *Procgen::newTerrainSampler() { return new TerrainSampler(); } + +Heightmap *Procgen::newHeightmap(int width, int height) { + return new Heightmap(width, height); +} + +Heightmap *Procgen::generateHeightmap(Params *params) { + lastError_.clear(); + if (!params) { + lastError_ = "generateHeightmap: null params"; + return nullptr; + } + const TerrainSampler sampler = TerrainSampler::fromParams(*params); + return new Heightmap(Heightmap::generate(sampler, params->getWidth(), params->getHeight())); +} + +bool Procgen::heightmapToGrid(Heightmap *heightmap, Params *params, Grid2D *out) { + lastError_.clear(); + if (!heightmap) { + lastError_ = "heightmapToGrid: null heightmap"; + return false; + } + if (!out) { + lastError_ = "heightmapToGrid: null grid"; + return false; + } + const TerrainBands bands = params ? TerrainBands::fromParams(*params) : TerrainBands(); + if (!heightmap->toGrid(*out, bands)) { + lastError_ = "heightmapToGrid: heightmap is empty"; + return false; + } + return true; +} + void Procgen::expose(ssq::Table &table) { auto cls = table.addClass(name, Procgen::create, false); expose(cls); @@ -331,6 +365,60 @@ void Procgen::expose(ssq::Table &table) { mesh.addFunc("getIndex", &MeshBuild::getIndex); mesh.addFunc("setMeta", &MeshBuild::setMeta); mesh.addFunc("getMeta", &MeshBuild::getMeta); + + auto sampler = table.addClass( + "ProcgenTerrainSampler", + std::function([]() -> TerrainSampler * { return nullptr; }), true); + sampler.addFunc("sample", &TerrainSampler::sample); + sampler.addFunc("sampleTile", &TerrainSampler::sampleTile); + sampler.addFunc("setSeed", &TerrainSampler::setSeed); + sampler.addFunc("getSeed", &TerrainSampler::getSeed); + sampler.addFunc("setScale", &TerrainSampler::setScale); + sampler.addFunc("getScale", &TerrainSampler::getScale); + sampler.addFunc("setFrequency", &TerrainSampler::setFrequency); + sampler.addFunc("getFrequency", &TerrainSampler::getFrequency); + sampler.addFunc("setWavelength", &TerrainSampler::setWavelength); + sampler.addFunc("getWavelength", &TerrainSampler::getWavelength); + sampler.addFunc("setOctaves", &TerrainSampler::setOctaves); + sampler.addFunc("getOctaves", &TerrainSampler::getOctaves); + sampler.addFunc("setLacunarity", &TerrainSampler::setLacunarity); + sampler.addFunc("getLacunarity", &TerrainSampler::getLacunarity); + sampler.addFunc("setGain", &TerrainSampler::setGain); + sampler.addFunc("getGain", &TerrainSampler::getGain); + sampler.addFunc("setRidge", &TerrainSampler::setRidge); + sampler.addFunc("getRidge", &TerrainSampler::getRidge); + sampler.addFunc("setWarp", &TerrainSampler::setWarp); + sampler.addFunc("getWarp", &TerrainSampler::getWarp); + sampler.addFunc("setExponent", &TerrainSampler::setExponent); + sampler.addFunc("getExponent", &TerrainSampler::getExponent); + sampler.addFunc("setContinent", &TerrainSampler::setContinent); + sampler.addFunc("getContinent", &TerrainSampler::getContinent); + sampler.addFunc("setIsland", &TerrainSampler::setIsland); + sampler.addFunc("getIsland", &TerrainSampler::getIsland); + sampler.addFunc("setCoastSoftness", &TerrainSampler::setCoastSoftness); + sampler.addFunc("getCoastSoftness", &TerrainSampler::getCoastSoftness); + sampler.addFunc("setWorldSize", &TerrainSampler::setWorldSize); + sampler.addFunc("getWorldWidth", &TerrainSampler::getWorldWidth); + sampler.addFunc("getWorldHeight", &TerrainSampler::getWorldHeight); + sampler.addFunc("setBase", &TerrainSampler::setBase); + sampler.addFunc("getBase", &TerrainSampler::getBase); + sampler.addFunc("setAmplitude", &TerrainSampler::setAmplitude); + sampler.addFunc("getAmplitude", &TerrainSampler::getAmplitude); + sampler.addFunc("setClamp", &TerrainSampler::setClamp); + sampler.addFunc("isClamped", &TerrainSampler::isClamped); + sampler.addFunc("getClampMin", &TerrainSampler::getClampMin); + sampler.addFunc("getClampMax", &TerrainSampler::getClampMax); + + auto heightmap = table.addClass( + "ProcgenHeightmap", std::function([]() -> Heightmap * { return nullptr; }), + true); + heightmap.addFunc("resize", &Heightmap::resize); + heightmap.addFunc("getWidth", &Heightmap::getWidth); + heightmap.addFunc("getHeight", &Heightmap::getHeight); + heightmap.addFunc("setHeight", &Heightmap::setHeight); + heightmap.addFunc("height", &Heightmap::height); + heightmap.addFunc("sampleBilinear", &Heightmap::sampleBilinear); + heightmap.addFunc("sampleBilinearSeamless", &Heightmap::sampleBilinearSeamless); } void Procgen::expose(ssq::Class &cls) { @@ -359,6 +447,10 @@ void Procgen::expose(ssq::Class &cls) { cls.addFunc("getMeshRecipeCount", &Procgen::getMeshRecipeCount); cls.addFunc("getMeshRecipeId", &Procgen::getMeshRecipeId); cls.addFunc("hasMeshRecipe", &Procgen::hasMeshRecipe); + cls.addFunc("newTerrainSampler", &Procgen::newTerrainSampler); + cls.addFunc("newHeightmap", &Procgen::newHeightmap); + cls.addFunc("generateHeightmap", &Procgen::generateHeightmap); + cls.addFunc("heightmapToGrid", &Procgen::heightmapToGrid); } } // namespace eve::procgen diff --git a/src/modules/procgen/Procgen.h b/src/modules/procgen/Procgen.h index ddeb27e2..e8997dba 100644 --- a/src/modules/procgen/Procgen.h +++ b/src/modules/procgen/Procgen.h @@ -6,6 +6,8 @@ #include "procgen/OutputSpec.h" #include "procgen/Palette.h" #include "procgen/Params.h" +#include "procgen/heightmap/Heightmap.h" +#include "procgen/heightmap/TerrainSampler.h" #include #include @@ -71,7 +73,7 @@ public: bool hasTextureRecipe(const std::string &recipeId) const; // --- Mesh recipes (Marching Cubes, …) --- - /** CPU mesh (caller owns). Recipes: mesh.marchingcubes. */ + /** CPU mesh (caller owns). Recipes: mesh.marchingcubes, mesh.hexplanet. */ MeshBuild *buildMesh(const std::string &recipeId, Params *params); /** Build + upload to GPU Mesh (owned by Graphics). */ graphics::Mesh *generateMesh(const std::string &recipeId, Params *params, @@ -81,6 +83,16 @@ public: std::string getMeshRecipeId(int index) const; bool hasMeshRecipe(const std::string &recipeId) const; + // --- Phase D: terrain height sampling --- + /** Sampling function over continuous map coordinates (caller owns). */ + TerrainSampler *newTerrainSampler(); + /** Empty in-memory heightmap (caller owns). */ + Heightmap *newHeightmap(int width, int height); + /** Build a sampler from params (seed/scale/octaves/…) and materialize it (caller owns). */ + Heightmap *generateHeightmap(Params *params); + /** Classify a heightmap into a semantic Grid2D using params bands (waterMax…). */ + bool heightmapToGrid(Heightmap *heightmap, Params *params, Grid2D *out); + PaletteTable &palettes() { return palettes_; } private: diff --git a/src/modules/procgen/algorithms/BuiltinAlgorithms.cpp b/src/modules/procgen/algorithms/BuiltinAlgorithms.cpp index d4ac877e..9db6087d 100644 --- a/src/modules/procgen/algorithms/BuiltinAlgorithms.cpp +++ b/src/modules/procgen/algorithms/BuiltinAlgorithms.cpp @@ -195,6 +195,8 @@ bool genCaveDrunkard(const Params ¶ms, Grid2D &out, std::string &error) { // Defined in WfcSimple.cpp void registerWfcSimple(GeneratorRegistry ®istry); +// Defined in heightmap/BuiltinHeightmap.cpp +void registerTerrainHeightmapAlgorithm(GeneratorRegistry ®istry); void GeneratorRegistry::registerBuiltins() { if (builtinsRegistered_) return; @@ -204,6 +206,7 @@ void GeneratorRegistry::registerBuiltins() { registerAlgorithm("maze.backtrack", genMazeBacktrack); registerAlgorithm("noise.terrain", genNoiseTerrain); registerWfcSimple(*this); + registerTerrainHeightmapAlgorithm(*this); builtinsRegistered_ = true; } diff --git a/src/modules/procgen/algorithms/MarchingCubes.cpp b/src/modules/procgen/algorithms/MarchingCubes.cpp index 1a76746e..358fe2ee 100644 --- a/src/modules/procgen/algorithms/MarchingCubes.cpp +++ b/src/modules/procgen/algorithms/MarchingCubes.cpp @@ -1,8 +1,11 @@ #include "procgen/algorithms/MarchingCubes.h" +#include "procgen/algorithms/RockMesh.h" +#include "procgen/algorithms/TreeMesh.h" #include #include #include +#include namespace eve::procgen { namespace { @@ -94,8 +97,142 @@ float fbm3(float x, float y, float z, uint32_t seed, int octaves) { return norm > 0.f ? sum / norm : 0.f; } +struct Vec3 { + float x = 0.f, y = 0.f, z = 0.f; +}; + +Vec3 add(Vec3 a, Vec3 b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; } +Vec3 sub(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; } +Vec3 mul(Vec3 a, float s) { return {a.x * s, a.y * s, a.z * s}; } +float dot(Vec3 a, Vec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; } +Vec3 cross(Vec3 a, Vec3 b) { + return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; +} +Vec3 normalized(Vec3 v) { + const float len = std::sqrt(dot(v, v)); + return len > 1e-8f ? mul(v, 1.f / len) : Vec3{0.f, 1.f, 0.f}; +} + +struct Triangle { + uint32_t a, b, c; +}; + +uint64_t edgeKey(uint32_t a, uint32_t b) { + if (a > b) std::swap(a, b); + return (uint64_t(a) << 32u) | uint64_t(b); +} + +uint32_t midpoint(uint32_t a, uint32_t b, std::vector &vertices, + std::map &cache) { + const uint64_t key = edgeKey(a, b); + const auto it = cache.find(key); + if (it != cache.end()) return it->second; + const uint32_t index = uint32_t(vertices.size()); + vertices.push_back(normalized(add(vertices[a], vertices[b]))); + cache.emplace(key, index); + return index; +} + +void addPlanetVertex(MeshBuild &out, Vec3 p, float radius) { + p = normalized(p); + constexpr float kPi = 3.14159265358979323846f; + const float u = std::atan2(p.z, p.x) / (2.f * kPi) + 0.5f; + const float v = std::asin(std::clamp(p.y, -1.f, 1.f)) / kPi + 0.5f; + out.addVertex(p.x * radius, p.y * radius, p.z * radius, p.x, p.y, p.z, u, v); +} + } // namespace +bool generateHexPlanetMesh(const Params ¶ms, MeshBuild &out, std::string &error) { + const int subdivisions = params.getInt("subdivisions", 2); + const float radius = params.getFloat("radius", 1.f); + const float inset = params.getFloat("tileInset", 0.06f); + if (subdivisions < 0 || subdivisions > 7) { + error = "mesh.hexplanet: subdivisions must be in [0, 7]"; + return false; + } + if (!(radius > 0.f)) { + error = "mesh.hexplanet: radius must be positive"; + return false; + } + if (inset < 0.f || inset >= 0.5f) { + error = "mesh.hexplanet: tileInset must be in [0, 0.5)"; + return false; + } + + const float phi = (1.f + std::sqrt(5.f)) * 0.5f; + std::vector vertices = { + {-1, phi, 0}, {1, phi, 0}, {-1, -phi, 0}, {1, -phi, 0}, + {0, -1, phi}, {0, 1, phi}, {0, -1, -phi}, {0, 1, -phi}, + {phi, 0, -1}, {phi, 0, 1}, {-phi, 0, -1}, {-phi, 0, 1}, + }; + for (Vec3 &v : vertices) v = normalized(v); + std::vector faces = { + {0, 11, 5}, {0, 5, 1}, {0, 1, 7}, {0, 7, 10}, {0, 10, 11}, + {1, 5, 9}, {5, 11, 4}, {11, 10, 2}, {10, 7, 6}, {7, 1, 8}, + {3, 9, 4}, {3, 4, 2}, {3, 2, 6}, {3, 6, 8}, {3, 8, 9}, + {4, 9, 5}, {2, 4, 11}, {6, 2, 10}, {8, 6, 7}, {9, 8, 1}, + }; + for (int level = 0; level < subdivisions; ++level) { + std::map cache; + std::vector next; + next.reserve(faces.size() * 4u); + for (const Triangle &f : faces) { + const uint32_t ab = midpoint(f.a, f.b, vertices, cache); + const uint32_t bc = midpoint(f.b, f.c, vertices, cache); + const uint32_t ca = midpoint(f.c, f.a, vertices, cache); + next.insert(next.end(), {{f.a, ab, ca}, {f.b, bc, ab}, {f.c, ca, bc}, {ab, bc, ca}}); + } + faces.swap(next); + } + + std::vector> incident(vertices.size()); + std::vector faceCenters; + faceCenters.reserve(faces.size()); + for (uint32_t i = 0; i < faces.size(); ++i) { + const Triangle &f = faces[i]; + faceCenters.push_back(normalized(add(add(vertices[f.a], vertices[f.b]), vertices[f.c]))); + incident[f.a].push_back(i); + incident[f.b].push_back(i); + incident[f.c].push_back(i); + } + + out.clear(); + int pentagons = 0, hexagons = 0; + for (uint32_t cell = 0; cell < vertices.size(); ++cell) { + const Vec3 center = vertices[cell]; + const Vec3 reference = std::fabs(center.y) < 0.9f ? normalized(cross({0, 1, 0}, center)) + : normalized(cross({1, 0, 0}, center)); + const Vec3 tangent = cross(center, reference); + auto &ring = incident[cell]; + std::sort(ring.begin(), ring.end(), [&](uint32_t lhs, uint32_t rhs) { + const Vec3 a = sub(faceCenters[lhs], mul(center, dot(faceCenters[lhs], center))); + const Vec3 b = sub(faceCenters[rhs], mul(center, dot(faceCenters[rhs], center))); + return std::atan2(dot(a, tangent), dot(a, reference)) < + std::atan2(dot(b, tangent), dot(b, reference)); + }); + if (ring.size() == 5) ++pentagons; + else if (ring.size() == 6) ++hexagons; + + const uint32_t base = uint32_t(out.getVertexCount()); + addPlanetVertex(out, center, radius); + for (uint32_t faceIndex : ring) { + // Pull dual corners toward this cell's center. Re-normalizing keeps every tile spherical. + addPlanetVertex(out, normalized(add(mul(faceCenters[faceIndex], 1.f - inset), + mul(center, inset))), radius); + } + for (uint32_t i = 0; i < ring.size(); ++i) { + out.addTriangle(base, base + 1u + i, base + 1u + (i + 1u) % uint32_t(ring.size())); + } + } + out.setMeta("algorithm", "mesh.hexplanet"); + out.setMeta("cells", std::to_string(vertices.size())); + out.setMeta("pentagons", std::to_string(pentagons)); + out.setMeta("hexagons", std::to_string(hexagons)); + out.setMeta("subdivisions", std::to_string(subdivisions)); + return true; +} + bool marchingCubes(const float *density, int nx, int ny, int nz, float isolevel, MeshBuild &out, std::string *error) { if (!density) { @@ -220,6 +357,38 @@ bool fillDensityField(const Params ¶ms, std::vector &density, int &nx if (field == "sphere") { const float r = params.getFloat("radius", 0.7f); d = r - std::sqrt(px * px + py * py + pz * pz); + } else if (field == "rock") { + // An ellipsoid SDF whose radius is displaced by low-frequency strata and + // higher-frequency erosion. Quantising the direction before sampling the + // strata creates broad, natural fracture planes instead of a noisy sphere. + const float radius = params.getFloat("radius", 0.68f); + const float flattening = + std::clamp(params.getFloat("flattening", 0.22f), 0.f, 0.7f); + const float angularity = + std::clamp(params.getFloat("angularity", 0.35f), 0.f, 1.f); + const float erosion = + std::clamp(params.getFloat("erosion", 0.18f), 0.f, 0.45f); + const float detailScale = std::max(0.25f, scale); + const float sy = std::max(0.3f, 1.f - flattening); + const float ex = px; + const float ey = py / sy; + const float ez = pz; + const float len = std::sqrt(ex * ex + ey * ey + ez * ez); + const float invLen = len > 1e-5f ? 1.f / len : 0.f; + const float steps = 3.f + angularity * 9.f; + const float qx = std::round(ex * invLen * steps) / steps; + const float qy = std::round(ey * invLen * steps) / steps; + const float qz = std::round(ez * invLen * steps) / steps; + const float strata = fbm3((qx + 2.3f) * detailScale, + (qy + 4.7f) * detailScale, + (qz + 8.1f) * detailScale, seed, octaves); + const float pits = fbm3((px + 7.2f) * detailScale * 2.7f, + (py + 1.9f) * detailScale * 2.7f, + (pz + 5.4f) * detailScale * 2.7f, + seed + 7919u, std::max(2, octaves - 1)); + const float displacement = strata * (0.08f + angularity * 0.16f) - + std::max(0.f, pits) * erosion; + d = radius + displacement - len; } else if (field == "torus") { const float R = params.getFloat("majorRadius", 0.55f); const float r = params.getFloat("minorRadius", 0.22f); @@ -235,7 +404,7 @@ bool fillDensityField(const Params ¶ms, std::vector &density, int &nx d = n - params.getFloat("threshold", 0.05f); } else { error = "mesh.marchingcubes: unknown field '" + field + - "' (use sphere|torus|noise|terrain)"; + "' (use sphere|rock|torus|noise|terrain)"; return false; } // Soft boundary falloff so surfaces close. @@ -300,6 +469,9 @@ std::vector MeshRecipeRegistry::list() const { void MeshRecipeRegistry::registerBuiltins() { if (builtinsRegistered_) return; registerRecipe("mesh.marchingcubes", generateMarchingCubesMesh); + registerRecipe("mesh.rock", generateRockMesh); + registerRecipe("mesh.hexplanet", generateHexPlanetMesh); + registerRecipe("mesh.tree", generateTreeMesh); builtinsRegistered_ = true; } diff --git a/src/modules/procgen/algorithms/MarchingCubes.h b/src/modules/procgen/algorithms/MarchingCubes.h index edaaf2ea..d86bd151 100644 --- a/src/modules/procgen/algorithms/MarchingCubes.h +++ b/src/modules/procgen/algorithms/MarchingCubes.h @@ -27,6 +27,13 @@ bool fillDensityField(const Params ¶ms, std::vector &density, int &nx /** Build mesh from Params (field + resolution + isolevel). */ bool generateMarchingCubesMesh(const Params ¶ms, MeshBuild &out, std::string &error); +/** + * Build the dual of a subdivided icosahedron. The result is a closed planet made + * of hexagonal cells plus the twelve pentagons required by spherical topology. + * Params: radius (1), subdivisions (2), tileInset (0.06, in [0, 0.5)). + */ +bool generateHexPlanetMesh(const Params ¶ms, MeshBuild &out, std::string &error); + using MeshRecipeFn = std::function; class MeshRecipeRegistry { diff --git a/src/modules/procgen/algorithms/RockMesh.cpp b/src/modules/procgen/algorithms/RockMesh.cpp new file mode 100644 index 00000000..10f47982 --- /dev/null +++ b/src/modules/procgen/algorithms/RockMesh.cpp @@ -0,0 +1,283 @@ +#include "procgen/algorithms/RockMesh.h" + +#include +#include +#include +#include +#include + +namespace eve::procgen { +namespace { + +struct Vec3 { + float x = 0.f, y = 0.f, z = 0.f; +}; + +struct Tri { + uint32_t a = 0, b = 0, c = 0; +}; + +struct ShapeProfile { + const char *name; + float axisX, axisY, axisZ; + float power; + float skewX, skewZ; + int cuts; + float cutDepth; +}; + +Vec3 normalize(Vec3 v) { + const float len = std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z); + if (len <= 1e-8f) return {0.f, 1.f, 0.f}; + return {v.x / len, v.y / len, v.z / len}; +} + +Vec3 cross(Vec3 a, Vec3 b) { + return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x}; +} + +inline float fade(float t) { return t * t * t * (t * (t * 6.f - 15.f) + 10.f); } +inline float mix(float a, float b, float t) { return a + (b - a) * t; } + +float grad3(uint32_t h, float x, float y, float z) { + const uint32_t g = h & 15u; + const float u = g < 8 ? x : y; + const float v = g < 4 ? y : (g == 12 || g == 14 ? x : z); + return ((g & 1u) ? -u : u) + ((g & 2u) ? -v : v); +} + +float noise3(float x, float y, float z, uint32_t seed) { + const int xi = int(std::floor(x)), yi = int(std::floor(y)), zi = int(std::floor(z)); + const float xf = x - float(xi), yf = y - float(yi), zf = z - float(zi); + const float u = fade(xf), v = fade(yf), w = fade(zf); + auto hash = [&](int ix, int iy, int iz) { + return uint32_t(ix) * 374761393u + uint32_t(iy) * 668265263u + + uint32_t(iz) * 1274126177u + seed * 2246822519u; + }; + const float n000 = grad3(hash(xi, yi, zi), xf, yf, zf); + const float n100 = grad3(hash(xi + 1, yi, zi), xf - 1.f, yf, zf); + const float n010 = grad3(hash(xi, yi + 1, zi), xf, yf - 1.f, zf); + const float n110 = grad3(hash(xi + 1, yi + 1, zi), xf - 1.f, yf - 1.f, zf); + const float n001 = grad3(hash(xi, yi, zi + 1), xf, yf, zf - 1.f); + const float n101 = grad3(hash(xi + 1, yi, zi + 1), xf - 1.f, yf, zf - 1.f); + const float n011 = grad3(hash(xi, yi + 1, zi + 1), xf, yf - 1.f, zf - 1.f); + const float n111 = grad3(hash(xi + 1, yi + 1, zi + 1), xf - 1.f, yf - 1.f, zf - 1.f); + return mix(mix(mix(n000, n100, u), mix(n010, n110, u), v), + mix(mix(n001, n101, u), mix(n011, n111, u), v), w); +} + +float fbm3(float x, float y, float z, uint32_t seed, int octaves) { + float sum = 0.f, amplitude = 0.5f, frequency = 1.f, norm = 0.f; + for (int i = 0; i < octaves; ++i) { + sum += noise3(x * frequency, y * frequency, z * frequency, + seed + uint32_t(i) * 1013u) * amplitude; + norm += amplitude; + amplitude *= 0.5f; + frequency *= 2.f; + } + return norm > 0.f ? sum / norm : 0.f; +} + +uint32_t nextRandom(uint32_t &state) { + state ^= state << 13u; + state ^= state >> 17u; + state ^= state << 5u; + return state; +} + +float random01(uint32_t &state) { + return float(nextRandom(state) & 0x00ffffffu) / float(0x01000000u); +} + +float randomSigned(uint32_t &state) { return random01(state) * 2.f - 1.f; } + +bool resolveShape(const std::string &requested, uint32_t seed, ShapeProfile &shape) { + static constexpr ShapeProfile profiles[] = { + {"boulder", 1.f, 1.f, 0.94f, 0.92f, 0.02f, -0.01f, 3, 0.13f}, + {"slab", 1.28f, 0.62f, 1.02f, 0.74f, 0.08f, -0.03f, 5, 0.18f}, + {"block", 1.04f, 0.90f, 0.96f, 0.46f, 0.03f, 0.02f, 6, 0.20f}, + {"shard", 0.70f, 1.34f, 0.56f, 0.62f, 0.22f, -0.12f, 7, 0.24f}, + }; + std::string name = requested; + if (name == "mixed") name = profiles[seed % 4u].name; + for (const ShapeProfile &candidate : profiles) { + if (name == candidate.name) { + shape = candidate; + return true; + } + } + return false; +} + +float shapedCoordinate(float value, float power) { + return std::copysign(std::pow(std::abs(value), power), value); +} + +uint64_t edgeKey(uint32_t a, uint32_t b) { + if (a > b) std::swap(a, b); + return (uint64_t(a) << 32u) | uint64_t(b); +} + +uint32_t midpoint(uint32_t a, uint32_t b, std::vector &vertices, + std::unordered_map &cache) { + const uint64_t key = edgeKey(a, b); + const auto found = cache.find(key); + if (found != cache.end()) return found->second; + const Vec3 &va = vertices[a], &vb = vertices[b]; + const uint32_t index = uint32_t(vertices.size()); + vertices.push_back(normalize({(va.x + vb.x) * 0.5f, (va.y + vb.y) * 0.5f, + (va.z + vb.z) * 0.5f})); + cache.emplace(key, index); + return index; +} + +void createIcosphere(int subdivisions, std::vector &vertices, std::vector &triangles) { + constexpr float phi = 1.6180339887498948482f; + vertices = {{-1, phi, 0}, {1, phi, 0}, {-1, -phi, 0}, {1, -phi, 0}, + {0, -1, phi}, {0, 1, phi}, {0, -1, -phi}, {0, 1, -phi}, + {phi, 0, -1}, {phi, 0, 1}, {-phi, 0, -1}, {-phi, 0, 1}}; + for (Vec3 &v : vertices) v = normalize(v); + triangles = {{0, 11, 5}, {0, 5, 1}, {0, 1, 7}, {0, 7, 10}, {0, 10, 11}, + {1, 5, 9}, {5, 11, 4}, {11, 10, 2}, {10, 7, 6}, {7, 1, 8}, + {3, 9, 4}, {3, 4, 2}, {3, 2, 6}, {3, 6, 8}, {3, 8, 9}, + {4, 9, 5}, {2, 4, 11}, {6, 2, 10}, {8, 6, 7}, {9, 8, 1}}; + + for (int level = 0; level < subdivisions; ++level) { + std::unordered_map cache; + std::vector next; + next.reserve(triangles.size() * 4); + for (const Tri &t : triangles) { + const uint32_t ab = midpoint(t.a, t.b, vertices, cache); + const uint32_t bc = midpoint(t.b, t.c, vertices, cache); + const uint32_t ca = midpoint(t.c, t.a, vertices, cache); + next.push_back({t.a, ab, ca}); + next.push_back({t.b, bc, ab}); + next.push_back({t.c, ca, bc}); + next.push_back({ab, bc, ca}); + } + triangles.swap(next); + } +} + +} // namespace + +bool generateRockMesh(const Params ¶ms, MeshBuild &out, std::string &error) { + const int subdivisions = params.getInt("subdivisions", 3); + if (subdivisions < 0 || subdivisions > 5) { + error = "mesh.rock: subdivisions must be in [0,5]"; + return false; + } + const float radius = std::max(0.05f, params.getFloat("radius", 0.72f)); + const float flattening = std::clamp(params.getFloat("flattening", 0.22f), 0.f, 0.7f); + const float angularity = std::clamp(params.getFloat("angularity", 0.38f), 0.f, 1.f); + const float erosion = std::clamp(params.getFloat("erosion", 0.16f), 0.f, 0.45f); + const float scale = std::max(0.25f, params.getFloat("scale", 2.4f)); + const int octaves = std::clamp(params.getInt("octaves", 4), 1, 8); + const uint32_t seed = params.getSeed(); + const std::string requestedShape = params.getString("baseShape", "mixed"); + ShapeProfile shape{}; + if (!resolveShape(requestedShape, seed, shape)) { + error = "mesh.rock: unknown baseShape '" + requestedShape + + "' (use mixed|boulder|slab|block|shard)"; + return false; + } + + uint32_t randomState = seed ^ 0x9e3779b9u; + const float variation = std::clamp(params.getFloat("variation", 0.42f), 0.f, 1.f); + shape.axisX *= 1.f + randomSigned(randomState) * 0.18f * variation; + shape.axisY *= 1.f + randomSigned(randomState) * 0.14f * variation; + shape.axisZ *= 1.f + randomSigned(randomState) * 0.18f * variation; + shape.power = std::clamp(shape.power + randomSigned(randomState) * 0.12f * variation, + 0.32f, 1.1f); + shape.skewX += randomSigned(randomState) * 0.12f * variation; + shape.skewZ += randomSigned(randomState) * 0.12f * variation; + if (params.has("axisX")) shape.axisX = std::max(0.2f, params.getFloat("axisX", shape.axisX)); + if (params.has("axisY")) shape.axisY = std::max(0.2f, params.getFloat("axisY", shape.axisY)); + if (params.has("axisZ")) shape.axisZ = std::max(0.2f, params.getFloat("axisZ", shape.axisZ)); + if (params.has("shapePower")) + shape.power = std::clamp(params.getFloat("shapePower", shape.power), 0.25f, 1.25f); + if (params.has("skewX")) shape.skewX = params.getFloat("skewX", shape.skewX); + if (params.has("skewZ")) shape.skewZ = params.getFloat("skewZ", shape.skewZ); + const int cutCount = std::clamp(params.getInt("cutCount", shape.cuts), 0, 12); + const float cutDepth = std::clamp(params.getFloat("cutDepth", shape.cutDepth), 0.f, 0.42f); + + std::vector directions; + std::vector triangles; + createIcosphere(subdivisions, directions, triangles); + std::vector positions(directions.size()); + std::vector normals(directions.size()); + const float steps = 3.f + angularity * 9.f; + for (size_t i = 0; i < directions.size(); ++i) { + const Vec3 d = directions[i]; + const float qx = std::round(d.x * steps) / steps; + const float qy = std::round(d.y * steps) / steps; + const float qz = std::round(d.z * steps) / steps; + const float strata = fbm3((qx + 2.3f) * scale, (qy + 4.7f) * scale, + (qz + 8.1f) * scale, seed, octaves); + const float pits = fbm3((d.x + 7.2f) * scale * 2.7f, + (d.y + 1.9f) * scale * 2.7f, + (d.z + 5.4f) * scale * 2.7f, + seed + 7919u, std::max(2, octaves - 1)); + const float displacement = strata * (0.08f + angularity * 0.16f) - + std::max(0.f, pits) * erosion; + const float r = std::max(radius * 0.45f, radius + displacement); + Vec3 p = {shapedCoordinate(d.x, shape.power) * r * shape.axisX, + shapedCoordinate(d.y, shape.power) * r * shape.axisY * (1.f - flattening), + shapedCoordinate(d.z, shape.power) * r * shape.axisZ}; + p.x += p.y * shape.skewX; + p.z += p.y * shape.skewZ; + positions[i] = p; + } + + // Clamp the deformed surface against deterministic planes. This preserves the icosphere's + // economical topology while creating broad fracture faces and non-round silhouettes. + for (int cut = 0; cut < cutCount; ++cut) { + Vec3 plane = normalize({randomSigned(randomState), randomSigned(randomState), + randomSigned(randomState)}); + if (cut == 0) plane = normalize({0.2f, -1.f, 0.1f}); // stable resting face + const float support = radius * std::sqrt( + plane.x * plane.x * shape.axisX * shape.axisX + + plane.y * plane.y * shape.axisY * shape.axisY * (1.f - flattening) * + (1.f - flattening) + + plane.z * plane.z * shape.axisZ * shape.axisZ); + const float depth = cutDepth * (0.72f + random01(randomState) * 0.56f); + const float limit = support * (1.f - depth); + for (Vec3 &p : positions) { + const float excess = p.x * plane.x + p.y * plane.y + p.z * plane.z - limit; + if (excess > 0.f) { + p.x -= plane.x * excess; + p.y -= plane.y * excess; + p.z -= plane.z * excess; + } + } + } + + for (const Tri &t : triangles) { + const Vec3 &a = positions[t.a], &b = positions[t.b], &c = positions[t.c]; + const Vec3 face = cross({b.x - a.x, b.y - a.y, b.z - a.z}, + {c.x - a.x, c.y - a.y, c.z - a.z}); + for (uint32_t index : {t.a, t.b, t.c}) { + normals[index].x += face.x; + normals[index].y += face.y; + normals[index].z += face.z; + } + } + for (Vec3 &n : normals) n = normalize(n); + + out.clear(); + out.reserve(int(positions.size()), int(triangles.size() * 3)); + for (size_t i = 0; i < positions.size(); ++i) { + const Vec3 &p = positions[i], &n = normals[i]; + out.addVertex(p.x, p.y, p.z, n.x, n.y, n.z, p.x + 0.5f, p.y + 0.5f); + } + for (const Tri &t : triangles) out.addTriangle(t.a, t.b, t.c); + out.setMeta("algorithm", "mesh.rock"); + out.setMeta("topology", "icosphere"); + out.setMeta("baseShape", shape.name); + out.setMeta("cutCount", std::to_string(cutCount)); + out.setMeta("subdivisions", std::to_string(subdivisions)); + return true; +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/algorithms/RockMesh.h b/src/modules/procgen/algorithms/RockMesh.h new file mode 100644 index 00000000..6c3cba88 --- /dev/null +++ b/src/modules/procgen/algorithms/RockMesh.h @@ -0,0 +1,13 @@ +#pragma once + +#include "procgen/MeshBuild.h" +#include "procgen/Params.h" + +#include + +namespace eve::procgen { + +/** Build a shared-vertex, deformed icosphere rock for economical game props. */ +bool generateRockMesh(const Params ¶ms, MeshBuild &out, std::string &error); + +} // namespace eve::procgen diff --git a/src/modules/procgen/algorithms/TreeMesh.cpp b/src/modules/procgen/algorithms/TreeMesh.cpp new file mode 100644 index 00000000..9317d9c2 --- /dev/null +++ b/src/modules/procgen/algorithms/TreeMesh.cpp @@ -0,0 +1,558 @@ +#include "procgen/algorithms/TreeMesh.h" + +#include +#include +#include +#include +#include + +namespace eve::procgen { +namespace { + +constexpr float kPi = 3.14159265358979323846f; + +struct V3 { + float x = 0.f, y = 0.f, z = 0.f; +}; + +V3 add(V3 a, V3 b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; } +V3 sub(V3 a, V3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; } +V3 mul(V3 a, float s) { return {a.x * s, a.y * s, a.z * s}; } +float dot(V3 a, V3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; } +float distanceSquared(V3 a, V3 b) { return dot(sub(a, b), sub(a, b)); } +V3 cross(V3 a, V3 b) { return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; } +V3 norm(V3 a) { + const float n = std::sqrt(std::max(1e-12f, dot(a, a))); + return mul(a, 1.f / n); +} + +float randomRange(std::mt19937& rng, float lo, float hi) { return std::uniform_real_distribution(lo, hi)(rng); } + +float hash01(uint32_t value) { + value ^= value >> 16; + value *= 0x7feb352du; + value ^= value >> 15; + value *= 0x846ca68bu; + value ^= value >> 16; + return float(value & 0x00ffffffu) / float(0x01000000u); +} + +void basisFor(V3 axis, V3& right, V3& forward) { + axis = norm(axis); + const V3 helper = std::fabs(axis.y) < 0.92f ? V3{0.f, 1.f, 0.f} : V3{1.f, 0.f, 0.f}; + right = norm(cross(helper, axis)); + forward = norm(cross(axis, right)); +} + +void addTaperedCylinder(MeshBuild& out, V3 a, V3 b, float r0, float r1, int sides) { + const V3 axis = norm(sub(b, a)); + V3 right, forward; + basisFor(axis, right, forward); + const uint32_t base = uint32_t(out.getVertexCount()); + for (int ring = 0; ring < 2; ++ring) { + const V3 center = ring ? b : a; + const float radius = ring ? r1 : r0; + for (int i = 0; i < sides; ++i) { + const float t = float(i) / float(sides); + const float angle = t * 2.f * kPi; + const V3 radial = add(mul(right, std::cos(angle)), mul(forward, std::sin(angle))); + const V3 p = add(center, mul(radial, radius)); + // Left side of the tree atlas is bark; foliage uses the right side. + out.addVertex(p.x, p.y, p.z, radial.x, radial.y, radial.z, t * 0.45f, float(ring)); + } + } + for (int i = 0; i < sides; ++i) { + const uint32_t n = uint32_t((i + 1) % sides); + const uint32_t i0 = base + uint32_t(i), i1 = base + n; + const uint32_t i2 = base + uint32_t(sides) + uint32_t(i); + const uint32_t i3 = base + uint32_t(sides) + n; + out.addTriangle(i0, i2, i1); + out.addTriangle(i1, i2, i3); + } +} + +void addLeafCard(MeshBuild& out, V3 c, V3 direction, float size, float twist) { + V3 right, up; + basisFor(norm(direction), right, up); + right = add(mul(right, std::cos(twist)), mul(up, std::sin(twist))); + up = norm(cross(norm(direction), right)); + const V3 r = mul(right, size * 0.5f), u = mul(up, size); + const V3 normal = norm(cross(right, up)); + const uint32_t base = uint32_t(out.getVertexCount()); + const V3 points[4] = {sub(sub(c, r), u), add(sub(c, u), r), add(add(c, r), u), add(sub(c, r), u)}; + const float uv[4][2] = {{0, 0}, {1, 0}, {1, 1}, {0, 1}}; + for (int i = 0; i < 4; ++i) + out.addVertex(points[i].x, points[i].y, points[i].z, normal.x, normal.y, normal.z, 0.55f + uv[i][0] * 0.45f, + uv[i][1]); + out.addTriangle(base, base + 1, base + 2); + out.addTriangle(base, base + 2, base + 3); + out.addTriangle(base + 2, base + 1, base); + out.addTriangle(base + 3, base + 2, base); +} + +void addCanopyBlob(MeshBuild& out, V3 center, V3 radius, int rings, int sides) { + const uint32_t base = uint32_t(out.getVertexCount()); + for (int y = 0; y <= rings; ++y) { + const float v = float(y) / float(rings); + const float phi = v * kPi; + for (int x = 0; x < sides; ++x) { + const float u = float(x) / float(sides); + const float theta = u * 2.f * kPi; + const V3 n = {std::sin(phi) * std::cos(theta), std::cos(phi), std::sin(phi) * std::sin(theta)}; + const V3 p = {center.x + n.x * radius.x, center.y + n.y * radius.y, center.z + n.z * radius.z}; + out.addVertex(p.x, p.y, p.z, n.x, n.y, n.z, 0.55f + u * 0.45f, v); + } + } + for (int y = 0; y < rings; ++y) { + for (int x = 0; x < sides; ++x) { + const int nx = (x + 1) % sides; + const uint32_t a = base + uint32_t(y * sides + x); + const uint32_t b = base + uint32_t(y * sides + nx); + const uint32_t c = base + uint32_t((y + 1) * sides + x); + const uint32_t d = base + uint32_t((y + 1) * sides + nx); + out.addTriangle(a, c, b); + out.addTriangle(b, c, d); + } + } +} + +struct Tip { + V3 p; + V3 dir; + float scale; +}; + +struct StemPath { + std::vector points; + V3 endDirection; +}; + +StemPath makeCurvedStem(V3 start, V3 direction, V3 bendAxis, float length, float curve, float curveBack, + float verticalAcceleration, int segments) { + StemPath path; + path.points.reserve(size_t(segments + 1)); + direction = norm(direction); + bendAxis = norm(sub(bendAxis, mul(direction, dot(bendAxis, direction)))); + const V3 up{0.f, 1.f, 0.f}; + for (int i = 0; i <= segments; ++i) { + const float t = float(i) / float(segments); + // Both offsets have zero slope at the branch base, avoiding an immediate kink. + const float primary = curve * t * t; + const float back = curveBack * std::sin(2.f * kPi * t) * t * (1.f - t); + const V3 p = add(start, add(mul(direction, length * t), add(mul(bendAxis, length * (primary + back)), + mul(up, length * verticalAcceleration * t * t)))); + path.points.push_back(p); + } + path.endDirection = norm(sub(path.points.back(), path.points[path.points.size() - 2])); + return path; +} + +void addStem(MeshBuild& out, const StemPath& path, float r0, float r1, int sides) { + const int segmentCount = int(path.points.size()) - 1; + for (int i = 0; i < segmentCount; ++i) { + const float t0 = float(i) / float(segmentCount); + const float t1 = float(i + 1) / float(segmentCount); + const float a = std::pow(t0, 1.25f); + const float b = std::pow(t1, 1.25f); + addTaperedCylinder(out, path.points[size_t(i)], path.points[size_t(i + 1)], r0 + (r1 - r0) * a, + r0 + (r1 - r0) * b, sides); + } +} + +void samplePath(const StemPath& path, float t, V3& point, V3& direction) { + const int segmentCount = int(path.points.size()) - 1; + const float x = std::clamp(t, 0.f, 1.f) * float(segmentCount); + const int i = std::min(int(x), segmentCount - 1); + const float local = x - float(i); + point = add(path.points[size_t(i)], mul(sub(path.points[size_t(i + 1)], path.points[size_t(i)]), local)); + direction = norm(sub(path.points[size_t(i + 1)], path.points[size_t(i)])); +} + +struct ColonyNode { + V3 p; + V3 dir; + V3 heading{0.f, 1.f, 0.f}; + int parent = -1; + int descendants = 1; + int children = 0; + int depth = 0; + float remainingLength = 0.f; + float vigor = 1.f; + float budHeight = 0.f; +}; + +struct ColonizationSettings { + float height; + float foliageStart; + float crownRadius; + float trunkRadius; + float tropism; + float droop; + float inertia; + int attractorCount; + int iterations; + float influenceRadius; + float killRadius; + float step; + float maxTurnAngle; + float maxCumulativeAngle; + float lengthFalloff; + float radiusFalloff; + float lowerLeafCoverage; + float upperLeafCoverage; + int maxChildren; + int sides; +}; + +void growSpaceColonizedTree(MeshBuild& out, const StemPath& trunk, std::mt19937& rng, + const ColonizationSettings& settings, std::vector& foliageAnchors) { + V3 crownCenter, ignoredDirection; + samplePath(trunk, settings.foliageStart + (1.f - settings.foliageStart) * 0.53f, crownCenter, ignoredDirection); + const float crownHalfHeight = settings.height * (1.f - settings.foliageStart) * 0.52f; + + std::vector attractors; + attractors.reserve(size_t(settings.attractorCount)); + while (int(attractors.size()) < settings.attractorCount) { + const V3 q{randomRange(rng, -1.f, 1.f), randomRange(rng, -1.f, 1.f), randomRange(rng, -1.f, 1.f)}; + if (dot(q, q) > 1.f) continue; + // A modest upward bias avoids a perfectly symmetric, balloon-like crown. + attractors.push_back(add(crownCenter, {q.x * settings.crownRadius, + q.y * crownHalfHeight + 0.12f * crownHalfHeight * (1.f - q.y * q.y), + q.z * settings.crownRadius})); + } + + std::vector nodes; + // Multiple trunk buds provide the primary scaffold. Colonization then shapes and + // splits those limbs; starting from a single bud produces a shrub-like fan. + const int seedCount = 9; + for (int i = 0; i < seedCount; ++i) { + V3 p, dir; + const float t = + settings.foliageStart + (1.f - settings.foliageStart) * (0.08f + 0.78f * float(i) / float(seedCount - 1)); + samplePath(trunk, t, p, dir); + const float crownHeight = float(i) / float(seedCount - 1); + const float lengthScale = 1.f - settings.lengthFalloff * crownHeight; + const float vigor = 1.f - settings.radiusFalloff * crownHeight; + nodes.push_back({p, dir, dir, -1, 1, 0, 0, settings.crownRadius * 1.35f * lengthScale, vigor, crownHeight}); + } + + std::vector alive(size_t(settings.attractorCount), 1); + const float influenceSquared = settings.influenceRadius * settings.influenceRadius; + const float killSquared = settings.killRadius * settings.killRadius; + const float separationSquared = settings.step * settings.step * 0.22f; + const float maxTurn = settings.maxTurnAngle * kPi / 180.f; + const float maxTurnCos = std::cos(maxTurn); + const float maxTurnSin = std::sin(maxTurn); + const float cumulativeTurn = settings.maxCumulativeAngle * kPi / 180.f; + const float cumulativeTurnCos = std::cos(cumulativeTurn); + const float cumulativeTurnSin = std::sin(cumulativeTurn); + for (int iteration = 0; iteration < settings.iterations; ++iteration) { + const size_t nodeCount = nodes.size(); + std::vector directionSums(nodeCount); + std::vector directionCounts(nodeCount, 0); + int liveAttractors = 0; + for (size_t ai = 0; ai < attractors.size(); ++ai) { + if (!alive[ai]) continue; + ++liveAttractors; + size_t nearest = 0; + float best = influenceSquared; + bool found = false; + for (size_t ni = 0; ni < nodeCount; ++ni) { + const V3 toward = sub(attractors[ai], nodes[ni].p); + const float d = dot(toward, toward); + if (d < killSquared) { + alive[ai] = 0; + found = false; + break; + } + // Once a shoot has left the trunk, attraction from behind may not + // reverse it. This is the main distinction between a branch and a vine. + if (nodes[ni].depth > 0) { + const V3 attractionDirection = norm(toward); + if (dot(attractionDirection, nodes[ni].dir) < -0.08f || + dot(attractionDirection, nodes[ni].heading) < 0.04f) + continue; + } + if (d < best) { + best = d; + nearest = ni; + found = true; + } + } + if (found) { + directionSums[nearest] = add(directionSums[nearest], norm(sub(attractors[ai], nodes[nearest].p))); + ++directionCounts[nearest]; + } + } + if (liveAttractors == 0) break; + + std::vector additions; + for (size_t ni = 0; ni < nodeCount; ++ni) { + if (directionCounts[ni] == 0 || nodes[ni].children >= settings.maxChildren || + nodes[ni].remainingLength < settings.step) + continue; + V3 direction = norm( + add(mul(directionSums[ni], 1.f / float(directionCounts[ni])), mul(nodes[ni].dir, settings.inertia))); + const float vertical = settings.tropism - settings.droop * std::max(0.f, 1.f - direction.y); + direction = norm(add(direction, {0.f, vertical, 0.f})); + // Clamp curvature per growth step. Linear blending alone can still turn + // sharply when the remaining attractors move to the other side of a tip. + const float alignment = std::clamp(dot(nodes[ni].dir, direction), -1.f, 1.f); + if (nodes[ni].depth > 0 && alignment < maxTurnCos) { + V3 tangent = sub(direction, mul(nodes[ni].dir, alignment)); + if (dot(tangent, tangent) > 1e-8f) + direction = norm(add(mul(nodes[ni].dir, maxTurnCos), mul(norm(tangent), maxTurnSin))); + else + direction = nodes[ni].dir; + } + if (nodes[ni].depth > 0) { + const float headingAlignment = std::clamp(dot(nodes[ni].heading, direction), -1.f, 1.f); + if (headingAlignment < cumulativeTurnCos) { + V3 tangent = sub(direction, mul(nodes[ni].heading, headingAlignment)); + if (dot(tangent, tangent) > 1e-8f) + direction = + norm(add(mul(nodes[ni].heading, cumulativeTurnCos), mul(norm(tangent), cumulativeTurnSin))); + else + direction = nodes[ni].heading; + } + } else if (direction.y < -0.05f) { + // Primary limbs may spread horizontally, but do not launch downward. + direction = norm({direction.x, -0.05f, direction.z}); + } + const V3 candidate = add(nodes[ni].p, mul(direction, settings.step)); + bool separated = true; + for (size_t oi = 0; oi < nodes.size() && separated; ++oi) + separated = distanceSquared(candidate, nodes[oi].p) >= separationSquared; + for (size_t oi = 0; oi < additions.size() && separated; ++oi) + separated = distanceSquared(candidate, additions[oi].p) >= separationSquared; + if (separated) { + const V3 heading = nodes[ni].depth == 0 ? direction : nodes[ni].heading; + additions.push_back({candidate, direction, heading, int(ni), 1, 0, nodes[ni].depth + 1, + nodes[ni].remainingLength - settings.step, nodes[ni].vigor, nodes[ni].budHeight}); + ++nodes[ni].children; + } + } + if (additions.empty()) break; + nodes.insert(nodes.end(), additions.begin(), additions.end()); + } + + for (int i = int(nodes.size()) - 1; i >= 0; --i) { + const int parent = nodes[size_t(i)].parent; + if (parent >= 0) { + nodes[size_t(parent)].descendants += nodes[size_t(i)].descendants; + } + } + const float twigRadius = settings.trunkRadius * 0.055f; + for (size_t i = 0; i < nodes.size(); ++i) { + const ColonyNode& node = nodes[i]; + if (node.parent < 0) continue; + const ColonyNode& parent = nodes[size_t(node.parent)]; + const float r0 = std::min(settings.trunkRadius * 0.34f * parent.vigor, + twigRadius * parent.vigor * (1.f + std::sqrt(float(parent.descendants)))); + const float r1 = std::min(r0 * 0.92f, twigRadius * node.vigor * (1.f + std::sqrt(float(node.descendants)))); + addTaperedCylinder(out, parent.p, node.p, r0, r1, std::max(3, settings.sides - 2)); + if (node.children == 0) { + foliageAnchors.push_back({node.p, node.dir, 0.42f + 0.22f * node.vigor}); + } else if (node.depth >= 2 && (node.depth % 2) == 0) { + // Older lower limbs carry foliage along the branch, whereas young upper + // shoots keep most foliage near their tips. + const float coverage = + settings.lowerLeafCoverage + (settings.upperLeafCoverage - settings.lowerLeafCoverage) * node.budHeight; + if (hash01(uint32_t(i) * 747796405u + 2891336453u) < coverage) + foliageAnchors.push_back({node.p, node.dir, 0.48f + 0.20f * (1.f - node.budHeight)}); + } + } +} + +} // namespace + +bool generateTreeMesh(const Params& params, MeshBuild& out, std::string& error) { + const std::string style = params.getString("style", "lowpoly"); + const std::string leafMode = params.getString("leafMode", "cards"); + const std::string branchAlgorithm = params.getString("branchAlgorithm", "weberPenn"); + if (style != "lowpoly" && style != "realistic") { + error = "mesh.tree: style must be lowpoly|realistic"; + return false; + } + if (leafMode != "cards" && leafMode != "canopy" && leafMode != "none") { + error = "mesh.tree: leafMode must be cards|canopy|none"; + return false; + } + if (branchAlgorithm != "weberPenn" && branchAlgorithm != "spaceColonization") { + error = "mesh.tree: branchAlgorithm must be weberPenn|spaceColonization"; + return false; + } + + const bool realistic = style == "realistic"; + const float height = std::max(0.5f, params.getFloat("height", 6.f)); + const float trunkRadius = std::max(0.02f, params.getFloat("trunkRadius", height * 0.055f)); + const float crownRadius = std::max(0.1f, params.getFloat("crownRadius", height * 0.34f)); + const float leafSize = std::max(0.02f, params.getFloat("leafSize", height * 0.075f)); + const float density = std::clamp(params.getFloat("leafDensity", 0.65f), 0.f, 1.f); + const float foliageStart = std::clamp(params.getFloat("foliageStart", 0.35f), 0.1f, 0.9f); + const int branchLevels = std::clamp(params.getInt("branchLevels", realistic ? 3 : 2), 1, 5); + const int branchCount = std::clamp(params.getInt("branchCount", realistic ? 9 : 6), 2, 20); + const int sides = std::clamp(params.getInt("radialSegments", realistic ? 10 : 6), 3, 24); + const int curveSegments = std::clamp(params.getInt("curveSegments", realistic ? 9 : 5), 2, 20); + const float trunkCurve = std::clamp(params.getFloat("trunkCurve", 0.10f), 0.f, 0.45f); + const float curveBack = std::clamp(params.getFloat("curveBack", 0.16f), -0.5f, 0.5f); + const float branchCurve = std::clamp(params.getFloat("branchCurve", 0.13f), 0.f, 0.5f); + const float branchAngle = std::clamp(params.getFloat("branchAngle", 62.f), 5.f, 88.f); + const float angleVariation = std::clamp(params.getFloat("branchAngleVariation", 12.f), 0.f, 40.f); + const float phyllotaxis = params.getFloat("phyllotaxis", 137.5f) * kPi / 180.f; + const float tropism = std::clamp(params.getFloat("tropism", 0.22f), -0.5f, 0.8f); + const float droop = std::clamp(params.getFloat("droop", 0.18f), 0.f, 0.8f); + const float apicalDominance = std::clamp(params.getFloat("apicalDominance", 0.62f), 0.f, 1.f); + const int attractorCount = std::clamp(params.getInt("attractorCount", realistic ? 180 : 80), 12, 1200); + const int colonizationIterations = std::clamp(params.getInt("colonizationIterations", realistic ? 46 : 30), 4, 160); + const float influenceRadius = std::max(0.05f, params.getFloat("influenceRadius", crownRadius * 1.08f)); + const float killRadius = std::max(0.01f, params.getFloat("killRadius", crownRadius * 0.13f)); + const float growthStep = std::max(0.01f, params.getFloat("growthStep", crownRadius * (realistic ? 0.105f : 0.14f))); + const float branchInertia = std::clamp(params.getFloat("branchInertia", 1.20f), 0.f, 4.f); + const float maxTurnAngle = std::clamp(params.getFloat("maxTurnAngle", realistic ? 16.f : 22.f), 2.f, 60.f); + const float maxCumulativeAngle = std::clamp(params.getFloat("maxCumulativeAngle", 58.f), 10.f, 85.f); + const float branchLengthFalloff = std::clamp(params.getFloat("branchLengthFalloff", 0.58f), 0.f, 0.9f); + const float branchRadiusFalloff = std::clamp(params.getFloat("branchRadiusFalloff", 0.50f), 0.f, 0.9f); + const float lowerLeafCoverage = std::clamp(params.getFloat("lowerLeafCoverage", 0.72f), 0.f, 1.f); + const float upperLeafCoverage = std::clamp(params.getFloat("upperLeafCoverage", 0.18f), 0.f, 1.f); + const int maxChildren = std::clamp(params.getInt("maxChildren", 2), 1, 4); + std::mt19937 rng(params.getSeed()); + out.clear(); + + const V3 root{0.f, 0.f, 0.f}; + const float trunkAzimuth = randomRange(rng, 0.f, 2.f * kPi); + const V3 trunkBend{std::cos(trunkAzimuth), 0.f, std::sin(trunkAzimuth)}; + const StemPath trunk = + makeCurvedStem(root, {0.f, 1.f, 0.f}, trunkBend, height, trunkCurve * randomRange(rng, 0.72f, 1.28f), + curveBack * randomRange(rng, 0.72f, 1.28f), 0.f, curveSegments + 2); + addStem(out, trunk, trunkRadius, trunkRadius * 0.16f, sides); + + std::vector tips; + std::vector foliageAnchors; + if (branchAlgorithm == "spaceColonization") { + const ColonizationSettings colonization{ + .height = height, + .foliageStart = foliageStart, + .crownRadius = crownRadius, + .trunkRadius = trunkRadius, + .tropism = tropism, + .droop = droop * 0.45f, + .inertia = branchInertia, + .attractorCount = attractorCount, + .iterations = colonizationIterations, + .influenceRadius = influenceRadius, + .killRadius = killRadius, + .step = growthStep, + .maxTurnAngle = maxTurnAngle, + .maxCumulativeAngle = maxCumulativeAngle, + .lengthFalloff = branchLengthFalloff, + .radiusFalloff = branchRadiusFalloff, + .lowerLeafCoverage = lowerLeafCoverage, + .upperLeafCoverage = upperLeafCoverage, + .maxChildren = maxChildren, + .sides = sides, + }; + growSpaceColonizedTree(out, trunk, rng, colonization, foliageAnchors); + } else { + for (int level = 0; level < branchLevels; ++level) { + const int count = std::max(2, branchCount - level * 2); + const float levelScale = std::pow(0.62f, float(level)); + std::vector next; + for (int i = 0; i < count; ++i) { + const float h = foliageStart + (1.f - foliageStart) * (float(i) + 0.35f) / float(count); + const float angle = float(i) * phyllotaxis + randomRange(rng, -0.20f, 0.20f) + level; + V3 start; + V3 parentDirection; + if (level == 0) { + samplePath(trunk, h, start, parentDirection); + } else { + const Tip& parent = tips[size_t(i) % tips.size()]; + start = parent.p; + parentDirection = parent.dir; + } + V3 ringRight, ringForward; + basisFor(parentDirection, ringRight, ringForward); + const V3 radial = add(mul(ringRight, std::cos(angle)), mul(ringForward, std::sin(angle))); + const float levelAngle = + branchAngle - float(level) * 7.f + randomRange(rng, -angleVariation, angleVariation); + const float radians = std::clamp(levelAngle, 5.f, 88.f) * kPi / 180.f; + V3 dir = norm(add(mul(parentDirection, std::cos(radians)), mul(radial, std::sin(radians)))); + dir = norm(add(dir, mul(V3{0.f, 1.f, 0.f}, apicalDominance * 0.24f * h))); + const float relativeHeight = + std::clamp((start.y / height - foliageStart) / (1.f - foliageStart), 0.f, 1.f); + const float lengthTaper = 1.f - branchLengthFalloff * relativeHeight; + const float radiusTaper = 1.f - branchRadiusFalloff * relativeHeight; + const float length = crownRadius * randomRange(rng, 0.72f, 1.18f) * lengthTaper * levelScale; + const float r = trunkRadius * (0.36f * radiusTaper) * levelScale; + V3 bendRight, bendForward; + basisFor(dir, bendRight, bendForward); + const V3 bendAxis = + norm(add(mul(bendRight, std::cos(angle + 1.1f)), mul(bendForward, std::sin(angle + 1.1f)))); + const float flexibility = 1.f + float(level) * 0.38f; + const float downWeight = droop * (0.35f + 0.65f * (1.f - h)) * flexibility; + const float verticalAcceleration = tropism * (0.55f + apicalDominance * h) - downWeight; + const StemPath stem = makeCurvedStem(start, dir, bendAxis, length, + branchCurve * flexibility * randomRange(rng, 0.65f, 1.35f), + curveBack * 0.55f * flexibility * randomRange(rng, 0.55f, 1.25f), + verticalAcceleration, std::max(2, curveSegments - level)); + addStem(out, stem, r, r * 0.22f, std::max(3, sides - level * 2)); + const float coverage = lowerLeafCoverage + (upperLeafCoverage - lowerLeafCoverage) * relativeHeight; + const uint32_t coverageKey = params.getSeed() ^ uint32_t(level * 131 + i * 977); + if (hash01(coverageKey) < coverage && stem.points.size() > 3) { + const size_t middle = stem.points.size() / 2; + const V3 middleDirection = norm(sub(stem.points[middle], stem.points[middle - 1])); + foliageAnchors.push_back( + {stem.points[middle], middleDirection, levelScale * (0.72f + 0.28f * (1.f - relativeHeight))}); + } + next.push_back({stem.points.back(), stem.endDirection, levelScale}); + } + foliageAnchors.insert(foliageAnchors.end(), next.begin(), next.end()); + tips.swap(next); + } + } + + if (leafMode == "cards") { + const int perTip = int(std::round((realistic ? 12.f : 6.f) * density)); + for (const Tip& tip : foliageAnchors) { + for (int i = 0; i < perTip; ++i) { + V3 c = add(tip.p, {randomRange(rng, -1.f, 1.f) * crownRadius * 0.24f * tip.scale, + randomRange(rng, -0.25f, 0.55f) * crownRadius * tip.scale, + randomRange(rng, -1.f, 1.f) * crownRadius * 0.24f * tip.scale}); + const V3 face = + norm({randomRange(rng, -1.f, 1.f), randomRange(rng, -0.2f, 0.8f), randomRange(rng, -1.f, 1.f)}); + addLeafCard(out, c, face, leafSize * randomRange(rng, 0.72f, 1.25f), randomRange(rng, 0.f, kPi)); + } + } + } else if (leafMode == "canopy" && density > 0.f) { + // A canopy lobe belongs to a branch: center it just behind the terminal + // point so the branch visibly penetrates the foliage instead of ending in air. + int wanted = std::max(1, int(std::round(float(foliageAnchors.size()) * (0.28f + density * 0.62f)))); + if (branchAlgorithm == "spaceColonization") wanted = int(foliageAnchors.size()); + const int stride = std::max(1, int(foliageAnchors.size()) / wanted); + int emitted = 0; + for (int i = int(foliageAnchors.size()) - 1; i >= 0 && emitted < wanted; i -= stride) { + const Tip& tip = foliageAnchors[size_t(i)]; + const float lobeMin = branchAlgorithm == "spaceColonization" ? 0.14f : 0.27f; + const float lobeMax = branchAlgorithm == "spaceColonization" ? 0.22f : 0.40f; + const float r = crownRadius * randomRange(rng, lobeMin, lobeMax) * (0.72f + 0.28f * density) * + (0.58f + 0.42f * tip.scale); + V3 c = sub(tip.p, mul(tip.dir, r * 0.22f)); + c.y += r * randomRange(rng, 0.08f, 0.22f); + addCanopyBlob(out, c, {r, r * randomRange(rng, 0.72f, 1.15f), r}, realistic ? 8 : 4, realistic ? 12 : 7); + ++emitted; + } + } + + out.setMeta("recipe", "mesh.tree"); + out.setMeta("style", style); + out.setMeta("leafMode", leafMode); + out.setMeta("branchAlgorithm", branchAlgorithm); + out.setMeta("seed", std::to_string(params.getSeed())); + if (out.empty()) { + error = "mesh.tree: generated an empty mesh"; + return false; + } + return true; +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/algorithms/TreeMesh.h b/src/modules/procgen/algorithms/TreeMesh.h new file mode 100644 index 00000000..5ac482fa --- /dev/null +++ b/src/modules/procgen/algorithms/TreeMesh.h @@ -0,0 +1,13 @@ +#pragma once + +#include "procgen/MeshBuild.h" +#include "procgen/Params.h" + +#include + +namespace eve::procgen { + +/** Build a deterministic procedural tree. Registered as the `mesh.tree` recipe. */ +bool generateTreeMesh(const Params& params, MeshBuild& out, std::string& error); + +} // namespace eve::procgen diff --git a/src/modules/procgen/heightmap/BuiltinHeightmap.cpp b/src/modules/procgen/heightmap/BuiltinHeightmap.cpp new file mode 100644 index 00000000..e98afe11 --- /dev/null +++ b/src/modules/procgen/heightmap/BuiltinHeightmap.cpp @@ -0,0 +1,38 @@ +#include "procgen/GeneratorRegistry.h" +#include "procgen/Semantic.h" +#include "procgen/heightmap/Heightmap.h" + +#include + +namespace eve::procgen { +namespace { + +bool genTerrainHeightmap(const Params ¶ms, Grid2D &out, std::string &error) { + const int w = params.getWidth(); + const int h = params.getHeight(); + if (w < 1 || h < 1) { + error = "terrain.heightmap: width/height must be positive"; + return false; + } + const TerrainSampler sampler = TerrainSampler::fromParams(params); + const TerrainBands bands = TerrainBands::fromParams(params); + + const Heightmap hm = Heightmap::generate(sampler, w, h); + if (!hm.toGrid(out, bands)) { + error = "terrain.heightmap: failed to classify heightmap"; + return false; + } + + out.setMeta("algorithm", "terrain.heightmap"); + out.setMeta("scale", std::to_string(sampler.getScale())); + out.setMeta("octaves", std::to_string(sampler.getOctaves())); + return true; +} + +} // namespace + +void registerTerrainHeightmapAlgorithm(GeneratorRegistry ®istry) { + registry.registerAlgorithm("terrain.heightmap", genTerrainHeightmap); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/heightmap/Heightmap.cpp b/src/modules/procgen/heightmap/Heightmap.cpp new file mode 100644 index 00000000..0ef7356f --- /dev/null +++ b/src/modules/procgen/heightmap/Heightmap.cpp @@ -0,0 +1,95 @@ +#include "procgen/heightmap/Heightmap.h" + +#include +#include + +namespace eve::procgen { + +Heightmap::Heightmap(int width, int height) { resize(width, height); } + +void Heightmap::resize(int width, int height) { + width_ = width > 0 ? width : 0; + height_ = height > 0 ? height : 0; + data_.assign(size_t(width_) * size_t(height_), 0.f); +} + +int Heightmap::getWidth() const { return width_; } +int Heightmap::getHeight() const { return height_; } + +bool Heightmap::inBounds(int x, int y) const { + return x >= 0 && y >= 0 && x < width_ && y < height_; +} + +void Heightmap::setHeight(int x, int y, float h) { + if (!inBounds(x, y)) return; + data_[size_t(y * width_ + x)] = h; +} + +float Heightmap::height(int x, int y) const { + if (!inBounds(x, y)) return 0.f; + return data_[size_t(y * width_ + x)]; +} + +namespace { +float lerpHeight(float a, float b, float t) { return a + (b - a) * t; } +} // namespace + +float Heightmap::sampleBilinear(float x, float y) const { + if (width_ <= 0 || height_ <= 0) return 0.f; + const float fx = std::clamp(x, 0.f, float(width_ - 1)); + const float fy = std::clamp(y, 0.f, float(height_ - 1)); + const int x0 = int(std::floor(fx)); + const int y0 = int(std::floor(fy)); + const int x1 = std::min(x0 + 1, width_ - 1); + const int y1 = std::min(y0 + 1, height_ - 1); + const float tx = fx - float(x0); + const float ty = fy - float(y0); + const float top = lerpHeight(data_[size_t(y0 * width_ + x0)], data_[size_t(y0 * width_ + x1)], tx); + const float bot = lerpHeight(data_[size_t(y1 * width_ + x0)], data_[size_t(y1 * width_ + x1)], tx); + return lerpHeight(top, bot, ty); +} + +float Heightmap::sampleBilinearSeamless(float x, float y) const { + if (width_ <= 0 || height_ <= 0) return 0.f; + float px = std::fmod(x, float(width_)); + float py = std::fmod(y, float(height_)); + if (px < 0.f) px += float(width_); + if (py < 0.f) py += float(height_); + const int x0 = int(std::floor(px)); + const int y0 = int(std::floor(py)); + const int x1 = (x0 + 1) % width_; + const int y1 = (y0 + 1) % height_; + const float tx = px - float(x0); + const float ty = py - float(y0); + const float top = lerpHeight(data_[size_t(y0 * width_ + x0)], data_[size_t(y0 * width_ + x1)], tx); + const float bot = lerpHeight(data_[size_t(y1 * width_ + x0)], data_[size_t(y1 * width_ + x1)], tx); + return lerpHeight(top, bot, ty); +} + +Heightmap Heightmap::generate(const TerrainSampler &sampler, int width, int height) { + TerrainSampler local = sampler; + if (local.getWorldWidth() <= 0 || local.getWorldHeight() <= 0) { + local.setWorldSize(width, height); + } + Heightmap hm(width, height); + const auto fn = local.asFunction(); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + hm.setHeight(x, y, fn(float(x) + 0.5f, float(y) + 0.5f)); + } + } + return hm; +} + +bool Heightmap::toGrid(Grid2D &out, const TerrainBands &bands) const { + if (width_ <= 0 || height_ <= 0) return false; + out.resize(width_, height_); + for (int y = 0; y < height_; ++y) { + for (int x = 0; x < width_; ++x) { + out.setCell(x, y, int(bands.semanticAt(data_[size_t(y * width_ + x)]))); + } + } + return true; +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/heightmap/Heightmap.h b/src/modules/procgen/heightmap/Heightmap.h new file mode 100644 index 00000000..4e7ea0e4 --- /dev/null +++ b/src/modules/procgen/heightmap/Heightmap.h @@ -0,0 +1,51 @@ +#pragma once + +#include "procgen/Grid2D.h" +#include "procgen/heightmap/TerrainSampler.h" + +#include +#include + +namespace eve::procgen { + +/** + * In-memory terrain heightmap: a dense float grid (row-major, + * index = y * width + x) materialized from a TerrainSampler or filled by hand. + * + * Use `generate()` to sample a TerrainSampler per-pixel, `height(x, y)` / + * `sampleBilinear(x, y)` to read values back, and `toGrid()` to classify the + * heights into a semantic Grid2D ready for tilemap generation. + */ +class Heightmap { +public: + Heightmap() = default; + Heightmap(int width, int height); + + void resize(int width, int height); + int getWidth() const; + int getHeight() const; + bool inBounds(int x, int y) const; + + void setHeight(int x, int y, float h); + float height(int x, int y) const; + + /** Bilinear height at continuous coordinate within [0, w) x [0, h). */ + float sampleBilinear(float x, float y) const; + /** Bilinear height with wrap-around (seamless tiling). */ + float sampleBilinearSeamless(float x, float y) const; + + const std::vector &data() const { return data_; } + + /** Materialize a Heightmap by sampling `sampler` at every pixel center. */ + static Heightmap generate(const TerrainSampler &sampler, int width, int height); + + /** Classify heights into a semantic Grid2D (source for tilemap generation). */ + bool toGrid(Grid2D &out, const TerrainBands &bands) const; + +private: + int width_ = 0; + int height_ = 0; + std::vector data_; +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/heightmap/TerrainSampler.cpp b/src/modules/procgen/heightmap/TerrainSampler.cpp new file mode 100644 index 00000000..2b4a649f --- /dev/null +++ b/src/modules/procgen/heightmap/TerrainSampler.cpp @@ -0,0 +1,178 @@ +#include "procgen/heightmap/TerrainSampler.h" + +#include "procgen/Semantic.h" + +#include +#include + +namespace eve::procgen { + +void TerrainSampler::setSeed(uint32_t seed) { field_.seed = seed; } +uint32_t TerrainSampler::getSeed() const { return field_.seed; } +void TerrainSampler::setScale(float scale) { frequency_ = std::max(0.0001f, scale); } +float TerrainSampler::getScale() const { return frequency_; } +void TerrainSampler::setFrequency(float frequency) { setScale(frequency); } +float TerrainSampler::getFrequency() const { return frequency_; } +void TerrainSampler::setWavelength(float wavelength) { + setFrequency(1.f / std::max(0.001f, wavelength)); +} +float TerrainSampler::getWavelength() const { return 1.f / frequency_; } +void TerrainSampler::setOctaves(int octaves) { octaves_ = std::max(1, octaves); } +int TerrainSampler::getOctaves() const { return octaves_; } +void TerrainSampler::setLacunarity(float lacunarity) { lacunarity_ = std::max(0.1f, lacunarity); } +float TerrainSampler::getLacunarity() const { return lacunarity_; } +void TerrainSampler::setGain(float gain) { gain_ = std::max(0.01f, gain); } +float TerrainSampler::getGain() const { return gain_; } +void TerrainSampler::setRidge(float ridge) { ridge_ = std::clamp(ridge, 0.f, 1.f); } +float TerrainSampler::getRidge() const { return ridge_; } +void TerrainSampler::setWarp(float warp) { warp_ = std::max(0.f, warp); } +float TerrainSampler::getWarp() const { return warp_; } +void TerrainSampler::setExponent(float exponent) { exponent_ = std::max(0.1f, exponent); } +float TerrainSampler::getExponent() const { return exponent_; } +void TerrainSampler::setContinent(float continent) { continent_ = std::clamp(continent, 0.f, 1.f); } +float TerrainSampler::getContinent() const { return continent_; } +void TerrainSampler::setIsland(float island) { island_ = std::clamp(island, 0.f, 1.f); } +float TerrainSampler::getIsland() const { return island_; } +void TerrainSampler::setCoastSoftness(float softness) { coastSoft_ = std::clamp(softness, 0.02f, 0.4f); } +float TerrainSampler::getCoastSoftness() const { return coastSoft_; } +void TerrainSampler::setWorldSize(int width, int height) { + worldW_ = width > 0 ? width : 0; + worldH_ = height > 0 ? height : 0; +} +int TerrainSampler::getWorldWidth() const { return worldW_; } +int TerrainSampler::getWorldHeight() const { return worldH_; } +void TerrainSampler::setBase(float base) { base_ = base; } +float TerrainSampler::getBase() const { return base_; } +void TerrainSampler::setAmplitude(float amplitude) { amplitude_ = std::max(0.f, amplitude); } +float TerrainSampler::getAmplitude() const { return amplitude_; } +void TerrainSampler::setClamp(bool enabled, float minHeight, float maxHeight) { + clamp_ = enabled; + clampMin_ = std::min(minHeight, maxHeight); + clampMax_ = std::max(minHeight, maxHeight); +} +bool TerrainSampler::isClamped() const { return clamp_; } +float TerrainSampler::getClampMin() const { return clampMin_; } +float TerrainSampler::getClampMax() const { return clampMax_; } + +namespace { +float smoother(float edge0, float edge1, float x) { + if (edge1 <= edge0) return x < edge0 ? 0.f : 1.f; + const float t = std::clamp((x - edge0) / (edge1 - edge0), 0.f, 1.f); + return t * t * (3.f - 2.f * t); +} +} // namespace + +float TerrainSampler::sample(float x, float y) const { + const float mx = x * frequency_; + const float my = y * frequency_; + + // Continent: 3 low/mid octaves (~100 / 50 / 25 px bays on a 512 map). + // Warp at the same scale so the silhouette twists without pixel breakup. + float cx = mx * 0.36f; + float cy = my * 0.36f; + const float maskWarp = 0.70f; + cx += (field_.perlinNoise(mx * 0.18f + 2.4f, my * 0.18f + 9.1f) - 0.5f) * maskWarp; + cy += (field_.perlinNoise(mx * 0.18f + 7.6f, my * 0.18f + 1.3f) - 0.5f) * maskWarp; + float macro = field_.fbmPerlin(cx, cy, 3, 2.f, 0.42f); + + if (island_ > 0.f && worldW_ > 0 && worldH_ > 0) { + const float nx = x / float(worldW_) * 2.f - 1.f; + const float ny = y / float(worldH_) * 2.f - 1.f; + const float dSq = 1.f - (1.f - nx * nx) * (1.f - ny * ny); + const float dEu = std::sqrt(std::min(1.f, nx * nx + ny * ny)); + const float d = std::clamp(dSq * 0.5f + dEu * 0.5f, 0.f, 1.f); + // Multiply, don't lerp: corners sink, the noise silhouette stays. + macro *= 1.f - island_ * std::pow(d, 1.25f); + } + + // Medium-scale shore wiggle (~70 px). Amplitude stays below the smoothstep + // width so it cannot punch isolated pixels through the waterline. + const float wiggle = + (field_.perlinNoise(mx * 0.42f + 11.f, my * 0.42f + 4.f) - 0.5f) * coastSoft_ * 0.55f; + const float sea = 0.54f - continent_ * 0.14f + wiggle; + const float land = smoother(sea - coastSoft_ * 0.45f, sea + coastSoft_ * 0.55f, macro); + const float inland = land * land; + + float dx = mx; + float dy = my; + if (warp_ > 0.f) { + dx += (field_.fbmPerlin(mx + 19.1f, my + 7.3f, 2) - 0.5f) * warp_; + dy += (field_.fbmPerlin(mx + 5.2f, my + 31.7f, 2) - 0.5f) * warp_; + } + + float detail = field_.fbmPerlin(dx, dy, octaves_, lacunarity_, gain_); + if (ridge_ > 0.f) { + const float mountains = + field_.ridgedPerlin(dx * 1.4f, dy * 1.4f, octaves_, lacunarity_, gain_); + detail = detail + (mountains - detail) * ridge_; + } + detail = std::pow(std::clamp(detail, 0.f, 1.f), exponent_); + + // land=0 stays below waterMax (0.25). Detail cannot open holes in the sea. + const float shelf = 0.06f + land * 0.32f; + const float e = shelf + inland * detail * 0.62f; + + float h = base_ + e * amplitude_; + if (clamp_) h = std::clamp(h, clampMin_, clampMax_); + return h; +} + +float TerrainSampler::sampleTile(int tileX, int tileY) const { + return sample(float(tileX) + 0.5f, float(tileY) + 0.5f); +} + +std::function TerrainSampler::asFunction() const { + return [self = *this](float x, float y) { return self.sample(x, y); }; +} + +TerrainSampler TerrainSampler::fromParams(const Params ¶ms) { + TerrainSampler s; + s.setSeed(params.getSeed()); + s.setWorldSize(params.getWidth(), params.getHeight()); + + const float wavelength = params.getFloat("wavelength", 0.f); + if (wavelength > 0.f) { + s.setWavelength(wavelength); + } else if (params.has("frequency")) { + s.setFrequency(params.getFloat("frequency", 1.f / 32.f)); + } else { + s.setScale(params.getFloat("scale", 1.f / 32.f)); + } + + s.setOctaves(params.getInt("octaves", 5)); + s.setLacunarity(params.getFloat("lacunarity", 2.f)); + s.setGain(params.getFloat("gain", 0.5f)); + s.setRidge(params.getFloat("ridge", 0.35f)); + s.setWarp(params.getFloat("warp", 0.35f)); + s.setExponent(params.getFloat("exponent", 2.f)); + s.setContinent(params.getFloat("continent", 0.55f)); + s.setIsland(params.getFloat("island", 0.38f)); + s.setCoastSoftness(params.getFloat("coast", 0.12f)); + s.setBase(params.getFloat("base", 0.f)); + s.setAmplitude(params.getFloat("amplitude", 1.f)); + const float lo = params.getFloat("heightMin", 0.f); + const float hi = params.getFloat("heightMax", 1.f); + s.setClamp(params.getInt("clamp", 1) != 0, lo, hi); + return s; +} + +uint32_t TerrainBands::semanticAt(float height) const { + if (height < waterMax) return Semantic::Water; + if (height < sandMax) return Semantic::Sand; + if (height < grassMax) return Semantic::Grass; + if (height < dirtMax) return Semantic::Dirt; + if (height < stoneMax) return Semantic::Stone; + return Semantic::Snow; +} + +TerrainBands TerrainBands::fromParams(const Params ¶ms) { + TerrainBands bands; + bands.waterMax = std::clamp(params.getFloat("waterMax", 0.25f), 0.f, 1.f); + bands.sandMax = std::clamp(params.getFloat("sandMax", 0.35f), 0.f, 1.f); + bands.grassMax = std::clamp(params.getFloat("grassMax", 0.65f), 0.f, 1.f); + bands.dirtMax = std::clamp(params.getFloat("dirtMax", 0.80f), 0.f, 1.f); + bands.stoneMax = std::clamp(params.getFloat("stoneMax", 0.92f), 0.f, 1.f); + return bands; +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/heightmap/TerrainSampler.h b/src/modules/procgen/heightmap/TerrainSampler.h new file mode 100644 index 00000000..a9ab8423 --- /dev/null +++ b/src/modules/procgen/heightmap/TerrainSampler.h @@ -0,0 +1,111 @@ +#pragma once + +#include "procgen/Params.h" +#include "procgen/texture/NoiseField.h" + +#include +#include + +namespace eve::procgen { + +/** + * Deterministic terrain height sampling (Red Blob Games / Perlin fBm recipe). + * + * `sample(x, y)` maps any continuous map coordinate (tile/world units) to a + * height in [0, 1] by default. Same coordinate + same config ⇒ same height. + * + * Frequency is **cycles per world unit**. A wavelength of 32 (frequency 1/32) + * means one large hill every 32 tiles/pixels — this is what makes coherent + * terrain instead of white noise. `scale` is an alias for frequency. + * + * Pipeline: + * 1. 2-octave Perlin continent (low frequency) — this is the land shape + * 2. low-frequency domain warp of that field (bays / peninsulas) + * 3. optional multiplicative island falloff (kills map corners, not the shape) + * 4. smoothstep → solid coastline; hills / ridges only inland + */ +class TerrainSampler { +public: + TerrainSampler() = default; + + float sample(float x, float y) const; + /** Height at the center of map tile (tileX, tileY). */ + float sampleTile(int tileX, int tileY) const; + std::function asFunction() const; + + void setSeed(uint32_t seed); + uint32_t getSeed() const; + /** Cycles per world unit (alias of frequency). Default 1/32. */ + void setScale(float scale); + float getScale() const; + void setFrequency(float frequency); + float getFrequency() const; + /** Distance per large oscillation. Sets frequency = 1/wavelength. */ + void setWavelength(float wavelength); + float getWavelength() const; + void setOctaves(int octaves); + int getOctaves() const; + void setLacunarity(float lacunarity); + float getLacunarity() const; + void setGain(float gain); + float getGain() const; + void setRidge(float ridge); + float getRidge() const; + void setWarp(float warp); + float getWarp() const; + void setExponent(float exponent); + float getExponent() const; + void setContinent(float continent); + float getContinent() const; + void setIsland(float island); + float getIsland() const; + /** Shore width of the land mask smoothstep. Smaller = cleaner, harder coast. */ + void setCoastSoftness(float softness); + float getCoastSoftness() const; + void setWorldSize(int width, int height); + int getWorldWidth() const; + int getWorldHeight() const; + void setBase(float base); + float getBase() const; + void setAmplitude(float amplitude); + float getAmplitude() const; + void setClamp(bool enabled, float minHeight, float maxHeight); + bool isClamped() const; + float getClampMin() const; + float getClampMax() const; + + static TerrainSampler fromParams(const Params ¶ms); + +private: + NoiseField field_; + float frequency_ = 1.f / 32.f; + int octaves_ = 5; + float lacunarity_ = 2.f; + float gain_ = 0.5f; + float ridge_ = 0.35f; + float warp_ = 0.35f; + float exponent_ = 2.f; + float continent_ = 0.55f; + float island_ = 0.38f; + float coastSoft_ = 0.12f; + int worldW_ = 0; + int worldH_ = 0; + float base_ = 0.f; + float amplitude_ = 1.f; + bool clamp_ = true; + float clampMin_ = 0.f; + float clampMax_ = 1.f; +}; + +struct TerrainBands { + float waterMax = 0.25f; + float sandMax = 0.35f; + float grassMax = 0.65f; + float dirtMax = 0.80f; + float stoneMax = 0.92f; + + uint32_t semanticAt(float height) const; + static TerrainBands fromParams(const Params ¶ms); +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/texture/NoiseField.cpp b/src/modules/procgen/texture/NoiseField.cpp index 5530700f..9bc98476 100644 --- a/src/modules/procgen/texture/NoiseField.cpp +++ b/src/modules/procgen/texture/NoiseField.cpp @@ -7,6 +7,7 @@ namespace eve::procgen { namespace { float fade(float t) { return t * t * (3.f - 2.f * t); } +float fade5(float t) { return t * t * t * (t * (t * 6.f - 15.f) + 10.f); } int wrapIndex(int i, int period) { if (period <= 0) return i; @@ -42,6 +43,32 @@ float NoiseField::valueNoise(float x, float y) const { return nx0 + (nx1 - nx0) * fy; } +float NoiseField::perlinNoise(float x, float y) const { + const int x0 = int(std::floor(x)); + const int y0 = int(std::floor(y)); + const float dx = x - float(x0); + const float dy = y - float(y0); + const float fx = fade5(dx); + const float fy = fade5(dy); + + auto grad = [&](int ix, int iy, float px, float py) -> float { + // 8 compass gradients (Perlin 2002 style). + static const float gx[8] = {1.f, -1.f, 1.f, -1.f, 1.f, -1.f, 0.f, 0.f}; + static const float gy[8] = {1.f, 1.f, -1.f, -1.f, 0.f, 0.f, 1.f, -1.f}; + const int h = int(hash01(ix, iy) * 7.999f) & 7; + return gx[h] * (px - float(ix)) + gy[h] * (py - float(iy)); + }; + + const float n00 = grad(x0, y0, x, y); + const float n10 = grad(x0 + 1, y0, x, y); + const float n01 = grad(x0, y0 + 1, x, y); + const float n11 = grad(x0 + 1, y0 + 1, x, y); + const float nx0 = n00 + (n10 - n00) * fx; + const float nx1 = n01 + (n11 - n01) * fx; + const float n = nx0 + (nx1 - nx0) * fy; + return std::clamp(n * 0.5f + 0.5f, 0.f, 1.f); +} + float NoiseField::fbm(float x, float y, int octaves, float lacunarity, float gain) const { octaves = std::max(1, octaves); float sum = 0.f, amp = 1.f, norm = 0.f, freq = 1.f; @@ -54,6 +81,21 @@ float NoiseField::fbm(float x, float y, int octaves, float lacunarity, float gai return norm > 0.f ? sum / norm : 0.f; } +float NoiseField::fbmPerlin(float x, float y, int octaves, float lacunarity, float gain) const { + octaves = std::max(1, octaves); + float sum = 0.f, amp = 1.f, norm = 0.f, freq = 1.f; + for (int i = 0; i < octaves; ++i) { + // Offset each octave so they are uncorrelated (Red Blob Games). + const float ox = float(i) * 17.8f; + const float oy = float(i) * 23.5f; + sum += perlinNoise(x * freq + ox, y * freq + oy) * amp; + norm += amp; + amp *= gain; + freq *= lacunarity; + } + return norm > 0.f ? sum / norm : 0.f; +} + float NoiseField::ridged(float x, float y, int octaves, float lacunarity, float gain) const { octaves = std::max(1, octaves); float sum = 0.f, amp = 1.f, norm = 0.f, freq = 1.f; @@ -68,6 +110,22 @@ float NoiseField::ridged(float x, float y, int octaves, float lacunarity, float return norm > 0.f ? sum / norm : 0.f; } +float NoiseField::ridgedPerlin(float x, float y, int octaves, float lacunarity, float gain) const { + octaves = std::max(1, octaves); + float sum = 0.f, amp = 1.f, norm = 0.f, freq = 1.f; + for (int i = 0; i < octaves; ++i) { + const float ox = float(i) * 11.3f; + const float oy = float(i) * 19.7f; + float n = perlinNoise(x * freq + ox, y * freq + oy); + n = 1.f - std::fabs(n * 2.f - 1.f); + sum += n * n * amp; + norm += amp; + amp *= gain; + freq *= lacunarity; + } + return norm > 0.f ? sum / norm : 0.f; +} + float NoiseField::warp(float x, float y, float amp, int octaves) const { const float wx = fbm(x + 19.1f, y + 7.3f, octaves) - 0.5f; const float wy = fbm(x + 5.2f, y + 31.7f, octaves) - 0.5f; diff --git a/src/modules/procgen/texture/NoiseField.h b/src/modules/procgen/texture/NoiseField.h index fba8187c..4435e289 100644 --- a/src/modules/procgen/texture/NoiseField.h +++ b/src/modules/procgen/texture/NoiseField.h @@ -15,8 +15,13 @@ struct NoiseField { float hash01(int ix, int iy) const; float valueNoise(float x, float y) const; + /** Classic 2D Perlin gradient noise, remapped to [0, 1]. */ + float perlinNoise(float x, float y) const; float fbm(float x, float y, int octaves = 4, float lacunarity = 2.f, float gain = 0.5f) const; + float fbmPerlin(float x, float y, int octaves = 4, float lacunarity = 2.f, float gain = 0.5f) const; float ridged(float x, float y, int octaves = 4, float lacunarity = 2.f, float gain = 0.5f) const; + float ridgedPerlin(float x, float y, int octaves = 4, float lacunarity = 2.f, + float gain = 0.5f) const; float warp(float x, float y, float amp, int octaves = 3) const; }; diff --git a/src/modules/system/System.cpp b/src/modules/system/System.cpp index 7df9206a..d7f41da1 100644 --- a/src/modules/system/System.cpp +++ b/src/modules/system/System.cpp @@ -4,7 +4,9 @@ #include "common/Module.h" #include "common/config.h" #include "graphics/Graphics.h" +#ifndef EVENGINE_WEBGPU #include "graphics/vulkan/Graphics.h" +#endif #include @@ -32,17 +34,21 @@ namespace eve::system { namespace { +#ifndef EVENGINE_WEBGPU eve::graphics::vulkan::Graphics *vulkanGraphicsOrNull() { auto *base = eve::ModuleManager::getInstance("Graphics"); if (!base) return nullptr; return dynamic_cast(base); } +#endif +#ifndef EVENGINE_WEBGPU bool gpuReady(eve::graphics::vulkan::Graphics *gfx) { if (!gfx) return false; vk::PhysicalDevice pd = gfx->getDevice().physical_device; return static_cast(pd) != VK_NULL_HANDLE; } +#endif std::string vendorNameFromId(uint32_t vendorID) { switch (vendorID) { @@ -61,6 +67,7 @@ std::string vendorNameFromId(uint32_t vendorID) { } } +#ifndef EVENGINE_WEBGPU const char *deviceTypeName(vk::PhysicalDeviceType t) { switch (t) { case vk::PhysicalDeviceType::eDiscreteGpu: return "discrete"; @@ -70,6 +77,7 @@ const char *deviceTypeName(vk::PhysicalDeviceType t) { default: return "other"; } } +#endif } // namespace @@ -200,24 +208,39 @@ void System::setClipboardText(const std::string &text) { } std::string System::getGpuName() const { +#ifdef EVENGINE_WEBGPU + return {}; +#else auto *gfx = vulkanGraphicsOrNull(); if (!gpuReady(gfx)) return {}; return gfx->getDevice().physical_device.properties.deviceName.data(); +#endif } std::string System::getGpuVendor() const { +#ifdef EVENGINE_WEBGPU + return {}; +#else auto *gfx = vulkanGraphicsOrNull(); if (!gpuReady(gfx)) return {}; return vendorNameFromId(gfx->getDevice().physical_device.properties.vendorID); +#endif } std::string System::getGpuDeviceType() const { +#ifdef EVENGINE_WEBGPU + return "webgpu"; +#else auto *gfx = vulkanGraphicsOrNull(); if (!gpuReady(gfx)) return {}; return deviceTypeName(gfx->getDevice().physical_device.properties.deviceType); +#endif } int System::getGpuMemoryTotalMB() const { +#ifdef EVENGINE_WEBGPU + return 0; +#else auto *gfx = vulkanGraphicsOrNull(); if (!gpuReady(gfx)) return 0; const auto &mem = gfx->getDevice().physical_device.memory_properties; @@ -227,6 +250,7 @@ int System::getGpuMemoryTotalMB() const { bytes += mem.memoryHeaps[i].size; } return static_cast(bytes / (1024ull * 1024ull)); +#endif } void System::expose(ssq::Table &table) { diff --git a/src/modules/thread/Channel.cpp b/src/modules/thread/Channel.cpp index 6099455b..0b1e22e6 100644 --- a/src/modules/thread/Channel.cpp +++ b/src/modules/thread/Channel.cpp @@ -5,62 +5,66 @@ namespace eve { namespace thread { -Channel::Channel(std::string name) : name_(std::move(name)) {} +Channel::Channel() : state_(std::make_shared()) {} + +Channel::Channel(std::string name) : state_(std::make_shared(std::move(name))) {} Channel::~Channel() = default; -std::string Channel::getName() const { return name_; } +std::string Channel::getName() const { return state_->name; } void Channel::push(std::string value) { { - std::lock_guard lock(mu_); - queue_.push(std::move(value)); + std::lock_guard lock(state_->mu); + state_->queue.push(std::move(value)); } - cv_.notify_one(); + state_->cv.notify_one(); } std::string Channel::pop() { - std::lock_guard lock(mu_); - if (queue_.empty()) + std::lock_guard lock(state_->mu); + if (state_->queue.empty()) return {}; - std::string v = std::move(queue_.front()); - queue_.pop(); + std::string v = std::move(state_->queue.front()); + state_->queue.pop(); return v; } std::string Channel::demand() { - std::unique_lock lock(mu_); - cv_.wait(lock, [this] { return !queue_.empty(); }); - std::string v = std::move(queue_.front()); - queue_.pop(); + auto state = state_; + std::unique_lock lock(state->mu); + state->cv.wait(lock, [&state] { return !state->queue.empty(); }); + std::string v = std::move(state->queue.front()); + state->queue.pop(); return v; } std::string Channel::supply(int timeoutMs) { - std::unique_lock lock(mu_); + auto state = state_; + std::unique_lock lock(state->mu); if (timeoutMs < 0) timeoutMs = 0; - if (!cv_.wait_for(lock, std::chrono::milliseconds(timeoutMs), [this] { return !queue_.empty(); })) + if (!state->cv.wait_for(lock, std::chrono::milliseconds(timeoutMs), [&state] { return !state->queue.empty(); })) return {}; - std::string v = std::move(queue_.front()); - queue_.pop(); + std::string v = std::move(state->queue.front()); + state->queue.pop(); return v; } bool Channel::hasData() const { - std::lock_guard lock(mu_); - return !queue_.empty(); + std::lock_guard lock(state_->mu); + return !state_->queue.empty(); } int Channel::getCount() const { - std::lock_guard lock(mu_); - return static_cast(queue_.size()); + std::lock_guard lock(state_->mu); + return static_cast(state_->queue.size()); } void Channel::clear() { - std::lock_guard lock(mu_); - while (!queue_.empty()) - queue_.pop(); + std::lock_guard lock(state_->mu); + while (!state_->queue.empty()) + state_->queue.pop(); } } // namespace thread diff --git a/src/modules/thread/Channel.h b/src/modules/thread/Channel.h index 41ade07b..63b1fc0c 100644 --- a/src/modules/thread/Channel.h +++ b/src/modules/thread/Channel.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -15,7 +16,7 @@ namespace thread { */ class Channel { public: - Channel() = default; + Channel(); explicit Channel(std::string name); ~Channel(); @@ -34,10 +35,18 @@ public: void clear(); private: - std::string name_; - mutable std::mutex mu_; - std::condition_variable cv_; - std::queue queue_; + struct State { + explicit State(std::string channelName = {}) : name(std::move(channelName)) {} + + std::string name; + mutable std::mutex mu; + std::condition_variable cv; + std::queue queue; + }; + + std::shared_ptr state_; + + friend class ThreadPool; }; } // namespace thread diff --git a/src/modules/thread/Task.cpp b/src/modules/thread/Task.cpp index 30ddd070..e02e9c08 100644 --- a/src/modules/thread/Task.cpp +++ b/src/modules/thread/Task.cpp @@ -5,63 +5,71 @@ namespace eve { namespace thread { -Task::Task(std::function fn) : fn_(std::move(fn)) { - if (!fn_) +Task::Task(std::function fn) { + if (!fn) throw eve::Exception("Task function is null"); + state_ = std::make_shared(std::move(fn)); } +Task::Task(std::shared_ptr state) : state_(std::move(state)) {} + Task::~Task() = default; std::string Task::getStatus() const { - std::lock_guard lock(mu_); - return status_; + std::lock_guard lock(state_->mu); + return state_->status; } bool Task::isDone() const { - std::lock_guard lock(mu_); - return status_ == "done" || status_ == "failed"; + std::lock_guard lock(state_->mu); + return state_->status == "done" || state_->status == "failed"; } bool Task::hasFailed() const { - std::lock_guard lock(mu_); - return status_ == "failed"; + std::lock_guard lock(state_->mu); + return state_->status == "failed"; } std::string Task::getError() const { - std::lock_guard lock(mu_); - return error_; + std::lock_guard lock(state_->mu); + return state_->error; } void Task::wait() { - std::unique_lock lock(mu_); - cv_.wait(lock, [this] { return status_ == "done" || status_ == "failed"; }); + auto state = state_; + std::unique_lock lock(state->mu); + state->cv.wait(lock, [&state] { return state->status == "done" || state->status == "failed"; }); } void Task::run() { + run(state_); +} + +void Task::run(const std::shared_ptr &state) { { - std::lock_guard lock(mu_); - status_ = "running"; + std::lock_guard lock(state->mu); + state->status = "running"; } try { - fn_(); - std::lock_guard lock(mu_); - status_ = "done"; + state->fn(); + std::lock_guard lock(state->mu); + state->status = "done"; } catch (const eve::Exception &e) { - std::lock_guard lock(mu_); - status_ = "failed"; - error_ = e.what(); + std::lock_guard lock(state->mu); + state->status = "failed"; + state->error = e.what(); } catch (const std::exception &e) { - std::lock_guard lock(mu_); - status_ = "failed"; - error_ = e.what(); + std::lock_guard lock(state->mu); + state->status = "failed"; + state->error = e.what(); } catch (...) { - std::lock_guard lock(mu_); - status_ = "failed"; - error_ = "unknown exception"; + std::lock_guard lock(state->mu); + state->status = "failed"; + state->error = "unknown exception"; } - cv_.notify_all(); + state->cv.notify_all(); } } // namespace thread diff --git a/src/modules/thread/Task.h b/src/modules/thread/Task.h index fad50817..cb6bd702 100644 --- a/src/modules/thread/Task.h +++ b/src/modules/thread/Task.h @@ -1,8 +1,8 @@ #pragma once -#include #include #include +#include #include #include @@ -30,11 +30,22 @@ public: void run(); private: - std::function fn_; - mutable std::mutex mu_; - std::condition_variable cv_; - std::string status_ = "pending"; - std::string error_; + struct State { + explicit State(std::function taskFn) : fn(std::move(taskFn)) {} + + std::function fn; + mutable std::mutex mu; + std::condition_variable cv; + std::string status = "pending"; + std::string error; + }; + + explicit Task(std::shared_ptr state); + static void run(const std::shared_ptr &state); + + std::shared_ptr state_; + + friend class ThreadPool; }; } // namespace thread diff --git a/src/modules/thread/ThreadPool.cpp b/src/modules/thread/ThreadPool.cpp index cfde0d91..6d9eda4b 100644 --- a/src/modules/thread/ThreadPool.cpp +++ b/src/modules/thread/ThreadPool.cpp @@ -7,27 +7,70 @@ #include #include +#include #include #include namespace eve { namespace thread { +namespace { +constexpr int kMaxWorkerCount = 256; +thread_local const void *currentPoolState = nullptr; +std::mutex eventResolveMu; +} // namespace + +struct ThreadPool::State { + mutable std::mutex mu; + std::condition_variable cv; + std::condition_variable idleCv; + std::queue> queue; + int busy = 0; + bool stopping = false; +}; + ThreadPool::ThreadPool(int workerCount) { if (workerCount <= 0) { workerCount = static_cast(std::thread::hardware_concurrency()); if (workerCount <= 0) workerCount = 1; } +#if defined(__EMSCRIPTEN__) + // Emscripten spawns a real browser worker per pthread; hardware_concurrency + // on desktops reports 8-32 workers, which is wasteful (and exhausts the + // -sPTHREAD_POOL_SIZE pool). Cap to the pre-allocated pool size so the pool + // workers are reused instead of spawning more. + workerCount = std::min(workerCount, 4); +#endif + if (workerCount > kMaxWorkerCount) + throw eve::Exception("ThreadPool worker count exceeds limit (%d)", kMaxWorkerCount); + workerCount_ = workerCount; + state_ = std::make_shared(); workers_.reserve(static_cast(workerCount_)); - for (int i = 0; i < workerCount_; ++i) - workers_.emplace_back([this] { workerMain(); }); + try { + for (int i = 0; i < workerCount_; ++i) { + auto state = state_; + workers_.emplace_back([state] { workerMain(state); }); + } + } catch (...) { + { + std::lock_guard lock(state_->mu); + state_->stopping = true; + } + state_->cv.notify_all(); + for (auto &worker : workers_) { + if (worker.joinable()) + worker.join(); + } + workers_.clear(); + throw; + } } ThreadPool::~ThreadPool() { try { - stop(); + stopImpl(true); } catch (...) { } } @@ -35,29 +78,30 @@ ThreadPool::~ThreadPool() { int ThreadPool::getWorkerCount() const { return workerCount_; } int ThreadPool::getPendingCount() const { - std::lock_guard lock(mu_); - return static_cast(queue_.size()); + std::lock_guard lock(state_->mu); + return static_cast(state_->queue.size()); } bool ThreadPool::isRunning() const { - std::lock_guard lock(mu_); - return !stopping_; + std::lock_guard lock(state_->mu); + return !state_->stopping; } Task *ThreadPool::submit(std::function fn) { if (!fn) throw eve::Exception("ThreadPool::submit: null function"); - auto *task = new Task(std::move(fn)); + auto state = std::make_shared(std::move(fn)); + auto *task = new Task(state); { - std::lock_guard lock(mu_); - if (stopping_) { + std::lock_guard lock(state_->mu); + if (state_->stopping) { delete task; throw eve::Exception("ThreadPool is stopped"); } - queue_.push(task); + state_->queue.push(std::move(state)); } - cv_.notify_one(); + state_->cv.notify_one(); return task; } @@ -74,10 +118,15 @@ Task *ThreadPool::submitPush(Channel *channel, std::string message, int delayMs) throw eve::Exception("ThreadPool::submitPush: channel is null"); if (delayMs < 0) delayMs = 0; - return submit([channel, msg = std::move(message), delayMs] { + auto channelState = channel->state_; + return submit([channelState, msg = std::move(message), delayMs] { if (delayMs > 0) std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); - channel->push(msg); + { + std::lock_guard lock(channelState->mu); + channelState->queue.push(msg); + } + channelState->cv.notify_one(); }); } @@ -86,39 +135,65 @@ Task *ThreadPool::submitPost(std::string name, std::string data, int delayMs) { throw eve::Exception("ThreadPool::submitPost: name must not be empty"); if (delayMs < 0) delayMs = 0; - return submit([name = std::move(name), data = std::move(data), delayMs] { - if (delayMs > 0) - std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); - auto *ev = ModuleManager::getInstance("Event"); + event::Event *ev = nullptr; + { + // ModuleManager is not thread-safe. Resolve/create the singleton while + // submitPost is still on the submitting (script/main) thread. + std::lock_guard lock(eventResolveMu); + ev = ModuleManager::getInstance("Event"); if (!ev) ev = event::Event::create(); + } + return submit([ev, name = std::move(name), data = std::move(data), delayMs] { + if (delayMs > 0) + std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); ev->pushData(name, data); }); } void ThreadPool::waitAll() { - std::unique_lock lock(mu_); - idleCv_.wait(lock, [this] { return queue_.empty() && busy_ == 0; }); + auto state = state_; + if (currentPoolState == state.get()) + throw eve::Exception("ThreadPool::waitAll cannot be called from its worker"); + std::unique_lock lock(state->mu); + state->idleCv.wait(lock, [&state] { return state->queue.empty() && state->busy == 0; }); } void ThreadPool::stop() { + if (currentPoolState == state_.get()) + throw eve::Exception("ThreadPool::stop cannot be called from its worker"); + stopImpl(false); +} + +void ThreadPool::stopImpl(bool allowWorkerCaller) { + std::lock_guard lifecycleLock(lifecycleMu_); + auto state = state_; + const bool calledByWorker = currentPoolState == state.get(); + if (calledByWorker && !allowWorkerCaller) + throw eve::Exception("ThreadPool::stop cannot be called from its worker"); + { - std::lock_guard lock(mu_); - if (stopping_) - return; - stopping_ = true; + std::lock_guard lock(state->mu); + state->stopping = true; } - cv_.notify_all(); - const auto self = std::this_thread::get_id(); + state->cv.notify_all(); + + // Destruction from a running job cannot safely join any pool worker: a + // different worker may itself be waiting for the current Task to finish. + // Workers only retain State, not ThreadPool, so detaching all of them here + // is safe and lets them drain accepted work before exiting. + if (calledByWorker) { + for (auto &worker : workers_) { + if (worker.joinable()) + worker.detach(); + } + workers_.clear(); + return; + } + for (auto &w : workers_) { if (!w.joinable()) continue; - // MSVC throws std::system_error(resource_deadlock_would_occur) if this - // thread is a worker (stop from a task / TLS teardown). - if (w.get_id() == self) { - w.detach(); - continue; - } try { w.join(); } catch (const std::system_error &) { @@ -129,27 +204,30 @@ void ThreadPool::stop() { workers_.clear(); } -void ThreadPool::workerMain() { +void ThreadPool::workerMain(std::shared_ptr state) { + currentPoolState = state.get(); for (;;) { - Task *task = nullptr; + std::shared_ptr task; { - std::unique_lock lock(mu_); - cv_.wait(lock, [this] { return stopping_ || !queue_.empty(); }); - if (stopping_ && queue_.empty()) + std::unique_lock lock(state->mu); + state->cv.wait(lock, [&state] { return state->stopping || !state->queue.empty(); }); + if (state->stopping && state->queue.empty()) { + currentPoolState = nullptr; return; - task = queue_.front(); - queue_.pop(); - ++busy_; + } + task = std::move(state->queue.front()); + state->queue.pop(); + ++state->busy; } if (task) - task->run(); + Task::run(task); { - std::lock_guard lock(mu_); - --busy_; - if (queue_.empty() && busy_ == 0) - idleCv_.notify_all(); + std::lock_guard lock(state->mu); + --state->busy; + if (state->queue.empty() && state->busy == 0) + state->idleCv.notify_all(); } } } diff --git a/src/modules/thread/ThreadPool.h b/src/modules/thread/ThreadPool.h index 529aed1d..1698fbfb 100644 --- a/src/modules/thread/ThreadPool.h +++ b/src/modules/thread/ThreadPool.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -32,7 +33,7 @@ public: int getPendingCount() const; bool isRunning() const; - /** Submit a C++ callable. Caller owns the returned Task* (delete when done). */ + /** Submit a C++ callable. Caller owns the Task wrapper; work owns its shared state. */ Task *submit(std::function fn); /** Sleep on a worker, then mark done — useful from scripts / tests. */ @@ -47,23 +48,22 @@ public: */ Task *submitPost(std::string name, std::string data = "", int delayMs = 0); - /** Block until the queue is empty and no worker is busy. */ + /** Block until idle. Throws when called by a worker belonging to this pool. */ void waitAll(); - /** Stop accepting work, join workers. Idempotent. */ + /** Stop accepting work and join workers. Worker calls are rejected. Idempotent. */ void stop(); private: - void workerMain(); + struct State; + + static void workerMain(std::shared_ptr state); + void stopImpl(bool allowWorkerCaller); int workerCount_ = 0; std::vector workers_; - mutable std::mutex mu_; - std::condition_variable cv_; - std::condition_variable idleCv_; - std::queue queue_; - int busy_ = 0; - bool stopping_ = false; + std::shared_ptr state_; + std::mutex lifecycleMu_; }; } // namespace thread diff --git a/src/modules/ui/imgui/ImGuiBackend.cpp b/src/modules/ui/imgui/ImGuiBackend.cpp index 5d2f9cde..f6612d52 100644 --- a/src/modules/ui/imgui/ImGuiBackend.cpp +++ b/src/modules/ui/imgui/ImGuiBackend.cpp @@ -1,15 +1,22 @@ #include "ui/imgui/ImGuiBackend.h" +#include "ui/Theme.h" #include "common/Exception.h" #include "common/config.h" #include "graphics/Graphics.h" -#include "graphics/vulkan/Graphics.h" -#include "ui/Theme.h" -#include "vkbuilder.hpp" #include #include + +#ifdef EVENGINE_WEBGPU +#include "graphics/webgpu/Graphics.h" +#include "imgui_impl_wgpu.h" +#else +#include "graphics/vulkan/Graphics.h" #include +#include +#include +#endif #include #include @@ -17,16 +24,17 @@ #include #include #include -#include namespace eve::ui { namespace { +#ifndef EVENGINE_WEBGPU void checkVk(VkResult err) { if (err == 0) return; throw eve::Exception("ImGui Vulkan error: VkResult = %d", int(err)); } +#endif /** Base glyph size in logical px (before the DPI scale is applied). */ constexpr float kBaseFontSizePx = 16.f; @@ -90,22 +98,30 @@ bool ImGuiBackend::init(SDL_Window *window, eve::graphics::Graphics *gfx) { if (initialized_) return true; if (!window || !gfx) return false; - auto *vkg = dynamic_cast(gfx); - if (!vkg) return false; - if (!vkg->getSwapchainRenderPass()) return false; - gfx_ = gfx; window_ = window; uiScale_ = computeInitialScale(); IMGUI_CHECKVERSION(); - ImGui::CreateContext(); + ctx_ = ::ImGui::CreateContext(); // Start from unified theme tokens (not a one-off ImGui palette). setThemeUiScale(uiScale_); applyThemeToImGui(globalTheme(), uiScale_); ImGui_ImplSDL2_InitForVulkan(window); +#ifdef EVENGINE_WEBGPU + auto *wgg = dynamic_cast(gfx); + if (!wgg) return false; + ImGui_ImplWGPU_Init(wgg->getDevice().Get(), 2, wgg->getSurfaceFormat()); + loadFonts(); + ImGui_ImplWGPU_CreateFontsTexture(); + fontsUploaded_ = true; +#else + auto *vkg = dynamic_cast(gfx); + if (!vkg) return false; + if (!vkg->getSwapchainRenderPass()) return false; + auto &device = vkg->getDevice(); VkDescriptorPoolSize poolSizes[] = { {VK_DESCRIPTOR_TYPE_SAMPLER, 1000}, @@ -134,8 +150,6 @@ bool ImGuiBackend::init(SDL_Window *window, eve::graphics::Graphics *gfx) { uint32_t imageCount = vkg->getSwapchainImageCount(); if (imageCount < 2) imageCount = 2; - // The UI MSAA render pass must exist (and stay stable) before ImGui builds - // its pipeline against it; the sample count must match that render pass. vkg->ensureUiColorResources(); ImGui_ImplVulkan_InitInfo initInfo{}; @@ -163,13 +177,23 @@ bool ImGuiBackend::init(SDL_Window *window, eve::graphics::Graphics *gfx) { }); ImGui_ImplVulkan_DestroyFontUploadObjects(); fontsUploaded_ = true; +#endif gfx_->setPresentOverlay(&ImGuiBackend::presentOverlayThunk, this); + // The ImGui context + Vulkan pipeline are bound to the native window. When + // the window is destroyed, tear down so the next init() rebuilds against a + // fresh window — even if SDL hands back the same pointer. + gfx_->addWindowDestroyedCallback(&ImGuiBackend::windowDestroyedThunk, this); initialized_ = true; return true; } +void ImGuiBackend::windowDestroyedThunk(void *userdata) { + auto *self = static_cast(userdata); + if (self) self->shutdown(); +} + void ImGuiBackend::shutdown() { if (!initialized_) return; if (frameOpen_) { @@ -179,18 +203,37 @@ void ImGuiBackend::shutdown() { if (gfx_) { if (gfx_->getPresentOverlayUser() == this) gfx_->setPresentOverlay(nullptr, nullptr); } +#ifdef EVENGINE_WEBGPU + ImGui_ImplWGPU_Shutdown(); + ImGui_ImplSDL2_Shutdown(); + ImGui::DestroyContext(); +#else auto *vkg = dynamic_cast(gfx_); if (vkg) { vkDeviceWaitIdle(static_cast(vkg->getDevice().instance)); } - ImGui_ImplVulkan_Shutdown(); - ImGui_ImplSDL2_Shutdown(); - ImGui::DestroyContext(); + // The ImGui backend data lives on the context this backend created. It may + // not be the currently active context (headless tests switch contexts), so + // explicitly select it before tearing down ImGui_Impl* state. + if (ctx_) { + ::ImGuiContext *prev = ::ImGui::GetCurrentContext(); + ::ImGuiContext *mine = ctx_; + ::ImGui::SetCurrentContext(mine); + ::ImGui_ImplVulkan_Shutdown(); + ::ImGui_ImplSDL2_Shutdown(); + ::ImGui::DestroyContext(mine); + ctx_ = nullptr; + // Restore the previous current context unless it was the one we just + // destroyed (DestroyContext already reset it to null). + if (prev && prev != mine) ::ImGui::SetCurrentContext(prev); + else ::ImGui::SetCurrentContext(nullptr); + } if (imguiDescriptorPool_ && vkg) { vkDestroyDescriptorPool(static_cast(vkg->getDevice().instance), static_cast(imguiDescriptorPool_), nullptr); imguiDescriptorPool_ = nullptr; } +#endif gfx_ = nullptr; window_ = nullptr; fontsUploaded_ = false; @@ -204,13 +247,15 @@ void ImGuiBackend::processEvent(const SDL_Event *event) { void ImGuiBackend::newFrame() { if (!initialized_ || !window_) return; - // present() may soft-skip on Android while the surface settles after - // orientation change; close the previous ImGui frame so NewFrame is safe. if (frameOpen_) { ImGui::EndFrame(); frameOpen_ = false; } +#ifdef EVENGINE_WEBGPU + ImGui_ImplWGPU_NewFrame(); +#else ImGui_ImplVulkan_NewFrame(); +#endif ImGui_ImplSDL2_NewFrame(window_); ImGui::NewFrame(); frameOpen_ = true; @@ -222,11 +267,7 @@ void ImGuiBackend::applyScale(float scale) { const bool changed = scale != uiScale_; uiScale_ = scale; setThemeUiScale(scale); - // Re-apply design tokens so geometry stays in sync with theme + DPI. applyThemeToImGui(globalTheme(), scale); - // The font atlas is rasterized at physical-pixel size, so a scale change - // must re-rasterize it rather than rely on FontGlobalScale (which would - // blur the glyphs again). if (changed) rebuildFonts(); } @@ -237,17 +278,19 @@ void ImGuiBackend::setScale(float scale) { float ImGuiBackend::computeInitialScale() const { #if defined(EVENGINE_ANDROID) || defined(EVENGINE_IOS) - // Readable size on phone/tablet (mdpi = 160). float ddpi = 160.f; if (SDL_GetDisplayDPI(0, &ddpi, nullptr, nullptr) != 0 || ddpi < 1.f) ddpi = 320.f; float s = ddpi / 160.f; return std::clamp(s, 1.75f, 3.25f); #else - // Desktop: match the native window DPI scale (drawable / logical size). - // Requires SDL_WINDOW_ALLOW_HIGHDPI to report a scale > 1 on HiDPI displays. int logicalW = 0, logicalH = 0, pixelW = 0, pixelH = 0; SDL_GetWindowSize(window_, &logicalW, &logicalH); - SDL_GL_GetDrawableSize(window_, &pixelW, &pixelH); +#ifdef EVENGINE_WEBGPU + // No GL context on WebGPU; drawable size == window size for the canvas. + SDL_GetWindowSize(window_, &pixelW, &pixelH); +#else + SDL_Vulkan_GetDrawableSize(window_, &pixelW, &pixelH); +#endif if (logicalW > 0 && pixelW > 0) { float s = float(pixelW) / float(logicalW); if (s > 0.f) return std::clamp(s, 1.f, 4.f); @@ -261,7 +304,6 @@ void ImGuiBackend::loadFonts() { ImFontAtlas *atlas = io.Fonts; if (!atlas) return; - // Base glyph size in logical px at uiScale=1; physical size = base * uiScale. const float sizePx = kBaseFontSizePx * uiScale_; ImFontConfig cfg{}; @@ -278,8 +320,6 @@ void ImGuiBackend::loadFonts() { } if (!added) atlas->AddFontDefault(&cfg); - // Best-effort icon-font merge (FontAwesome, private-use glyphs). Missing - // file is silently ignored so the default glyphs above still render. ImFontConfig iconCfg{}; iconCfg.OversampleH = 2; iconCfg.OversampleV = 2; @@ -293,6 +333,12 @@ void ImGuiBackend::loadFonts() { } void ImGuiBackend::rebuildFonts() { +#ifdef EVENGINE_WEBGPU + loadFonts(); + ImGui_ImplWGPU_InvalidateDeviceObjects(); + ImGui_ImplWGPU_CreateFontsTexture(); + fontsUploaded_ = true; +#else auto *vkg = dynamic_cast(gfx_); if (!vkg) return; loadFonts(); @@ -306,6 +352,7 @@ void ImGuiBackend::rebuildFonts() { }); ImGui_ImplVulkan_DestroyFontUploadObjects(); fontsUploaded_ = true; +#endif } bool ImGuiBackend::wantCaptureMouse() const { @@ -318,12 +365,17 @@ bool ImGuiBackend::wantCaptureKeyboard() const { return ImGui::GetIO().WantCaptureKeyboard; } -void ImGuiBackend::renderDrawData(void *vkCommandBuffer) { - if (!initialized_ || !vkCommandBuffer) return; +void ImGuiBackend::renderDrawData(void *commandBuffer) { + if (!initialized_ || !commandBuffer) return; ImGui::Render(); frameOpen_ = false; +#ifdef EVENGINE_WEBGPU + WGPURenderPassEncoder enc = *static_cast(commandBuffer); + ImGui_ImplWGPU_RenderDrawData(ImGui::GetDrawData(), enc); +#else ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), - static_cast(vkCommandBuffer)); + static_cast(commandBuffer)); +#endif } void ImGuiBackend::presentOverlayThunk(void *userdata, void *commandBuffer) { diff --git a/src/modules/ui/imgui/ImGuiBackend.h b/src/modules/ui/imgui/ImGuiBackend.h index 97204557..bc56e79e 100644 --- a/src/modules/ui/imgui/ImGuiBackend.h +++ b/src/modules/ui/imgui/ImGuiBackend.h @@ -2,6 +2,8 @@ #include "ui/UIBackend.h" +#include + namespace eve::ui { /** Dear ImGui + SDL input + Vulkan present overlay. */ @@ -29,6 +31,7 @@ public: private: void renderDrawData(void *vkCommandBuffer); static void presentOverlayThunk(void *userdata, void *commandBuffer); + static void windowDestroyedThunk(void *userdata); void applyScale(float scale); /** Initial UI scale derived from window DPI (desktop) or display DPI (mobile). */ float computeInitialScale() const; @@ -43,7 +46,8 @@ private: float uiScale_ = 1.f; eve::graphics::Graphics *gfx_ = nullptr; SDL_Window *window_ = nullptr; - void *imguiDescriptorPool_ = nullptr; // VkDescriptorPool + void *imguiDescriptorPool_ = nullptr; // VkDescriptorPool + ImGuiContext *ctx_ = nullptr; // ImGui context owned by this backend }; } // namespace eve::ui diff --git a/src/modules/ui/imgui/imgui_impl_wgpu.cpp b/src/modules/ui/imgui/imgui_impl_wgpu.cpp new file mode 100644 index 00000000..698df0ff --- /dev/null +++ b/src/modules/ui/imgui/imgui_impl_wgpu.cpp @@ -0,0 +1,456 @@ +// dear imgui: Renderer Backend for WebGPU (wgpu), adapted for EVEngine's +// vendored imgui v1.83. Uses the stable webgpu.h C API so the same file +// compiles against Dawn (native) and Emscripten (browser). +// +// This is a self-contained renderer: imgui draws are issued into an existing +// WGPURenderPassEncoder supplied by the host (EVEngine's present overlay). + +// The source scanner compiles every *.cpp under ui/imgui/ on all platforms; +// keep this TU empty when the WebGPU backend is not active. +// config.h must be included before the guard: EVENGINE_WEBGPU is defined by +// config.h, not by a -D flag, so including it inside the guard would make this +// TU always empty. +#include "common/config.h" + +#if defined(EVENGINE_WEBGPU) + +#include "imgui_impl_wgpu.h" + +#include +#include + +// Avoid clashes with Windows headers. +#if defined(WIN32) && defined(min) +#undef min +#undef max +#endif + +//------------------------------------------------------------------------- +// Configuration +//------------------------------------------------------------------------- +#ifndef IMGUI_IMPL_WEBGPU_DEFAULT_FONT_SAMPLER +#define IMGUI_IMPL_WEBGPU_DEFAULT_FONT_SAMPLER 0 +#endif + +//----------------------------------------------------------------------------- +// Implementation +//----------------------------------------------------------------------------- + +struct ImGui_ImplWGPU_Data { + WGPUDevice wgpuDevice = nullptr; + WGPUTextureFormat rtFormat = WGPUTextureFormat_BGRA8Unorm; + + WGPUQueue defaultQueue = nullptr; + + WGPUBindGroupLayout bindGroupLayout = nullptr; + WGPUBindGroup bindGroup = nullptr; + WGPUBuffer uniformBuffer = nullptr; + uint32_t uniformBufferSize = 0; + WGPURenderPipeline renderPipeline = nullptr; + + WGPUTexture fontTexture = nullptr; + WGPUTextureView fontTextureView = nullptr; + WGPUSampler fontSampler = nullptr; + + int numFramesInFlight = 1; + int frameIndex = 0; +}; + +// Backend data stored in io.BackendRendererUserData to allow support for multiple +// Dear ImGui contexts. +static ImGui_ImplWGPU_Data *ImGui_ImplWGPU_GetBackendData() { + return ImGui::GetCurrentContext() ? (ImGui_ImplWGPU_Data *)ImGui::GetIO().BackendRendererUserData + : nullptr; +} + +static const char *ImGui_ImplWGPU_GetWGSLVertexShader() { + return R"( +struct Uniforms { + mvp: mat4x4f, +}; +struct VertexInput { + @location(0) position: vec2f, + @location(1) uv: vec2f, + @location(2) color: vec4f, +}; +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, + @location(1) color: vec4f, +}; +@group(0) @binding(0) var uniforms: Uniforms; +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + out.position = uniforms.mvp * vec4f(in.position, 0.0, 1.0); + out.uv = in.uv; + out.color = in.color; + return out; +} +)"; +} + +static const char *ImGui_ImplWGPU_GetWGSLFragmentShader() { + return R"( +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, + @location(1) color: vec4f, +}; +@group(0) @binding(1) var fontSampler: sampler; +@group(0) @binding(2) var fontTexture: texture_2d; +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4f { + return textureSample(fontTexture, fontSampler, in.uv) * in.color; +} +)"; +} + +static void ImGui_ImplWGPU_SetupRenderState(ImDrawData *draw_data, + WGPURenderPassEncoder pass_encoder) { + ImGui_ImplWGPU_Data *bd = ImGui_ImplWGPU_GetBackendData(); + + // Setup orthographic projection matrix into our constant buffer. + { + float L = draw_data->DisplayPos.x; + float R = draw_data->DisplayPos.x + draw_data->DisplaySize.x; + float T = draw_data->DisplayPos.y; + float B = draw_data->DisplayPos.y + draw_data->DisplaySize.y; + float mvp[4][4] = { + {2.0f / (R - L), 0.0f, 0.0f, 0.0f}, + {0.0f, 2.0f / (T - B), 0.0f, 0.0f}, + {0.0f, 0.0f, 0.5f, 0.0f}, + {(R + L) / (L - R), (T + B) / (B - T), 0.5f, 1.0f}, + }; + wgpuQueueWriteBuffer(bd->defaultQueue, bd->uniformBuffer, 0, mvp, sizeof(mvp)); + } + + wgpuRenderPassEncoderSetPipeline(pass_encoder, bd->renderPipeline); + wgpuRenderPassEncoderSetBindGroup(pass_encoder, 0, bd->bindGroup, 0, nullptr); +} + +static void ImGui_ImplWGPU_CreatePipeline() { + ImGui_ImplWGPU_Data *bd = ImGui_ImplWGPU_GetBackendData(); + WGPUDevice device = bd->wgpuDevice; + + // Shader modules. + WGPUShaderSourceWGSL wgslVert{}; + wgslVert.chain.sType = WGPUSType_ShaderSourceWGSL; + wgslVert.code = WGPUStringView{ImGui_ImplWGPU_GetWGSLVertexShader(), + strlen(ImGui_ImplWGPU_GetWGSLVertexShader())}; + WGPUShaderModuleDescriptor vsDesc{}; + vsDesc.nextInChain = &wgslVert.chain; + WGPUShaderModule vsModule = wgpuDeviceCreateShaderModule(device, &vsDesc); + + WGPUShaderSourceWGSL wgslFrag{}; + wgslFrag.chain.sType = WGPUSType_ShaderSourceWGSL; + wgslFrag.code = WGPUStringView{ImGui_ImplWGPU_GetWGSLFragmentShader(), + strlen(ImGui_ImplWGPU_GetWGSLFragmentShader())}; + WGPUShaderModuleDescriptor fsDesc{}; + fsDesc.nextInChain = &wgslFrag.chain; + WGPUShaderModule fsModule = wgpuDeviceCreateShaderModule(device, &fsDesc); + + // Bind group layout. + WGPUBindGroupLayoutEntry entries[3] = {}; + entries[0].binding = 0; + entries[0].visibility = WGPUShaderStage_Vertex; + entries[0].buffer.type = WGPUBufferBindingType_Uniform; + entries[0].buffer.minBindingSize = 64; + entries[1].binding = 1; + entries[1].visibility = WGPUShaderStage_Fragment; + entries[1].sampler.type = WGPUSamplerBindingType_Filtering; + entries[2].binding = 2; + entries[2].visibility = WGPUShaderStage_Fragment; + entries[2].texture.sampleType = WGPUTextureSampleType_Float; + entries[2].texture.viewDimension = WGPUTextureViewDimension_2D; + WGPUBindGroupLayoutDescriptor bglDesc{}; + bglDesc.entryCount = 3; + bglDesc.entries = entries; + bd->bindGroupLayout = wgpuDeviceCreateBindGroupLayout(device, &bglDesc); + + // Pipeline layout. + WGPUPipelineLayoutDescriptor plDesc{}; + plDesc.bindGroupLayoutCount = 1; + plDesc.bindGroupLayouts = &bd->bindGroupLayout; + WGPUPipelineLayout pipelineLayout = wgpuDeviceCreatePipelineLayout(device, &plDesc); + + // Vertex layout: ImDrawVert { vec2 pos; vec2 uv; u32 col; } + WGPUVertexAttribute attrs[3] = {}; + attrs[0].format = WGPUVertexFormat_Float32x2; + attrs[0].offset = IM_OFFSETOF(ImDrawVert, pos); + attrs[0].shaderLocation = 0; + attrs[1].format = WGPUVertexFormat_Float32x2; + attrs[1].offset = IM_OFFSETOF(ImDrawVert, uv); + attrs[1].shaderLocation = 1; + attrs[2].format = WGPUVertexFormat_Unorm8x4; + attrs[2].offset = IM_OFFSETOF(ImDrawVert, col); + attrs[2].shaderLocation = 2; + WGPUVertexBufferLayout vbLayout{}; + vbLayout.arrayStride = sizeof(ImDrawVert); + vbLayout.stepMode = WGPUVertexStepMode_Vertex; + vbLayout.attributeCount = 3; + vbLayout.attributes = attrs; + + WGPUBlendState blend{}; + blend.color.srcFactor = WGPUBlendFactor_SrcAlpha; + blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha; + blend.color.operation = WGPUBlendOperation_Add; + blend.alpha.srcFactor = WGPUBlendFactor_One; + blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha; + blend.alpha.operation = WGPUBlendOperation_Add; + + WGPUColorTargetState colorTarget{}; + colorTarget.format = bd->rtFormat; + colorTarget.blend = &blend; + colorTarget.writeMask = WGPUColorWriteMask_All; + + WGPURenderPipelineDescriptor desc{}; + desc.layout = pipelineLayout; + desc.vertex.module = vsModule; + desc.vertex.entryPoint = WGPUStringView{"vs_main", 6}; + desc.vertex.bufferCount = 1; + desc.vertex.buffers = &vbLayout; + desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + desc.primitive.frontFace = WGPUFrontFace_CCW; + desc.primitive.cullMode = WGPUCullMode_None; + desc.primitive.stripIndexFormat = WGPUIndexFormat_Undefined; + WGPUFragmentState fragState{}; + fragState.module = fsModule; + fragState.entryPoint = WGPUStringView{"fs_main", 6}; + fragState.targetCount = 1; + fragState.targets = &colorTarget; + desc.fragment = &fragState; + desc.multisample.count = 1; + bd->renderPipeline = wgpuDeviceCreateRenderPipeline(device, &desc); +} + +static void ImGui_ImplWGPU_CreateUniformBuffer() { + ImGui_ImplWGPU_Data *bd = ImGui_ImplWGPU_GetBackendData(); + WGPUDevice device = bd->wgpuDevice; + + bd->uniformBufferSize = 64; + WGPUBufferDescriptor desc{}; + desc.size = bd->uniformBufferSize; + desc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst; + desc.mappedAtCreation = false; + bd->uniformBuffer = wgpuDeviceCreateBuffer(device, &desc); +} + +bool ImGui_ImplWGPU_CreateFontsTexture() { + ImGuiIO &io = ImGui::GetIO(); + ImGui_ImplWGPU_Data *bd = ImGui_ImplWGPU_GetBackendData(); + WGPUDevice device = bd->wgpuDevice; + + unsigned char *pixels; + int width, height, size; + io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height, &size); + size_t stride = static_cast(width) * 4; + + WGPUTextureDescriptor texDesc{}; + texDesc.dimension = WGPUTextureDimension_2D; + texDesc.size.width = width; + texDesc.size.height = height; + texDesc.size.depthOrArrayLayers = 1; + texDesc.sampleCount = 1; + texDesc.format = WGPUTextureFormat_RGBA8Unorm; + texDesc.mipLevelCount = 1; + texDesc.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_CopyDst; + bd->fontTexture = wgpuDeviceCreateTexture(device, &texDesc); + + WGPUTexelCopyBufferLayout layout{}; + layout.offset = 0; + layout.bytesPerRow = stride; + layout.rowsPerImage = height; + WGPUTexelCopyTextureInfo copyDst{}; + copyDst.texture = bd->fontTexture; + copyDst.mipLevel = 0; + copyDst.aspect = WGPUTextureAspect_All; + WGPUExtent3D extent{static_cast(width), static_cast(height), 1}; + wgpuQueueWriteTexture(bd->defaultQueue, ©Dst, pixels, stride * height, &layout, &extent); + + WGPUTextureViewDescriptor viewDesc{}; + viewDesc.format = WGPUTextureFormat_RGBA8Unorm; + viewDesc.dimension = WGPUTextureViewDimension_2D; + viewDesc.baseMipLevel = 0; + viewDesc.mipLevelCount = 1; + viewDesc.baseArrayLayer = 0; + viewDesc.arrayLayerCount = 1; + bd->fontTextureView = wgpuTextureCreateView(bd->fontTexture, &viewDesc); + + WGPUSamplerDescriptor samplerDesc{}; + samplerDesc.minFilter = WGPUFilterMode_Linear; + samplerDesc.magFilter = WGPUFilterMode_Linear; + samplerDesc.mipmapFilter = WGPUMipmapFilterMode_Linear; + samplerDesc.addressModeU = WGPUAddressMode_ClampToEdge; + samplerDesc.addressModeV = WGPUAddressMode_ClampToEdge; + samplerDesc.addressModeW = WGPUAddressMode_ClampToEdge; + bd->fontSampler = wgpuDeviceCreateSampler(device, &samplerDesc); + + WGPUBindGroupEntry entries[3] = {}; + entries[0].binding = 0; + entries[0].buffer = bd->uniformBuffer; + entries[0].size = bd->uniformBufferSize; + entries[1].binding = 1; + entries[1].sampler = bd->fontSampler; + entries[2].binding = 2; + entries[2].textureView = bd->fontTextureView; + + WGPUBindGroupDescriptor bgDesc{}; + bgDesc.layout = bd->bindGroupLayout; + bgDesc.entryCount = 3; + bgDesc.entries = entries; + bd->bindGroup = wgpuDeviceCreateBindGroup(device, &bgDesc); + + io.Fonts->SetTexID((ImTextureID)(intptr_t)bd->fontTextureView); + return true; +} + +void ImGui_ImplWGPU_InvalidateDeviceObjects() { + ImGui_ImplWGPU_Data *bd = ImGui_ImplWGPU_GetBackendData(); + if (!bd) return; + if (bd->fontTexture) { wgpuTextureRelease(bd->fontTexture); bd->fontTexture = nullptr; } + if (bd->fontTextureView) { wgpuTextureViewRelease(bd->fontTextureView); bd->fontTextureView = nullptr; } + if (bd->fontSampler) { wgpuSamplerRelease(bd->fontSampler); bd->fontSampler = nullptr; } + if (bd->bindGroup) { wgpuBindGroupRelease(bd->bindGroup); bd->bindGroup = nullptr; } + if (bd->bindGroupLayout) { wgpuBindGroupLayoutRelease(bd->bindGroupLayout); bd->bindGroupLayout = nullptr; } + if (bd->uniformBuffer) { wgpuBufferRelease(bd->uniformBuffer); bd->uniformBuffer = nullptr; } + if (bd->renderPipeline) { wgpuRenderPipelineRelease(bd->renderPipeline); bd->renderPipeline = nullptr; } + if (ImGui::GetCurrentContext() && ImGui::GetIO().Fonts) + ImGui::GetIO().Fonts->SetTexID(0); +} + +bool ImGui_ImplWGPU_Init(WGPUDevice device, int num_frames_in_flight, WGPUTextureFormat rt_format) { + ImGuiIO &io = ImGui::GetIO(); + IM_ASSERT(io.BackendRendererUserData == NULL && "Already initialized a renderer backend!"); + + ImGui_ImplWGPU_Data *bd = IM_NEW(ImGui_ImplWGPU_Data)(); + io.BackendRendererUserData = (void *)bd; + io.BackendRendererName = "imgui_impl_webgpu"; + io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset; + + bd->wgpuDevice = device; + bd->rtFormat = rt_format; + bd->numFramesInFlight = num_frames_in_flight; + bd->defaultQueue = wgpuDeviceGetQueue(device); + + ImGui_ImplWGPU_CreateUniformBuffer(); + ImGui_ImplWGPU_CreatePipeline(); + ImGui_ImplWGPU_CreateFontsTexture(); + + return true; +} + +void ImGui_ImplWGPU_Shutdown() { + ImGui_ImplWGPU_Data *bd = ImGui_ImplWGPU_GetBackendData(); + IM_ASSERT(bd != NULL && "No renderer backend to shutdown, or already shutdown?"); + ImGui_ImplWGPU_InvalidateDeviceObjects(); + if (bd->defaultQueue) { wgpuQueueRelease(bd->defaultQueue); bd->defaultQueue = nullptr; } + if (bd->wgpuDevice) { wgpuDeviceRelease(bd->wgpuDevice); bd->wgpuDevice = nullptr; } + ImGuiIO &io = ImGui::GetIO(); + io.BackendRendererName = NULL; + io.BackendRendererUserData = NULL; + IM_DELETE(bd); +} + +void ImGui_ImplWGPU_NewFrame() { + ImGui_ImplWGPU_Data *bd = ImGui_ImplWGPU_GetBackendData(); + IM_ASSERT(bd != NULL && "Did you call ImGui_ImplWGPU_Init()?"); + bd->frameIndex = (bd->frameIndex + 1) % bd->numFramesInFlight; +} + +void ImGui_ImplWGPU_RenderDrawData(ImDrawData *draw_data, WGPURenderPassEncoder pass_encoder) { + ImGui_ImplWGPU_Data *bd = ImGui_ImplWGPU_GetBackendData(); + if (draw_data->CmdListsCount == 0) return; + + ImGui_ImplWGPU_SetupRenderState(draw_data, pass_encoder); + + // Will project scissor/clipping rectangles into framebuffer space. + ImVec2 clip_off = draw_data->DisplayPos; + ImVec2 clip_scale = draw_data->FramebufferScale; + + const float fb_width = draw_data->DisplaySize.x * clip_scale.x; + const float fb_height = draw_data->DisplaySize.y * clip_scale.y; + if (fb_width <= 0.0f || fb_height <= 0.0f) return; + + for (int n = 0; n < draw_data->CmdListsCount; n++) { + const ImDrawList *cmd_list = draw_data->CmdLists[n]; + + // Create and upload vertex/index buffers. + size_t vbSize = (size_t)cmd_list->VtxBuffer.Size * sizeof(ImDrawVert); + size_t ibSize = (size_t)cmd_list->IdxBuffer.Size * sizeof(ImDrawIdx); + WGPUBuffer vb = nullptr; + WGPUBuffer ib = nullptr; + + WGPUBufferDescriptor vbDesc{}; + vbDesc.size = vbSize; + vbDesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst; + vbDesc.mappedAtCreation = false; + vb = wgpuDeviceCreateBuffer(bd->wgpuDevice, &vbDesc); + wgpuQueueWriteBuffer(bd->defaultQueue, vb, 0, cmd_list->VtxBuffer.Data, vbSize); + + WGPUBufferDescriptor ibDesc{}; + ibDesc.size = ibSize; + ibDesc.usage = WGPUBufferUsage_Index | WGPUBufferUsage_CopyDst; + ibDesc.mappedAtCreation = false; + ib = wgpuDeviceCreateBuffer(bd->wgpuDevice, &ibDesc); + wgpuQueueWriteBuffer(bd->defaultQueue, ib, 0, cmd_list->IdxBuffer.Data, ibSize); + + wgpuRenderPassEncoderSetVertexBuffer(pass_encoder, 0, vb, 0, vbSize); + wgpuRenderPassEncoderSetIndexBuffer(pass_encoder, ib, + sizeof(ImDrawIdx) == 2 ? WGPUIndexFormat_Uint16 + : WGPUIndexFormat_Uint32, + 0, ibSize); + + for (int cmd_i = 0; cmd_i < cmd_list->CmdBuffer.Size; cmd_i++) { + const ImDrawCmd *pcmd = &cmd_list->CmdBuffer[cmd_i]; + if (pcmd->UserCallback != NULL) { + pcmd->UserCallback(cmd_list, pcmd); + } else { + // Project scissor/clipping rectangles into framebuffer space. + ImVec2 clip_min((pcmd->ClipRect.x - clip_off.x) * clip_scale.x, + (pcmd->ClipRect.y - clip_off.y) * clip_scale.y); + ImVec2 clip_max((pcmd->ClipRect.z - clip_off.x) * clip_scale.x, + (pcmd->ClipRect.w - clip_off.y) * clip_scale.y); + if (clip_min.x < 0.0f) clip_min.x = 0.0f; + if (clip_min.y < 0.0f) clip_min.y = 0.0f; + if (clip_max.x > fb_width) clip_max.x = fb_width; + if (clip_max.y > fb_height) clip_max.y = fb_height; + if (clip_max.x <= clip_min.x || clip_max.y <= clip_min.y) continue; + + wgpuRenderPassEncoderSetScissorRect(pass_encoder, + (uint32_t)clip_min.x, (uint32_t)clip_min.y, + (uint32_t)(clip_max.x - clip_min.x), + (uint32_t)(clip_max.y - clip_min.y)); + + // Bind font texture when the texture id changes. + WGPUTextureView fontTextureView = + (WGPUTextureView)(intptr_t)pcmd->TextureId; + if (fontTextureView != bd->fontTextureView) { + WGPUBindGroupEntry entries[3] = {}; + entries[0].binding = 0; + entries[0].buffer = bd->uniformBuffer; + entries[0].size = bd->uniformBufferSize; + entries[1].binding = 1; + entries[1].sampler = bd->fontSampler; + entries[2].binding = 2; + entries[2].textureView = fontTextureView; + WGPUBindGroupDescriptor bgDesc{}; + bgDesc.layout = bd->bindGroupLayout; + bgDesc.entryCount = 3; + bgDesc.entries = entries; + WGPUBindGroup bg = wgpuDeviceCreateBindGroup(bd->wgpuDevice, &bgDesc); + wgpuRenderPassEncoderSetBindGroup(pass_encoder, 0, bg, 0, nullptr); + wgpuBindGroupRelease(bg); + } + + wgpuRenderPassEncoderDrawIndexed(pass_encoder, pcmd->ElemCount, 1, + pcmd->IdxOffset, (int32_t)pcmd->VtxOffset, 0); + } + } + + wgpuBufferRelease(vb); + wgpuBufferRelease(ib); + } +} + +#endif // EVENGINE_WEBGPU diff --git a/src/modules/ui/imgui/imgui_impl_wgpu.h b/src/modules/ui/imgui/imgui_impl_wgpu.h new file mode 100644 index 00000000..0d7e4325 --- /dev/null +++ b/src/modules/ui/imgui/imgui_impl_wgpu.h @@ -0,0 +1,18 @@ +// dear imgui: Renderer Backend for WebGPU (wgpu), adapted for EVEngine's +// vendored imgui v1.83. Uses the stable webgpu.h C API so the same file +// compiles against Dawn (native) and Emscripten (browser). + +#pragma once + +#include "imgui.h" + +#include + +IMGUI_IMPL_API bool ImGui_ImplWGPU_Init(WGPUDevice device, int num_frames_in_flight, + WGPUTextureFormat rt_format); +IMGUI_IMPL_API void ImGui_ImplWGPU_Shutdown(); +IMGUI_IMPL_API void ImGui_ImplWGPU_NewFrame(); +IMGUI_IMPL_API void ImGui_ImplWGPU_RenderDrawData(ImDrawData *draw_data, + WGPURenderPassEncoder pass_encoder); +IMGUI_IMPL_API bool ImGui_ImplWGPU_CreateFontsTexture(); +IMGUI_IMPL_API void ImGui_ImplWGPU_InvalidateDeviceObjects(); diff --git a/src/modules/window/sdl/Window.cpp b/src/modules/window/sdl/Window.cpp index ae156c8b..ae917837 100644 --- a/src/modules/window/sdl/Window.cpp +++ b/src/modules/window/sdl/Window.cpp @@ -1,7 +1,9 @@ #include "Window.h" #include +#ifndef EVENGINE_WEBGPU #include +#endif #include #include @@ -82,7 +84,10 @@ bool Window::setWindowSettings(WindowSettings f) { f.height = mode.h; } - Uint32 sdlflags = SDL_WINDOW_VULKAN; + Uint32 sdlflags = 0; +#ifndef EVENGINE_WEBGPU + sdlflags = SDL_WINDOW_VULKAN; +#endif // On Android, disable fullscreen first on window creation so it's // possible to change the orientation by specifying portait width and @@ -174,7 +179,13 @@ bool Window::setWindowSettings(WindowSettings f) { if (graphics) { int pw = 0, ph = 0; +#ifdef EVENGINE_WEBGPU + // No Vulkan drawable-size helper on WebGPU; the window size is the + // canvas size (the browser applies DPI scaling itself). + SDL_GetWindowSize(window, &pw, &ph); +#else SDL_Vulkan_GetDrawableSize(window, &pw, &ph); +#endif if (pw <= 0 || ph <= 0) { pw = f.width; ph = f.height; @@ -557,6 +568,12 @@ void Window::close(bool allowExceptions) { SDL_DestroyWindow(window); window = nullptr; + // The Vulkan surface/swapchain is tied to the destroyed native window. + // Drop it now so the next initWithWindow() rebuilds it against a fresh + // window — SDL may hand back the same pointer for the recreated window, + // so pointer identity cannot be used to detect a stale surface. + if (graphics) graphics->onNativeWindowDestroyed(); + // The old window may have generated pending events which are no longer // relevant. Destroy them all! SDL_FlushEvent(SDL_WINDOWEVENT); @@ -571,7 +588,11 @@ void Window::updateSettings(const WindowSettings &newsettings, bool updateGraphi settings = newsettings; if (window) { SDL_GetWindowSize(window, &windowWidth, &windowHeight); +#ifdef EVENGINE_WEBGPU + SDL_GetWindowSize(window, &pixelWidth, &pixelHeight); +#else SDL_Vulkan_GetDrawableSize(window, &pixelWidth, &pixelHeight); +#endif } if (updateGraphicsViewport && graphics && window) { graphics->setViewportSize(windowWidth, windowHeight, pixelWidth, pixelHeight); diff --git a/src/scripts/async.nut b/src/scripts/async.nut index d1b79634..d514c631 100644 --- a/src/scripts/async.nut +++ b/src/scripts/async.nut @@ -13,9 +13,13 @@ if (!("_async" in getroottable())) { maxDrain = 1000 // Pending worker polls: { ch, resolve, reject, task } waits = [] + // Task-correlated completions: { task, value, resolve, reject } + taskWaits = [] eventWaits = [] // { name, resolve, reject } }; } +if (!("taskWaits" in _async)) + _async.taskWaits <- []; function nextTick(fn) { if (typeof fn != "function") @@ -133,6 +137,30 @@ function _async_poll_waits() { _async.waits = remain; } +function _async_poll_tasks() { + if (_async.taskWaits.len() == 0) + return; + local remain = []; + foreach (w in _async.taskWaits) { + local done = false; + try { + if (w.task.hasFailed()) { + w.reject(w.task.getError()); + done = true; + } else if (w.task.isDone()) { + w.resolve(w.value); + done = true; + } + } catch (e) { + try { w.reject(e); } catch (e2) {} + done = true; + } + if (!done) + remain.append(w); + } + _async.taskWaits = remain; +} + // Drain timers, worker waits, and microtasks. Call once per frame (load.nut). function async_pump() { if (_async.draining) @@ -141,6 +169,7 @@ function async_pump() { try { _async_fire_timers(); _async_poll_waits(); + _async_poll_tasks(); _async_drain_microtasks(); // Timers / waits may have queued more microtasks. _async_drain_microtasks(); @@ -356,14 +385,16 @@ function async_dispatch_event(name, data) { return matched; } -// Delay on a worker, then post Event(name, data); resolves when that event is polled. +// Delay on a worker, then post Event(name, data). The Promise is correlated to +// its own native Task, so same-name events cannot resolve the wrong request. function asyncPost(name, data, ms) { if (data == null) data = ""; if (ms == null) ms = 0; return Promise(function(resolve, reject) { local pool = thread.getPool(); - pool.submitPost(name, "" + data, ms); - _async.eventWaits.append({ name = name, resolve = resolve, reject = reject }); + local value = "" + data; + local task = pool.submitPost(name, value, ms); + _async.taskWaits.append({ task = task, value = value, resolve = resolve, reject = reject }); }); } diff --git a/src/scripts/load_web.nut b/src/scripts/load_web.nut new file mode 100644 index 00000000..0334343d --- /dev/null +++ b/src/scripts/load_web.nut @@ -0,0 +1,131 @@ +// WebGPU (browser/WASM) root script — trimmed module set. +// The Emscripten build compiles out the modules whose third-party deps (Poco, +// OpenAL, assimp/medialoader, freetype) were dropped, so this root only wires +// the modules that exist in the WASM runtime. + +function file_exists(path) { + try { + file(path, "r").close(); + return true; + } catch (e) { + return false; + } +} + +function path_endswith(str, suffix) { + if (str == null || suffix == null) return false; + if (str.len() < suffix.len()) return false; + return str.slice(str.len() - suffix.len()) == suffix; +} + +function normalize_path(path) { + if (path == null) return ""; + local s = path; + while (s.len() >= 2 && s.slice(0, 2) == "./") + s = s.slice(2); + return s; +} + +config <- { + width = 800 + height = 600 + title = "EVEngine WebGPU" + debug = false + hotReload = false +}; + +if (file_exists("config.nut")) { + dofile("config.nut"); +} + +win <- eve.Window(); +gfx <- eve.Graphics(); +win.setGraphics(gfx); + +local s = eve.WindowSettings(); +s.width = config.width; +s.height = config.height; +s.centered = true; +if (!win.setWindowSettings(s)) { + print("setWindowSettings failed\n"); + return; +} + +config.width = win.getWidth(); +config.height = win.getHeight(); + +event <- eve.Event(); +timer <- eve.Timer(); +system <- eve.System(); +math <- eve.Math(); +spatial <- eve.Spatial(); +editor <- eve.Editor(); +ui <- eve.UI(); +scene <- eve.Scene(); +physics <- eve.Physics(); +keyboard <- eve.Keyboard(); +mouse <- eve.Mouse(); +touch <- eve.Touch(); +gpgpu <- eve.Gpgpu(); +thread <- eve.Thread(); +fs <- eve.Filesystem(); + +eve_init <- function() {}; +eve_update <- function(dt) {}; +eve_render <- function() { + gfx.clear(); +}; +eve_quit <- function() {}; + +if (file_exists("main.nut")) { + dofile("main.nut"); +} else if ("demoScript" in eve && eve.demoScript != null && eve.demoScript != "") { + try { + compilestring(eve.demoScript)(); + } catch (e) { + print("Embedded demo failed to load: " + e + "\n"); + } +} + +try { + eve_init(); +} catch (e) { + print("eve_init failed: " + e + "\n"); +} + +// One frame of the game loop, driven from C++ via emscripten_set_main_loop +// (requestAnimationFrame). Returns false to stop the loop (e.g. on quit). +eve_frame <- function() { + event.pump(); + while (true) { + local name = event.poll(); + if (name == "") break; + if (name == "quit") return false; + } + + config.width = win.getWidth(); + config.height = win.getHeight(); + + local dt = timer.step(); + try { + eve_update(dt); + } catch (e) { + print("eve_update error: " + e + "\n"); + } + try { + eve_render(); + } catch (e) { + print("eve_render error: " + e + "\n"); + } + try { + ui.dispatchEvents(); + } catch (e) { + print("ui.dispatchEvents error: " + e + "\n"); + } + try { + gfx.present(); + } catch (e) { + print("present error: " + e + "\n"); + } + return true; +}; diff --git a/src/scripts/scripts.h b/src/scripts/scripts.h index 641903cb..914a7af8 100644 --- a/src/scripts/scripts.h +++ b/src/scripts/scripts.h @@ -3,6 +3,7 @@ namespace eve { extern const char* boot_content; extern const char* load_content; +extern const char* load_web_content; extern const char* async_content; extern const char* demo_content; extern const char* vertex_content; diff --git a/src/scripts/template.cpp.in b/src/scripts/template.cpp.in index e778ba48..bd3bafb5 100644 --- a/src/scripts/template.cpp.in +++ b/src/scripts/template.cpp.in @@ -5,6 +5,8 @@ const char* boot_content = R"(@boot_content@)"; const char* load_content = R"(@load_content@)"; +const char* load_web_content = R"(@load_web_content@)"; + const char* async_content = R"(@async_content@)"; const char* demo_content = R"(@demo_content@)"; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 796ddd37..1c7fc789 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -69,6 +69,7 @@ file(GLOB all_test_cpp softbody.cpp hotreload.cpp shader.cpp + grass.cpp simplesquirrel.cpp ScriptECS.cpp sound.cpp @@ -96,6 +97,7 @@ file(GLOB all_test_cpp callgraph.cpp renderflow.cpp debugger.cpp + debugger_audit.cpp dap.cpp mcp.cpp main.cpp @@ -105,6 +107,16 @@ if(EVENGINE_BUILD_DEMO) list(APPEND all_test_cpp demo.cpp) endif() +# Desktop-only DevTools (DAP / MCP / debugger / callgraph / renderflow) are not +# compiled into the Android runtime, so their test files cannot link there. +# all_test_cpp holds absolute paths from file(GLOB), so filter by basename. +if(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) + foreach(_eve_devtool_test IN ITEMS callgraph.cpp renderflow.cpp debugger.cpp dap.cpp mcp.cpp) + list(FILTER all_test_cpp EXCLUDE REGEX "/${_eve_devtool_test}$") + endforeach() + message(STATUS "Android test: removed devtools test files, remaining=${all_test_cpp}") +endif() + file(GLOB all_test_scripts filesystem.nut graphic.nut @@ -121,15 +133,36 @@ add_custom_command(OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/nut_scripts.cpp WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} ) -add_executable( - unit_test - ${all_test_cpp} - ${CMAKE_CURRENT_BINARY_DIR}/nut_scripts.cpp -) +# On Android the zeroerr runner is packaged as the app's native entry point +# (libmain.so loaded by SDLActivity), replacing the engine main.cpp. +if(EVENGINE_ANDROID_TEST_APP) + add_library(main SHARED + ${all_test_cpp} + ${CMAKE_CURRENT_BINARY_DIR}/nut_scripts.cpp + ) +else() + add_executable( + unit_test + ${all_test_cpp} + ${CMAKE_CURRENT_BINARY_DIR}/nut_scripts.cpp + ) +endif() if(WIN32) set(SystemLibraries ${SystemLibraries} winmm version imm32 Setupapi ws2_32 iphlpapi shlwapi) +elseif(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) + find_library(ANDROID_LOG_LIB log) + find_library(ANDROID_LIB android) + find_library(ANDROID_OPENSLES_LIB OpenSLES) + set(SystemLibraries ${SystemLibraries} + ${ANDROID_LOG_LIB} + ${ANDROID_LIB} + ${ANDROID_OPENSLES_LIB} + dl + m + c++_shared + ) elseif(APPLE) set(SystemLibraries ${SystemLibraries} dl pthread iconv @@ -193,6 +226,11 @@ else() set(ThirdParty ${ThirdParty} simplesquirrel_staticmd sqstdlib_staticmd squirrel_staticmd SDL2 PocoDataSQLitemd PocoDatamd PocoFoundationmd PocoNetmd PocoJSONmd PocoXMLmd ${SystemLibraries}) + elseif(ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android")) + # Same unprefixed static names as the engine Android build; SDL2 stays shared. + set(ThirdParty ${ThirdParty} simplesquirrel_static sqstdlib_static squirrel_static SDL2 SDL2main + PocoDataSQLite PocoData PocoNet PocoXML PocoJSON PocoFoundation + ${SystemLibraries}) else() set(ThirdParty ${ThirdParty} simplesquirrel_static sqstdlib_static squirrel_static SDL2 PocoDataSQLite PocoData PocoNet PocoXML PocoJSON PocoFoundation @@ -211,7 +249,6 @@ set(EVELIBS EVTouch EVGraphics EVImage - EVDevTools EVCommon EVEvent EVTimer @@ -248,6 +285,10 @@ set(EVELIBS EVStylize EVEditor) +if(NOT (ANDROID OR (CMAKE_SYSTEM_NAME STREQUAL "Android"))) + list(APPEND EVELIBS EVDevTools) +endif() + if(EVENGINE_BUILD_DEMO) list(APPEND EVELIBS EVDemo) @@ -257,14 +298,30 @@ if(EVENGINE_PLATFORM_LIB) list(APPEND EVELIBS ${EVENGINE_PLATFORM_LIB}) endif() -target_link_libraries(unit_test ${EVELIBS} zeroerr ${ThirdParty} ${EVENGINE_VULKAN_LIB}) -target_compile_definitions(unit_test PRIVATE - EVENGINE_TEST_BINARY_DIR="${CMAKE_CURRENT_BINARY_DIR}" - EVENGINE_SOURCE_DIR="${CMAKE_SOURCE_DIR}") +if(EVENGINE_ANDROID_TEST_APP) + target_link_libraries(main ${EVELIBS} zeroerr ${ThirdParty} ${EVENGINE_VULKAN_LIB}) + # Source/binary roots are resolved at runtime: the Android test runner chdirs to + # the unpacked asset root before running, so "." means "repo root on device". + target_compile_definitions(main PRIVATE + EVENGINE_TEST_BINARY_DIR="." + EVENGINE_SOURCE_DIR="." + EVENGINE_ANDROID=1) + # Keep SDL_main / module registration symbols visible for the dlopened SDLActivity. + set_target_properties(main PROPERTIES + CXX_VISIBILITY_PRESET default + VISIBILITY_INLINES_HIDDEN OFF) +else() + target_link_libraries(unit_test ${EVELIBS} zeroerr ${ThirdParty} ${EVENGINE_VULKAN_LIB}) + target_compile_definitions(unit_test PRIVATE + EVENGINE_TEST_BINARY_DIR="${CMAKE_CURRENT_BINARY_DIR}" + EVENGINE_SOURCE_DIR="${CMAKE_SOURCE_DIR}") +endif() # Large classic scenes (Sponza / DamagedHelmet). Cornell Box is committed. # Enable with -DEVENGINE_DOWNLOAD_CLASSIC_SCENES=ON, or build target # `download_classic_scenes` before running ClassicScenes.* tests. +# (Skipped on the Android test app: bash/python3 download steps do not run there.) +if(NOT EVENGINE_ANDROID_TEST_APP) option(EVENGINE_DOWNLOAD_CLASSIC_SCENES "Download large classic render test scenes (Sponza, DamagedHelmet)" OFF) set(_eve_classic_scenes_script "${CMAKE_SOURCE_DIR}/scripts/download_classic_scenes.sh") @@ -329,6 +386,9 @@ if(EVENGINE_DOWNLOAD_SKINNED_CHARACTER) COMMENT "PRE_BUILD: download skinned character (CesiumMan)" VERBATIM) endif() +endif() # NOT EVENGINE_ANDROID_TEST_APP +if(NOT EVENGINE_ANDROID_TEST_APP) include(${CMAKE_SOURCE_DIR}/cmake/ZeroErrDiscoverTests.cmake) zeroerr_discover_tests(unit_test) +endif() diff --git a/test/ClassicScenes.cpp b/test/ClassicScenes.cpp index e3b1cf9f..82e49556 100644 --- a/test/ClassicScenes.cpp +++ b/test/ClassicScenes.cpp @@ -46,12 +46,8 @@ using namespace eve::graphics; namespace { -std::string pathBesideThisSource(const char *filename) { - std::string here = __FILE__; - auto slash = here.find_last_of("/\\"); - std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); - return dir + "/" + filename; -} +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() std::vector readBinaryFile(const std::string &path) { std::ifstream in(path, std::ios::binary); diff --git a/test/IBL.cpp b/test/IBL.cpp index 970106a2..0e6b5c81 100644 --- a/test/IBL.cpp +++ b/test/IBL.cpp @@ -24,12 +24,8 @@ using namespace eve::graphics; namespace { -std::string pathBesideThisSource(const char *filename) { - std::string here = __FILE__; - auto slash = here.find_last_of("/\\"); - std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); - return dir + "/" + filename; -} +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() std::vector readBinaryFile(const std::string &path) { std::ifstream in(path, std::ios::binary); diff --git a/test/PathBesideSource.h b/test/PathBesideSource.h new file mode 100644 index 00000000..32be9c1e --- /dev/null +++ b/test/PathBesideSource.h @@ -0,0 +1,65 @@ +#pragma once +// test/PathBesideSource.h +// +// Shared helper for resolving test asset paths relative to the test source file. +// On desktop the path is dirname(__FILE__); on Android the build-host path baked +// into __FILE__ is replaced with the on-device unpacked tree root so that +// `pathBesideThisSource("assets/...")` resolves to /evengine_test/assets/... +// +// Deliberately uses its own `eve_test_path` namespace (NOT `eve`): this header is +// included *inside* the anonymous namespaces of several test files, so introducing +// `namespace eve` there would shadow the global ::eve and break every +// `eve::window::...` / `eve::image::...` reference in those files. + +#include + +#if defined(EVENGINE_ANDROID) +#include +#endif + +namespace eve_test_path { + +#if defined(EVENGINE_ANDROID) +inline std::string androidTestRoot() { + static const std::string root = [] { + const char *internal = SDL_AndroidGetInternalStoragePath(); + return internal ? std::string(internal) + "/evengine_test" : std::string(); + }(); + return root; +} + +// Map a build-host source path (__FILE__) onto the on-device test layout. +// All test sources are flat in test/, and the APK unpacks assets/test/* directly +// into the device root (/evengine_test/*), so the basename is the only +// meaningful part of __FILE__ here. +inline std::string androidSourcePath(const char *file) { + std::string s = file ? file : ""; + const std::string root = androidTestRoot(); + if (root.empty()) + return s; + size_t slash = s.find_last_of("/\\"); + std::string rel = (slash == std::string::npos) ? s : s.substr(slash + 1); + return root + "/" + rel; +} +#endif // EVENGINE_ANDROID + +inline std::string pathBesideSourceImpl(const char *file, const char *relative) { +#if defined(EVENGINE_ANDROID) + std::string source = androidSourcePath(file); + size_t slash = source.find_last_of('/'); + std::string dir = (slash == std::string::npos) ? std::string(".") : source.substr(0, slash); + return dir + "/" + relative; +#else + std::string here = file ? file : ""; + auto slash = here.find_last_of("/\\"); + std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); + return dir + "/" + relative; +#endif +} + +} // namespace eve_test_path + +#define EVE_DEFINE_PATH_BESIDE_SOURCE() \ + static std::string pathBesideThisSource(const char *filename) { \ + return eve_test_path::pathBesideSourceImpl(__FILE__, filename); \ + } diff --git a/test/RenderImageAudit.cpp b/test/RenderImageAudit.cpp index c86fdbba..903c8d77 100644 --- a/test/RenderImageAudit.cpp +++ b/test/RenderImageAudit.cpp @@ -866,12 +866,8 @@ struct CloseWin { } }; -std::string pathBesideThisSource(const char *relative) { - std::string here = __FILE__; - auto slash = here.find_last_of("/\\"); - std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); - return dir + "/" + relative; -} +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() Mesh *makeFloor(Graphics *gfx, float half = 4.5f) { const float pos[] = { diff --git a/test/Texture/grass_sway_0.png b/test/Texture/grass_sway_0.png new file mode 100644 index 00000000..b3f93ed3 Binary files /dev/null and b/test/Texture/grass_sway_0.png differ diff --git a/test/Texture/grass_sway_1.png b/test/Texture/grass_sway_1.png new file mode 100644 index 00000000..e2ee935e Binary files /dev/null and b/test/Texture/grass_sway_1.png differ diff --git a/test/Texture/grass_sway_2.png b/test/Texture/grass_sway_2.png new file mode 100644 index 00000000..7b82d9c5 Binary files /dev/null and b/test/Texture/grass_sway_2.png differ diff --git a/test/Texture/grass_sway_3.png b/test/Texture/grass_sway_3.png new file mode 100644 index 00000000..0a260d5b Binary files /dev/null and b/test/Texture/grass_sway_3.png differ diff --git a/test/Texture/leaf_sway_0.png b/test/Texture/leaf_sway_0.png new file mode 100644 index 00000000..441780ea Binary files /dev/null and b/test/Texture/leaf_sway_0.png differ diff --git a/test/Texture/leaf_sway_1.png b/test/Texture/leaf_sway_1.png new file mode 100644 index 00000000..eca0b43f Binary files /dev/null and b/test/Texture/leaf_sway_1.png differ diff --git a/test/UIOverlaySmoke.cpp b/test/UIOverlaySmoke.cpp index 4ef76af9..44cd3a23 100644 --- a/test/UIOverlaySmoke.cpp +++ b/test/UIOverlaySmoke.cpp @@ -84,7 +84,7 @@ TEST_CASE("UI.smoke.RenderSystemSameFrameVulkanOverlay") { } // After present+readback, swapchain content should be readable. - Color p = gfx->getPixel(gfx->getPixelWidth() / 2, gfx->getPixelHeight() / 2); + Color p = gfx->getPixel(gfx->getWidth() / 2, gfx->getHeight() / 2); CHECK(std::isfinite(p.r)); CHECK(std::isfinite(p.g)); CHECK(std::isfinite(p.b)); diff --git a/test/UISystem.cpp b/test/UISystem.cpp index 7572f447..2cbe681b 100644 --- a/test/UISystem.cpp +++ b/test/UISystem.cpp @@ -81,8 +81,18 @@ TEST_CASE("UISystem.render.headlessImGuiWalk") { UIHost *h = UIHost::createHost("walk"); h->setTree(window("Panel", {text("Label", "label"), button("Btn", "btn")})); + // This test is headless: it drives UISystem::render() with a private ImGui + // context. When run in the same process after a real-window UI test, the UI + // module's ImGui backend keeps its own context (and present-overlay hook). + // ImGui::CreateContext() only auto-switches current when GImGui is NULL, so + // explicitly switch to the private context and back — otherwise the UI + // backend's context would be the one NewFrame/EndFrame and DestroyContext + // operate on, leaving a dangling ImGui context for later present() calls. + ImGuiContext *savedContext = ImGui::GetCurrentContext(); + IMGUI_CHECKVERSION(); - ImGui::CreateContext(); + ImGuiContext *headlessContext = ImGui::CreateContext(); + ImGui::SetCurrentContext(headlessContext); ImGuiIO &io = ImGui::GetIO(); io.DisplaySize = ImVec2(800.f, 600.f); io.IniFilename = nullptr; @@ -94,7 +104,8 @@ TEST_CASE("UISystem.render.headlessImGuiWalk") { ImGui::NewFrame(); UISystem::render(); ImGui::EndFrame(); - ImGui::DestroyContext(); + ImGui::DestroyContext(headlessContext); + if (savedContext) ImGui::SetCurrentContext(savedContext); CHECK(!h->tree()->dirty); } diff --git a/test/animation_skinned.cpp b/test/animation_skinned.cpp index f6b0c99f..43714267 100644 --- a/test/animation_skinned.cpp +++ b/test/animation_skinned.cpp @@ -42,12 +42,8 @@ using namespace eve::graphics; namespace { -std::string pathBesideThisSource(const char *relative) { - std::string here = __FILE__; - auto slash = here.find_last_of("/\\"); - std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); - return dir + "/" + relative; -} +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() bool fileExists(const std::string &path) { return std::filesystem::is_regular_file(path); @@ -585,7 +581,12 @@ TEST_CASE("animation.skinned.cesiumMan.animationFactory") { loadCesiumMan("ev_ut_animation_skinned_factory")); REQUIRE(model.get() != nullptr); - std::unique_ptr anim(Animation::create()); + // Animation::create() returns the process-wide module singleton owned by + // ModuleManager; do NOT wrap it in unique_ptr (that would delete the + // singleton out from under ModuleManager and leave a dangling pointer for + // later tests that call Animation::create() again). + Animation *anim = Animation::create(); + REQUIRE(anim != nullptr); std::unique_ptr sk(anim->newSkeletonFromModel(model.get())); REQUIRE(sk.get() != nullptr); const int meshIndex = findFirstSkinnedMesh(model.get()); diff --git a/test/animation_sprite_spine.cpp b/test/animation_sprite_spine.cpp index da9fdba8..96e0007a 100644 --- a/test/animation_sprite_spine.cpp +++ b/test/animation_sprite_spine.cpp @@ -239,6 +239,18 @@ TEST_CASE("animation.spine.skeletonAndAnim") { player->collectDrawItems(items); CHECK_EQ(static_cast(items.size()), 2); CHECK_EQ(items[0].layer, 0); + + std::vector mirrored; + player->setScale(-1.f, 1.f); + player->collectDrawItems(mirrored); + REQUIRE_EQ(static_cast(mirrored.size()), 2); + // Negative X scale reconstructs the reflected quad with a 180-degree + // rotation. A V-axis UV compensation keeps attachments upright while + // preserving the requested left/right mirror. + CHECK(std::fabs(mirrored[0].u0 - items[0].u0) < 1e-6f); + CHECK(std::fabs(mirrored[0].u1 - items[0].u1) < 1e-6f); + CHECK(std::fabs(mirrored[0].v0 - items[0].v1) < 1e-6f); + CHECK(std::fabs(mirrored[0].v1 - items[0].v0) < 1e-6f); } TEST_CASE("animation.spine.modulePump") { diff --git a/test/async.cpp b/test/async.cpp index a6a7045e..7be75899 100644 --- a/test/async.cpp +++ b/test/async.cpp @@ -112,6 +112,27 @@ TEST_CASE("async.asyncDelay.worker") { CHECK_EQ(out, std::string("hello")); } +TEST_CASE("async.asyncPost.sameNameCorrelatedByTask") { + std::string out = runAsyncSnippet(R"( + result <- "pending"; + local slow = ""; + local fast = ""; + asyncPost("same-name-correlation", "slow", 25).then(function(v) { slow = v; }); + asyncPost("same-name-correlation", "fast", 1).then(function(v) { fast = v; }); + thread.getPool().waitAll(); + + local name = event.poll(); + while (name != "") { + async_dispatch_event(name, event.getLastData()); + name = event.poll(); + } + for (local i = 0; i < 6; i++) + async_pump(); + result = slow + "," + fast; + )"); + CHECK_EQ(out, std::string("slow,fast")); +} + TEST_CASE("async.postMain.event") { auto *th = eve::thread::Thread::create(); auto *ev = eve::event::Event::create(); diff --git a/test/audio.cpp b/test/audio.cpp index f0327d97..f868aa1b 100644 --- a/test/audio.cpp +++ b/test/audio.cpp @@ -20,12 +20,8 @@ namespace { -std::string pathBesideThisSource(const char *filename) { - std::string here = __FILE__; - auto slash = here.find_last_of("/\\"); - std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); - return dir + "/" + filename; -} +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() std::vector readBinaryFile(const std::string &path) { std::ifstream in(path, std::ios::binary); diff --git a/test/box2d.cpp b/test/box2d.cpp index 3d309280..75e814e3 100644 --- a/test/box2d.cpp +++ b/test/box2d.cpp @@ -91,6 +91,105 @@ TEST_CASE("box2d.fixture.sensor") { CHECK(std::fabs(fixture->getFriction() - 0.7f) < 0.001f); } +TEST_CASE("box2d.fixture.tagsFiltersAndBodyProperties") { + auto *mod = Physics::create(); + std::unique_ptr world(mod->newWorld(0.f, 0.f, true)); + std::unique_ptr body(world->newBody("dynamic", 30.f, 60.f)); + std::unique_ptr fixture(body->newRectangleFixture(30.f, 60.f, 2.f)); + + fixture->setTag("prop.crate"); + fixture->setCategoryBits(0x0020); + fixture->setMaskBits(0x0006); + fixture->setGroupIndex(-3); + CHECK_EQ(fixture->getTag(), std::string("prop.crate")); + CHECK_EQ(fixture->getCategoryBits(), 0x0020); + CHECK_EQ(fixture->getMaskBits(), 0x0006); + CHECK_EQ(fixture->getGroupIndex(), -3); + CHECK_EQ(fixture->getBodyId(), body->getId()); + CHECK_GT(body->getMass(), 0.f); + CHECK(std::fabs(body->getWorldCenterX() - 30.f) < 0.01f); + CHECK(std::fabs(body->getWorldCenterY() - 60.f) < 0.01f); + body->setLinearVelocity(300.f, 400.f); + CHECK(std::fabs(body->getLinearSpeed() - 500.f) < 0.1f); +} + +TEST_CASE("box2d.contacts.exposeTagsAndImpactData") { + auto *mod = Physics::create(); + std::unique_ptr world(mod->newWorld(0.f, 0.f, true)); + Body *wall = world->newBody("static", 300.f, 100.f); + Fixture *wallFixture = wall->newRectangleFixture(20.f, 160.f, 0.f, 0.2f, 0.f); + wallFixture->setTag("terrain"); + Body *crate = world->newBody("dynamic", 80.f, 100.f); + Fixture *crateFixture = crate->newRectangleFixture(40.f, 40.f, 1.f, 0.2f, 0.f); + crateFixture->setTag("prop.crate"); + crate->setBullet(true); + crate->setLinearVelocity(900.f, 0.f); + + for (int i = 0; i < 30 && world->getImpactCount() == 0; ++i) + world->update(1.f / 60.f); + + REQUIRE_GT(world->getBeginContactCount(), 0); + CHECK_EQ(world->getBeginContactFixtureATag(0), std::string("terrain")); + CHECK_EQ(world->getBeginContactFixtureBTag(0), std::string("prop.crate")); + REQUIRE_GT(world->getImpactCount(), 0); + CHECK_GT(world->getImpactRelativeNormalSpeed(0), 100.f); + CHECK_GT(world->getImpactNormalImpulse(0), 0.f); + CHECK(std::fabs(world->getImpactNormalX(0)) > 0.9f); + CHECK(std::fabs(world->getImpactPointX(0) - 290.f) < 5.f); + + world->clearContactEvents(); + CHECK_EQ(world->getBeginContactCount(), 0); + CHECK_EQ(world->getEndContactCount(), 0); + CHECK_EQ(world->getImpactCount(), 0); + crate->setPosition(80.f, 100.f); + crate->setLinearVelocity(0.f, 0.f); + for (int i = 0; i < 5; ++i) world->update(1.f / 60.f); + CHECK_EQ(world->getImpactCount(), 0); + + delete crateFixture; + delete crate; + delete wallFixture; + delete wall; +} + +TEST_CASE("box2d.gameplay.sameKickMovesLightPropFaster") { + auto *mod = Physics::create(); + std::unique_ptr world(mod->newWorld(0.f, 0.f, true)); + Body *crate = world->newBody("dynamic", 0.f, 0.f); + crate->newRectangleFixture(40.f, 40.f, 1.f, 0.45f, 0.05f); + Body *rock = world->newBody("dynamic", 100.f, 0.f); + rock->newRectangleFixture(40.f, 40.f, 8.f, 0.85f, 0.05f); + + crate->applyLinearImpulse(720.f, -105.f); + rock->applyLinearImpulse(720.f, -105.f); + + CHECK_GT(rock->getMass(), crate->getMass() * 7.f); + CHECK_GT(crate->getLinearSpeed(), rock->getLinearSpeed() * 7.f); +} + +TEST_CASE("box2d.contacts.destroyFixturePurgesQueuedEvents") { + auto *mod = Physics::create(); + std::unique_ptr world(mod->newWorld(0.f, 0.f, true)); + Body *wall = world->newBody("static", 100.f, 100.f); + Fixture *wallFixture = wall->newRectangleFixture(40.f, 40.f); + wallFixture->setTag("terrain"); + Body *box = world->newBody("dynamic", 100.f, 100.f); + Fixture *boxFixture = box->newRectangleFixture(30.f, 30.f, 1.f); + boxFixture->setTag("prop.crate"); + world->update(1.f / 60.f); + REQUIRE_GT(world->getBeginContactCount(), 0); + + boxFixture->destroy(); + CHECK_EQ(world->getBeginContactCount(), 0); + CHECK_EQ(world->getEndContactCount(), 0); + CHECK_EQ(world->getImpactCount(), 0); + + delete boxFixture; + delete box; + delete wallFixture; + delete wall; +} + TEST_CASE("box2d.body.destroy") { auto *mod = Physics::create(); std::unique_ptr world(mod->newWorld(0.f, 0.f, true)); diff --git a/test/dap.cpp b/test/dap.cpp index b0d390c5..780e1076 100644 --- a/test/dap.cpp +++ b/test/dap.cpp @@ -18,7 +18,9 @@ #include #include +#include #include +#include #include #include #include @@ -455,3 +457,125 @@ TEST_CASE("devtools.dap.scriptBreakpointHitViaHook") { dt.detach(); dap.stop(); } + +TEST_CASE("devtools.dap.realScriptBreakpointAndCallStack") { + auto& dap = DebugAdapter::instance(); + auto& dbg = Debugger::instance(); + auto& dt = DevTool::instance(); + + dap.stop(); + dt.detach(); + dbg.clearBreakpoints(); + + const char* src = + "function inner() {\n" // line 1 + " local a = 1\n" // line 2 + " local b = a + 1\n" // line 3 <-- breakpoint + "}\n" + "function outer() {\n" + " inner()\n" + "}\n" + "outer()\n"; + + std::atomic attached{false}; + std::atomic go{false}; + std::atomic scriptDone{false}; + + // Run the script on its own thread. DevTool must be attached HERE because + // g_active is thread_local and the Squirrel debug hook fires on this thread. + // DevTool::attach() internally calls detach()->stopDap(), so the DAP server + // must NOT be listening yet — the main thread waits for `attached` first. + std::thread scriptThread([&] { + ssq::VM vm(1024, ssq::Libs::ALL); + HSQUIRRELVM v = vm.getHandle(); + dt.attach(v, /*sampleLocals=*/false); + attached = true; + while (!go.load()) std::this_thread::sleep_for(std::chrono::milliseconds(2)); + const SQInteger len = static_cast(std::strlen(src)); + SQRESULT rc = sq_compilebuffer(v, src, len, _SC("e2e.nut"), SQTrue); + REQUIRE(SQ_SUCCEEDED(rc)); + sq_pushroottable(v); + REQUIRE(SQ_SUCCEEDED(sq_call(v, 1, SQFalse, SQTrue))); + sq_poptop(v); + dt.detach(); + scriptDone = true; + }); + + // Wait until DevTool is attached (and DAP stopped), then start DAP + handshake. + while (!attached.load()) std::this_thread::sleep_for(std::chrono::milliseconds(2)); + + const int port = dap.listen(0); + REQUIRE(port > 0); + + DapClient client(port); + pump(20); + client.sendRequest("initialize", "{\"adapterID\":\"eve\"}"); + REQUIRE(client.expectResponse("initialize")); + client.sendRequest("launch", "{\"program\":\".\"}"); + REQUIRE(client.expectResponse("launch")); + client.sendRequest("setBreakpoints", + "{\"source\":{\"path\":\"e2e.nut\",\"name\":\"e2e.nut\"}," + "\"breakpoints\":[{\"line\":3}]}"); + REQUIRE(client.expectResponse("setBreakpoints")); + client.sendRequest("configurationDone"); + REQUIRE(client.expectResponse("configurationDone")); + + // Release the script now that breakpoints are installed. + client.clearEvents(); + go = true; + + // The breakpoint hit produces a `stopped` event with the real location. + auto stopped = client.expectEvent("stopped", 8000); + REQUIRE(stopped); + { + auto body = stopped->getObject("body"); + REQUIRE(body); + CHECK_EQ(body->optValue("reason", ""), std::string("breakpoint")); + CHECK_EQ(body->optValue("line", 0), 3); + } + + // The call stack must include the CURRENT (innermost) frame `inner` first. + client.sendRequest("stackTrace", "{\"threadId\":1,\"startFrame\":0,\"levels\":20}"); + auto stResp = client.expectResponse("stackTrace"); + REQUIRE(stResp); + { + auto body = stResp->getObject("body"); + REQUIRE(body); + auto frames = body->getArray("stackFrames"); + REQUIRE(frames); + REQUIRE(frames->size() >= 3u); + auto f0 = frames->getObject(0); + REQUIRE(f0); + CHECK_EQ(f0->optValue("line", 0), 3); + CHECK(f0->optValue("name", "").find("inner") != std::string::npos); + auto f1 = frames->getObject(1); + REQUIRE(f1); + CHECK(f1->optValue("name", "").find("outer") != std::string::npos); + auto f2 = frames->getObject(2); + REQUIRE(f2); + CHECK(f2->optValue("name", "").find("main") != std::string::npos); + } + + // F5 continue from VS Code → script finishes. + client.sendRequest("continue", "{\"threadId\":1}"); + REQUIRE(client.expectResponse("continue")); + + // Poll-based join: if a previous CHECK/REQUIRE failed the script may never + // finish, so never block on an unconditional join (would hang the suite). + for (int i = 0; i < 200 && !scriptDone.load(); ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + dap.poll(); + } + if (scriptThread.joinable()) { + if (scriptDone.load()) { + scriptThread.join(); + } else { + scriptThread.detach(); // test already failed; avoid std::terminate + } + } + const bool done = scriptDone.load(); + CHECK(done); + CHECK(!dbg.isPaused()); + + dap.stop(); +} diff --git a/test/debugger_audit.cpp b/test/debugger_audit.cpp new file mode 100644 index 00000000..b22d53f1 --- /dev/null +++ b/test/debugger_audit.cpp @@ -0,0 +1,299 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "devtools/Debugger.hpp" + +#include +#include + +#include +#include +#include +#include + +using namespace eve::dev; + +namespace { + +struct AuditDriver { + Debugger* dbg = nullptr; + int stops = 0; + std::vector stopLines; + std::vector stopFuncs; + std::vector stopDepths; + std::vector> actions; + std::function capture; // optional: capture extra state on each stop +}; + +void auditHook(HSQUIRRELVM v, SQInteger type, const SQChar* sourcename, SQInteger line, + const SQChar* funcname) { + auto* st = static_cast(sq_getforeignptr(v)); + if (!st || !st->dbg) return; + if (type != 'l') return; + SourceLoc loc; + loc.source = sourcename ? sourcename : ""; + loc.line = static_cast(line); + loc.function = funcname ? funcname : ""; + if (st->dbg->onScriptLine(loc)) { + const int idx = st->stops; + st->stopLines.push_back(loc.line); + st->stopFuncs.push_back(loc.function); + st->stopDepths.push_back(st->dbg->scriptStackDepth()); + if (st->capture) st->capture(); + if (idx < static_cast(st->actions.size())) { + st->actions[idx](); + } else { + st->dbg->resume(); + } + ++st->stops; + } +} + +/** Compile+run a buffer through the Debugger, driving it with `driver`. */ +void runAudit(const char* src, const char* bufname, AuditDriver& driver) { + ssq::VM vm(1024, ssq::Libs::ALL); + HSQUIRRELVM v = vm.getHandle(); + + Debugger& d = Debugger::instance(); + d.detach(); + driver.dbg = &d; + d.attach(v); + + sq_setforeignptr(v, &driver); + sq_enabledebuginfo(v, SQTrue); + sq_setnativedebughook(v, auditHook); + + SQRESULT rc = sq_compilebuffer(v, src, static_cast(std::strlen(src)), _SC(bufname), + SQTrue); + REQUIRE(SQ_SUCCEEDED(rc)); + sq_pushroottable(v); + REQUIRE(SQ_SUCCEEDED(sq_call(v, 1, SQFalse, SQTrue))); + sq_poptop(v); + + sq_setnativedebughook(v, nullptr); + sq_setforeignptr(v, nullptr); + d.detach(); + driver.dbg = nullptr; +} + +} // namespace + +// --------------------------------------------------------------------------- +// Call stack retrieval at a breakpoint (nested calls) +// --------------------------------------------------------------------------- + +TEST_CASE("devtools.audit.callStackAtNestedBreakpoint") { + const char* src = + "function inner() {\n" + " local a = 1\n" + "}\n" + "function outer() {\n" + " inner()\n" + "}\n" + "outer()\n"; + + AuditDriver driver; + driver.actions = {[]() { Debugger::instance().resume(); }}; + + std::vector captured; + driver.capture = [&]() { captured = Debugger::instance().stackTrace(16); }; + + Debugger& d = Debugger::instance(); + d.clearBreakpoints(); + d.setBreakpoint("audit1.nut", 2); + runAudit(src, "audit1.nut", driver); + + REQUIRE(driver.stops >= 1); + REQUIRE(captured.size() >= 3u); + CHECK_EQ(captured[0].loc.line, 2); + CHECK(captured[0].name.find("inner") != std::string::npos); + CHECK_EQ(captured[0].loc.source, std::string("audit1.nut")); + CHECK(captured[1].name.find("outer") != std::string::npos); + CHECK_EQ(captured[1].loc.line, 5); + // Top-level script frame present. + CHECK(!captured[2].loc.source.empty()); +} + +// --------------------------------------------------------------------------- +// stepOut: finish current function, stop in the caller after the call +// --------------------------------------------------------------------------- + +TEST_CASE("devtools.audit.stepOutReturnsToCaller") { + const char* src = + "function callee() {\n" + " local a = 1\n" + " local b = 2\n" + "}\n" + "function caller() {\n" + " callee()\n" + " local c = 3\n" + "}\n" + "caller()\n"; + + AuditDriver driver; + // Stop 1 (callee body) -> stepOut. Stop 2 (caller after call) -> resume. + driver.actions = { + []() { Debugger::instance().stepOut(); }, + []() { Debugger::instance().resume(); }, + }; + + Debugger& d = Debugger::instance(); + d.clearBreakpoints(); + d.setBreakpoint("audit2.nut", 2); + runAudit(src, "audit2.nut", driver); + + REQUIRE(driver.stops >= 2); + CHECK_EQ(driver.stopLines[0], 2); + CHECK(driver.stopFuncs[0].find("callee") != std::string::npos); + // After stepOut we should be back in the caller, past the call site. + CHECK(driver.stopFuncs[1].find("caller") != std::string::npos); + CHECK_EQ(driver.stopLines[1], 7); +} + +// --------------------------------------------------------------------------- +// stepInto: enter the callee and grow the call stack +// --------------------------------------------------------------------------- + +TEST_CASE("devtools.audit.stepIntoEntersAndGrowsStack") { + const char* src = + "function callee() {\n" + " local a = 1\n" + "}\n" + "function caller() {\n" + " callee()\n" + " local b = 2\n" + "}\n" + "caller()\n"; + + AuditDriver driver; + driver.actions = { + []() { Debugger::instance().stepInto(); }, + []() { Debugger::instance().resume(); }, + }; + + Debugger& d = Debugger::instance(); + d.clearBreakpoints(); + d.setBreakpoint("audit3.nut", 5); // callee() call site in caller + runAudit(src, "audit3.nut", driver); + + REQUIRE(driver.stops >= 2); + CHECK_EQ(driver.stopLines[0], 5); + CHECK(driver.stopFuncs[1].find("callee") != std::string::npos); + CHECK(driver.stopDepths[1] > driver.stopDepths[0]); +} + +// --------------------------------------------------------------------------- +// stepOver: skip the callee, stop on the next statement in the caller +// --------------------------------------------------------------------------- + +TEST_CASE("devtools.audit.stepOverSkipsCallee") { + const char* src = + "function callee() {\n" + " local a = 1\n" + "}\n" + "function caller() {\n" + " callee()\n" + " local b = 2\n" + "}\n" + "caller()\n"; + + AuditDriver driver; + driver.actions = { + []() { Debugger::instance().stepOver(); }, + []() { Debugger::instance().resume(); }, + }; + + Debugger& d = Debugger::instance(); + d.clearBreakpoints(); + d.setBreakpoint("audit4.nut", 5); // callee() call site in caller + runAudit(src, "audit4.nut", driver); + + REQUIRE(driver.stops >= 2); + CHECK_EQ(driver.stopLines[1], 6); // local b = 2 in caller, not inside callee + CHECK(driver.stopFuncs[1].find("caller") != std::string::npos); + CHECK_EQ(driver.stopDepths[1], driver.stopDepths[0]); +} + +// --------------------------------------------------------------------------- +// Breakpoints keep hitting on every loop iteration after continue +// --------------------------------------------------------------------------- + +TEST_CASE("devtools.audit.breakpointRehitsInLoop") { + const char* src = + "local total = 0\n" + "for (local i = 0; i < 3; ++i) {\n" + " total += i\n" + "}\n" + "print(total)\n"; + + AuditDriver driver; + // Continue (resume) after every stop; the breakpoint must hit each iteration. + driver.actions = { + []() { Debugger::instance().resume(); }, + []() { Debugger::instance().resume(); }, + []() { Debugger::instance().resume(); }, + }; + + Debugger& d = Debugger::instance(); + d.clearBreakpoints(); + d.setBreakpoint("audit5.nut", 3); + runAudit(src, "audit5.nut", driver); + + // i = 0,1,2 �?exactly three stops at the loop body line. + CHECK_EQ(driver.stops, 3); + for (int l : driver.stopLines) CHECK_EQ(l, 3); +} + +// --------------------------------------------------------------------------- +// Locals of the current frame are readable at a breakpoint +// --------------------------------------------------------------------------- + +TEST_CASE("devtools.audit.localsAtBreakpoint") { + const char* src = + "function compute(x, y) {\n" + " local z = x + y\n" + " local name = \"audit\"\n" + " return z\n" + "}\n" + "compute(3, 4)\n"; + + AuditDriver driver; + driver.actions = {[]() { Debugger::instance().resume(); }}; + + std::vector locals; + driver.capture = [&]() { locals = Debugger::instance().locals(0); }; + + Debugger& d = Debugger::instance(); + d.clearBreakpoints(); + // Pause on `return z`: all four locals (x, y, z, name) are assigned by then. + d.setBreakpoint("audit6.nut", 4); + runAudit(src, "audit6.nut", driver); + + REQUIRE(driver.stops >= 1); + bool sawX = false, sawY = false, sawZ = false, sawName = false; + for (const auto& lv : locals) { + if (lv.name == "x") { + sawX = true; + CHECK(lv.value.find("3") != std::string::npos); + } else if (lv.name == "y") { + sawY = true; + CHECK(lv.value.find("4") != std::string::npos); + } else if (lv.name == "z") { + sawZ = true; + CHECK(lv.value.find("7") != std::string::npos); + } else if (lv.name == "name") { + sawName = true; + CHECK(lv.value.find("audit") != std::string::npos); + } + } + CHECK(sawX); + CHECK(sawY); + CHECK(sawZ); + CHECK(sawName); + + // evaluate() must read the CURRENT frame's locals (regression: it used to + // read the caller's frame at level 1). + auto ev = Debugger::instance().evaluate("z"); + CHECK_EQ(ev.type, std::string("integer")); + CHECK(ev.value.find("7") != std::string::npos); +} diff --git a/test/event.cpp b/test/event.cpp index 8b840f06..1f60a53d 100644 --- a/test/event.cpp +++ b/test/event.cpp @@ -3,6 +3,9 @@ #include "event/Event.h" +#include +#include + using eve::event::Event; using eve::event::Message; using eve::event::Variant; @@ -104,3 +107,19 @@ TEST_CASE("event.pumpSmoke") { auto* ev = Event::create(); ev->pump(); } + +TEST_CASE("event.workerPushWakesWait") { + auto *ev = Event::create(); + ev->clear(); + std::thread producer([ev] { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + ev->pushData("worker-wake", "ready"); + }); + Message *message = ev->wait(); + producer.join(); + REQUIRE(message != nullptr); + CHECK_EQ(message->name, std::string("worker-wake")); + REQUIRE(message->args.size() == 1); + CHECK_EQ(message->args[0].s, std::string("ready")); + delete message; +} diff --git a/test/font.cpp b/test/font.cpp index 7a148e37..43360f58 100644 --- a/test/font.cpp +++ b/test/font.cpp @@ -18,12 +18,8 @@ namespace { // FontAwesome private-use icon with a non-empty bitmap in the fixture. constexpr int kIconCodepoint = 0xF000; -std::string pathBesideThisSource(const char *filename) { - std::string here = __FILE__; - auto slash = here.find_last_of("/\\"); - std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); - return dir + "/" + filename; -} +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() std::vector readBinaryFile(const std::string &path) { std::ifstream in(path, std::ios::binary); diff --git a/test/graphics_font.cpp b/test/graphics_font.cpp index be53ae36..5b03d087 100644 --- a/test/graphics_font.cpp +++ b/test/graphics_font.cpp @@ -27,12 +27,8 @@ namespace { constexpr int kIconCodepoint = 0xF000; constexpr const char *kIconUtf8 = "\xEF\x80\x80"; // UTF-8 for U+F000 -std::string pathBesideThisSource(const char *filename) { - std::string here = __FILE__; - auto slash = here.find_last_of("/\\"); - std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); - return dir + "/" + filename; -} +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() std::vector readBinaryFile(const std::string &path) { std::ifstream in(path, std::ios::binary); diff --git a/test/grass.cpp b/test/grass.cpp new file mode 100644 index 00000000..511b30ff --- /dev/null +++ b/test/grass.cpp @@ -0,0 +1,569 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "filesystem/FileData.h" +#include "graphics/Grass.h" +#include "graphics/Graphics.h" +#include "graphics/Light.h" +#include "graphics/Mesh.h" +#include "graphics/RenderControl.h" +#include "graphics/RenderSystem.h" +#include "graphics/RenderSystem3D.h" +#include "graphics/Shader.h" +#include "graphics/shaders/mesh3d_grass_frag_spv.inc" +#include "graphics/shaders/mesh3d_grass_vert_spv.inc" +#include "image/Image.h" +#include "image/ImageData.h" +#include "window/Window.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using eve::graphics::Camera3D; +using eve::graphics::Graphics; +using eve::graphics::GrassField; +using eve::graphics::Light3D; +using eve::graphics::Mesh; +using eve::graphics::Renderable2D; +using eve::graphics::Renderable3D; +using eve::graphics::RenderSystem; +using eve::graphics::RenderSystem3D; +using eve::graphics::Shader; +using eve::graphics::Texture; +using eve::graphics::grass::BillboardMesh; +using eve::graphics::grass::Point; +using eve::graphics::grass::PackedAtlasInfo; +using eve::graphics::grass::SampleParams; + +namespace { + +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() + +void makeTestPlane(std::vector &pos, std::vector &nrm, std::vector &idx) { + eve::graphics::grass::makePlane(4.f, 4.f, 4, 4, pos, nrm, idx); +} + +float minPairDist(const std::vector &pts) { + float best = 1e9f; + for (size_t i = 0; i < pts.size(); ++i) { + for (size_t j = i + 1; j < pts.size(); ++j) { + const glm::vec3 d = pts[i].position - pts[j].position; + best = std::min(best, glm::length(d)); + } + } + return best; +} + +} // namespace + +TEST_CASE("graphics.Grass.bindDefaults") { + Shader sh; + sh.setKind(Shader::Kind::eMesh3D); + eve::graphics::grass::bindDefaults(&sh); + CHECK(sh.hasUniform("time")); + CHECK(sh.hasUniform("frameDuration")); + CHECK(sh.hasUniform("lightGreen")); + CHECK(sh.hasUniform("darkGreen")); + CHECK(sh.hasUniform("alwaysDark")); + CHECK_EQ(sh.getUniformIndex("time"), 0); + CHECK_EQ(sh.getUniformIndex("alwaysDark"), 5); + CHECK_EQ(sh.getUniformIndex("lightGreen"), 6); + CHECK_EQ(sh.getUniformIndex("darkGreen"), 9); + CHECK_EQ(sh.getUniformIndex("frameCount"), 12); + CHECK_EQ(sh.getUniformIndex("atlasCols"), 13); + CHECK_EQ(sh.getUniformIndex("atlasRows"), 14); + CHECK_EQ(sh.getUniformIndex("grassVariantCount"), 15); + CHECK_EQ(sh.getUniformIndex("leafVariantCount"), 16); + CHECK_EQ(sh.getUniformIndex("leafRowOffset"), 17); + CHECK_EQ(sh.usedFloats(), 18); +} + +TEST_CASE("graphics.Grass.paramNames") { + CHECK_EQ(eve::graphics::grass::paramCount(), 18); + CHECK_EQ(eve::graphics::grass::paramName(0), std::string("time")); + CHECK_EQ(eve::graphics::grass::paramName(5), std::string("alwaysDark")); + CHECK_EQ(eve::graphics::grass::paramName(12), std::string("frameCount")); + CHECK_EQ(eve::graphics::grass::paramName(13), std::string("atlasCols")); + CHECK_EQ(eve::graphics::grass::paramName(17), std::string("leafRowOffset")); +} + +TEST_CASE("graphics.Grass.spvMagic") { + CHECK(mesh3d_grass_vert_spv_count > 0); + CHECK(mesh3d_grass_frag_spv_count > 0); + CHECK_EQ(mesh3d_grass_vert_spv[0], 0x07230203u); + CHECK_EQ(mesh3d_grass_frag_spv[0], 0x07230203u); +} + +TEST_CASE("graphics.Grass.poissonMinDistance") { + std::vector pos, nrm; + std::vector idx; + makeTestPlane(pos, nrm, idx); + + SampleParams p; + p.radius = 0.45f; + p.maxPoints = 200; + p.seed = 7; + p.minSlopeDot = 0.1f; + const auto pts = + eve::graphics::grass::samplePoisson(pos.data(), nrm.data(), int(pos.size() / 3), idx.data(), + int(idx.size()), p); + CHECK(pts.size() >= 8); + CHECK(int(pts.size()) <= p.maxPoints); + const float minD = minPairDist(pts); + CHECK(minD + 1e-4f >= p.radius); + + for (const auto &pt : pts) { + CHECK(std::abs(pt.position.y) < 1e-4f); + CHECK(pt.position.x >= -2.01f); + CHECK(pt.position.x <= 2.01f); + CHECK(pt.position.z >= -2.01f); + CHECK(pt.position.z <= 2.01f); + } +} + +TEST_CASE("graphics.Grass.haltonCountAndCoverage") { + std::vector pos, nrm; + std::vector idx; + makeTestPlane(pos, nrm, idx); + + const auto pts = eve::graphics::grass::sampleHalton( + pos.data(), nrm.data(), int(pos.size() / 3), idx.data(), int(idx.size()), 64, 3, 0.1f); + CHECK_EQ(int(pts.size()), 64); + CHECK_EQ(pts.front().id, 0u); + CHECK_EQ(pts.back().id, 63u); + + float minX = 1e9f, maxX = -1e9f, minZ = 1e9f, maxZ = -1e9f; + for (const auto &pt : pts) { + minX = std::min(minX, pt.position.x); + maxX = std::max(maxX, pt.position.x); + minZ = std::min(minZ, pt.position.z); + maxZ = std::max(maxZ, pt.position.z); + } + CHECK(maxX - minX > 2.f); + CHECK(maxZ - minZ > 2.f); +} + +TEST_CASE("graphics.Grass.sparseIsSparser") { + std::vector pos, nrm; + std::vector idx; + makeTestPlane(pos, nrm, idx); + + SampleParams dense; + dense.radius = 0.3f; + dense.maxPoints = 400; + dense.seed = 1; + SampleParams sparse = dense; + sparse.radius = 1.2f; + sparse.maxPoints = 80; + sparse.seed = 99; + + const auto d = eve::graphics::grass::samplePoisson( + pos.data(), nrm.data(), int(pos.size() / 3), idx.data(), int(idx.size()), dense); + const auto s = eve::graphics::grass::samplePoisson( + pos.data(), nrm.data(), int(pos.size() / 3), idx.data(), int(idx.size()), sparse); + CHECK(d.size() > s.size()); + CHECK(s.size() >= 1); +} + +TEST_CASE("graphics.Grass.billboardRootPivot") { + std::vector pts(1); + pts[0].position = glm::vec3(3.f, 1.5f, -2.f); + pts[0].id = 11; + pts[0].scale = 1.25f; + const BillboardMesh mesh = eve::graphics::grass::buildBillboards(pts, 0.4f, 0.8f); + CHECK_EQ(int(mesh.posXYZ.size()), 12); + CHECK_EQ(int(mesh.uvST.size()), 8); + CHECK_EQ(int(mesh.indices.size()), 6); + for (int i = 0; i < 4; ++i) { + CHECK(std::abs(mesh.posXYZ[size_t(i * 3 + 0)] - 3.f) < 1e-5f); + CHECK(std::abs(mesh.posXYZ[size_t(i * 3 + 1)] - 1.5f) < 1e-5f); + CHECK(std::abs(mesh.posXYZ[size_t(i * 3 + 2)] + 2.f) < 1e-5f); + CHECK(std::abs(mesh.nrmXYZ[size_t(i * 3 + 0)] - 11.f) < 1e-5f); + CHECK(std::abs(mesh.nrmXYZ[size_t(i * 3 + 1)] - 1.25f) < 1e-5f); + CHECK(std::abs(mesh.nrmXYZ[size_t(i * 3 + 2)]) < 1e-5f); + } + // Bottom edge UVs sit at v=0; their midpoint is the root (u=0.5). + CHECK(std::abs(mesh.uvST[1]) < 1e-5f); + CHECK(std::abs(mesh.uvST[3]) < 1e-5f); + CHECK(std::abs(0.5f * (mesh.uvST[0] + mesh.uvST[2]) - 0.5f) < 1e-5f); +} + +TEST_CASE("graphics.Grass.swayDesyncByInstanceId") { + const int a = eve::graphics::grass::swayFrame(0.f, 0.12f, 0, 4); + const int b = eve::graphics::grass::swayFrame(0.f, 0.12f, 1, 4); + const int c = eve::graphics::grass::swayFrame(0.f, 0.12f, 2, 4); + CHECK(a >= 0); + CHECK(a < 4); + CHECK(b >= 0); + CHECK(b < 4); + CHECK(c >= 0); + CHECK(c < 4); + CHECK((a != b || b != c)); + + const int later = eve::graphics::grass::swayFrame(0.36f, 0.12f, 0, 4); + CHECK(later != a); +} + +TEST_CASE("graphics.Grass.swayAtlasFourFrames") { + std::vector rgba; + eve::graphics::grass::makeSwayAtlasRGBA(32, 32, 4, rgba); + CHECK_EQ(eve::graphics::grass::swayAtlasWidth(32, 4), 128); + CHECK_EQ(eve::graphics::grass::swayAtlasHeight(32), 32); + CHECK_EQ(int(rgba.size()), 128 * 32 * 4); + + int opaque = 0; + int perFrame[4] = {}; + for (int y = 0; y < 32; ++y) { + for (int x = 0; x < 128; ++x) { + const uint8_t a = rgba[(size_t(y) * 128u + size_t(x)) * 4u + 3u]; + if (a > 16) { + ++opaque; + perFrame[x / 32]++; + } + } + } + CHECK(opaque > 80); + for (int f = 0; f < 4; ++f) CHECK(perFrame[f] > 10); + + // Filled tuft: the root band is denser than the tips (not five 1px sticks). + int baseOpaque = 0, tipOpaque = 0; + for (int x = 0; x < 32; ++x) { + for (int y = 24; y < 32; ++y) { + if (rgba[(size_t(y) * 128u + size_t(x)) * 4u + 3u] > 16) ++baseOpaque; + } + for (int y = 0; y < 8; ++y) { + if (rgba[(size_t(y) * 128u + size_t(x)) * 4u + 3u] > 16) ++tipOpaque; + } + } + CHECK(baseOpaque > tipOpaque); + CHECK(baseOpaque > 40); + + // Frames actually differ (wind lean). + int diffs = 0; + for (int y = 0; y < 32; ++y) { + for (int x = 0; x < 32; ++x) { + const uint8_t a0 = rgba[(size_t(y) * 128u + size_t(x)) * 4u + 3u]; + const uint8_t a3 = rgba[(size_t(y) * 128u + size_t(x + 96)) * 4u + 3u]; + if (a0 != a3) ++diffs; + } + } + CHECK(diffs > 8); +} + +TEST_CASE("graphics.Grass.fileSwayAtlasPacksFourGrassAndTwoLeaf") { + const std::vector grass = { + pathBesideThisSource("Texture/grass_sway_0.png"), + pathBesideThisSource("Texture/grass_sway_1.png"), + pathBesideThisSource("Texture/grass_sway_2.png"), + pathBesideThisSource("Texture/grass_sway_3.png"), + }; + const std::vector leaf = { + pathBesideThisSource("Texture/leaf_sway_0.png"), + pathBesideThisSource("Texture/leaf_sway_1.png"), + }; + for (const auto &p : grass) REQUIRE(std::filesystem::exists(p)); + for (const auto &p : leaf) REQUIRE(std::filesystem::exists(p)); + + std::vector rgba; + PackedAtlasInfo packed; + eve::graphics::grass::packSwayAtlasRGBA(grass, leaf, rgba, packed); + CHECK_EQ(packed.grassVariants, 4); + CHECK_EQ(packed.leafVariants, 2); + CHECK_EQ(packed.atlasCols, 8); + CHECK_EQ(packed.atlasRows, 4); + CHECK_EQ(packed.leafRowOffset, 2); + CHECK_EQ(packed.frames, 4); + CHECK(packed.width >= 512); + CHECK(packed.height >= 512); + CHECK_EQ(int(rgba.size()), packed.width * packed.height * 4); + + auto opaqueInCell = [&](int col, int row) { + const int cellW = packed.width / packed.atlasCols; + const int cellH = packed.height / packed.atlasRows; + int n = 0; + for (int y = row * cellH; y < (row + 1) * cellH; ++y) { + for (int x = col * cellW; x < (col + 1) * cellW; ++x) { + if (rgba[(size_t(y) * size_t(packed.width) + size_t(x)) * 4u + 3u] > 16) ++n; + } + } + return n; + }; + // Each grass variant is a 2x2 block on the top two frame-rows. + for (int v = 0; v < 4; ++v) { + CHECK(opaqueInCell(v * 2, 0) + opaqueInCell(v * 2 + 1, 0) + opaqueInCell(v * 2, 1) + + opaqueInCell(v * 2 + 1, 1) > + 80); + } + // Leaf variants occupy the bottom two frame-rows. + CHECK(opaqueInCell(0, 2) + opaqueInCell(1, 2) + opaqueInCell(0, 3) + opaqueInCell(1, 3) > 80); + CHECK(opaqueInCell(2, 2) + opaqueInCell(3, 2) + opaqueInCell(2, 3) + opaqueInCell(3, 3) > 80); +} + +TEST_CASE("graphics.Grass.layerFlag") { + Shader sh; + sh.setKind(Shader::Kind::eMesh3D); + eve::graphics::grass::bindDefaults(&sh); + eve::graphics::grass::bindLayer(&sh, true); + float dark = 0.f; + REQUIRE_EQ(sh.getFromVar("alwaysDark", &dark, sizeof(dark)), int(sizeof(dark))); + CHECK(dark > 0.5f); + eve::graphics::grass::bindLayer(&sh, false); + REQUIRE_EQ(sh.getFromVar("alwaysDark", &dark, sizeof(dark)), int(sizeof(dark))); + CHECK(dark < 0.5f); +} + +TEST_CASE("graphics.Grass.billboardAlwaysDarkFlag") { + std::vector pts(1); + pts[0].position = glm::vec3(0.f, 0.f, 0.f); + pts[0].id = 3; + pts[0].scale = 1.f; + const BillboardMesh dark = eve::graphics::grass::buildBillboards(pts, 0.4f, 0.8f, true); + CHECK_EQ(int(dark.nrmXYZ.size()), 12); + for (int i = 0; i < 4; ++i) CHECK(dark.nrmXYZ[size_t(i * 3 + 2)] > 0.5f); +} + +namespace { + +void resetGrassScene() { + if (ecs::current()->getManager() != nullptr) { + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [mr] = *it; + mr->visible = false; + } + } + if (ecs::current()->getManager() != nullptr) { + auto camView = ecs::View(); + for (auto it = camView.begin(); it != camView.end(); ++it) { + auto [data] = *it; + data->active = false; + } + } + if (ecs::current()->getManager() != nullptr) { + auto lightView = ecs::View(); + for (auto it = lightView.begin(); it != lightView.end(); ++it) { + auto [d] = *it; + d->enabled = false; + } + } + if (ecs::current()->getManager() != nullptr) { + auto view = ecs::View(); + for (auto it = view.begin(); it != view.end(); ++it) { + auto [sp] = *it; + sp->visible = false; + } + } +} + +Texture *makeSolidTex(Graphics *gfx, uint8_t r, uint8_t g, uint8_t b) { + const uint8_t px[4] = {r, g, b, 255}; + return gfx->newTexture(1, 1, px); +} + +Mesh *makeGroundMesh(Graphics *gfx, float sizeX, float sizeZ) { + std::vector pos, nrm; + std::vector idx; + eve::graphics::grass::makePlane(sizeX, sizeZ, 12, 12, pos, nrm, idx); + std::vector uv(size_t(pos.size() / 3) * 2u, 0.f); + for (size_t i = 0; i < pos.size() / 3; ++i) { + uv[i * 2] = pos[i * 3] / sizeX + 0.5f; + uv[i * 2 + 1] = pos[i * 3 + 2] / sizeZ + 0.5f; + } + return gfx->newMeshFromArrays(pos.data(), nrm.data(), uv.data(), int(pos.size() / 3), + idx.data(), int(idx.size())); +} + +void savePng(eve::image::ImageData *frame, const std::string &path) { + REQUIRE(frame != nullptr); + eve::image::Image::create(); + eve::filesystem::FileData *png = + frame->encode(medialoader::FormatHandler::ENCODED_PNG, "grass.png", false); + REQUIRE(png != nullptr); + REQUIRE(png->getSize() > 0); + std::error_code ec; + std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); + { + std::ofstream out(path, std::ios::binary); + REQUIRE(out.good()); + out.write(static_cast(png->getData()), + static_cast(png->getSize())); + REQUIRE(out.good()); + } + delete png; + std::printf("\ngrass render saved: %s\n", path.c_str()); +} + +} // namespace + +TEST_CASE("graphics.Grass.gpuRenderScreenshot") { + auto *win = eve::window::Window::create(); + auto *gfx = Graphics::create(); + REQUIRE(win != nullptr); + REQUIRE(gfx != nullptr); + win->setGraphics(gfx); + gfx->setMsaaSamples(0); + eve::window::WindowSettings s; + s.width = 960; + s.height = 540; + s.centered = true; + REQUIRE(win->setWindowSettings(s)); + + resetGrassScene(); + + auto *rc = gfx->getRenderControl(); + REQUIRE(rc != nullptr); + rc->disable("aa"); + rc->disable("msaa"); + rc->disable("ao"); + rc->disable("gi"); + rc->enable("shadow"); + rc->enable("forward"); + rc->compile(); + + constexpr float kField = 10.f; + auto *ground = Renderable3D::create(); + ground->setMesh(makeGroundMesh(gfx, kField, kField)); + ground->setTexture(makeSolidTex(gfx, 68, 54, 30)); + ground->setPosition(0.f, 0.f, 0.f); + ground->setMetallic(0.f); + ground->setRoughness(0.92f); + ground->setCastShadow(false); + ground->setReceiveShadow(true); + + GrassField::BakeParams bake; + bake.denseRadius = 0.10f; + bake.sparseRadius = 0.46f; + bake.maxDense = 7000; + bake.maxSparse = 220; + bake.width = 0.58f; + bake.height = 0.98f; + bake.seed = 11; + bake.minSlopeDot = 0.2f; + bake.grassAtlasFiles = { + pathBesideThisSource("Texture/grass_sway_0.png"), + pathBesideThisSource("Texture/grass_sway_1.png"), + pathBesideThisSource("Texture/grass_sway_2.png"), + pathBesideThisSource("Texture/grass_sway_3.png"), + }; + bake.leafAtlasFiles = { + pathBesideThisSource("Texture/leaf_sway_0.png"), + pathBesideThisSource("Texture/leaf_sway_1.png"), + }; + + GrassField *field = gfx->newGrassField(); + REQUIRE(field != nullptr); + field->bakePlane(kField, kField, 16, 16, bake); + field->setTime(0.22f); + std::printf("\ngrass dense=%d sparse=%d\n", field->getDenseCount(), field->getSparseCount()); + REQUIRE(field->getDenseCount() >= 800); + REQUIRE(field->getDenseMesh() != nullptr); + REQUIRE(field->getShader() != nullptr); + REQUIRE(field->getAtlas() != nullptr); + + auto *dense = Renderable3D::create(); + dense->setMesh(field->getDenseMesh()); + dense->setTexture(field->getAtlas()); + dense->setShader(field->getShader()); + dense->setCastShadow(false); + dense->setReceiveShadow(true); + + if (field->getSparseMesh()) { + auto *sparse = Renderable3D::create(); + sparse->setMesh(field->getSparseMesh()); + sparse->setTexture(field->getAtlas()); + sparse->setShader(field->getShader()); + sparse->setCastShadow(false); + sparse->setReceiveShadow(true); + } + + auto *post = Renderable3D::create(); + post->setMesh(gfx->newMeshCylinder(18, 1, true)); + post->setTexture(makeSolidTex(gfx, 168, 148, 122)); + post->setPosition(1.35f, 0.95f, -0.4f); + post->setScale(0.28f, 0.95f, 0.28f); + post->setMetallic(0.f); + post->setRoughness(0.7f); + post->setCastShadow(true); + post->setReceiveShadow(false); + + auto *cam = Camera3D::createCamera(); + cam->setEye(4.8f, 2.9f, 5.9f); + cam->setTarget(0.f, 0.45f, 0.f); + cam->setAmbient(0.18f, 0.20f, 0.16f); + cam->data()->nearZ = 0.1f; + cam->data()->farZ = 80.f; + + auto *sun = Light3D::createLight("dir"); + sun->setDirection(0.62f, 1.05f, 0.28f); + sun->setColor(1.f, 0.97f, 0.88f, 2.4f); + sun->setCastShadow(true); + sun->setShadowStrength(1.f); + sun->setShadowBias(0.003f); + + auto *hud = Renderable2D::create(); + hud->transform()->x = 0; + hud->transform()->y = 0; + hud->sprite()->width = 2; + hud->sprite()->height = 2; + hud->sprite()->r = 0.f; + hud->sprite()->g = 0.f; + hud->sprite()->b = 0.f; + hud->sprite()->a = 0.f; + hud->sprite()->visible = true; + + gfx->setScreenReadbackEnabled(true); + gfx->setBackgroundColor(::Color(0.52f, 0.72f, 0.92f, 1.f)); + + for (int i = 0; i < 4; ++i) { + RenderSystem3D::render(*gfx); + RenderSystem::render(*gfx); + } + + int greenish = 0; + int darkGreen = 0; + int litGreen = 0; + const int w = gfx->getWidth(); + const int h = gfx->getHeight(); + for (int y = 0; y < h; y += 2) { + for (int x = 0; x < w; x += 2) { + const ::Color c = gfx->getPixel(x, y); + if (c.g > c.r + 0.04f && c.g > c.b + 0.02f) { + ++greenish; + const float luma = (c.r + c.g + c.b) / 3.f; + if (luma < 0.28f) ++darkGreen; + if (luma > 0.38f) ++litGreen; + } + } + } + CHECK(greenish > 400); + CHECK(darkGreen > 8); + CHECK(litGreen > 40); + + eve::image::Image::create(); + eve::image::ImageData *frame = gfx->newImageData(); + REQUIRE(frame != nullptr); + std::unique_ptr ownedFrame(frame); + const std::string outDir = std::string(EVENGINE_TEST_BINARY_DIR) + "/out"; + savePng(frame, outDir + "/grass_field.png"); + savePng(frame, "/opt/cursor/artifacts/grass_field.png"); + + std::vector atlasRgba; + PackedAtlasInfo atlasInfo; + eve::graphics::grass::packSwayAtlasRGBA(bake.grassAtlasFiles, bake.leafAtlasFiles, atlasRgba, + atlasInfo); + auto *atlasImg = new eve::image::ImageData(atlasInfo.width, atlasInfo.height, "RGBA8", + atlasRgba.data(), false); + savePng(atlasImg, outDir + "/grass_atlas.png"); + savePng(atlasImg, "/opt/cursor/artifacts/grass_atlas.png"); + delete atlasImg; + + win->close(); +} diff --git a/test/main.cpp b/test/main.cpp index 5542f493..f93cf89b 100644 --- a/test/main.cpp +++ b/test/main.cpp @@ -1,5 +1,89 @@ #include "zeroerr/unittest.h" -int main(int argc, const char** argv) { - return zeroerr::UnitTest().parseArgs(argc, argv).run(); +#if defined(EVENGINE_ANDROID) +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +// Route C++ iostream output to Android logcat so `adb logcat` can show the +// zeroerr console reporter (stdout/stderr are not attached to anything on Android). +class LogcatBuf : public std::streambuf { +public: + LogcatBuf() { setp(buffer_, buffer_ + sizeof(buffer_) - 1); } + ~LogcatBuf() override { sync(); } + +protected: + int overflow(int ch) override { + if (ch != traits_type::eof()) { + *pptr() = static_cast(ch); + pbump(1); + } + return sync() == 0 ? traits_type::not_eof(ch) : traits_type::eof(); + } + + int sync() override { + if (pptr() == pbase()) + return 0; + *pptr() = '\0'; + __android_log_print(ANDROID_LOG_INFO, "EVEngineTest", "%s", pbase()); + setp(buffer_, buffer_ + sizeof(buffer_) - 1); + return 0; + } + +private: + char buffer_[1024]; +}; + +// Root of the unpacked test tree: /evengine_test. The Java +// activity unpacks the APK's assets/test + assets/examples here before SDL_main runs. +std::string testRoot() { + const char *internal = SDL_AndroidGetInternalStoragePath(); + if (!internal) + return {}; + return std::string(internal) + "/evengine_test"; +} + +} // namespace +#endif // EVENGINE_ANDROID + +// Signature must match the `extern "C" int SDL_main(int, char*[])` declared by +// SDL_main.h so the symbol stays unmangled ("SDL_main" for the SDLActivity JNI +// dlsym). A `const char**` parameter would become a C++ overload and be mangled. +int main(int argc, char **argv) { +#if defined(EVENGINE_ANDROID) + static LogcatBuf logcatBuf; + std::cout.rdbuf(&logcatBuf); + std::cerr.rdbuf(&logcatBuf); + std::clog.rdbuf(&logcatBuf); + std::cout.setf(std::ios::unitbuf); + std::cerr.setf(std::ios::unitbuf); + + const std::string root = testRoot(); + if (root.empty()) { + __android_log_print(ANDROID_LOG_ERROR, "EVEngineTest", "no internal storage path"); + } else if (chdir(root.c_str()) != 0) { + __android_log_print(ANDROID_LOG_ERROR, "EVEngineTest", "chdir(%s) failed: %s", + root.c_str(), strerror(errno)); + } else { + __android_log_print(ANDROID_LOG_INFO, "EVEngineTest", "test root: %s", root.c_str()); + } + + // SDLActivity already ran SDL_SetMainReady(); init the subsystems the tests use. + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS | SDL_INIT_TIMER | SDL_INIT_AUDIO) != 0) { + __android_log_print(ANDROID_LOG_ERROR, "EVEngineTest", "SDL_Init failed: %s", + SDL_GetError()); + } + __android_log_print(ANDROID_LOG_INFO, "EVEngineTest", "unit_test android starting"); +#endif // EVENGINE_ANDROID + + return zeroerr::UnitTest().parseArgs(argc, const_cast(argv)).run(); } diff --git a/test/model3d.cpp b/test/model3d.cpp index 9cfb03d3..7628fe02 100644 --- a/test/model3d.cpp +++ b/test/model3d.cpp @@ -43,12 +43,8 @@ using namespace eve::graphics; namespace { -std::string pathBesideThisSource(const char *filename) { - std::string here = __FILE__; - auto slash = here.find_last_of("/\\"); - std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); - return dir + "/" + filename; -} +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() std::vector readBinaryFile(const std::string &path) { std::ifstream in(path, std::ios::binary); diff --git a/test/particles_attach_extra.cpp b/test/particles_attach_extra.cpp index a5f91678..b3445987 100644 --- a/test/particles_attach_extra.cpp +++ b/test/particles_attach_extra.cpp @@ -40,12 +40,8 @@ using namespace eve::ik; namespace { -std::string pathBesideThisSource(const char *relative) { - std::string here = __FILE__; - auto slash = here.find_last_of("/\\"); - std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); - return dir + "/" + relative; -} +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() bool fileExists(const std::string &path) { return std::filesystem::is_regular_file(path); } @@ -124,6 +120,11 @@ TEST_CASE("particles.attach.resetClearsParticlesKeepsAttach") { CHECK(e->isAttached()); CHECK_EQ(e->getAttachKind(), std::string("ik2d")); CHECK(std::fabs(e->getX() - 11.f) < 1e-2f); + + // The emitter lives in a process-wide ECS; detach before the local + // skeleton is destroyed so later ParticleSimSystem::update() calls in + // other tests don't dereference the freed skeleton. + e->detach(); } TEST_CASE("particles.attach.moveToOverwrittenBySync") { @@ -139,6 +140,7 @@ TEST_CASE("particles.attach.moveToOverwrittenBySync") { ParticleSimSystem::update(0.016f); CHECK(std::fabs(e->getX() - 5.f) < 1e-2f); CHECK(std::fabs(e->getY() - 6.f) < 1e-2f); + e->detach(); } TEST_CASE("particles.attach.applyConfigPreservesAttach") { @@ -157,6 +159,7 @@ TEST_CASE("particles.attach.applyConfigPreservesAttach") { e->start(); ParticleSimSystem::update(0.1f); CHECK_GE(e->getCount(), 3); + e->detach(); } TEST_CASE("particles.attach.layerAndVisibleIndependent") { @@ -175,6 +178,7 @@ TEST_CASE("particles.attach.layerAndVisibleIndependent") { ParticleSimSystem::update(0.016f); CHECK(std::fabs(e->getX() - 1.f) < 1e-2f); CHECK(std::fabs(e->getY() - 2.f) < 1e-2f); + e->detach(); } TEST_CASE("particles.attach.ellipseAreaOnBone") { @@ -197,6 +201,7 @@ TEST_CASE("particles.attach.ellipseAreaOnBone") { if (dx * dx + dy * dy <= 1.01f) ++inside; } CHECK_EQ(inside, e->getCount()); + e->detach(); } TEST_CASE("particles.attach.tangentialForceUsesBoneOrigin") { @@ -218,6 +223,7 @@ TEST_CASE("particles.attach.tangentialForceUsesBoneOrigin") { ParticleSimSystem::update(0.1f); // Tangential to radial (+X) is +Y (rdx,rdy)=(1,0) → (-rdy, rdx)=(0,1) CHECK(e->sim()->particles[0].vy > 0.f); + e->detach(); } TEST_CASE("particles.attach.particleLifetimeExpiresOnBone") { @@ -235,6 +241,7 @@ TEST_CASE("particles.attach.particleLifetimeExpiresOnBone") { ParticleSimSystem::update(0.1f); CHECK_EQ(e->getCount(), 0); CHECK(e->isAttached()); + e->detach(); } TEST_CASE("particles.attach.largeDtBurstOnBone") { @@ -255,6 +262,7 @@ TEST_CASE("particles.attach.largeDtBurstOnBone") { CHECK(std::fabs(e->sim()->particles[size_t(i)].x - 7.f) < 1e-2f); CHECK(std::fabs(e->sim()->particles[size_t(i)].y - 8.f) < 1e-2f); } + e->detach(); } TEST_CASE("particles.attach.zeroDtStillSyncs") { @@ -277,6 +285,7 @@ TEST_CASE("particles.attach.zeroDtStillSyncs") { CHECK(std::fabs(e->getX() - 15.f) < 1e-2f); CHECK(std::fabs(e->getY() - 16.f) < 1e-2f); CHECK_EQ(e->getCount(), 2); // no new spawns at dt=0 + e->detach(); } TEST_CASE("particles.attach.cycleThroughAllKinds") { @@ -333,6 +342,7 @@ TEST_CASE("particles.attach.scaleChangeMidLifetimeResyncs") { e->setAttachScale(3.f); CHECK(std::fabs(e->getX() - 6.f) < 1e-3f); CHECK(std::fabs(e->getY() - 12.f) < 1e-3f); + e->detach(); } TEST_CASE("particles.attach.spinAndSizeVariationOnBone") { @@ -358,6 +368,7 @@ TEST_CASE("particles.attach.spinAndSizeVariationOnBone") { } CHECK(spinOk); CHECK(sizeVaried); + e->detach(); } TEST_CASE("particles.attach.ik2dConstraintsStillEmits") { @@ -383,6 +394,7 @@ TEST_CASE("particles.attach.ik2dConstraintsStillEmits") { CHECK(std::fabs(e->sim()->particles[size_t(i)].x - sk->getX(tip)) < 1e-1f); CHECK(std::fabs(e->sim()->particles[size_t(i)].y - sk->getY(tip)) < 1e-1f); } + e->detach(); } TEST_CASE("particles.attach.twoEmittersDifferentPlanes") { @@ -402,6 +414,8 @@ TEST_CASE("particles.attach.twoEmittersDifferentPlanes") { CHECK(std::fabs(xy->getY() - 4.f) < 1e-3f); CHECK(std::fabs(xz->getX() - 2.f) < 1e-3f); CHECK(std::fabs(xz->getY() - 6.f) < 1e-3f); + xy->detach(); + xz->detach(); } TEST_CASE("particles.attach.animChainOffsetThreeBones") { @@ -425,6 +439,7 @@ TEST_CASE("particles.attach.animChainOffsetThreeBones") { // world c = 10+1+1=12, + local offset 0.5 → 12.5 CHECK(std::fabs(e->getX() - 12.5f) < 1e-2f); CHECK(std::fabs(e->getY() - 0.f) < 1e-2f); + e->detach(); } TEST_CASE("particles.attach.spineBobAnimContinuousRate") { @@ -453,6 +468,7 @@ TEST_CASE("particles.attach.spineBobAnimContinuousRate") { } CHECK_GE(e->getCount(), 20); CHECK_GT(maxY - minY, 5.f); + e->detach(); } TEST_CASE("particles.attach.ik3dSolverStepContinuous") { @@ -481,6 +497,7 @@ TEST_CASE("particles.attach.ik3dSolverStepContinuous") { CHECK(std::fabs(e->getX() - sk->getX(tip)) < 1e-2f); } CHECK_GE(moved, 1); + e->detach(); } TEST_CASE("particles.skin.filterByIndexAndClearFilter") { @@ -512,6 +529,8 @@ TEST_CASE("particles.skin.filterByIndexAndClearFilter") { e->setSkinBoneFilter(-1, 0.f); // clear filter → all verts e->emitFromSkin(32); CHECK_EQ(e->getCount(), 32); + e->clearSkinSource(); + e->detach(); } TEST_CASE("particles.skin.emitFromSkinRespectsBuffer") { @@ -536,6 +555,8 @@ TEST_CASE("particles.skin.emitFromSkinRespectsBuffer") { e->emitFromSkin(100); CHECK_EQ(e->getCount(), 10); CHECK_EQ(e->getBufferSize(), 10); + e->clearSkinSource(); + e->detach(); } TEST_CASE("particles.attach.cesiumManBoneByNameIfPresent") { @@ -581,5 +602,6 @@ TEST_CASE("particles.attach.smokeAndFirePresetsOnIk3d") { e->start(); ParticleSimSystem::update(0.05f); CHECK_GE(e->getCount(), 1); + e->detach(); } } diff --git a/test/particles_attach_more.cpp b/test/particles_attach_more.cpp index 508b6c43..0c5ead1b 100644 --- a/test/particles_attach_more.cpp +++ b/test/particles_attach_more.cpp @@ -38,12 +38,8 @@ using namespace eve::ik; namespace { -std::string pathBesideThisSource(const char *relative) { - std::string here = __FILE__; - auto slash = here.find_last_of("/\\"); - std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); - return dir + "/" + relative; -} +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() bool fileExists(const std::string &path) { return std::filesystem::is_regular_file(path); } @@ -129,6 +125,7 @@ TEST_CASE("particles.attach.emitterLifeExpiresWhileAttached") { CHECK_EQ(e->getCount(), n); // Attach sync still updates origin even when inactive. CHECK(std::fabs(e->getX() - sk->getX(0)) < 1e-2f); + e->detach(); } TEST_CASE("particles.attach.bufferCapUnderRateOnBone") { @@ -145,6 +142,7 @@ TEST_CASE("particles.attach.bufferCapUnderRateOnBone") { ParticleSimSystem::update(1.f); CHECK_EQ(e->getCount(), 8); CHECK_EQ(e->getBufferSize(), 8); + e->detach(); } TEST_CASE("particles.attach.rebindDifferentBone") { @@ -164,6 +162,7 @@ TEST_CASE("particles.attach.rebindDifferentBone") { CHECK_EQ(e->getAttachKind(), std::string("ik2d")); CHECK(std::fabs(e->getX() - sk->getX(tip)) < 1e-2f); CHECK(std::fabs(e->getX() - sk->getX(mid)) > 1.f); + e->detach(); } TEST_CASE("particles.attach.zeroAndNegativeScale") { @@ -184,6 +183,7 @@ TEST_CASE("particles.attach.zeroAndNegativeScale") { e->setAttachScale(-2.f); CHECK(std::fabs(e->getX() - (-4.f)) < 1e-3f); CHECK(std::fabs(e->getY() - (-6.f)) < 1e-3f); + e->detach(); } TEST_CASE("particles.attach.stopStartPreservesAttach") { @@ -213,6 +213,7 @@ TEST_CASE("particles.attach.stopStartPreservesAttach") { ParticleSimSystem::update(0.05f); CHECK_GT(e->getCount(), n); CHECK_EQ(e->getAttachKind(), std::string("ik3d")); + e->detach(); } TEST_CASE("particles.attach.emissionAreaAroundBone") { @@ -241,6 +242,7 @@ TEST_CASE("particles.attach.emissionAreaAroundBone") { } CHECK_GT(maxX - minX, 5.f); CHECK_GT(maxY - minY, 2.f); + e->detach(); } TEST_CASE("particles.attach.followRotationAffectsSpawnVelocity") { @@ -266,6 +268,7 @@ TEST_CASE("particles.attach.followRotationAffectsSpawnVelocity") { // Direction ≈ π/2 → velocity mostly +Y CHECK(std::fabs(p.vx) < 1.f); CHECK(p.vy > 8.f); + e->detach(); } TEST_CASE("particles.attach.radialForceUsesMovingOrigin") { @@ -297,6 +300,7 @@ TEST_CASE("particles.attach.radialForceUsesMovingOrigin") { ParticleSimSystem::update(0.1f); // Away from origin at 100: particle at 10 → direction -X → negative vx CHECK(e->sim()->particles[0].vx < 0.f); + e->detach(); } TEST_CASE("particles.attach.getAttachBoneAfterDetach") { @@ -332,6 +336,7 @@ TEST_CASE("particles.attach.stableWhenPoseUnchanged") { // xz of (5,6,7) → (5,7) CHECK(std::fabs(x0 - 5.f) < 1e-3f); CHECK(std::fabs(y0 - 7.f) < 1e-3f); + e->detach(); } TEST_CASE("particles.attach.spineRotatedParentOffset") { @@ -355,6 +360,7 @@ TEST_CASE("particles.attach.spineRotatedParentOffset") { const float ey = spine->getBoneWorldY(hand) + d * 5.f; CHECK(std::fabs(e->getX() - ex) < 1e-2f); CHECK(std::fabs(e->getY() - ey) < 1e-2f); + e->detach(); } TEST_CASE("particles.attach.ik2dSolverStepTracksTip") { @@ -384,6 +390,7 @@ TEST_CASE("particles.attach.ik2dSolverStepTracksTip") { CHECK(std::fabs(e->getY() - sk->getY(tip)) < 1e-2f); } CHECK_GE(moved, 1); + e->detach(); } TEST_CASE("particles.attach.ik3dOffsetAlongBoneProjected") { @@ -400,6 +407,7 @@ TEST_CASE("particles.attach.ik3dOffsetAlongBoneProjected") { e->attachToSkeleton3D(sk.get(), tip); CHECK(std::fabs(e->getX() - 3.f) < 1e-2f); CHECK(std::fabs(e->getY() - 0.f) < 1e-2f); + e->detach(); } TEST_CASE("particles.attach.animByNameAfterDetach") { @@ -420,6 +428,7 @@ TEST_CASE("particles.attach.animByNameAfterDetach") { CHECK(e->isAttached()); CHECK_EQ(e->getAttachBone(), head); CHECK(std::fabs(e->getY() - 2.f) < 1e-3f); + e->detach(); } TEST_CASE("particles.attach.presetStillWorksWhenAttached") { @@ -436,6 +445,7 @@ TEST_CASE("particles.attach.presetStillWorksWhenAttached") { e->start(); ParticleSimSystem::update(0.05f); CHECK_GE(e->getCount(), 1); + e->detach(); } TEST_CASE("particles.attach.emitZeroAndNegativeNoOp") { @@ -447,6 +457,7 @@ TEST_CASE("particles.attach.emitZeroAndNegativeNoOp") { e->emit(0); e->emit(-5); CHECK_EQ(e->getCount(), 0); + e->detach(); } TEST_CASE("particles.skin.planeXzYzCesiumMan") { @@ -507,6 +518,8 @@ TEST_CASE("particles.skin.planeXzYzCesiumMan") { spreadYZ = (maxX - minX) + (maxY - minY); } CHECK_GT(spreadYZ, 0.5f); + e->clearSkinSource(); + e->detach(); } TEST_CASE("particles.skin.clearRestoresBoneOriginSpawn") { @@ -543,6 +556,7 @@ TEST_CASE("particles.skin.clearRestoresBoneOriginSpawn") { CHECK(std::fabs(e->sim()->particles[size_t(i)].x - e->getX()) < 1e-2f); CHECK(std::fabs(e->sim()->particles[size_t(i)].y - e->getY()) < 1e-2f); } + e->detach(); } TEST_CASE("particles.attach.manualSyncAttachWithoutSim") { @@ -557,6 +571,7 @@ TEST_CASE("particles.attach.manualSyncAttachWithoutSim") { e->syncAttach(); CHECK(std::fabs(e->getX() - 8.f) < 1e-2f); CHECK(std::fabs(e->getY() - 9.f) < 1e-2f); + e->detach(); } TEST_CASE("particles.attach.followRotationToggleResyncs") { @@ -582,4 +597,5 @@ TEST_CASE("particles.attach.followRotationToggleResyncs") { e->setDirection(0.3f); e->syncAttach(); CHECK(std::fabs(e->getDirection() - 0.3f) < 1e-4f); + e->detach(); } diff --git a/test/particles_attach_skin.cpp b/test/particles_attach_skin.cpp index 3477959f..b03e2c2f 100644 --- a/test/particles_attach_skin.cpp +++ b/test/particles_attach_skin.cpp @@ -26,12 +26,8 @@ using namespace eve::animation; namespace { -std::string pathBesideThisSource(const char *relative) { - std::string here = __FILE__; - auto slash = here.find_last_of("/\\"); - std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); - return dir + "/" + relative; -} +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() bool fileExists(const std::string &path) { return std::filesystem::is_regular_file(path); } @@ -161,6 +157,7 @@ TEST_CASE("particles.attach.followRotation") { e->setFollowBoneRotation(true); e->attachToBone(pose.get(), root); CHECK(std::fabs(e->getDirection() - (3.14159265f * 0.5f)) < 0.05f); + e->detach(); } TEST_CASE("particles.attach.emitsAtBone") { @@ -185,6 +182,7 @@ TEST_CASE("particles.attach.emitsAtBone") { CHECK(std::fabs(sim->particles[size_t(i)].x - 50.f) < 1e-2f); CHECK(std::fabs(sim->particles[size_t(i)].y - 60.f) < 1e-2f); } + e->detach(); } TEST_CASE("particles.skin.surfaceEmitCesiumMan") { @@ -308,6 +306,7 @@ TEST_CASE("particles.attach.animPoseDynamicEmitAcrossFrames") { // tip world x = root.x + 1 → scaled ×10; frames 0..4 → x = 10,30,50,70,90 CHECK(std::fabs(sim->particles[0].x - 10.f) < 1e-2f); CHECK(std::fabs(sim->particles[4].x - 90.f) < 1e-2f); + e->detach(); } TEST_CASE("particles.attach.yzPlaneAndDetachClearsKind") { @@ -376,6 +375,7 @@ TEST_CASE("particles.attach.clipSampleMovesEmitter") { CHECK_EQ(e->getCount(), 8); // Animated bone should move enough that spawn positions spread out. CHECK_GT(maxX - minX + maxY - minY, 1.f); + e->detach(); } TEST_CASE("particles.skin.emptyBoneFilterFallsBack") { @@ -413,6 +413,8 @@ TEST_CASE("particles.skin.emptyBoneFilterFallsBack") { // Fallback spawn at emitter position. CHECK(std::fabs(e->sim()->particles[0].x - 12.f) < 1e-2f); CHECK(std::fabs(e->sim()->particles[0].y - 34.f) < 1e-2f); + e->clearSkinSource(); + e->detach(); } TEST_CASE("particles.attach.followRotationOffKeepsDirection") { @@ -430,4 +432,5 @@ TEST_CASE("particles.attach.followRotationOffKeepsDirection") { e->setFollowBoneRotation(false); e->attachToBone(pose.get(), root); CHECK(std::fabs(e->getDirection() - 0.25f) < 1e-4f); + e->detach(); } diff --git a/test/particles_dynamic_bones.cpp b/test/particles_dynamic_bones.cpp index 69e3b7ca..d75ead90 100644 --- a/test/particles_dynamic_bones.cpp +++ b/test/particles_dynamic_bones.cpp @@ -108,6 +108,7 @@ TEST_CASE("particles.attach.spineBoneFollowsAndEmits") { e->attachToSpineBoneByName(spine.get(), "Missing"); CHECK(!e->isAttached()); CHECK_EQ(e->getAttachKind(), std::string("none")); + e->detach(); } TEST_CASE("particles.attach.spineDynamicEmitAcrossAnim") { @@ -140,6 +141,7 @@ TEST_CASE("particles.attach.spineDynamicEmitAcrossAnim") { } CHECK_EQ(e->getCount(), 6); CHECK_GE(moved, 1); // hand translate keyframes should move spawn X + e->detach(); } TEST_CASE("particles.attach.spineFollowRotation") { @@ -156,6 +158,7 @@ TEST_CASE("particles.attach.spineFollowRotation") { e->setFollowBoneRotation(true); e->attachToSpineBone(spine.get(), body); CHECK(std::fabs(e->getDirection() - float(M_PI * 0.5)) < 0.05f); + e->detach(); } TEST_CASE("particles.attach.ik2dBoneEmit") { @@ -194,6 +197,7 @@ TEST_CASE("particles.attach.ik2dBoneEmit") { sk->forwardKinematics(); ParticleSimSystem::update(0.016f); CHECK(std::fabs(e->getX() - sk->getX(tip)) < 1e-2f); + e->detach(); } TEST_CASE("particles.attach.ik2dFollowRotationAndSolver") { @@ -219,6 +223,7 @@ TEST_CASE("particles.attach.ik2dFollowRotationAndSolver") { const float fy = sk->getOrientationY(tip); const float expect = std::atan2(fy, fx); CHECK(std::fabs(e->getDirection() - expect) < 0.1f); + e->detach(); } TEST_CASE("particles.attach.ik3dBoneEmitProjected") { @@ -251,6 +256,7 @@ TEST_CASE("particles.attach.ik3dBoneEmitProjected") { e->start(); ParticleSimSystem::update(0.05f); CHECK_GE(e->getCount(), 2); + e->detach(); } TEST_CASE("particles.attach.switchKindsClearsPrevious") { @@ -268,6 +274,7 @@ TEST_CASE("particles.attach.switchKindsClearsPrevious") { CHECK_EQ(e->getAttachKind(), std::string("ik3d")); CHECK(e->attach()->ik2d == nullptr); CHECK(e->attach()->ik3d == sk3.get()); + e->detach(); } TEST_CASE("particles.attach.nullAndOutOfRangeDisable") { @@ -320,6 +327,7 @@ TEST_CASE("particles.attach.invalidPlaneFallsBackToXy") { CHECK_EQ(e->attach()->plane, std::string("xy")); CHECK(std::fabs(e->getX() - 3.f) < 1e-3f); CHECK(std::fabs(e->getY() - 4.f) < 1e-3f); + e->detach(); } TEST_CASE("particles.attach.multiEmittersOnSameSkeleton") { @@ -356,6 +364,8 @@ TEST_CASE("particles.attach.multiEmittersOnSameSkeleton") { // Spawn clusters stay near their respective bones. CHECK(std::fabs(rootFx->sim()->particles[0].x - sk->getX(0)) < 0.5f); CHECK(std::fabs(tipFx->sim()->particles[0].x - sk->getX(tip)) < 0.5f); + rootFx->detach(); + tipFx->detach(); } TEST_CASE("particles.attach.rateEmitTracksMovingIk2d") { @@ -382,6 +392,7 @@ TEST_CASE("particles.attach.rateEmitTracksMovingIk2d") { } // Tip world x ≈ root.x + length; root moves 0,10,20,30,40 → tip ~1,11,21,31,41 CHECK(xs.back() - xs.front() > 20.f); + e->detach(); } TEST_CASE("particles.attach.ik2dOffsetAlongOrientation") { @@ -404,6 +415,7 @@ TEST_CASE("particles.attach.ik2dOffsetAlongOrientation") { e->syncAttach(); CHECK(std::fabs(e->getX() - 10.f) < 1e-2f); CHECK(std::fabs(e->getY() - 3.f) < 1e-2f); + e->detach(); } TEST_CASE("particles.attach.ik3dYzPlaneAndFollowRotation") { @@ -431,6 +443,7 @@ TEST_CASE("particles.attach.ik3dYzPlaneAndFollowRotation") { CHECK(std::fabs(e->getDirection() - expect) < 0.15f); else (void)fx; // orientation collapsed on yz — direction unchanged is fine + e->detach(); } TEST_CASE("particles.attach.ik3dSolverMovesEmitter") { @@ -455,6 +468,7 @@ TEST_CASE("particles.attach.ik3dSolverMovesEmitter") { CHECK(std::fabs(e->getX() - sk->getX(tip)) < 5e-2f); CHECK(std::fabs(e->getY() - sk->getY(tip)) < 5e-2f); CHECK(std::fabs(e->getX() - x0) + std::fabs(e->getY() - y0) > 0.2f); + e->detach(); } TEST_CASE("particles.attach.spineScaleAndModuleUpdate") { @@ -479,6 +493,7 @@ TEST_CASE("particles.attach.spineScaleAndModuleUpdate") { mod->update(0.1f); // module path must sync attach + sim CHECK_GE(e->getCount(), 4); CHECK(std::fabs(e->sim()->particles[0].x - e->getX()) < 1e-2f); + e->detach(); } TEST_CASE("particles.attach.detachKeepsAliveParticles") { @@ -528,6 +543,7 @@ TEST_CASE("particles.attach.pauseStopsSpawnButStillSyncs") { ParticleSimSystem::update(0.05f); CHECK_EQ(e->getCount(), n); // paused: no new particles CHECK(std::fabs(e->getX() - sk->getX(0)) < 1e-2f); // but attach still syncs + e->detach(); } TEST_CASE("particles.attach.switchSpineToAnimClearsSpine") { @@ -556,6 +572,7 @@ TEST_CASE("particles.attach.switchSpineToAnimClearsSpine") { CHECK(e->attach()->pose == pose.get()); CHECK(std::fabs(e->getX() - 7.f) < 1e-3f); CHECK(std::fabs(e->getY() - 8.f) < 1e-3f); + e->detach(); } TEST_CASE("particles.attach.animOffsetRotatedByBone") { @@ -577,6 +594,7 @@ TEST_CASE("particles.attach.animOffsetRotatedByBone") { e->attachToBone(pose.get(), root); CHECK(std::fabs(e->getX() - 0.f) < 1e-2f); CHECK(std::fabs(e->getY() - 2.f) < 1e-2f); + e->detach(); } TEST_CASE("particles.attach.skinTakesPriorityOverBoneOrigin") { @@ -601,4 +619,5 @@ TEST_CASE("particles.attach.skinTakesPriorityOverBoneOrigin") { CHECK(std::fabs(e->sim()->particles[size_t(i)].y - 200.f) < 1e-2f); } CHECK(!e->hasSkinSource()); + e->detach(); } diff --git a/test/procgen.cpp b/test/procgen.cpp index a251eddf..e1dec27d 100644 --- a/test/procgen.cpp +++ b/test/procgen.cpp @@ -13,13 +13,22 @@ #include "map/TileLayer.h" #include "image/ImageData.h" #include "graphics/Graphics.h" +#include "graphics/RenderSystem.h" +#include "graphics/RenderSystem3D.h" #include "window/Window.h" +#include "image/Image.h" +#include "filesystem/FileData.h" +#include "RenderImageAudit.h" #include #include #include +#include #include +#include +#include +#include #include #include #include @@ -220,6 +229,223 @@ TEST_CASE("procgen.registry.builtins") { CHECK(GeneratorRegistry::instance().has("wfc.simple")); } +TEST_CASE("procgen.mesh.rock.reproducibleAndControllable") { + MeshRecipeRegistry::instance().registerBuiltins(); + Params p; + p.setSeed(1847); + p.setInt("subdivisions", 3); + p.setFloat("radius", 0.7f); + p.setFloat("flattening", 0.3f); + p.setFloat("angularity", 0.45f); + p.setFloat("erosion", 0.16f); + p.setFloat("scale", 2.4f); + MeshBuild a, b, flatter; + std::string err; + REQUIRE(MeshRecipeRegistry::instance().generate("mesh.rock", p, a, err)); + REQUIRE(MeshRecipeRegistry::instance().generate("mesh.rock", p, b, err)); + CHECK_EQ(a.getVertexCount(), 642); + CHECK_EQ(a.getIndexCount() / 3, 1280); + CHECK(a.positions() == b.positions()); + CHECK(a.indices() == b.indices()); + CHECK(meshIndicesInRange(a)); + CHECK(meshNormalsFiniteUnit(a)); + + p.setFloat("flattening", 0.58f); + REQUIRE(MeshRecipeRegistry::instance().generate("mesh.rock", p, flatter, err)); + CHECK(a.positions() != flatter.positions()); + + p.setInt("subdivisions", 2); + MeshBuild lod1; + REQUIRE(MeshRecipeRegistry::instance().generate("mesh.rock", p, lod1, err)); + CHECK_EQ(lod1.getVertexCount(), 162); + CHECK_EQ(lod1.getIndexCount() / 3, 320); + + std::vector> shapePositions; + for (const char *shape : {"boulder", "slab", "block", "shard"}) { + p.setString("baseShape", shape); + p.setInt("subdivisions", 3); + MeshBuild variant; + REQUIRE(MeshRecipeRegistry::instance().generate("mesh.rock", p, variant, err)); + CHECK_EQ(variant.getMeta("baseShape", ""), shape); + CHECK_EQ(variant.getVertexCount(), 642); + CHECK(meshIndicesInRange(variant)); + CHECK(meshNormalsFiniteUnit(variant)); + shapePositions.push_back(variant.positions()); + } + CHECK(shapePositions[0] != shapePositions[1]); + CHECK(shapePositions[1] != shapePositions[2]); + CHECK(shapePositions[2] != shapePositions[3]); + + p.setString("baseShape", "invalid"); + MeshBuild invalid; + CHECK(!MeshRecipeRegistry::instance().generate("mesh.rock", p, invalid, err)); +} + +TEST_CASE("procgen.mesh.tree.stylesAndLeaves") { + MeshRecipeRegistry::instance().registerBuiltins(); + Params low; + low.setSeed(1234); + low.setString("style", "lowpoly"); + low.setString("leafMode", "cards"); + low.setFloat("leafDensity", 0.8f); + MeshBuild a, b; + std::string err; + CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", low, a, err)); + CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", low, b, err)); + CHECK(a.getVertexCount() > 0); + CHECK(a.positions() == b.positions()); + CHECK(a.indices() == b.indices()); + CHECK(meshIndicesInRange(a)); + CHECK(meshPositionsFinite(a)); + CHECK(meshNormalsFiniteUnit(a)); + CHECK_EQ(a.getMeta("style", ""), "lowpoly"); + CHECK_EQ(a.getMeta("leafMode", ""), "cards"); + + Params realistic; + realistic.setSeed(22); + realistic.setString("style", "realistic"); + realistic.setString("leafMode", "canopy"); + realistic.setString("branchAlgorithm", "spaceColonization"); + realistic.setFloat("leafDensity", 1.f); + MeshBuild canopy; + CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", realistic, canopy, err)); + CHECK(canopy.getVertexCount() > a.getVertexCount() / 2); + CHECK(meshIndicesInRange(canopy)); + CHECK(meshNormalsFiniteUnit(canopy)); + CHECK_EQ(canopy.getMeta("branchAlgorithm", ""), "spaceColonization"); + + Params sparseCoverage = realistic; + sparseCoverage.setFloat("lowerLeafCoverage", 0.f); + sparseCoverage.setFloat("upperLeafCoverage", 0.f); + Params fullCoverage = realistic; + fullCoverage.setFloat("lowerLeafCoverage", 1.f); + fullCoverage.setFloat("upperLeafCoverage", 1.f); + MeshBuild sparseLeaves, coveredBranches; + CHECK(MeshRecipeRegistry::instance().generate( + "mesh.tree", sparseCoverage, sparseLeaves, err)); + CHECK(MeshRecipeRegistry::instance().generate( + "mesh.tree", fullCoverage, coveredBranches, err)); + CHECK(coveredBranches.getVertexCount() > sparseLeaves.getVertexCount()); + + Params bare; + bare.setSeed(22); + bare.setString("leafMode", "none"); + MeshBuild noLeaves; + CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", bare, noLeaves, err)); + CHECK(noLeaves.getVertexCount() < canopy.getVertexCount()); + + Params straight = bare; + straight.setFloat("trunkCurve", 0.f); + straight.setFloat("branchCurve", 0.f); + straight.setFloat("curveBack", 0.f); + straight.setFloat("tropism", 0.f); + straight.setFloat("droop", 0.f); + Params expressive = straight; + expressive.setFloat("trunkCurve", 0.2f); + expressive.setFloat("branchCurve", 0.24f); + expressive.setFloat("curveBack", 0.2f); + expressive.setFloat("tropism", 0.35f); + expressive.setFloat("droop", 0.28f); + MeshBuild rigidTree, curvedTree; + CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", straight, rigidTree, err)); + CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", expressive, curvedTree, err)); + CHECK(rigidTree.positions() != curvedTree.positions()); + CHECK(meshPositionsFinite(curvedTree)); + CHECK(meshNormalsFiniteUnit(curvedTree)); +} + +TEST_CASE("procgen.mesh.tree.validatesOptions") { + MeshRecipeRegistry::instance().registerBuiltins(); + Params p; + p.setString("style", "watercolor"); + MeshBuild mesh; + std::string err; + CHECK(!MeshRecipeRegistry::instance().generate("mesh.tree", p, mesh, err)); + CHECK(err.find("style") != std::string::npos); + p.setString("style", "lowpoly"); + p.setString("branchAlgorithm", "crystalGrowth"); + CHECK(!MeshRecipeRegistry::instance().generate("mesh.tree", p, mesh, err)); + CHECK(err.find("branchAlgorithm") != std::string::npos); +} + +TEST_CASE("procgen.mesh.tree.renderDump") { + const char *outputPath = std::getenv("EVENGINE_TREE_RENDER_PNG"); + if (!outputPath || !outputPath[0]) return; + + auto *win = eve::window::Window::create(); + auto *gfx = Graphics::create(); + REQUIRE(win != nullptr); + REQUIRE(gfx != nullptr); + win->setGraphics(gfx); + eve::window::WindowSettings ws; + ws.width = 900; + ws.height = 700; + ws.centered = true; + REQUIRE(win->setWindowSettings(ws)); + + Params params; + params.setSeed(31415); + params.setString("style", "lowpoly"); + const char *leafModeOverride = std::getenv("EVENGINE_TREE_LEAF_MODE"); + params.setString("leafMode", leafModeOverride ? leafModeOverride : "canopy"); + params.setString("branchAlgorithm", "spaceColonization"); + params.setFloat("leafDensity", 0.82f); + params.setFloat("height", 6.2f); + params.setFloat("crownRadius", 2.15f); + params.setInt("branchLevels", 3); + params.setInt("branchCount", 8); + params.setInt("attractorCount", 120); + params.setInt("colonizationIterations", 38); + params.setFloat("branchInertia", 1.15f); + params.setFloat("tropism", 0.20f); + params.setFloat("droop", 0.08f); + params.setFloat("growthStep", 0.25f); + params.setFloat("maxTurnAngle", 18.f); + params.setInt("maxChildren", 2); + + Procgen generator; + Mesh *treeMesh = generator.generateMesh("mesh.tree", ¶ms, gfx); + REQUIRE(treeMesh != nullptr); + + // 4px bark/foliage atlas; UVs are partitioned by the mesh recipe. + const uint8_t atlasPixels[] = { + 111, 70, 42, 255, 128, 82, 47, 255, + 64, 119, 57, 255, 82, 145, 67, 255, + }; + Texture *atlas = gfx->newTexture(4, 1, atlasPixels); + REQUIRE(atlas != nullptr); + + auto *tree = Renderable3D::create(); + tree->setMesh(treeMesh); + tree->setTexture(atlas); + tree->setTint(1.f, 1.f, 1.f, 1.f); + tree->setRoughness(0.88f); + tree->setRotation(-18.f, 0.f, 0.f); + + auto *camera = Camera3D::createCamera(); + camera->setEye(9.2f, 5.1f, 11.4f); + camera->setTarget(0.f, 3.1f, 0.f); + camera->setUp(0.f, 1.f, 0.f); + camera->setFov(36.f); + camera->setAmbient(0.30f, 0.34f, 0.28f); + camera->setActive(true); + + gfx->setScreenReadbackEnabled(true); + gfx->setBackgroundColor(Color(0.075f, 0.105f, 0.095f, 1.f)); + RenderSystem3D::setDirectionalLight(-0.55f, -1.f, -0.35f, 1.45f, 1.32f, 1.08f); + + // Warm up pipelines and read back the final stable frame. + for (int frame = 0; frame < 4; ++frame) { + RenderSystem3D::render(*gfx); + RenderSystem::render(*gfx); + } + std::unique_ptr image(gfx->newImageData()); + REQUIRE(image.get() != nullptr); + REQUIRE(saveImagePng(*image, outputPath)); + std::printf("tree render saved: %s\n", outputPath); + win->close(); +} + TEST_CASE("procgen.dungeon.bsp.reproducible") { Params p; p.setSeed(42); @@ -493,6 +719,110 @@ TEST_CASE("procgen.mesh.marchingcubes.sphere") { CHECK(mesh.indices() == mesh2.indices()); } +TEST_CASE("procgen.mesh.hexplanet.topology") { + MeshRecipeRegistry::instance().registerBuiltins(); + Params p; + p.setFloat("radius", 2.f); + p.setInt("subdivisions", 2); + p.setFloat("tileInset", 0.05f); + MeshBuild mesh; + std::string err; + CHECK(MeshRecipeRegistry::instance().generate("mesh.hexplanet", p, mesh, err)); + CHECK(meshIndicesInRange(mesh)); + CHECK(meshPositionsFinite(mesh)); + CHECK(meshNormalsFiniteUnit(mesh)); + CHECK_EQ(mesh.getMeta("algorithm", ""), std::string("mesh.hexplanet")); + CHECK_EQ(mesh.getMeta("pentagons", ""), std::string("12")); + // V = 10 * 4^n + 2 for a subdivided icosahedron. + CHECK_EQ(mesh.getMeta("cells", ""), std::string("162")); + CHECK_EQ(mesh.getMeta("hexagons", ""), std::string("150")); + CHECK_EQ(mesh.getIndexCount() % 3, 0); + for (int i = 0; i < mesh.getVertexCount(); ++i) { + const float x = mesh.getPositionX(i), y = mesh.getPositionY(i), z = mesh.getPositionZ(i); + CHECK(std::fabs(std::sqrt(x * x + y * y + z * z) - 2.f) < 1e-4f); + } +} + +TEST_CASE("procgen.render.hexplanetPng") { + auto *win = eve::window::Window::create(); + auto *gfx = Graphics::create(); + REQUIRE(win != nullptr); + REQUIRE(gfx != nullptr); + win->setGraphics(gfx); + eve::window::WindowSettings settings; + settings.width = 768; + settings.height = 768; + settings.centered = true; + REQUIRE(win->setWindowSettings(settings)); + + Procgen *procgen = Procgen::create(); + Params params; + params.setFloat("radius", 1.f); + params.setInt("subdivisions", 3); + params.setFloat("tileInset", 0.12f); + Mesh *planetMesh = procgen->generateMesh("mesh.hexplanet", ¶ms, gfx); + REQUIRE(planetMesh != nullptr); + + const uint8_t oceanBlue[4] = {42, 155, 181, 255}; + Texture *planetTexture = gfx->newTexture(1, 1, oceanBlue); + REQUIRE(planetTexture != nullptr); + + auto *planet = eve::graphics::Renderable3D::create(); + planet->setMesh(planetMesh); + planet->setTexture(planetTexture); + planet->setMetallic(0.05f); + planet->setRoughness(0.72f); + planet->setCastShadow(false); + planet->setReceiveShadow(false); + planet->transform()->yaw = 0.42f; + planet->transform()->pitch = -0.22f; + + auto *camera = eve::graphics::Camera3D::createCamera(); + camera->setEye(2.65f, 1.55f, 3.05f); + camera->setTarget(0.f, 0.f, 0.f); + camera->setFov(38.f); + camera->setAmbient(0.16f, 0.19f, 0.24f); + + // A transparent 2D entity drives the normal present path used by render tests. + auto *present = eve::graphics::Renderable2D::create(); + present->transform()->x = 0.f; + present->transform()->y = 0.f; + present->sprite()->width = 1.f; + present->sprite()->height = 1.f; + present->sprite()->a = 0.f; + + gfx->setBackgroundColor(Color(0.012f, 0.018f, 0.035f, 1.f)); + gfx->setScreenReadbackEnabled(true); + eve::graphics::RenderSystem3D::setDirectionalLight(0.55f, 0.85f, 1.1f, 1.15f, 0.98f, + 0.88f); + for (int frame = 0; frame < 8; ++frame) { + planet->transform()->yaw += 0.025f; + eve::graphics::RenderSystem3D::render(*gfx); + eve::graphics::RenderSystem::render(*gfx); + } + + eve::image::Image::create(); + std::unique_ptr image(gfx->newImageData()); + REQUIRE(static_cast(image)); + std::unique_ptr png( + image->encode(medialoader::FormatHandler::ENCODED_PNG, "hex_planet.png", false)); + REQUIRE(static_cast(png)); + REQUIRE(png->getSize() > 0); + + const std::filesystem::path outDir = + std::filesystem::path(EVENGINE_TEST_BINARY_DIR) / "out"; + std::filesystem::create_directories(outDir); + const std::filesystem::path outPath = outDir / "hex_planet.png"; + std::ofstream output(outPath, std::ios::binary); + REQUIRE(output.good()); + output.write(static_cast(png->getData()), + static_cast(png->getSize())); + REQUIRE(output.good()); + output.close(); + std::printf("hex planet render saved: %s\n", outPath.string().c_str()); + win->close(); +} + TEST_CASE("procgen.mesh.marchingcubes.allFields") { MeshRecipeRegistry::instance().registerBuiltins(); const char *fields[] = {"sphere", "torus", "noise", "terrain"}; @@ -673,7 +1003,11 @@ TEST_CASE("procgen.mesh.marchingcubes.viaModule") { CHECK(meshIndicesInRange(*m)); CHECK(mod->hasMeshRecipe("mesh.marchingcubes")); CHECK(mod->getMeshRecipeCount() >= 1); - CHECK_EQ(mod->getMeshRecipeId(0), std::string("mesh.marchingcubes")); + bool listed = false; + for (int i = 0; i < mod->getMeshRecipeCount(); ++i) { + if (mod->getMeshRecipeId(i) == "mesh.marchingcubes") listed = true; + } + CHECK(listed); delete m; Params bad; diff --git a/test/runtime.cpp b/test/runtime.cpp new file mode 100644 index 00000000..48f352a3 --- /dev/null +++ b/test/runtime.cpp @@ -0,0 +1,80 @@ +#include "common/Runtime.h" + +#include "zeroerr/unittest.h" + +#include + +using namespace eve; + +TEST_CASE("runtime.scopeAndNativeStackAreRestored") { + Runtime runtime(256, ssq::Libs::ALL); + CHECK(Runtime::current() == nullptr); + const SQInteger top = runtime.vm().getTop(); + { + auto scope = runtime.enter(); + CHECK(Runtime::current() == &runtime); + CHECK_EQ(Runtime::stackDepth(), size_t(1)); + { + auto guard = runtime.guard(); + sq_pushinteger(runtime.handle(), 42); + CHECK_EQ(runtime.vm().getTop(), top + 1); + } + CHECK_EQ(runtime.vm().getTop(), top); + } + CHECK(Runtime::current() == nullptr); +} + +TEST_CASE("runtime.reflectsAndUnloadsScriptClasses") { + Runtime runtime(512, ssq::Libs::ALL); + const char* source = R"SQ( +class RuntimeProbe { + + speed = 3 + function update(dt) { speed += dt } +} +)SQ"; + + const Runtime::ScriptId id = runtime.runSource(source, "runtime-probe.nut"); + const ScriptInfo* script = runtime.script(id); + CHECK(script != nullptr); + CHECK(script->state == ScriptState::Loaded); + CHECK(std::find(script->classes.begin(), script->classes.end(), "RuntimeProbe") != + script->classes.end()); + + const ReflectedClass* cls = runtime.reflectedClass("RuntimeProbe"); + CHECK(cls != nullptr); + CHECK_EQ(cls->source, std::string("runtime-probe.nut")); + auto speed = std::find_if(cls->members.begin(), cls->members.end(), + [](const ReflectedMember& member) { return member.name == "speed"; }); + CHECK(speed != cls->members.end()); + CHECK(!speed->method); + CHECK(std::find_if(speed->attributes.begin(), speed->attributes.end(), + [](const ReflectedAttribute& attribute) { + return attribute.name == "editor" && attribute.value == "slider"; + }) != speed->attributes.end()); + + CHECK(runtime.unload(id)); + CHECK(runtime.reflectedClass("RuntimeProbe") == nullptr); + bool removed = false; + try { + runtime.findClass("RuntimeProbe"); + } catch (const ssq::NotFoundException&) { + removed = true; + } + CHECK(removed); +} + +TEST_CASE("runtimeWrapsCompileErrorsAndRestoresStack") { + Runtime runtime(256, ssq::Libs::ALL); + const SQInteger top = runtime.vm().getTop(); + bool caught = false; + try { + runtime.compileSource("class Broken {", "broken.nut"); + } catch (const ScriptException& error) { + caught = true; + CHECK(error.stage() == ScriptStage::Compile); + CHECK_EQ(error.source(), std::string("broken.nut")); + } + CHECK(caught); + CHECK_EQ(runtime.vm().getTop(), top); +} diff --git a/test/sound.cpp b/test/sound.cpp index 0580f61e..8f40d32b 100644 --- a/test/sound.cpp +++ b/test/sound.cpp @@ -18,12 +18,8 @@ namespace { -std::string pathBesideThisSource(const char *filename) { - std::string here = __FILE__; - auto slash = here.find_last_of("/\\"); - std::string dir = (slash == std::string::npos) ? std::string(".") : here.substr(0, slash); - return dir + "/" + filename; -} +#include "PathBesideSource.h" +EVE_DEFINE_PATH_BESIDE_SOURCE() std::vector readBinaryFile(const std::string &path) { std::ifstream in(path, std::ios::binary); diff --git a/test/thread.cpp b/test/thread.cpp index 759305c7..2889fc10 100644 --- a/test/thread.cpp +++ b/test/thread.cpp @@ -165,3 +165,87 @@ TEST_CASE("thread.pool.stopRejectsSubmit") { CHECK(!pool->isRunning()); CHECK(expectException([&] { pool->submitSleep(1); })); } + +TEST_CASE("thread.pool.queueOwnsTaskState") { + std::unique_ptr pool(threadModule()->newThreadPool(1)); + eve::thread::Task *task = pool->submitSleep(10); + delete task; + pool->waitAll(); + CHECK_EQ(pool->getPendingCount(), 0); +} + +TEST_CASE("thread.pool.jobOwnsAnonymousChannelState") { + std::unique_ptr pool(threadModule()->newThreadPool(1)); + auto *channel = threadModule()->newChannel(); + std::unique_ptr task(pool->submitPush(channel, "late", 10)); + delete channel; + task->wait(); + CHECK_EQ(task->getStatus(), std::string("done")); +} + +TEST_CASE("thread.pool.workerWaitAllRejected") { + std::unique_ptr pool(threadModule()->newThreadPool(1)); + std::atomic rejected{false}; + std::unique_ptr task(pool->submit([&] { + try { + pool->waitAll(); + } catch (const eve::Exception &) { + rejected.store(true, std::memory_order_relaxed); + } + })); + task->wait(); + CHECK(rejected.load(std::memory_order_relaxed)); +} + +TEST_CASE("thread.pool.workerStopRejected") { + std::unique_ptr pool(threadModule()->newThreadPool(1)); + std::atomic rejected{false}; + std::unique_ptr task(pool->submit([&] { + try { + pool->stop(); + } catch (const eve::Exception &) { + rejected.store(true, std::memory_order_relaxed); + } + })); + task->wait(); + CHECK(rejected.load(std::memory_order_relaxed)); + CHECK(pool->isRunning()); +} + +TEST_CASE("thread.pool.workerCountBounded") { + CHECK(expectException([&] { + std::unique_ptr pool(threadModule()->newThreadPool(257)); + })); +} + +TEST_CASE("thread.pool.destructorFromWorkerIsSafe") { + auto *pool = threadModule()->newThreadPool(1); + std::unique_ptr task(pool->submit([pool] { delete pool; })); + task->wait(); + CHECK_EQ(task->getStatus(), std::string("done")); +} + +TEST_CASE("thread.pool.workerDestructorDoesNotJoinDependentWorker") { + auto *pool = threadModule()->newThreadPool(2); + std::atomic deletingTask{nullptr}; + std::atomic waiterReady{false}; + + std::unique_ptr waiter(pool->submit([&] { + eve::thread::Task *target = nullptr; + while ((target = deletingTask.load(std::memory_order_acquire)) == nullptr) + std::this_thread::yield(); + waiterReady.store(true, std::memory_order_release); + target->wait(); + })); + std::unique_ptr destroyer(pool->submit([&] { + while (!waiterReady.load(std::memory_order_acquire)) + std::this_thread::yield(); + delete pool; + })); + deletingTask.store(destroyer.get(), std::memory_order_release); + + destroyer->wait(); + waiter->wait(); + CHECK_EQ(destroyer->getStatus(), std::string("done")); + CHECK_EQ(waiter->getStatus(), std::string("done")); +}